@tea-agent/loop-agent 0.43.0 → 0.44.0-next.10
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 +408 -367
- package/dist/application/evaluation/budget.js +21 -0
- package/dist/application/evaluation/corpus-hash.js +10 -15
- package/dist/application/evaluation/corpus.js +2 -1
- package/dist/application/evaluation/frontend-browser-acceptance.js +77 -0
- package/dist/application/evaluation/frontend-corpus-browser-acceptance.js +175 -0
- package/dist/application/evaluation/frontend-gateway-evidence.js +226 -0
- package/dist/application/evaluation/frontend-pair-registration.js +143 -0
- package/dist/application/evaluation/frontend-paired-summary.js +150 -0
- package/dist/application/evaluation/frontend-run-observation.js +144 -0
- package/dist/application/evaluation/frontend-shared-evidence.js +105 -0
- package/dist/application/evaluation/types.js +45 -12
- package/dist/application/task-lifecycle/observe.js +26 -1
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +11 -0
- package/dist/cli/program.js +4 -0
- package/dist/commands/task-advance.js +3 -5
- package/dist/commands/task-source-prepare.js +13 -1
- package/dist/executors/dag-pi/guards.js +4 -0
- package/dist/executors/dag-pi/model-config.js +30 -0
- package/dist/executors/dag-pi/plan/fact-adoption.js +55 -0
- package/dist/{workflows/dag/frontend-plan-completeness.js → executors/dag-pi/plan/facts.js} +22 -28
- package/dist/executors/dag-pi/plan/finalize-tools.js +259 -0
- package/dist/executors/dag-pi/plan/ledger-contract.js +55 -0
- package/dist/executors/dag-pi/plan/record-tools.js +861 -0
- package/dist/executors/dag-pi/plan/schema.js +129 -0
- package/dist/executors/dag-pi/plan/session.js +1 -0
- package/dist/executors/dag-pi/plan/tool-helpers.js +16 -0
- package/dist/executors/dag-pi/tools/contract-tools.js +515 -0
- package/dist/executors/dag-pi/tools/design-terminal-tools.js +159 -0
- package/dist/executors/dag-pi/tools/plan-decision-tools.js +529 -0
- package/dist/executors/dag-pi/tools/review-terminal-tools.js +159 -0
- package/dist/executors/dag-pi/tools/scout-evidence-tools.js +270 -0
- package/dist/executors/dag-pi-executor.js +398 -3384
- package/dist/executors/pi-executor.js +6 -1
- package/dist/executors/pi-sdk-executor.js +112 -19
- package/dist/executors/shell-executor.js +88 -12
- package/dist/executors/shell-presets.js +1 -1
- package/dist/executors/shell-write-guard.js +26 -3
- package/dist/infrastructure/console/operation-store.js +48 -14
- package/dist/infrastructure/evaluation/corpus-store.js +37 -12
- package/dist/shared/file-identity.js +23 -0
- package/dist/shared/git-workspace-status.js +482 -0
- package/dist/shared/operator/capabilities.js +10 -5
- package/dist/shared/path-safety.js +2 -2
- package/dist/shared/wizard-local-telemetry.js +51 -0
- package/dist/task/config-types.js +22 -8
- package/dist/task/contract/project.js +13 -0
- package/dist/task/contract/schema.js +2 -7
- package/dist/task/frontend-preflight.js +1 -1
- package/dist/task/source-prepare/parse-intent.js +124 -5
- package/dist/task/source-prepare/prepare.js +6 -1
- package/dist/task/source-prepare/semantic-intake.js +18 -3
- package/dist/task/source-prepare/source-execution.js +65 -0
- package/dist/task/source-prepare/source-fidelity-pi.js +22 -9
- package/dist/task/source-prepare/source-provider-budget.js +290 -0
- package/dist/worker/console/chat/assistant-content.js +5 -34
- package/dist/worker/console/chat/chat-event-store.js +62 -10
- package/dist/worker/console/chat/context-insights.js +723 -0
- package/dist/worker/console/chat/conversation-nodes.js +383 -0
- package/dist/worker/console/chat/db-connections.js +854 -0
- package/dist/worker/console/chat/pi-runtime/custom-tools/operator-tools.js +1 -1
- package/dist/worker/console/chat/pi-runtime/custom-tools/sql-tools.js +655 -0
- package/dist/worker/console/chat/pi-runtime.js +445 -36
- package/dist/worker/console/chat/provider-error.js +0 -12
- package/dist/worker/console/chat/repo-browser.js +52 -4
- package/dist/worker/console/chat/routes.js +577 -72
- package/dist/worker/console/chat/sdd-data-alignment.js +8 -31
- package/dist/worker/console/chat/session-store.js +147 -19
- package/dist/worker/console/chat/shortcuts.js +7 -0
- package/dist/worker/console/chat/timeline-message-order.js +22 -0
- package/dist/worker/console/chat/tools.js +34 -0
- package/dist/worker/console/chat/turn-process.js +237 -66
- package/dist/worker/console/chat/user-turn-outline.js +19 -0
- package/dist/worker/console/console-update-runtime.js +2 -2
- package/dist/worker/console/dag-confirmation.js +3 -0
- package/dist/worker/console/dag-execution-receipt.js +3 -0
- package/dist/worker/console/operator-actions.js +32 -3
- package/dist/worker/console/operator-user-error.js +28 -1
- package/dist/worker/console/pi-readiness.js +4 -4
- package/dist/worker/console/prd-intake-bridge.js +5 -12
- package/dist/worker/console/recovery-cta.js +14 -14
- package/dist/worker/console/server.js +5 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-8-j6y-sd.js → abnfDiagram-N423BO3Z-r1NAPU_5.js} +1 -1
- package/dist/worker/console/static/assets/{arc-eoQiMvuk.js → arc-dT5T4sVL.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-C4A3uMcI.js → architectureDiagram-T3A2C74G-CP_cOq46.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-D4zD2F-Q.js → blockDiagram-VBNYF7ZC-sToEZxEf.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-j1RkJziL.js → c4Diagram-5PPSVZJV-DSxwwi2m.js} +1 -1
- package/dist/worker/console/static/assets/channel-CyBY_yGk.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-YBHmhik1.js → chunk-2GRJ4B5K-B9fh0IkF.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-COfWyo9P.js → chunk-2Q5K7J3B-CAUNl-A6.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-I99OUkHY.js → chunk-5RXB4S5H-BomeqbNX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-XKxoJNJ4.js → chunk-5VM5RSS4-BNAuTOLE.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DLT_cYx1.js → chunk-6Q2QTUOP-DfzjRD5y.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CxcKZ9mV.js → chunk-GF5L2VYU-C3uE-yc2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-V1EqXdjY.js → chunk-JWPE2WC7-C3sQMMYr.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DebTtdFp.js → chunk-KBJHAD2P-D_AtEYQL.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-B-ivNRDf.js → chunk-RYQCIY6F-DW4TwhKv.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-DC_Ds11b.js → chunk-XXDRQBXY-CTQDmJcp.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D5lN4E_E.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D5lN4E_E.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-sWEqKwIb.js → cose-bilkent-JH36ORCC-iMqPuBsL.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BXa_dcu4.js → cynefin-VYW2F7L2-DktIuDWs.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-C-Mle84F.js → cynefinDiagram-MW4NZA55-BgVnjyEs.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-CXPDBITe.js → dagre-VZM6K2ZE-CR25E4bu.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-CC-WJQfa.js → diagram-7IWD3JNH-CPM2h5HY.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-CDAV5Vs4.js → diagram-B4RE2ZJO-DqF0x2g1.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-CmFiAcNz.js → diagram-LBJQPF4R-G8BYp2eb.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-DPBZHuyn.js → diagram-Q27KOJAE-BbC4kT_D.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-jUlm_Ds3.js → diagram-UB23O5K3-CKZwZ-G5.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-DWhQ3mfY.js → ebnfDiagram-BXEA7PRR-kJDmheia.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-Tr2gMqet.js → erDiagram-JOGREHBK-DFTVHyxB.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-DJjVQHPA.js → flowDiagram-UKHOOZJN-BqdN5_os.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-D74dQ4u0.js → ganttDiagram-PKOTCBZU-SYDjVakh.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-DwW0tZ0X.js → gitGraphDiagram-DS77QQ5N-Bf8bNpGA.js} +1 -1
- package/dist/worker/console/static/assets/index-B28Onmfy.css +1 -0
- package/dist/worker/console/static/assets/index-CTMZsDmh.js +486 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-ILCbxJyb.js → infoDiagram-6WML65LV-DIeAmJ45.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-DeuWBQ89.js → ishikawaDiagram-WSZJBQD7-oikqUAlo.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CF5ih8Fk.js → journeyDiagram-NVQOT4AX-mKEF_xYT.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-C7yOSuRO.js → kanban-definition-27J2QSJJ-BdWJHPnN.js} +1 -1
- package/dist/worker/console/static/assets/{linear-BDZ9riWi.js → linear-DGRlieMH.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-7pKqYtpZ.js → mermaid.core-4P9yPC9c.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-LeJDybSU.js → mindmap-definition-FAOFIHXS-5PTSkos6.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BJvT3pMD.js → pegDiagram-VL7TDLO6-DONIFpG5.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-_rGqpMan.js → pieDiagram-7S7Q4E2Y-D_Z4S70M.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-DPcSv5aA.js → quadrantDiagram-CIZ2JOQS-CAsjh8iY.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Drxx4hkJ.js → railroadDiagram-AXF67PYL-Cx4Dd6ac.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-BCTUdU4z.js → requirementDiagram-LRYGKXZP-cQb7mwyH.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-B6wzbZmB.js → sankeyDiagram-W5VNT64P-Cd-DhUdf.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BGt8d3QQ.js → sequenceDiagram-SI44F4Z6-AlKYRgIk.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-7nlhJKo7.js → sizeCapture-X5ZJPWSS-CUy_ET4e.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Cv2stqMA.js → stateDiagram-OKZ733FA-CTuVe6Sa.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BsWhs8O7.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-qDAo4Yc1.js → swimlanes-SLNWSIFB-ChFLJb-v.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CJcioYGH.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CKbDNKTr.js → timeline-definition-Z64GVDOM-DZZeiAPr.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DI-9EHic.js → vennDiagram-T6HMQDX7-t4O91cZU.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-BrzzRDpX.js → wardleyDiagram-T6FBY63Y-CV5qGHZu.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-BclH5hGh.js → xychartDiagram-ELKLHX3M-DyXbytUI.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/app/console-types.js +9 -1
- package/dist/worker/console/static-src/operator-chat/animation-frame-batcher.js +38 -0
- package/dist/worker/console/static-src/operator-chat/chat-link.js +20 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +186 -5
- package/dist/worker/console/static-src/operator-chat/chat-stream-continuity.js +101 -0
- package/dist/worker/console/static-src/operator-chat/compaction-message.js +18 -3
- package/dist/worker/console/static-src/operator-chat/context-insights-store.js +64 -0
- package/dist/worker/console/static-src/operator-chat/details-rail-surfaces.js +98 -0
- package/dist/worker/console/static-src/operator-chat/file-palette-nav.js +81 -0
- package/dist/worker/console/static-src/operator-chat/pending-user-message.js +17 -3
- package/dist/worker/console/static-src/operator-chat/process-label.js +27 -5
- package/dist/worker/console/static-src/operator-chat/tools-catalog.js +16 -7
- package/dist/worker/console/static-src/operator-chat/transcript-mode.js +21 -0
- package/dist/worker/console/static-src/operator-chat/turn-group-equality.js +4 -1
- package/dist/worker/console/static-src/operator-chat/turn-process-disclosure.js +13 -5
- package/dist/worker/console/static-src/operator-chat/turn-state-projection.js +166 -0
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +56 -2
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +211 -40
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +124 -27
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +88 -20
- package/dist/worker/console/static-src/operator-chat/useComposer.js +80 -6
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +13 -3
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +36 -17
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +18 -0
- package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
- package/dist/worker/console/static-src/shell/console-update-reload.js +1 -1
- package/dist/worker/console/workspace-context.js +2 -0
- package/dist/worker/console/workspace-initialization.js +3 -3
- package/dist/worker/observability/read-model.js +23 -2
- package/dist/worker/observe/node-context-insights.js +479 -0
- package/dist/worker/observe/static/console-theme.css +174 -0
- package/dist/worker/observe/static/console-theme.js +36 -0
- package/dist/worker/observe/static/operator-chrome.css +85 -0
- package/dist/worker/observe/static/operator-chrome.js +51 -0
- package/dist/worker/scheduler/scheduled-goal-dispatch.js +3 -2
- package/dist/worker/scheduler/scheduled-goal-evidence.js +66 -3
- package/dist/worker/scheduler/scheduled-goal-store.js +10 -0
- package/dist/workflows/dag/budget-enforcement.js +31 -2
- package/dist/workflows/dag/frontend-closeout.js +3 -1
- package/dist/workflows/dag/frontend-contract-facts.js +11 -8
- package/dist/workflows/dag/frontend-design-policy.js +6 -6
- package/dist/workflows/dag/frontend-durable-tools.js +90 -70
- package/dist/workflows/dag/frontend-implementation-contract.js +146 -57
- package/dist/workflows/dag/frontend-plan-canary.js +66 -16
- package/dist/workflows/dag/frontend-plan-decision-contract.js +13 -21
- package/dist/workflows/dag/frontend-plan-progress.js +92 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +10 -5
- package/dist/workflows/dag/frontend-recovery-run.js +51 -46
- package/dist/workflows/dag/frontend-repair-assertions.js +36 -0
- package/dist/workflows/dag/frontend-review-context.js +29 -1
- package/dist/workflows/dag/frontend-review-scopes.js +92 -2
- package/dist/workflows/dag/frontend-risk.js +8 -3
- package/dist/workflows/dag/frontend-root-observation.js +261 -0
- package/dist/workflows/dag/frontend-session-budget.js +52 -39
- package/dist/workflows/dag/frontend-session-context.js +10 -0
- package/dist/workflows/dag/frontend-test-execution-evidence.js +206 -48
- package/dist/workflows/dag/frontend-typed-event-store.js +11 -1
- package/dist/workflows/dag/frontend-verification-trace.js +6 -0
- package/dist/workflows/dag/frontend-writer-admission.js +3 -1
- package/dist/workflows/dag/init-hybrid.js +108 -37
- package/dist/workflows/dag/node-execution.js +11 -11
- package/dist/workflows/dag/output-protocol.js +1 -0
- package/dist/workflows/dag/rerun-feedback.js +35 -4
- package/dist/workflows/dag/rerun-task.js +62 -66
- package/dist/workflows/dag/runner.js +58 -12
- package/dist/workflows/dag/types.js +15 -8
- package/dist/workflows/loop/benchmark.js +11 -8
- package/docs/README.md +1 -1
- package/docs/architecture/runtime-boundaries.md +1 -1
- package/docs/governance/harness-methodology-verification.md +11 -0
- package/docs/templates/backend-test-dag.json +5 -4
- package/docs/templates/frontend-implementation-contract.schema.json +31 -11
- package/package.json +6 -4
- package/skills/frontend-design-review/SKILL.md +8 -11
- package/skills/frontend-design-review/references/review-checklist.md +3 -4
- package/skills/frontend-review/SKILL.md +5 -1
- package/skills/loop-agent/references/command-reference.md +8 -0
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/dist/worker/console/static/assets/channel-ChE7y-cx.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C01TCf2X.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C01TCf2X.js +0 -1
- package/dist/worker/console/static/assets/index-BWkIfcrK.css +0 -1
- package/dist/worker/console/static/assets/index-Cdkvw_H6.js +0 -469
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-DC7V5vcq.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-Bwy4QUTO.js +0 -8
|
@@ -1,21 +1,32 @@
|
|
|
1
1
|
import { classifyFrontendPlanRecovery } from "../workflows/dag/frontend-plan-recovery-policy.js";
|
|
2
|
-
import { collectFrontendExecutionGroups
|
|
2
|
+
import { collectFrontendExecutionGroups } from "../workflows/dag/frontend-execution-groups.js";
|
|
3
3
|
import { FRONTEND_SCOPE_TARGET_BYTES, packFrontendInputUnits, parseFrontendInputBlock, projectFrontendContractPrompt, projectFrontendInputScope } from "../workflows/dag/frontend-input-projection.js";
|
|
4
4
|
import { createDurableFrontendTools } from "../workflows/dag/frontend-durable-tools.js";
|
|
5
5
|
import { sha256OfCanonicalJson } from "../task/contract/hash.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import {
|
|
8
|
-
import { observeFrontendSession } from "../workflows/dag/frontend-session-budget.js";
|
|
7
|
+
import { loadFrontendReviewScopes } from "../workflows/dag/frontend-review-scopes.js";
|
|
8
|
+
import { observeFrontendFinalization, observeFrontendSession } from "../workflows/dag/frontend-session-budget.js";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { createHash
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
11
|
import { readFile, stat } from "node:fs/promises";
|
|
12
12
|
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
13
|
-
import { writeJsonAtomic } from "../infrastructure/harness/atomic-write.js";
|
|
14
|
-
import { mapContractBlockedOwner } from "../workflows/dag/frontend-human-decision.js";
|
|
15
|
-
import { routeFrontendProviderCapability } from "../workflows/dag/frontend-provider-capability-matrix.js";
|
|
16
13
|
import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
|
|
17
14
|
import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
|
|
18
15
|
import { buildPiWriterToolPolicyContext, createPiReaderCustomTools, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
|
|
16
|
+
import { resolveDagPiModelConfig } from "./dag-pi/model-config.js";
|
|
17
|
+
import { isRecordObject } from "./dag-pi/guards.js";
|
|
18
|
+
import { createFrontendReviewTerminalTools } from "./dag-pi/tools/review-terminal-tools.js";
|
|
19
|
+
import { createFrontendDesignTerminalTools } from "./dag-pi/tools/design-terminal-tools.js";
|
|
20
|
+
import { createFrontendScoutEvidenceTools } from "./dag-pi/tools/scout-evidence-tools.js";
|
|
21
|
+
import { createFrontendPlanDecisionTools, translateDecisionPatchFindings } from "./dag-pi/tools/plan-decision-tools.js";
|
|
22
|
+
import { createFrontendContractTools } from "./dag-pi/tools/contract-tools.js";
|
|
23
|
+
import { loadContractRequirementInheritance } from "./dag-pi/plan/ledger-contract.js";
|
|
24
|
+
import { createFrontendPlanProgressGuard } from "../workflows/dag/frontend-plan-progress.js";
|
|
25
|
+
import { buildPlanSchemas } from "./dag-pi/plan/schema.js";
|
|
26
|
+
import { buildPlanRecordTools } from "./dag-pi/plan/record-tools.js";
|
|
27
|
+
import { buildPlanFinalizeTools } from "./dag-pi/plan/finalize-tools.js";
|
|
28
|
+
import { createPlanFactAdopter } from "./dag-pi/plan/fact-adoption.js";
|
|
29
|
+
import { collectFrontendPlanMissingFacts, collectFrontendPlanPhaseMissingFacts, committedFactFromPlanRecord, planFactStringList, planFactScopeIntersects } from "./dag-pi/plan/facts.js";
|
|
19
30
|
import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
|
|
20
31
|
import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
|
|
21
32
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
@@ -24,9 +35,8 @@ import { splitDocumentIntoFragments, sha256Text, } from "../task/source-prepare/
|
|
|
24
35
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
25
36
|
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
26
37
|
import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
|
|
27
|
-
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
28
38
|
import { readTypedEventStoreFromJsonl, typedEventPayloadSha256, } from "../workflows/dag/frontend-typed-event-store.js";
|
|
29
|
-
import {
|
|
39
|
+
import { resolveFrontendContractRequirements, } from "../workflows/dag/frontend-contract-facts.js";
|
|
30
40
|
import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, OUTPUT_LIMIT_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
31
41
|
import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
32
42
|
import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
|
|
@@ -348,15 +358,7 @@ export const DAG_PI_WRITE_TOOLS = [
|
|
|
348
358
|
"ls",
|
|
349
359
|
"bash",
|
|
350
360
|
];
|
|
351
|
-
export const DEFAULT_DAG_PI_PROVIDER = "wizard-local";
|
|
352
361
|
const WRITER_OUTCOME_PROTOCOL_LINE = "IMPLEMENTATION_OUTCOME:";
|
|
353
|
-
export const DAG_PI_MODEL_PROVIDERS = {
|
|
354
|
-
"gpt-5.3-codex-spark": "wizard-local",
|
|
355
|
-
"gpt-5.5": "wizard-local",
|
|
356
|
-
"glm-5.2": "wizard-local",
|
|
357
|
-
"deepseek-v4-flash": "deepseek",
|
|
358
|
-
"deepseek-v4-pro": "deepseek",
|
|
359
|
-
};
|
|
360
362
|
const SAFE_PI_STEPS = new Set([
|
|
361
363
|
"analyze",
|
|
362
364
|
"plan",
|
|
@@ -829,360 +831,6 @@ export function scanDesignTerminalKindsFromSessionEvents(content) {
|
|
|
829
831
|
}
|
|
830
832
|
return kinds;
|
|
831
833
|
}
|
|
832
|
-
/**
|
|
833
|
-
* M5: build the two committed typed review terminal tools (approve_review /
|
|
834
|
-
* request_review_changes). Each tool validates its parameters with the review
|
|
835
|
-
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
836
|
-
* store, and returns a structured receipt. Terminal conflicts (a second
|
|
837
|
-
* terminal commit) are caught inside execute and returned as an error receipt
|
|
838
|
-
* rather than crashing the node.
|
|
839
|
-
*/
|
|
840
|
-
export async function createFrontendReviewTerminalTools(input) {
|
|
841
|
-
const [{ Type }, { defineTool }] = await Promise.all([
|
|
842
|
-
import("typebox"),
|
|
843
|
-
import("@earendil-works/pi-coding-agent"),
|
|
844
|
-
]);
|
|
845
|
-
const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
846
|
-
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
847
|
-
let store = input.store;
|
|
848
|
-
const attemptId = input.attemptId;
|
|
849
|
-
const findingSchema = Type.Object({
|
|
850
|
-
severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
|
|
851
|
-
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
852
|
-
line: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
853
|
-
issue: Type.String({ minLength: 1 }),
|
|
854
|
-
requiredChange: Type.Optional(Type.String({ minLength: 1 })),
|
|
855
|
-
}, { additionalProperties: false });
|
|
856
|
-
const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "review", inventory: input.inventory, getStore: () => store, attemptId });
|
|
857
|
-
const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding").map(r => [r.fact.id, r.fact.finding])).values()];
|
|
858
|
-
const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
|
|
859
|
-
const recordFindingTool = defineTool({
|
|
860
|
-
name: "record_review_finding", label: "record_review_finding",
|
|
861
|
-
description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
|
|
862
|
-
parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
|
|
863
|
-
async execute(_callId, params) {
|
|
864
|
-
const fact = { kind: "review-finding", id: params.id, finding: params.finding };
|
|
865
|
-
const requestId = `${attemptId}:finding:${randomUUID()}`;
|
|
866
|
-
const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
|
|
867
|
-
const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
|
|
868
|
-
const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
|
|
869
|
-
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
870
|
-
},
|
|
871
|
-
});
|
|
872
|
-
const approveParameters = Type.Object({
|
|
873
|
-
findings: Type.Optional(Type.Array(findingSchema, {
|
|
874
|
-
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
875
|
-
})),
|
|
876
|
-
}, { additionalProperties: false });
|
|
877
|
-
const requestParameters = Type.Object({
|
|
878
|
-
issueCategory: Type.Enum({
|
|
879
|
-
"implementation-mismatch": "implementation-mismatch",
|
|
880
|
-
"approved-design-defect": "approved-design-defect",
|
|
881
|
-
"target-surface-defect": "target-surface-defect",
|
|
882
|
-
"contract-requirement-gap": "contract-requirement-gap",
|
|
883
|
-
"unknown": "unknown",
|
|
884
|
-
}, { description: "Typed issue category (five-value enum)" }),
|
|
885
|
-
evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
|
|
886
|
-
description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
|
|
887
|
-
}),
|
|
888
|
-
findings: Type.Optional(Type.Array(findingSchema, {
|
|
889
|
-
description: "At least one finding", minItems: 1,
|
|
890
|
-
})),
|
|
891
|
-
}, { additionalProperties: false });
|
|
892
|
-
async function adoptReviewFact(kind, fact) {
|
|
893
|
-
const requestId = randomUUID();
|
|
894
|
-
try {
|
|
895
|
-
scopeProtocol.assertComplete();
|
|
896
|
-
const parsed = kind === "approve_review"
|
|
897
|
-
? approveReviewFactSchema.parse(fact)
|
|
898
|
-
: requestReviewChangesFactSchema.parse(fact);
|
|
899
|
-
const staged = stageTypedEventFact({
|
|
900
|
-
store,
|
|
901
|
-
requestId,
|
|
902
|
-
attemptId,
|
|
903
|
-
fact: parsed,
|
|
904
|
-
});
|
|
905
|
-
const adopted = await adoptTypedEventFact({
|
|
906
|
-
store,
|
|
907
|
-
requestId,
|
|
908
|
-
attemptId,
|
|
909
|
-
fact: parsed,
|
|
910
|
-
eventId: staged.eventId,
|
|
911
|
-
expectedRevision: store.revision,
|
|
912
|
-
});
|
|
913
|
-
return {
|
|
914
|
-
content: [
|
|
915
|
-
{
|
|
916
|
-
type: "text",
|
|
917
|
-
text: JSON.stringify({
|
|
918
|
-
ok: true,
|
|
919
|
-
kind,
|
|
920
|
-
eventId: adopted.eventId,
|
|
921
|
-
revision: adopted.revision,
|
|
922
|
-
}),
|
|
923
|
-
},
|
|
924
|
-
],
|
|
925
|
-
details: {
|
|
926
|
-
ok: true,
|
|
927
|
-
kind,
|
|
928
|
-
eventId: adopted.eventId,
|
|
929
|
-
revision: adopted.revision,
|
|
930
|
-
},
|
|
931
|
-
};
|
|
932
|
-
}
|
|
933
|
-
catch (error) {
|
|
934
|
-
const code = error?.code;
|
|
935
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
936
|
-
return {
|
|
937
|
-
content: [
|
|
938
|
-
{
|
|
939
|
-
type: "text",
|
|
940
|
-
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
941
|
-
},
|
|
942
|
-
],
|
|
943
|
-
details: { ok: false, kind, code, error: message },
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
const approveReviewTool = defineTool({
|
|
948
|
-
name: "approve_review",
|
|
949
|
-
label: "approve_review",
|
|
950
|
-
description: "Commit the authoritative approve_review terminal fact. Use only when the implementation passes review with no Critical/Important findings.",
|
|
951
|
-
promptSnippet: "Commit the authoritative approve_review terminal verdict (no Critical/Important findings).",
|
|
952
|
-
parameters: approveParameters,
|
|
953
|
-
async execute(_toolCallId, params) {
|
|
954
|
-
return adoptReviewFact("approve_review", {
|
|
955
|
-
kind: "approve_review",
|
|
956
|
-
verdict: "approve_review",
|
|
957
|
-
findings: allFindings(params?.findings),
|
|
958
|
-
});
|
|
959
|
-
},
|
|
960
|
-
});
|
|
961
|
-
const requestReviewChangesTool = defineTool({
|
|
962
|
-
name: "request_review_changes",
|
|
963
|
-
label: "request_review_changes",
|
|
964
|
-
description: "Commit the authoritative request_review_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
965
|
-
promptSnippet: "Commit the authoritative request_review_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
966
|
-
parameters: requestParameters,
|
|
967
|
-
async execute(_toolCallId, params) {
|
|
968
|
-
return adoptReviewFact("request_review_changes", {
|
|
969
|
-
kind: "request_review_changes",
|
|
970
|
-
verdict: "request_review_changes",
|
|
971
|
-
issueCategory: params?.issueCategory,
|
|
972
|
-
evidenceRefs: params?.evidenceRefs,
|
|
973
|
-
findings: allFindings(params?.findings),
|
|
974
|
-
});
|
|
975
|
-
},
|
|
976
|
-
});
|
|
977
|
-
const durable = await createDurableFrontendTools({
|
|
978
|
-
file: path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), attemptId, store: input.store,
|
|
979
|
-
binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveReviewTool, requestReviewChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
|
|
980
|
-
});
|
|
981
|
-
return { ...durable, scopeProtocol };
|
|
982
|
-
}
|
|
983
|
-
/**
|
|
984
|
-
* M8: build the two committed typed design terminal tools (approve_design /
|
|
985
|
-
* request_design_changes). Each tool validates its parameters with the design
|
|
986
|
-
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
987
|
-
* store, and returns a structured receipt. Terminal conflicts are caught
|
|
988
|
-
* inside execute and returned as an error receipt rather than crashing the
|
|
989
|
-
* node.
|
|
990
|
-
*/
|
|
991
|
-
export async function createFrontendDesignTerminalTools(input) {
|
|
992
|
-
const [{ Type }, { defineTool }] = await Promise.all([
|
|
993
|
-
import("typebox"),
|
|
994
|
-
import("@earendil-works/pi-coding-agent"),
|
|
995
|
-
]);
|
|
996
|
-
const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
997
|
-
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
998
|
-
let store = input.store;
|
|
999
|
-
const attemptId = input.attemptId;
|
|
1000
|
-
const findingSchema = Type.Object({
|
|
1001
|
-
severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
|
|
1002
|
-
file: Type.Optional(Type.String({ minLength: 1 })),
|
|
1003
|
-
line: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
1004
|
-
issue: Type.String({ minLength: 1 }),
|
|
1005
|
-
requiredChange: Type.Optional(Type.String({ minLength: 1 })),
|
|
1006
|
-
}, { additionalProperties: false });
|
|
1007
|
-
const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "design", inventory: input.inventory, getStore: () => store, attemptId });
|
|
1008
|
-
const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding").map(r => [r.fact.id, r.fact.finding])).values()];
|
|
1009
|
-
const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
|
|
1010
|
-
const recordFindingTool = defineTool({
|
|
1011
|
-
name: "record_design_finding", label: "record_design_finding",
|
|
1012
|
-
description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
|
|
1013
|
-
parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
|
|
1014
|
-
async execute(_callId, params) {
|
|
1015
|
-
const fact = { kind: "design-finding", id: params.id, finding: params.finding };
|
|
1016
|
-
const requestId = `${attemptId}:finding:${randomUUID()}`;
|
|
1017
|
-
const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
|
|
1018
|
-
const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
|
|
1019
|
-
const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
|
|
1020
|
-
return { content: [{ type: "text", text: JSON.stringify(details) }], details };
|
|
1021
|
-
},
|
|
1022
|
-
});
|
|
1023
|
-
const approveParameters = Type.Object({
|
|
1024
|
-
findings: Type.Optional(Type.Array(findingSchema, {
|
|
1025
|
-
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
1026
|
-
})),
|
|
1027
|
-
}, { additionalProperties: false });
|
|
1028
|
-
const requestParameters = Type.Object({
|
|
1029
|
-
issueCategory: Type.Enum({
|
|
1030
|
-
"implementation-mismatch": "implementation-mismatch",
|
|
1031
|
-
"approved-design-defect": "approved-design-defect",
|
|
1032
|
-
"target-surface-defect": "target-surface-defect",
|
|
1033
|
-
"contract-requirement-gap": "contract-requirement-gap",
|
|
1034
|
-
"unknown": "unknown",
|
|
1035
|
-
}, { description: "Typed issue category (five-value enum)" }),
|
|
1036
|
-
evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
|
|
1037
|
-
description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
|
|
1038
|
-
}),
|
|
1039
|
-
findings: Type.Optional(Type.Array(findingSchema, {
|
|
1040
|
-
description: "At least one finding", minItems: 1,
|
|
1041
|
-
})),
|
|
1042
|
-
}, { additionalProperties: false });
|
|
1043
|
-
async function adoptDesignFact(kind, fact) {
|
|
1044
|
-
const requestId = randomUUID();
|
|
1045
|
-
try {
|
|
1046
|
-
scopeProtocol.assertComplete();
|
|
1047
|
-
const parsed = kind === "approve_design"
|
|
1048
|
-
? approveDesignFactSchema.parse(fact)
|
|
1049
|
-
: requestDesignChangesFactSchema.parse(fact);
|
|
1050
|
-
const staged = stageTypedEventFact({
|
|
1051
|
-
store,
|
|
1052
|
-
requestId,
|
|
1053
|
-
attemptId,
|
|
1054
|
-
fact: parsed,
|
|
1055
|
-
});
|
|
1056
|
-
const adopted = await adoptTypedEventFact({
|
|
1057
|
-
store,
|
|
1058
|
-
requestId,
|
|
1059
|
-
attemptId,
|
|
1060
|
-
fact: parsed,
|
|
1061
|
-
eventId: staged.eventId,
|
|
1062
|
-
expectedRevision: store.revision,
|
|
1063
|
-
});
|
|
1064
|
-
return {
|
|
1065
|
-
content: [
|
|
1066
|
-
{
|
|
1067
|
-
type: "text",
|
|
1068
|
-
text: JSON.stringify({
|
|
1069
|
-
ok: true,
|
|
1070
|
-
kind,
|
|
1071
|
-
eventId: adopted.eventId,
|
|
1072
|
-
revision: adopted.revision,
|
|
1073
|
-
}),
|
|
1074
|
-
},
|
|
1075
|
-
],
|
|
1076
|
-
details: {
|
|
1077
|
-
ok: true,
|
|
1078
|
-
kind,
|
|
1079
|
-
eventId: adopted.eventId,
|
|
1080
|
-
revision: adopted.revision,
|
|
1081
|
-
},
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
catch (error) {
|
|
1085
|
-
const code = error?.code;
|
|
1086
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
1087
|
-
return {
|
|
1088
|
-
content: [
|
|
1089
|
-
{
|
|
1090
|
-
type: "text",
|
|
1091
|
-
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
1092
|
-
},
|
|
1093
|
-
],
|
|
1094
|
-
details: { ok: false, kind, code, error: message },
|
|
1095
|
-
};
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
const approveDesignTool = defineTool({
|
|
1099
|
-
name: "approve_design",
|
|
1100
|
-
label: "approve_design",
|
|
1101
|
-
description: "Commit the authoritative approve_design terminal fact. Use only when the plan passes design review with no Critical/Important findings.",
|
|
1102
|
-
promptSnippet: "Commit the authoritative approve_design terminal verdict (no Critical/Important findings).",
|
|
1103
|
-
parameters: approveParameters,
|
|
1104
|
-
async execute(_toolCallId, params) {
|
|
1105
|
-
return adoptDesignFact("approve_design", {
|
|
1106
|
-
kind: "approve_design",
|
|
1107
|
-
verdict: "approve_design",
|
|
1108
|
-
findings: allFindings(params?.findings),
|
|
1109
|
-
});
|
|
1110
|
-
},
|
|
1111
|
-
});
|
|
1112
|
-
const requestDesignChangesTool = defineTool({
|
|
1113
|
-
name: "request_design_changes",
|
|
1114
|
-
label: "request_design_changes",
|
|
1115
|
-
description: "Commit the authoritative request_design_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
1116
|
-
promptSnippet: "Commit the authoritative request_design_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
1117
|
-
parameters: requestParameters,
|
|
1118
|
-
async execute(_toolCallId, params) {
|
|
1119
|
-
return adoptDesignFact("request_design_changes", {
|
|
1120
|
-
kind: "request_design_changes",
|
|
1121
|
-
verdict: "request_design_changes",
|
|
1122
|
-
issueCategory: params?.issueCategory,
|
|
1123
|
-
evidenceRefs: params?.evidenceRefs,
|
|
1124
|
-
findings: allFindings(params?.findings),
|
|
1125
|
-
});
|
|
1126
|
-
},
|
|
1127
|
-
});
|
|
1128
|
-
const durable = await createDurableFrontendTools({
|
|
1129
|
-
file: path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), attemptId, store: input.store,
|
|
1130
|
-
binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveDesignTool, requestDesignChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
|
|
1131
|
-
});
|
|
1132
|
-
return { ...durable, scopeProtocol };
|
|
1133
|
-
}
|
|
1134
|
-
/**
|
|
1135
|
-
* Source fidelity ledger (AC-005/AC-006): load the contract node's committed
|
|
1136
|
-
* requirement facts and build a requirement-id → provenance map. The contract
|
|
1137
|
-
* node declared sourceFragmentIds/sourceRefs from the ledger's
|
|
1138
|
-
* requirement→fragment mapping; the plan inherits them by id so the compiled
|
|
1139
|
-
* canonical contract carries authoritative provenance without the plan
|
|
1140
|
-
* re-deriving (or fabricating) it. Best-effort: missing/unreadable contract
|
|
1141
|
-
* ledger yields an empty map and the plan compiles as before (the design
|
|
1142
|
-
* policy shell will then fail closed on missing provenance).
|
|
1143
|
-
*/
|
|
1144
|
-
async function loadContractRequirementInheritance(runDir) {
|
|
1145
|
-
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1146
|
-
let records;
|
|
1147
|
-
try {
|
|
1148
|
-
records = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
1149
|
-
}
|
|
1150
|
-
catch {
|
|
1151
|
-
return new Map();
|
|
1152
|
-
}
|
|
1153
|
-
const byId = new Map();
|
|
1154
|
-
for (const record of records) {
|
|
1155
|
-
const fact = record.fact;
|
|
1156
|
-
if (!fact || typeof fact !== "object")
|
|
1157
|
-
continue;
|
|
1158
|
-
const recordFact = fact;
|
|
1159
|
-
if (recordFact.origin !== "contract" ||
|
|
1160
|
-
recordFact.kind !== "requirement") {
|
|
1161
|
-
continue;
|
|
1162
|
-
}
|
|
1163
|
-
// Contract requirement facts carry id/sourceFragmentIds/sourceRefs on
|
|
1164
|
-
// the fact itself (origin=contract, kind=requirement, id, text, ...),
|
|
1165
|
-
// not inside an `entry` wrapper.
|
|
1166
|
-
const id = typeof recordFact.id === "string" ? recordFact.id : "";
|
|
1167
|
-
if (!id)
|
|
1168
|
-
continue;
|
|
1169
|
-
const sourceFragmentIds = Array.isArray(recordFact.sourceFragmentIds)
|
|
1170
|
-
? recordFact.sourceFragmentIds.filter((value) => typeof value === "string")
|
|
1171
|
-
: undefined;
|
|
1172
|
-
const sourceRefs = Array.isArray(recordFact.sourceRefs)
|
|
1173
|
-
? recordFact.sourceRefs.filter((value) => typeof value === "string")
|
|
1174
|
-
: undefined;
|
|
1175
|
-
if (sourceFragmentIds || sourceRefs) {
|
|
1176
|
-
byId.set(id, {
|
|
1177
|
-
...(typeof recordFact.text === "string" ? { text: recordFact.text } : {}),
|
|
1178
|
-
...(recordFact.execution !== undefined ? { execution: frontendExecutionSchema.parse(recordFact.execution) } : {}),
|
|
1179
|
-
...(sourceFragmentIds ? { sourceFragmentIds } : {}),
|
|
1180
|
-
...(sourceRefs ? { sourceRefs } : {}),
|
|
1181
|
-
});
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
return byId;
|
|
1185
|
-
}
|
|
1186
834
|
export async function resolveFrontendDecisionAuthority(input) {
|
|
1187
835
|
const inheritance = await loadContractRequirementInheritance(input.runDir);
|
|
1188
836
|
if (inheritance.size === 0)
|
|
@@ -1447,1353 +1095,125 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
1447
1095
|
const { loadTypedEventStore, readCommittedEvents, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1448
1096
|
const { adoptStagedFact, adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
1449
1097
|
const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-committed-facts.js");
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1098
|
+
const session = {
|
|
1099
|
+
store: input.store,
|
|
1100
|
+
attemptId: input.attemptId,
|
|
1101
|
+
activeRequirementScope: [],
|
|
1102
|
+
};
|
|
1103
|
+
const scopedRequirementIds = () => [...session.activeRequirementScope];
|
|
1454
1104
|
const contractInheritance = await loadContractRequirementInheritance(input.runDir);
|
|
1455
1105
|
const executionGroups = collectFrontendExecutionGroups([...contractInheritance].map(([id, r]) => ({ id, ...r })));
|
|
1456
|
-
const
|
|
1457
|
-
const
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
expectedOutcome: Type.Optional(Type.String({
|
|
1462
|
-
description: "Optional. When omitted, the runtime derives it from the contract requirement. Prefer omitting it to keep this tool call small.",
|
|
1463
|
-
})),
|
|
1464
|
-
implementationTargets: stringArray,
|
|
1465
|
-
verificationTargetIds: Type.Optional(stringArray),
|
|
1466
|
-
evidenceGap: Type.Optional(Type.Object({
|
|
1467
|
-
requirementId: optionalString,
|
|
1468
|
-
description: Type.String({}),
|
|
1469
|
-
blocking: Type.Boolean(),
|
|
1470
|
-
}, { additionalProperties: false })),
|
|
1471
|
-
}, { additionalProperties: false });
|
|
1472
|
-
const uiStateSchema = Type.Object({
|
|
1473
|
-
name: Type.String({}),
|
|
1474
|
-
applicable: Type.Boolean(),
|
|
1475
|
-
expectedBehavior: optionalString,
|
|
1476
|
-
implementationTargets: optionalStringArray,
|
|
1477
|
-
verificationTargetIds: optionalStringArray,
|
|
1478
|
-
notApplicableReason: optionalString,
|
|
1479
|
-
reason: Type.Optional(Type.String({})),
|
|
1480
|
-
}, { additionalProperties: false });
|
|
1481
|
-
const interactionSchema = Type.Object({
|
|
1482
|
-
name: optionalString,
|
|
1483
|
-
id: Type.Optional(Type.String({})),
|
|
1484
|
-
trigger: Type.String({}),
|
|
1485
|
-
expectedBehavior: Type.String({}),
|
|
1486
|
-
implementationTargets: stringArray,
|
|
1487
|
-
verificationTargetIds: stringArray,
|
|
1488
|
-
}, { additionalProperties: false });
|
|
1489
|
-
const mockEndpointSchema = Type.Object({
|
|
1490
|
-
method: Type.Enum({ GET: "GET", POST: "POST", PUT: "PUT", PATCH: "PATCH", DELETE: "DELETE", HEAD: "HEAD", OPTIONS: "OPTIONS" }),
|
|
1491
|
-
path: Type.String({}),
|
|
1492
|
-
fixture: optionalString,
|
|
1493
|
-
consumer: optionalString,
|
|
1494
|
-
}, { additionalProperties: false });
|
|
1495
|
-
const mockApiSchema = Type.Object({
|
|
1496
|
-
strategy: Type.Union([
|
|
1497
|
-
Type.Literal("native"),
|
|
1498
|
-
Type.Literal("browser-intercept"),
|
|
1499
|
-
Type.Literal("request-adapter"),
|
|
1500
|
-
Type.Literal("not-needed"),
|
|
1501
|
-
], {
|
|
1502
|
-
description: "native | browser-intercept | request-adapter | not-needed",
|
|
1503
|
-
}),
|
|
1504
|
-
activation: Type.String({}),
|
|
1505
|
-
endpoints: Type.Array(mockEndpointSchema),
|
|
1506
|
-
}, { additionalProperties: false });
|
|
1507
|
-
const designEvidenceSchema = Type.Object({
|
|
1508
|
-
source: Type.String({}),
|
|
1509
|
-
paths: stringArray,
|
|
1510
|
-
conflicts: stringArray,
|
|
1511
|
-
}, { additionalProperties: false });
|
|
1512
|
-
// Verification mode is runtime-owned (contract v2): the plan submits a
|
|
1513
|
-
// commandId referencing the frozen command directory, never a type. A
|
|
1514
|
-
// soft Type.String would let the model commit values that only fail at
|
|
1515
|
-
// compile time — keep the reference a required string and validate it
|
|
1516
|
-
// against the directory at the tool boundary below.
|
|
1517
|
-
const verificationTargetScopeSchema = Type.Union([
|
|
1518
|
-
Type.Literal("unit"),
|
|
1519
|
-
Type.Literal("component"),
|
|
1520
|
-
Type.Literal("integration"),
|
|
1521
|
-
]);
|
|
1522
|
-
const verificationTargetSchema = Type.Object({
|
|
1523
|
-
id: Type.String({}),
|
|
1524
|
-
commandId: Type.String({
|
|
1525
|
-
description: "Frozen command directory key (e.g. verify-npm-run-build); the runtime resolves mode and label from it.",
|
|
1526
|
-
}),
|
|
1527
|
-
file: Type.String({}),
|
|
1528
|
-
requirementIds: stringArray,
|
|
1529
|
-
scope: Type.Optional(verificationTargetScopeSchema),
|
|
1530
|
-
uiStates: Type.Optional(Type.Array(Type.String({}), {
|
|
1531
|
-
description: "Optional only at this tool boundary. An omitted value is deterministically recorded as []. Pass an explicit array for new calls.",
|
|
1532
|
-
})),
|
|
1533
|
-
}, { additionalProperties: false });
|
|
1534
|
-
const evidenceGapSchema = Type.Object({
|
|
1535
|
-
requirementId: optionalString,
|
|
1536
|
-
description: Type.String({}),
|
|
1537
|
-
blocking: Type.Boolean(),
|
|
1538
|
-
}, { additionalProperties: false });
|
|
1539
|
-
const uiComponentChoiceSchema = Type.Object({
|
|
1540
|
-
purpose: Type.String({}),
|
|
1541
|
-
component: Type.String({}),
|
|
1542
|
-
decision: Type.Union([
|
|
1543
|
-
Type.Literal("specified"),
|
|
1544
|
-
Type.Literal("reuse-existing"),
|
|
1545
|
-
Type.Literal("new"),
|
|
1546
|
-
], {
|
|
1547
|
-
description: "specified | reuse-existing | new",
|
|
1548
|
-
}),
|
|
1549
|
-
specReference: Type.Optional(Type.Object({
|
|
1550
|
-
path: Type.String({}),
|
|
1551
|
-
section: Type.String({}),
|
|
1552
|
-
line: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
1553
|
-
}, { additionalProperties: false })),
|
|
1554
|
-
rationale: Type.Optional(Type.String({ minLength: 1 })),
|
|
1555
|
-
covers: Type.Optional(Type.Array(Type.String({}), {
|
|
1556
|
-
description: "UI state and/or interaction names this single component choice covers (one choice may cover many ids).",
|
|
1557
|
-
})),
|
|
1558
|
-
evidencePath: Type.Optional(Type.String({
|
|
1559
|
-
description: "REQUIRED for decision=reuse-existing: repo-relative path whose existing file is the reuse evidence. Greenfield paths must use decision=new.",
|
|
1560
|
-
})),
|
|
1561
|
-
}, { additionalProperties: false });
|
|
1562
|
-
const stringList = (value) => Array.isArray(value)
|
|
1563
|
-
? value.filter((item) => typeof item === "string")
|
|
1564
|
-
: [];
|
|
1565
|
-
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0
|
|
1566
|
-
? value.trim()
|
|
1567
|
-
: undefined;
|
|
1568
|
-
const planToolReceipt = (details) => ({
|
|
1569
|
-
content: [
|
|
1570
|
-
{
|
|
1571
|
-
type: "text",
|
|
1572
|
-
text: JSON.stringify(details),
|
|
1573
|
-
},
|
|
1574
|
-
],
|
|
1575
|
-
details,
|
|
1106
|
+
const planSchemas = buildPlanSchemas(Type);
|
|
1107
|
+
const adoptPlanFact = createPlanFactAdopter({
|
|
1108
|
+
session,
|
|
1109
|
+
stageTypedEventFact,
|
|
1110
|
+
adoptTypedEventFact,
|
|
1576
1111
|
});
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
// not the plan decision ledger. Route it through the frozen seven-kind
|
|
1581
|
-
// matrix and fail closed to `unsupported-provider-capability` instead of
|
|
1582
|
-
// silently widening the plan catalog.
|
|
1583
|
-
const capabilityRoute = routeFrontendProviderCapability({ factKind: kind });
|
|
1584
|
-
if (capabilityRoute.ok) {
|
|
1585
|
-
return {
|
|
1586
|
-
ok: false,
|
|
1587
|
-
kind,
|
|
1588
|
-
code: "unsupported-provider-capability",
|
|
1589
|
-
error: `plan ledger cannot adopt provider capability fact kind: ${kind}`,
|
|
1590
|
-
};
|
|
1591
|
-
}
|
|
1592
|
-
try {
|
|
1593
|
-
const staged = stageTypedEventFact({
|
|
1594
|
-
store,
|
|
1595
|
-
requestId,
|
|
1596
|
-
attemptId,
|
|
1597
|
-
fact: fact,
|
|
1598
|
-
});
|
|
1599
|
-
const committed = await adoptTypedEventFact({
|
|
1600
|
-
store,
|
|
1601
|
-
requestId,
|
|
1602
|
-
attemptId,
|
|
1603
|
-
fact: fact,
|
|
1604
|
-
eventId: staged.eventId,
|
|
1605
|
-
expectedRevision: store.revision,
|
|
1606
|
-
});
|
|
1607
|
-
return {
|
|
1608
|
-
ok: true,
|
|
1609
|
-
kind,
|
|
1610
|
-
eventId: committed.eventId,
|
|
1611
|
-
revision: committed.revision,
|
|
1612
|
-
error: "",
|
|
1613
|
-
};
|
|
1614
|
-
}
|
|
1615
|
-
catch (error) {
|
|
1616
|
-
return {
|
|
1617
|
-
ok: false,
|
|
1618
|
-
kind,
|
|
1619
|
-
code: error?.code,
|
|
1620
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1621
|
-
};
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
const recordRouteSelectionTool = defineTool({
|
|
1625
|
-
name: "record_route_selection",
|
|
1626
|
-
label: "record_route_selection",
|
|
1627
|
-
description: "Record the route selection needed by this plan. Repository target surface and file ownership belong to Scout/runtime. Example: {\"routes\": [\"/<route>\"]}",
|
|
1628
|
-
promptSnippet: "Record the selected routes.",
|
|
1629
|
-
parameters: Type.Object({ routes: stringArray }, { additionalProperties: false }),
|
|
1630
|
-
async execute(_toolCallId, params) {
|
|
1631
|
-
const routes = stringList(params?.routes);
|
|
1632
|
-
const result = await adoptPlanFact("target-surface", `${attemptId}:record_route_selection:${randomUUID()}`, { kind: "target-surface", origin: "plan", routes });
|
|
1633
|
-
return planToolReceipt(result);
|
|
1634
|
-
},
|
|
1112
|
+
const { recordRouteSelectionTool, recordComponentChoiceTool, recordStateRegistryTool, recordStateFlowTool, recordDataFlowTool, recordMockApiTool, recordMockEndpointTool, recordDesignDeviationTool, recordDependencyTool, recordPlanRequirementTool, recordPlanGroupCoverageTool, recordPlanVerificationTargetTool, recordPlanEvidenceGapTool, } = buildPlanRecordTools({
|
|
1113
|
+
Type, defineTool, session, schemas: planSchemas, input, readCommittedEvents,
|
|
1114
|
+
scopedRequirementIds, executionGroups, adoptPlanFact,
|
|
1635
1115
|
});
|
|
1636
|
-
const
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
const
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
// existence evidence, same semantics as Scout pathEvidence.fresh).
|
|
1693
|
-
// A path with no file on disk is greenfield: decision=new is the
|
|
1694
|
-
// only honest choice (dogfood run dag-1788504923861-0f7b17a9
|
|
1695
|
-
// claimed 18 reuse-existing conventions in a not-yet-written
|
|
1696
|
-
// src/planner.ts and every gate let it through).
|
|
1697
|
-
const evidencePath = typeof choice.evidencePath === "string"
|
|
1698
|
-
? choice.evidencePath.trim()
|
|
1699
|
-
: "";
|
|
1700
|
-
if (!evidencePath) {
|
|
1701
|
-
return planToolReceipt({
|
|
1702
|
-
ok: false,
|
|
1703
|
-
kind: "component-choice",
|
|
1704
|
-
error: "decision=reuse-existing requires evidencePath naming the existing repo file that proves the reuse; if the file does not exist yet, use decision=new",
|
|
1705
|
-
});
|
|
1706
|
-
}
|
|
1707
|
-
if (input.workspaceRoot) {
|
|
1708
|
-
const absolute = path.resolve(input.workspaceRoot, evidencePath);
|
|
1709
|
-
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
1710
|
-
const contained = absolute === workspaceRoot ||
|
|
1711
|
-
absolute.startsWith(`${workspaceRoot}${path.sep}`);
|
|
1712
|
-
let exists = false;
|
|
1713
|
-
if (contained) {
|
|
1714
|
-
try {
|
|
1715
|
-
exists = (await stat(absolute)).isFile();
|
|
1716
|
-
}
|
|
1717
|
-
catch {
|
|
1718
|
-
exists = false;
|
|
1719
|
-
}
|
|
1720
|
-
}
|
|
1721
|
-
if (!exists) {
|
|
1722
|
-
return planToolReceipt({
|
|
1723
|
-
ok: false,
|
|
1724
|
-
kind: "component-choice",
|
|
1725
|
-
error: `decision=reuse-existing evidencePath "${evidencePath}" has no fresh existence evidence (file not found in the workspace); reuse requires an existing file — use decision=new for greenfield paths`,
|
|
1726
|
-
});
|
|
1116
|
+
const { finalizePlanTool, adoptStagedFactTool, readPlanFactsTool, } = buildPlanFinalizeTools({
|
|
1117
|
+
Type, defineTool, session, schemas: planSchemas, input, readCommittedEvents,
|
|
1118
|
+
assemblePlanPatchFromCommittedFacts, contractInheritance, adoptStagedFact, adoptPlanFact,
|
|
1119
|
+
});
|
|
1120
|
+
const planControl = createFrontendPlanProgressGuard();
|
|
1121
|
+
const durable = await createDurableFrontendTools({
|
|
1122
|
+
planControl,
|
|
1123
|
+
file: path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), attemptId: session.attemptId, store: input.store,
|
|
1124
|
+
setWorkingStore: next => { session.store = next; },
|
|
1125
|
+
binding: { sourceBinding: input.sourceBinding, skeleton: input.skeleton, requirementIds: input.requirementIds, writeSet: input.writeSetPatterns, declaredUiStateIds: input.declaredUiStateIds, citations: input.componentNewSourceReferences, contractInheritance },
|
|
1126
|
+
tools: [
|
|
1127
|
+
recordRouteSelectionTool,
|
|
1128
|
+
recordComponentChoiceTool,
|
|
1129
|
+
recordStateRegistryTool,
|
|
1130
|
+
recordStateFlowTool,
|
|
1131
|
+
recordDataFlowTool,
|
|
1132
|
+
recordMockApiTool,
|
|
1133
|
+
recordMockEndpointTool,
|
|
1134
|
+
recordDesignDeviationTool,
|
|
1135
|
+
recordDependencyTool,
|
|
1136
|
+
recordPlanRequirementTool,
|
|
1137
|
+
recordPlanGroupCoverageTool,
|
|
1138
|
+
recordPlanVerificationTargetTool,
|
|
1139
|
+
recordPlanEvidenceGapTool,
|
|
1140
|
+
adoptStagedFactTool,
|
|
1141
|
+
finalizePlanTool,
|
|
1142
|
+
],
|
|
1143
|
+
});
|
|
1144
|
+
const durableFinalizePlanTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "finalize_plan");
|
|
1145
|
+
if (!durableFinalizePlanTool)
|
|
1146
|
+
throw new Error("frontend plan durable finalize tool unavailable");
|
|
1147
|
+
return {
|
|
1148
|
+
planControl,
|
|
1149
|
+
customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
|
|
1150
|
+
adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
|
|
1151
|
+
for (const record of records) {
|
|
1152
|
+
if (record.phase !== "committed")
|
|
1153
|
+
continue;
|
|
1154
|
+
const fact = record.fact;
|
|
1155
|
+
if (!fact || typeof fact !== "object" || Array.isArray(fact))
|
|
1156
|
+
continue;
|
|
1157
|
+
const kind = typeof fact.kind === "string"
|
|
1158
|
+
? fact.kind
|
|
1159
|
+
: "plan-fact";
|
|
1160
|
+
const entry = fact.entry;
|
|
1161
|
+
const identity = (kind === "plan-requirement" || kind === "plan-verification-target") &&
|
|
1162
|
+
typeof entry?.id === "string"
|
|
1163
|
+
? `${kind}:${entry.id}`
|
|
1164
|
+
: undefined;
|
|
1165
|
+
const sourceShardPrefix = `${session.attemptId}:parallel-merge:${record.attemptId}:`;
|
|
1166
|
+
const requestId = `${sourceShardPrefix}${record.eventId}`;
|
|
1167
|
+
const committed = readCommittedEvents(session.store, session.attemptId);
|
|
1168
|
+
const replay = committed.find((candidate) => candidate.requestId === requestId);
|
|
1169
|
+
if (replay) {
|
|
1170
|
+
if (replay.payloadSha256 !== record.payloadSha256) {
|
|
1171
|
+
throw new Error(`frontend plan shard fact merge replay conflict: source event ${record.eventId} changed payload`);
|
|
1727
1172
|
}
|
|
1173
|
+
continue;
|
|
1728
1174
|
}
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
: {}),
|
|
1740
|
-
});
|
|
1741
|
-
// Echo the frozen citation the runtime derived: the model sees the
|
|
1742
|
-
// purpose↔citation mapping it just committed and can re-record the
|
|
1743
|
-
// choice (last-wins per purpose at compile) when it mismatches.
|
|
1744
|
-
const echo = {
|
|
1745
|
-
...result,
|
|
1746
|
-
...(choice.specReference
|
|
1747
|
-
? { derivedSpecReference: choice.specReference }
|
|
1748
|
-
: {}),
|
|
1749
|
-
};
|
|
1750
|
-
return planToolReceipt(echo);
|
|
1751
|
-
},
|
|
1752
|
-
});
|
|
1753
|
-
// Global UX vocabulary: one compact registry committed BEFORE any state
|
|
1754
|
-
// flow details. Coverage may slice by AC; UX must not — the registry is
|
|
1755
|
-
// the anti-duplication anchor that keeps every later slice on the same
|
|
1756
|
-
// named concepts instead of re-inventing them per AC chunk.
|
|
1757
|
-
const recordStateRegistryTool = defineTool({
|
|
1758
|
-
name: "record_state_registry",
|
|
1759
|
-
label: "record_state_registry",
|
|
1760
|
-
description: "Commit the GLOBAL UX vocabulary (origin=plan state-registry fact) BEFORE any record_state_flow call: uiStateNames (use the contract's declared authoritative state ids when provided) and interactionNames (stable behavior-domain kebab-case names, e.g. planner-task-edit / focus-queue-move — one name per behavior domain, never one per AC). Empty arrays explicitly mean the request has no UI state or interaction vocabulary. Re-record the full vocabulary to correct it; the latest commit wins, but it may not remove names still referenced by committed state-flow facts. Example: {\"uiStateNames\": [\"planner-empty\"], \"interactionNames\": [\"planner-task-create\", \"focus-queue-move\"]}",
|
|
1761
|
-
promptSnippet: "Record the global UI-state/interaction vocabulary once, before any state flow.",
|
|
1762
|
-
parameters: Type.Object({
|
|
1763
|
-
uiStateNames: stringArray,
|
|
1764
|
-
interactionNames: stringArray,
|
|
1765
|
-
}, { additionalProperties: false }),
|
|
1766
|
-
async execute(_toolCallId, params) {
|
|
1767
|
-
const uiStateNames = [...new Set(stringList(params?.uiStateNames).map((name) => name.trim()))];
|
|
1768
|
-
const interactionNames = [
|
|
1769
|
-
...new Set(stringList(params?.interactionNames).map((name) => name.trim())),
|
|
1770
|
-
];
|
|
1771
|
-
if (uiStateNames.some((name) => name.length === 0) || interactionNames.some((name) => name.length === 0)) {
|
|
1772
|
-
return planToolReceipt({
|
|
1773
|
-
ok: false,
|
|
1774
|
-
kind: "state-registry",
|
|
1775
|
-
error: "record_state_registry names must be non-empty strings",
|
|
1776
|
-
});
|
|
1777
|
-
}
|
|
1778
|
-
const invalidInteractionNames = interactionNames.filter((name) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name));
|
|
1779
|
-
if (invalidInteractionNames.length > 0) {
|
|
1780
|
-
return planToolReceipt({
|
|
1781
|
-
ok: false,
|
|
1782
|
-
kind: "state-registry",
|
|
1783
|
-
error: `record_state_registry interactionNames must be stable kebab-case behavior-domain names: ${invalidInteractionNames.join(", ")}`,
|
|
1175
|
+
const explicitReplacement = typeof fact.replaces === "string" &&
|
|
1176
|
+
fact.replaces === entry?.id;
|
|
1177
|
+
const conflicting = committed.find((candidate) => {
|
|
1178
|
+
const candidateFact = candidate.fact;
|
|
1179
|
+
const candidateEntry = candidateFact.entry;
|
|
1180
|
+
const sameSourceShard = candidate.requestId.startsWith(sourceShardPrefix);
|
|
1181
|
+
return (candidateFact.kind === kind &&
|
|
1182
|
+
typeof candidateEntry?.id === "string" &&
|
|
1183
|
+
`${kind}:${candidateEntry.id}` === identity &&
|
|
1184
|
+
!(sameSourceShard && explicitReplacement));
|
|
1784
1185
|
});
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1186
|
+
if (conflicting) {
|
|
1187
|
+
// Two shards may honestly emit the same fact (e.g. both
|
|
1188
|
+
// derive the same frozen verification target). Identical
|
|
1189
|
+
// payloads are duplicates to skip; divergent payloads are
|
|
1190
|
+
// a real conflict the ladder must resolve.
|
|
1191
|
+
if (conflicting.payloadSha256 === record.payloadSha256) {
|
|
1192
|
+
continue;
|
|
1193
|
+
}
|
|
1194
|
+
const fromSameSourceShard = conflicting.requestId.startsWith(sourceShardPrefix);
|
|
1195
|
+
if (fromSameSourceShard) {
|
|
1196
|
+
throw new Error(`frontend plan shard fact merge conflict: ${identity} was already committed by another shard with a different payload`);
|
|
1197
|
+
}
|
|
1198
|
+
// Divergent payload from a different source attempt means a
|
|
1199
|
+
// ladder retry re-derived the coverage phase: the fresh
|
|
1200
|
+
// derivation supersedes the stored record.
|
|
1201
|
+
const storeWithRecords = session.store;
|
|
1202
|
+
storeWithRecords.records = storeWithRecords.records.filter((candidate) => candidate.eventId !== conflicting.eventId);
|
|
1796
1203
|
}
|
|
1797
|
-
const
|
|
1798
|
-
if (
|
|
1799
|
-
|
|
1800
|
-
ok: false,
|
|
1801
|
-
kind: "state-registry",
|
|
1802
|
-
error: `record_state_registry must include every state from the contract's authoritative UI-state table; missing: ${missing.join(", ")}`,
|
|
1803
|
-
});
|
|
1204
|
+
const result = await adoptPlanFact(kind, requestId, fact);
|
|
1205
|
+
if (!result.ok) {
|
|
1206
|
+
throw new Error(`frontend plan shard fact merge failed: ${result.error}`);
|
|
1804
1207
|
}
|
|
1805
1208
|
}
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
const orphanedUiStates = [...liveNames.uiStateNames].filter((name) => !uiStateNames.includes(name));
|
|
1812
|
-
const orphanedInteractions = [...liveNames.interactionNames].filter((name) => !interactionNames.includes(name));
|
|
1813
|
-
if (orphanedUiStates.length > 0 || orphanedInteractions.length > 0) {
|
|
1814
|
-
return planToolReceipt({
|
|
1815
|
-
ok: false,
|
|
1816
|
-
kind: "state-registry",
|
|
1817
|
-
error: `record_state_registry cannot remove names still referenced by committed state-flow facts (uiStates: ${orphanedUiStates.join(", ") || "none"}; interactions: ${orphanedInteractions.join(", ") || "none"}); first correct/remove those state-flow entries, then re-record the full registry`,
|
|
1818
|
-
});
|
|
1819
|
-
}
|
|
1820
|
-
const result = await adoptPlanFact("state-registry", `${attemptId}:record_state_registry:${randomUUID()}`, {
|
|
1821
|
-
kind: "state-registry",
|
|
1822
|
-
origin: "plan",
|
|
1823
|
-
uiStateNames,
|
|
1824
|
-
interactionNames,
|
|
1825
|
-
});
|
|
1826
|
-
return planToolReceipt(result);
|
|
1827
|
-
},
|
|
1828
|
-
});
|
|
1829
|
-
const recordStateFlowTool = defineTool({
|
|
1830
|
-
name: "record_state_flow",
|
|
1831
|
-
label: "record_state_flow",
|
|
1832
|
-
description: "Record UI states and interactions as an origin=plan state-flow fact. REQUIRES a committed record_state_registry vocabulary first, and every name here must be in that registry; uiState names must also be contract-declared authoritative ids when the contract declares them. To correct stale named entries from an earlier UX batch, include removeUiStateNames and/or removeInteractionNames. Example: {\"uiStates\": [{\"name\": \"<state>\", \"applicable\": true, \"expectedBehavior\": \"<behavior>\", \"implementationTargets\": [\"<file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}], \"interactions\": [{\"name\": \"<interaction>\", \"trigger\": \"<user event>\", \"expectedBehavior\": \"<behavior>\", \"implementationTargets\": [\"<file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}]}",
|
|
1833
|
-
promptSnippet: "Record the plan state-flow fact.",
|
|
1834
|
-
parameters: Type.Object({
|
|
1835
|
-
uiStates: Type.Array(uiStateSchema),
|
|
1836
|
-
interactions: Type.Array(interactionSchema),
|
|
1837
|
-
removeUiStateNames: Type.Optional(stringArray),
|
|
1838
|
-
removeInteractionNames: Type.Optional(stringArray),
|
|
1839
|
-
}, { additionalProperties: false }),
|
|
1840
|
-
async execute(_toolCallId, params) {
|
|
1841
|
-
const rawUiStates = Array.isArray(params?.uiStates)
|
|
1842
|
-
? params.uiStates
|
|
1843
|
-
: [];
|
|
1844
|
-
const rawInteractions = Array.isArray(params?.interactions)
|
|
1845
|
-
? params.interactions
|
|
1846
|
-
: [];
|
|
1847
|
-
const removeUiStateNames = stringList(params?.removeUiStateNames);
|
|
1848
|
-
const removeInteractionNames = stringList(params?.removeInteractionNames);
|
|
1849
|
-
const uiStates = [];
|
|
1850
|
-
for (const rawState of rawUiStates) {
|
|
1851
|
-
if (!isRecordObject(rawState)) {
|
|
1852
|
-
return planToolReceipt({
|
|
1853
|
-
ok: false,
|
|
1854
|
-
kind: "state-flow",
|
|
1855
|
-
error: "record_state_flow uiStates entries must be objects",
|
|
1856
|
-
});
|
|
1857
|
-
}
|
|
1858
|
-
const { reason, notApplicableReason, ...state } = rawState;
|
|
1859
|
-
const canonicalReason = nonEmptyString(notApplicableReason);
|
|
1860
|
-
const aliasReason = nonEmptyString(reason);
|
|
1861
|
-
if (canonicalReason !== undefined &&
|
|
1862
|
-
aliasReason !== undefined &&
|
|
1863
|
-
canonicalReason !== aliasReason) {
|
|
1864
|
-
return planToolReceipt({
|
|
1865
|
-
ok: false,
|
|
1866
|
-
kind: "state-flow",
|
|
1867
|
-
error: "record_state_flow uiState has conflicting reason and notApplicableReason values",
|
|
1868
|
-
});
|
|
1869
|
-
}
|
|
1870
|
-
const resolvedReason = canonicalReason ?? aliasReason;
|
|
1871
|
-
if (state.applicable === false && !resolvedReason) {
|
|
1872
|
-
return planToolReceipt({
|
|
1873
|
-
ok: false,
|
|
1874
|
-
kind: "state-flow",
|
|
1875
|
-
error: "record_state_flow requires non-empty notApplicableReason when uiState.applicable is false",
|
|
1876
|
-
});
|
|
1877
|
-
}
|
|
1878
|
-
uiStates.push({
|
|
1879
|
-
...state,
|
|
1880
|
-
...(resolvedReason
|
|
1881
|
-
? { notApplicableReason: resolvedReason }
|
|
1882
|
-
: {}),
|
|
1883
|
-
});
|
|
1884
|
-
}
|
|
1885
|
-
const interactions = [];
|
|
1886
|
-
for (const rawInteraction of rawInteractions) {
|
|
1887
|
-
if (!isRecordObject(rawInteraction)) {
|
|
1888
|
-
return planToolReceipt({
|
|
1889
|
-
ok: false,
|
|
1890
|
-
kind: "state-flow",
|
|
1891
|
-
error: "record_state_flow interactions entries must be objects",
|
|
1892
|
-
});
|
|
1893
|
-
}
|
|
1894
|
-
const { id, name, ...interaction } = rawInteraction;
|
|
1895
|
-
const canonicalName = nonEmptyString(name);
|
|
1896
|
-
const aliasName = nonEmptyString(id);
|
|
1897
|
-
if (canonicalName !== undefined &&
|
|
1898
|
-
aliasName !== undefined &&
|
|
1899
|
-
canonicalName !== aliasName) {
|
|
1900
|
-
return planToolReceipt({
|
|
1901
|
-
ok: false,
|
|
1902
|
-
kind: "state-flow",
|
|
1903
|
-
error: "record_state_flow interaction has conflicting id and name values",
|
|
1904
|
-
});
|
|
1905
|
-
}
|
|
1906
|
-
const resolvedName = canonicalName ?? aliasName;
|
|
1907
|
-
if (!resolvedName) {
|
|
1908
|
-
return planToolReceipt({
|
|
1909
|
-
ok: false,
|
|
1910
|
-
kind: "state-flow",
|
|
1911
|
-
error: "record_state_flow requires non-empty interaction.name (id is accepted only as a legacy alias)",
|
|
1912
|
-
});
|
|
1913
|
-
}
|
|
1914
|
-
// Interaction -> VT forward references are legal only against
|
|
1915
|
-
// already-committed VT facts. In the segmented flow every VT
|
|
1916
|
-
// commits in the coverage segment before state flows run, so a
|
|
1917
|
-
// dangling reference here is a real defect (r20: *-BEHAVIOR
|
|
1918
|
-
// refs reached the final review untraceable).
|
|
1919
|
-
const interactionVtIds = stringList(interaction.verificationTargetIds);
|
|
1920
|
-
const committedVtIds = new Set(readCommittedEvents(store, attemptId)
|
|
1921
|
-
.map((event) => event.fact)
|
|
1922
|
-
.filter((fact) => fact.kind === "plan-verification-target")
|
|
1923
|
-
.map((fact) => fact.entry
|
|
1924
|
-
?.id)
|
|
1925
|
-
.filter((id) => typeof id === "string"));
|
|
1926
|
-
const unknownVtIds = interactionVtIds.filter((id) => !committedVtIds.has(id));
|
|
1927
|
-
if (unknownVtIds.length > 0) {
|
|
1928
|
-
return planToolReceipt({
|
|
1929
|
-
ok: false,
|
|
1930
|
-
kind: "state-flow",
|
|
1931
|
-
error: `record_state_flow interaction "${resolvedName}" references verification targets that are not recorded yet: ${unknownVtIds.join(", ")}; record them with record_plan_verification_target first, then re-record this state flow`,
|
|
1932
|
-
});
|
|
1933
|
-
}
|
|
1934
|
-
interactions.push({ ...interaction, name: resolvedName });
|
|
1935
|
-
}
|
|
1936
|
-
// UX vocabulary gate: every recorded state/interaction name must be
|
|
1937
|
-
// declared in the committed global registry first. This keeps UX out
|
|
1938
|
-
// of AC-number slicing — the model commits one compact vocabulary
|
|
1939
|
-
// (record_state_registry), then fills behavior-domain details, and a
|
|
1940
|
-
// later slice cannot silently rename an earlier concept (dogfood
|
|
1941
|
-
// dag-1788504923861-0f7b17a9: 5 renames of "prioritize" + 7 of
|
|
1942
|
-
// "focus queue" across AC chunks).
|
|
1943
|
-
const states = uiStates
|
|
1944
|
-
.map((state) => (typeof state?.name === "string" ? state.name : ""))
|
|
1945
|
-
.filter(Boolean);
|
|
1946
|
-
const committedRegistry = readCommittedEvents(store, attemptId)
|
|
1947
|
-
.map((event) => event.fact)
|
|
1948
|
-
.filter((fact) => isRecordObject(fact) && fact.kind === "state-registry")
|
|
1949
|
-
.at(-1);
|
|
1950
|
-
if (!committedRegistry) {
|
|
1951
|
-
return planToolReceipt({
|
|
1952
|
-
ok: false,
|
|
1953
|
-
kind: "state-flow",
|
|
1954
|
-
error: "record_state_flow requires a committed UX registry first: call record_state_registry with the full uiStateNames/interactionNames vocabulary, then record state flows against it",
|
|
1955
|
-
});
|
|
1956
|
-
}
|
|
1957
|
-
const registryUiStateNames = new Set(stringList(committedRegistry.uiStateNames));
|
|
1958
|
-
const registryInteractionNames = new Set(stringList(committedRegistry.interactionNames));
|
|
1959
|
-
const unknownStates = states.filter((name) => !registryUiStateNames.has(name));
|
|
1960
|
-
if (unknownStates.length > 0) {
|
|
1961
|
-
return planToolReceipt({
|
|
1962
|
-
ok: false,
|
|
1963
|
-
kind: "state-flow",
|
|
1964
|
-
error: `record_state_flow uiState names not in the committed registry: ${unknownStates.join(", ")}; re-record record_state_registry with the complete vocabulary first (registered: ${[...registryUiStateNames].join(", ") || "(none)"})`,
|
|
1965
|
-
});
|
|
1966
|
-
}
|
|
1967
|
-
const unknownInteractions = interactions
|
|
1968
|
-
.map((interaction) => typeof interaction?.name === "string" ? interaction.name : "")
|
|
1969
|
-
.filter((name) => name && !registryInteractionNames.has(name));
|
|
1970
|
-
if (unknownInteractions.length > 0) {
|
|
1971
|
-
return planToolReceipt({
|
|
1972
|
-
ok: false,
|
|
1973
|
-
kind: "state-flow",
|
|
1974
|
-
error: `record_state_flow interaction names not in the committed registry: ${unknownInteractions.join(", ")}; re-record record_state_registry with the complete vocabulary first (registered: ${[...registryInteractionNames].join(", ") || "(none)"})`,
|
|
1975
|
-
});
|
|
1976
|
-
}
|
|
1977
|
-
// Authoritative UI states: when the contract node declared the
|
|
1978
|
-
// source's UI-state table, planner states must bind those ids — no
|
|
1979
|
-
// invented variants (planner-empty/create-invalid/no-results/
|
|
1980
|
-
// editing/focus-full/storage-unavailable, not "planner-list").
|
|
1981
|
-
if (input.declaredUiStateIds &&
|
|
1982
|
-
input.declaredUiStateIds.length > 0) {
|
|
1983
|
-
const declared = new Set(input.declaredUiStateIds);
|
|
1984
|
-
const undeclaredStates = states.filter((name) => !declared.has(name));
|
|
1985
|
-
if (undeclaredStates.length > 0) {
|
|
1986
|
-
return planToolReceipt({
|
|
1987
|
-
ok: false,
|
|
1988
|
-
kind: "state-flow",
|
|
1989
|
-
error: `record_state_flow uiState names are not declared by the contract's authoritative UI-state table: ${undeclaredStates.join(", ")}; use the declared ids (${input.declaredUiStateIds.join(", ")}), or record a design deviation if the source table is genuinely incomplete`,
|
|
1990
|
-
});
|
|
1991
|
-
}
|
|
1992
|
-
}
|
|
1993
|
-
// Named entries are last-wins at assembly. A scoped correction may
|
|
1994
|
-
// replace its own bindings, but must retain other scopes' coverage.
|
|
1995
|
-
const scope = new Set(scopedRequirementIds());
|
|
1996
|
-
const committed = readCommittedEvents(store, attemptId).map(event => event.fact);
|
|
1997
|
-
const targetOwners = new Map();
|
|
1998
|
-
for (const fact of committed) {
|
|
1999
|
-
if (fact.kind === "plan-verification-target" && isRecordObject(fact.entry) && typeof fact.entry.id === "string") {
|
|
2000
|
-
targetOwners.set(fact.entry.id, stringList(fact.entry.requirementIds));
|
|
2001
|
-
}
|
|
2002
|
-
}
|
|
2003
|
-
for (const [field, removals] of [["uiStates", removeUiStateNames], ["interactions", removeInteractionNames]]) {
|
|
2004
|
-
const live = new Map();
|
|
2005
|
-
for (const fact of committed) {
|
|
2006
|
-
if (fact.kind !== "state-flow")
|
|
2007
|
-
continue;
|
|
2008
|
-
for (const name of stringList(fact[field === "uiStates" ? "removeUiStateNames" : "removeInteractionNames"]))
|
|
2009
|
-
live.delete(name);
|
|
2010
|
-
for (const entry of Array.isArray(fact[field]) ? fact[field] : []) {
|
|
2011
|
-
if (isRecordObject(entry) && typeof entry.name === "string")
|
|
2012
|
-
live.set(entry.name, entry);
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
const foreignBindings = (entry) => scope.size === 0 ? [] : stringList(entry.verificationTargetIds).filter(id => {
|
|
2016
|
-
const owners = targetOwners.get(id);
|
|
2017
|
-
return !owners?.length || owners.some(owner => !scope.has(owner));
|
|
2018
|
-
});
|
|
2019
|
-
for (const name of removals) {
|
|
2020
|
-
const previous = live.get(name);
|
|
2021
|
-
if (previous && foreignBindings(previous).length)
|
|
2022
|
-
return planToolReceipt({ ok: false, kind: "state-flow",
|
|
2023
|
-
error: `Cannot remove shared ${name} from this requirement scope; retain it and update its scoped bindings instead` });
|
|
2024
|
-
}
|
|
2025
|
-
for (const entry of field === "uiStates" ? uiStates : interactions) {
|
|
2026
|
-
const previous = live.get(String(entry.name));
|
|
2027
|
-
if (previous)
|
|
2028
|
-
entry.verificationTargetIds = [...new Set([...foreignBindings(previous), ...stringList(entry.verificationTargetIds)])];
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
const result = await adoptPlanFact("state-flow", `${attemptId}:record_state_flow:${randomUUID()}`, {
|
|
2032
|
-
kind: "state-flow",
|
|
2033
|
-
origin: "plan",
|
|
2034
|
-
scopeRequirementIds: scopedRequirementIds(),
|
|
2035
|
-
states,
|
|
2036
|
-
uiStates,
|
|
2037
|
-
interactions,
|
|
2038
|
-
removeUiStateNames,
|
|
2039
|
-
removeInteractionNames,
|
|
2040
|
-
});
|
|
2041
|
-
return planToolReceipt(result);
|
|
2042
|
-
},
|
|
2043
|
-
});
|
|
2044
|
-
const recordDataFlowTool = defineTool({
|
|
2045
|
-
name: "record_data_flow",
|
|
2046
|
-
label: "record_data_flow",
|
|
2047
|
-
description: "Record interaction/endpoint data flow as an origin=plan data-flow fact. Example: {\"interactions\": [\"<interaction name>\"], \"endpoints\": [\"GET <path>\"]}",
|
|
2048
|
-
promptSnippet: "Record the plan data-flow fact.",
|
|
2049
|
-
parameters: Type.Object({ interactions: stringArray, endpoints: stringArray, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
2050
|
-
async execute(_toolCallId, params) {
|
|
2051
|
-
const previous = params.replace ? undefined : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "data-flow").at(-1)?.fact;
|
|
2052
|
-
const result = await adoptPlanFact("data-flow", `${attemptId}:record_data_flow:${randomUUID()}`, {
|
|
2053
|
-
kind: "data-flow",
|
|
2054
|
-
origin: "plan",
|
|
2055
|
-
interactions: [...new Set([...stringList(previous?.interactions), ...stringList(params?.interactions)])],
|
|
2056
|
-
endpoints: [...new Set([...stringList(previous?.endpoints), ...stringList(params?.endpoints)])],
|
|
2057
|
-
});
|
|
2058
|
-
return planToolReceipt(result);
|
|
2059
|
-
},
|
|
2060
|
-
});
|
|
2061
|
-
const recordMockApiTool = defineTool({
|
|
2062
|
-
name: "record_mock_api",
|
|
2063
|
-
label: "record_mock_api",
|
|
2064
|
-
description: "Record the Mock/API strategy as an origin=plan mock-api fact. Example: {\"mockApi\": {\"strategy\": \"not-needed\", \"activation\": \"n/a\", \"endpoints\": []}}",
|
|
2065
|
-
promptSnippet: "Record the plan mock-api fact.",
|
|
2066
|
-
parameters: Type.Object({ mockApi: mockApiSchema }, { additionalProperties: false }),
|
|
2067
|
-
async execute(_toolCallId, params) {
|
|
2068
|
-
const mockApi = params?.mockApi ?? {
|
|
2069
|
-
strategy: "not-needed",
|
|
2070
|
-
activation: "",
|
|
2071
|
-
endpoints: [],
|
|
2072
|
-
};
|
|
2073
|
-
const endpoints = stringList((Array.isArray(mockApi?.endpoints) ? mockApi.endpoints : []).map((endpoint) => `${typeof endpoint?.method === "string" ? endpoint.method : ""} ${typeof endpoint?.path === "string" ? endpoint.path : ""}`.trim()));
|
|
2074
|
-
const result = await adoptPlanFact("mock-api", `${attemptId}:record_mock_api:${randomUUID()}`, {
|
|
2075
|
-
kind: "mock-api",
|
|
2076
|
-
origin: "plan",
|
|
2077
|
-
strategy: typeof mockApi?.strategy === "string" ? mockApi.strategy : "",
|
|
2078
|
-
endpoints,
|
|
2079
|
-
mockApi,
|
|
2080
|
-
});
|
|
2081
|
-
return planToolReceipt(result);
|
|
2082
|
-
},
|
|
2083
|
-
});
|
|
2084
|
-
const recordMockEndpointTool = defineTool({
|
|
2085
|
-
name: "record_mock_endpoint", label: "record_mock_endpoint",
|
|
2086
|
-
description: "Record one Mock/API endpoint. First record_mock_api with the policy and endpoints: []; then submit each endpoint separately. Never regenerate the whole endpoint collection. Use replace:true to revise an existing method/path, or replace:true plus remove:true to withdraw it.",
|
|
2087
|
-
parameters: Type.Object({ endpoint: mockEndpointSchema, remove: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
2088
|
-
async execute(_callId, params) {
|
|
2089
|
-
if (!readCommittedEvents(store, attemptId).some(r => r.fact.kind === "mock-api"))
|
|
2090
|
-
return planToolReceipt({ ok: false, kind: "mock-endpoint", code: "MOCK_POLICY_MISSING", error: "Record the mock policy before its endpoints" });
|
|
2091
|
-
return planToolReceipt(await adoptPlanFact("mock-endpoint", `${attemptId}:endpoint:${randomUUID()}`, { kind: "mock-endpoint", origin: "plan", endpoint: params.endpoint, ...(params.remove ? { removed: true } : {}) }));
|
|
2092
|
-
},
|
|
2093
|
-
});
|
|
2094
|
-
const recordDesignDeviationTool = defineTool({
|
|
2095
|
-
name: "record_design_deviation",
|
|
2096
|
-
label: "record_design_deviation",
|
|
2097
|
-
description: "Record design evidence conflicts as an origin=plan design-deviation fact. Example: {\"designEvidence\": {\"source\": \"<source>\", \"paths\": [\"<file>\"], \"conflicts\": [\"<conflicting requirement id>\"]}}",
|
|
2098
|
-
promptSnippet: "Record the plan design-deviation fact.",
|
|
2099
|
-
parameters: Type.Object({ designEvidence: designEvidenceSchema }, { additionalProperties: false }),
|
|
2100
|
-
async execute(_toolCallId, params) {
|
|
2101
|
-
const conflicts = stringList(params?.designEvidence?.conflicts);
|
|
2102
|
-
const result = await adoptPlanFact("design-deviation", `${attemptId}:record_design_deviation:${randomUUID()}`, { kind: "design-deviation", origin: "plan", conflicts });
|
|
2103
|
-
return planToolReceipt(result);
|
|
2104
|
-
},
|
|
2105
|
-
});
|
|
2106
|
-
const recordDependencyTool = defineTool({
|
|
2107
|
-
name: "record_dependency",
|
|
2108
|
-
label: "record_dependency",
|
|
2109
|
-
description: "Record the dependency policy as an origin=plan dependency fact. Example: {\"policy\": \"<dependency policy statement>\"}",
|
|
2110
|
-
promptSnippet: "Record the plan dependency fact.",
|
|
2111
|
-
parameters: Type.Object({ policy: Type.String({}) }, { additionalProperties: false }),
|
|
2112
|
-
async execute(_toolCallId, params) {
|
|
2113
|
-
const result = await adoptPlanFact("dependency", `${attemptId}:record_dependency:${randomUUID()}`, {
|
|
2114
|
-
kind: "dependency",
|
|
2115
|
-
origin: "plan",
|
|
2116
|
-
policy: typeof params?.policy === "string" ? params.policy : "",
|
|
2117
|
-
});
|
|
2118
|
-
return planToolReceipt(result);
|
|
2119
|
-
},
|
|
2120
|
-
});
|
|
2121
|
-
// Incremental plan payload records (one entry per call) so requirements,
|
|
2122
|
-
// verification targets, evidence gaps, and implementation steps never have
|
|
2123
|
-
// to be emitted as one large array inside a single finalize_plan call —
|
|
2124
|
-
// they aggregate from the ledger in commit order. Mirrors the contract
|
|
2125
|
-
// node's record_requirement pattern to stay within any model output budget.
|
|
2126
|
-
const recordPlanRequirementTool = defineTool({
|
|
2127
|
-
name: "record_plan_requirement",
|
|
2128
|
-
label: "record_plan_requirement",
|
|
2129
|
-
description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement. Verification targets are authoritative in record_plan_verification_target; omit verificationTargetIds here unless repairing legacy input. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. Entry carries id, implementationTargets, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"id\": \"<AC-XXX>\", \"implementationTargets\": [\"<deliverable file>\"]}}",
|
|
2130
|
-
promptSnippet: "Commit 1-4 plan requirement entries (up to 4 per message).",
|
|
2131
|
-
parameters: Type.Object({
|
|
2132
|
-
entry: requirementSchema,
|
|
2133
|
-
replace: Type.Optional(Type.Boolean()),
|
|
2134
|
-
}, { additionalProperties: false }),
|
|
2135
|
-
async execute(_toolCallId, params) {
|
|
2136
|
-
const rawEntry = params?.entry;
|
|
2137
|
-
if (!isRecordObject(rawEntry)) {
|
|
2138
|
-
return planToolReceipt({
|
|
2139
|
-
ok: false,
|
|
2140
|
-
kind: "plan-requirement",
|
|
2141
|
-
error: "record_plan_requirement requires a non-empty entry object",
|
|
2142
|
-
});
|
|
2143
|
-
}
|
|
2144
|
-
let entry = rawEntry;
|
|
2145
|
-
// An embedded evidenceGap with a blank description means "no gap":
|
|
2146
|
-
// small-output models emit the slot defensively with description ""
|
|
2147
|
-
// on every requirement. The canonical contract schema requires a
|
|
2148
|
-
// non-empty gap description (min 1 char), so passing the empty slot
|
|
2149
|
-
// through would deterministically fail the plan compile with
|
|
2150
|
-
// invalid-output and burn every retry. Drop the empty slot — the
|
|
2151
|
-
// field is optional and the runtime derives real blocking gaps when
|
|
2152
|
-
// a requirement has no proof.
|
|
2153
|
-
const rawGap = isRecordObject(entry.evidenceGap)
|
|
2154
|
-
? entry.evidenceGap
|
|
2155
|
-
: undefined;
|
|
2156
|
-
if (rawGap &&
|
|
2157
|
-
typeof rawGap.description === "string" &&
|
|
2158
|
-
rawGap.description.trim() === "") {
|
|
2159
|
-
const { evidenceGap: _omittedGap, ...rest } = entry;
|
|
2160
|
-
void _omittedGap;
|
|
2161
|
-
entry = rest;
|
|
2162
|
-
}
|
|
2163
|
-
// Canonical-identity check: a requirement id outside the frozen
|
|
2164
|
-
// canonical list (e.g. a BR-* business rule picked up from the PRD
|
|
2165
|
-
// prose) would commit an immutable fact that finalize's
|
|
2166
|
-
// canonical-coverage gate rejects with no in-node cure. Reject here
|
|
2167
|
-
// and name the allowed ids.
|
|
2168
|
-
const id = typeof entry.id === "string" ? entry.id : "";
|
|
2169
|
-
if (activeRequirementScope.length && !activeRequirementScope.includes(id))
|
|
2170
|
-
return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `Requirement ${id} is outside this session` });
|
|
2171
|
-
if (id &&
|
|
2172
|
-
input.requirementIds &&
|
|
2173
|
-
input.requirementIds.length > 0 &&
|
|
2174
|
-
!input.requirementIds.includes(id)) {
|
|
2175
|
-
return planToolReceipt({
|
|
2176
|
-
ok: false,
|
|
2177
|
-
kind: "plan-requirement",
|
|
2178
|
-
error: `record_plan_requirement id "${id}" is not a frozen canonical requirement; canonical ids are: ${input.requirementIds.join(", ")}`,
|
|
2179
|
-
});
|
|
2180
|
-
}
|
|
2181
|
-
// A requirement id is a canonical identity: recording it twice would
|
|
2182
|
-
// compile a duplicate requirements[] entry and fail design review.
|
|
2183
|
-
// Reject duplicates by default; replace=true is the explicit in-node
|
|
2184
|
-
// correction path and the compiler keeps the latest replacement.
|
|
2185
|
-
if (id && params?.replace !== true) {
|
|
2186
|
-
const existing = readCommittedEvents(store, attemptId).find((event) => {
|
|
2187
|
-
const fact = event.fact;
|
|
2188
|
-
if (!fact || fact.kind !== "plan-requirement")
|
|
2189
|
-
return false;
|
|
2190
|
-
const entryFact = fact.entry;
|
|
2191
|
-
return (typeof entryFact?.id === "string" &&
|
|
2192
|
-
entryFact.id === id);
|
|
2193
|
-
});
|
|
2194
|
-
if (existing) {
|
|
2195
|
-
return planToolReceipt({
|
|
2196
|
-
ok: false,
|
|
2197
|
-
kind: "plan-requirement",
|
|
2198
|
-
error: `record_plan_requirement duplicate: requirement ${id} is already recorded; do not record the same requirement id twice`,
|
|
2199
|
-
});
|
|
2200
|
-
}
|
|
2201
|
-
}
|
|
2202
|
-
if (id &&
|
|
2203
|
-
activeRequirementScope.length > 0 &&
|
|
2204
|
-
!activeRequirementScope.includes(id)) {
|
|
2205
|
-
return planToolReceipt({
|
|
2206
|
-
ok: false,
|
|
2207
|
-
kind: "plan-requirement",
|
|
2208
|
-
error: `record_plan_requirement id "${id}" is outside this session's requirement scope [${activeRequirementScope.join(", ")}]`,
|
|
2209
|
-
});
|
|
2210
|
-
}
|
|
2211
|
-
const result = await adoptPlanFact("plan-requirement", `${attemptId}:record_plan_requirement:${randomUUID()}`, {
|
|
2212
|
-
kind: "plan-requirement",
|
|
2213
|
-
origin: "plan",
|
|
2214
|
-
entry,
|
|
2215
|
-
...(params?.replace === true ? { replaces: id } : {}),
|
|
2216
|
-
});
|
|
2217
|
-
return planToolReceipt(result);
|
|
2218
|
-
},
|
|
2219
|
-
});
|
|
2220
|
-
const recordPlanGroupCoverageTool = defineTool({
|
|
2221
|
-
name: "record_plan_group_coverage", label: "record_plan_group_coverage",
|
|
2222
|
-
description: "Submit shared implementation/verification references for one declared execution group. Runtime expands to every canonical member and retains its full outcome and source bindings. A shared VT must actually verify each independent condition. Use per-requirement records for differences; never create UI for constraints or exclusions. replace:true explicitly revises the group.",
|
|
2223
|
-
parameters: Type.Object({ id: Type.String({ minLength: 1 }), implementationTargets: stringArray, verificationTargetIds: Type.Optional(stringArray), replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
|
|
2224
|
-
async execute(callId, params, signal, onUpdate, ctx) {
|
|
2225
|
-
const group = executionGroups.find(g => g.id === params.id && g.kind !== "unclassified");
|
|
2226
|
-
if (!group || (activeRequirementScope.length && group.requirementIds.some(id => !activeRequirementScope.includes(id))))
|
|
2227
|
-
return planToolReceipt({ ok: false, kind: "plan-requirement", code: "EXECUTION_GROUP_SCOPE_INVALID", error: "A known complete group must be present in this session; submit individual member records when the group spans scopes" });
|
|
2228
|
-
let last;
|
|
2229
|
-
for (const id of group.requirementIds) {
|
|
2230
|
-
const existing = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "plan-requirement" && r.fact.entry?.id === id).at(-1)?.fact.entry;
|
|
2231
|
-
if (existing && !params.replace) {
|
|
2232
|
-
if (JSON.stringify(existing.implementationTargets) !== JSON.stringify(params.implementationTargets))
|
|
2233
|
-
return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FACT_IDENTITY_CONFLICT", error: `${id}: existing coverage differs; use replace:true to revise explicitly` });
|
|
2234
|
-
continue;
|
|
2235
|
-
}
|
|
2236
|
-
last = await recordPlanRequirementTool.execute(`${callId}:${id}`, { entry: { id, implementationTargets: params.implementationTargets, ...(params.verificationTargetIds ? { verificationTargetIds: params.verificationTargetIds } : {}) }, ...(params.replace ? { replace: true } : {}) }, signal, onUpdate, ctx);
|
|
2237
|
-
if (!last.details?.ok)
|
|
2238
|
-
return last;
|
|
2239
|
-
}
|
|
2240
|
-
return planToolReceipt(await adoptPlanFact("plan-group-coverage", `${attemptId}:group:${randomUUID()}`, { kind: "plan-group-coverage", origin: "plan", id: group.id, requirementIds: group.requirementIds }));
|
|
2241
|
-
},
|
|
2242
|
-
});
|
|
2243
|
-
const recordPlanVerificationTargetTool = defineTool({
|
|
2244
|
-
name: "record_plan_verification_target",
|
|
2245
|
-
label: "record_plan_verification_target",
|
|
2246
|
-
description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Reference a frozen verification command by commandId (see the frozen command directory in your prompt: static commands are project-wide checks traced by file and command only; behavior commands bind an existing affected test file; preserve its test names). For behavior targets, call once per distinct behavior, not mechanically once per requirement: one target may cover multiple related requirementIds. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. A target id identifies a contract entry only; never require it in test names. Results bind by frozen command and test file. Entry carries id, commandId, file, requirementIds, and uiStates; optional scope (unit | component | integration) is display-only. Free-form symbol text is not accepted. IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"id\": \"VT-DASHBOARD-SHELL\", \"commandId\": \"<frozen behavior command id>\", \"file\": \"<test file>\", \"requirementIds\": [\"AC-001\", \"AC-002\"], \"uiStates\": []}}" +
|
|
2247
|
-
(input.canonicalVerificationTargetIds &&
|
|
2248
|
-
input.canonicalVerificationTargetIds.length > 0
|
|
2249
|
-
? ` Frozen canonical behavior target ids (use exactly for behavior targets): ${input.canonicalVerificationTargetIds.join(", ")}.`
|
|
2250
|
-
: ""),
|
|
2251
|
-
promptSnippet: "Commit 1-4 plan verification target entries (up to 4 per message).",
|
|
2252
|
-
parameters: Type.Object({
|
|
2253
|
-
entry: verificationTargetSchema,
|
|
2254
|
-
replace: Type.Optional(Type.Boolean()),
|
|
2255
|
-
}, { additionalProperties: false }),
|
|
2256
|
-
async execute(_toolCallId, params) {
|
|
2257
|
-
const rawEntry = params?.entry;
|
|
2258
|
-
if (!isRecordObject(rawEntry)) {
|
|
2259
|
-
return planToolReceipt({
|
|
2260
|
-
ok: false,
|
|
2261
|
-
kind: "plan-verification-target",
|
|
2262
|
-
error: "record_plan_verification_target requires a non-empty entry object",
|
|
2263
|
-
});
|
|
2264
|
-
}
|
|
2265
|
-
// Contract v2: the plan references a frozen command by commandId;
|
|
2266
|
-
// mode and label are resolved by the runtime from the frozen
|
|
2267
|
-
// command directory (run.json frontend-verify-shell bundle lanes).
|
|
2268
|
-
// Reject unknown ids and behavior commands bound to non-test
|
|
2269
|
-
// files here so the model fixes them in-node instead of the
|
|
2270
|
-
// attempt dying at materialization or at the writer focused-check.
|
|
2271
|
-
const verificationCommandId = typeof rawEntry.commandId === "string"
|
|
2272
|
-
? rawEntry.commandId.trim()
|
|
2273
|
-
: "";
|
|
2274
|
-
if (!verificationCommandId) {
|
|
2275
|
-
return planToolReceipt({
|
|
2276
|
-
ok: false,
|
|
2277
|
-
kind: "plan-verification-target",
|
|
2278
|
-
error: `record_plan_verification_target entry.commandId is required (received ${JSON.stringify(rawEntry.commandId ?? null)}); pick one id from the frozen command directory in your prompt`,
|
|
2279
|
-
});
|
|
2280
|
-
}
|
|
2281
|
-
const { deriveFrontendVerifyCommandDirectoryFromRun, isFrontendTestFilePath, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
2282
|
-
const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
|
|
2283
|
-
if (verifyDirectory.length > 0) {
|
|
2284
|
-
const directoryEntry = verifyDirectory.find((entry) => entry.commandId === verificationCommandId);
|
|
2285
|
-
if (!directoryEntry) {
|
|
2286
|
-
return planToolReceipt({
|
|
2287
|
-
ok: false,
|
|
2288
|
-
kind: "plan-verification-target",
|
|
2289
|
-
error: `record_plan_verification_target verification-target-unknown-command: unknown commandId "${verificationCommandId}"; available frozen commands: [${verifyDirectory.map((entry) => `${entry.commandId} (${entry.mode}: ${entry.label})`).join(", ")}]`,
|
|
2290
|
-
});
|
|
2291
|
-
}
|
|
2292
|
-
if (directoryEntry.mode === "behavior" &&
|
|
2293
|
-
typeof rawEntry.file === "string" &&
|
|
2294
|
-
!isFrontendTestFilePath(rawEntry.file)) {
|
|
2295
|
-
return planToolReceipt({
|
|
2296
|
-
ok: false,
|
|
2297
|
-
kind: "plan-verification-target",
|
|
2298
|
-
error: `record_plan_verification_target verification-target-phase-mismatch: behavior command "${directoryEntry.label}" (${directoryEntry.commandId}) must bind a test file (__tests__/, tests?/, e2e/, cypress/, *.test.*, *.spec.*, *.cy.*); received file "${rawEntry.file}"`,
|
|
2299
|
-
});
|
|
2300
|
-
}
|
|
2301
|
-
// Canonical-identity check for behavior targets: the PRD freezes
|
|
2302
|
-
// the exact behavior verification-target ids (e.g.
|
|
2303
|
-
// VT-SMOKE-COUNTER-BEHAVIOR). A committed non-canonical id is
|
|
2304
|
-
// immutable and design review rejects it as a
|
|
2305
|
-
// contract-requirement gap, so reject invented ids here.
|
|
2306
|
-
if (directoryEntry.mode === "behavior" &&
|
|
2307
|
-
input.canonicalVerificationTargetIds &&
|
|
2308
|
-
input.canonicalVerificationTargetIds.length > 0) {
|
|
2309
|
-
const canonicalTargetId = typeof rawEntry.id === "string" ? rawEntry.id.trim() : "";
|
|
2310
|
-
if (canonicalTargetId &&
|
|
2311
|
-
!input.canonicalVerificationTargetIds.includes(canonicalTargetId)) {
|
|
2312
|
-
return planToolReceipt({
|
|
2313
|
-
ok: false,
|
|
2314
|
-
kind: "plan-verification-target",
|
|
2315
|
-
error: `record_plan_verification_target id "${canonicalTargetId}" is not a frozen canonical behavior verification target; canonical ids are: ${input.canonicalVerificationTargetIds.join(", ")}`,
|
|
2316
|
-
});
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
}
|
|
2320
|
-
// Duplicate-id rejection: committed typed facts are immutable, so
|
|
2321
|
-
// re-recording the same VT id would deadlock the compile by default.
|
|
2322
|
-
// replace=true is the explicit in-node correction path.
|
|
2323
|
-
const vtId = typeof rawEntry.id === "string" ? rawEntry.id : "";
|
|
2324
|
-
if (vtId &&
|
|
2325
|
-
params?.replace !== true &&
|
|
2326
|
-
readCommittedEvents(store, attemptId).some((event) => {
|
|
2327
|
-
const fact = event.fact;
|
|
2328
|
-
if (!fact || fact.kind !== "plan-verification-target")
|
|
2329
|
-
return false;
|
|
2330
|
-
const entryFact = fact.entry;
|
|
2331
|
-
return entryFact?.id === vtId;
|
|
2332
|
-
})) {
|
|
2333
|
-
return planToolReceipt({
|
|
2334
|
-
ok: false,
|
|
2335
|
-
kind: "plan-verification-target",
|
|
2336
|
-
error: `record_plan_verification_target duplicate: verification target ${vtId} is already recorded; use replace=true for an in-node correction`,
|
|
2337
|
-
});
|
|
2338
|
-
}
|
|
2339
|
-
// WriteSet containment at the boundary: a committed VT fact whose
|
|
2340
|
-
// file is outside the task writeSet is immutable, and the finalize
|
|
2341
|
-
// pre-validation would then fail the whole attempt with no in-node
|
|
2342
|
-
// cure (r17 post-merge). Reject here with the allowed patterns.
|
|
2343
|
-
if (typeof rawEntry.file === "string" &&
|
|
2344
|
-
input.writeSetPatterns &&
|
|
2345
|
-
input.writeSetPatterns.length > 0 &&
|
|
2346
|
-
!input.writeSetPatterns.some((pattern) => pathMatchesPattern(rawEntry.file, pattern))) {
|
|
2347
|
-
return planToolReceipt({
|
|
2348
|
-
ok: false,
|
|
2349
|
-
kind: "plan-verification-target",
|
|
2350
|
-
error: `record_plan_verification_target file is outside the writeSet patterns [${input.writeSetPatterns.join(", ")}]: ${rawEntry.file}; verification targets must point inside the task writeSet`,
|
|
2351
|
-
});
|
|
2352
|
-
}
|
|
2353
|
-
// uiStates: [] means this verification target is intentionally not
|
|
2354
|
-
// bound to a named UI state. Keep that canonical representation even
|
|
2355
|
-
// when a model omits the optional tool-boundary field.
|
|
2356
|
-
const entryWithoutSymbol = { ...rawEntry };
|
|
2357
|
-
delete entryWithoutSymbol.symbol;
|
|
2358
|
-
const entry = {
|
|
2359
|
-
...entryWithoutSymbol,
|
|
2360
|
-
uiStates: stringList(rawEntry.uiStates),
|
|
2361
|
-
};
|
|
2362
|
-
// Cross-reference integrity at the boundary: the compile gate
|
|
2363
|
-
// rejects verification targets referencing UI states or
|
|
2364
|
-
// requirements that were never declared. Validate against the
|
|
2365
|
-
// facts already committed in this attempt so the model fixes the
|
|
2366
|
-
// reference in-node instead of burning the attempt at compile time
|
|
2367
|
-
// (r7: one full attempt lost to a single unknown UI state name).
|
|
2368
|
-
const committedEvents = readCommittedEvents(store, attemptId);
|
|
2369
|
-
const declaredUiStateNames = collectCanonicalStateFlowNames(committedEvents).uiStateNames;
|
|
2370
|
-
const unknownUiStates = entry.uiStates.filter((name) => !declaredUiStateNames.has(name));
|
|
2371
|
-
if (unknownUiStates.length > 0) {
|
|
2372
|
-
return planToolReceipt({
|
|
2373
|
-
ok: false,
|
|
2374
|
-
kind: "plan-verification-target",
|
|
2375
|
-
error: `record_plan_verification_target references UI states that were never declared: ${unknownUiStates.join(", ")}; declare every referenced UI state with record_state_flow first, or pass uiStates: [] for intentionally unbound targets`,
|
|
2376
|
-
});
|
|
2377
|
-
}
|
|
2378
|
-
const declaredRequirementIds = new Set(committedEvents.flatMap((event) => {
|
|
2379
|
-
const fact = event.fact;
|
|
2380
|
-
if (!fact || fact.kind !== "plan-requirement")
|
|
2381
|
-
return [];
|
|
2382
|
-
const requirementEntry = fact.entry;
|
|
2383
|
-
return typeof requirementEntry?.id === "string"
|
|
2384
|
-
? [requirementEntry.id]
|
|
2385
|
-
: [];
|
|
2386
|
-
}));
|
|
2387
|
-
const unknownRequirementIds = stringList(rawEntry.requirementIds).filter((id) => !declaredRequirementIds.has(id));
|
|
2388
|
-
const outOfScopeRequirementIds = stringList(rawEntry.requirementIds).filter((id) => activeRequirementScope.length > 0 &&
|
|
2389
|
-
!activeRequirementScope.includes(id));
|
|
2390
|
-
if (outOfScopeRequirementIds.length > 0) {
|
|
2391
|
-
return planToolReceipt({
|
|
2392
|
-
ok: false,
|
|
2393
|
-
kind: "plan-verification-target",
|
|
2394
|
-
error: `record_plan_verification_target references requirements outside this session's scope [${activeRequirementScope.join(", ")}]: ${outOfScopeRequirementIds.join(", ")}`,
|
|
2395
|
-
});
|
|
2396
|
-
}
|
|
2397
|
-
if (unknownRequirementIds.length > 0) {
|
|
2398
|
-
return planToolReceipt({
|
|
2399
|
-
ok: false,
|
|
2400
|
-
kind: "plan-verification-target",
|
|
2401
|
-
error: `record_plan_verification_target references unknown requirement ids: ${unknownRequirementIds.join(", ")}; record every referenced requirement with record_plan_requirement first`,
|
|
2402
|
-
});
|
|
2403
|
-
}
|
|
2404
|
-
const result = await adoptPlanFact("plan-verification-target", `${attemptId}:record_plan_verification_target:${randomUUID()}`, {
|
|
2405
|
-
kind: "plan-verification-target",
|
|
2406
|
-
origin: "plan",
|
|
2407
|
-
entry,
|
|
2408
|
-
...(params?.replace === true ? { replaces: vtId } : {}),
|
|
2409
|
-
});
|
|
2410
|
-
return planToolReceipt(result);
|
|
2411
|
-
},
|
|
2412
|
-
});
|
|
2413
|
-
const recordPlanEvidenceGapTool = defineTool({
|
|
2414
|
-
name: "record_plan_evidence_gap",
|
|
2415
|
-
label: "record_plan_evidence_gap",
|
|
2416
|
-
description: "Commit one plan evidence gap entry (origin=plan plan-evidence-gap fact). Call once per gap; entry carries requirementId, description, blocking. IMPORTANT: batch up to 4 record_* calls per assistant message; never batch more than 4 — a larger single message risks output truncation under a small output window. Example: {\"entry\": {\"requirementId\": \"<AC-XXX>\", \"description\": \"<what evidence is missing and why>\", \"blocking\": false}}",
|
|
2417
|
-
promptSnippet: "Commit 1-4 plan evidence gap entries (up to 4 per message).",
|
|
2418
|
-
parameters: Type.Object({
|
|
2419
|
-
entry: evidenceGapSchema,
|
|
2420
|
-
}, { additionalProperties: false }),
|
|
2421
|
-
async execute(_toolCallId, params) {
|
|
2422
|
-
const rawEntry = params?.entry;
|
|
2423
|
-
if (!isRecordObject(rawEntry)) {
|
|
2424
|
-
return planToolReceipt({
|
|
2425
|
-
ok: false,
|
|
2426
|
-
kind: "plan-evidence-gap",
|
|
2427
|
-
error: "record_plan_evidence_gap requires a non-empty entry object",
|
|
2428
|
-
});
|
|
2429
|
-
}
|
|
2430
|
-
const entry = rawEntry;
|
|
2431
|
-
// A standalone evidence gap IS the gap statement: a blank description
|
|
2432
|
-
// would fail the canonical contract schema (min 1 char) after the
|
|
2433
|
-
// whole attempt finished. Reject at the boundary so the model writes
|
|
2434
|
-
// a real description in-node instead of burning the attempt.
|
|
2435
|
-
if (typeof entry.description === "string" &&
|
|
2436
|
-
entry.description.trim() === "") {
|
|
2437
|
-
return planToolReceipt({
|
|
2438
|
-
ok: false,
|
|
2439
|
-
kind: "plan-evidence-gap",
|
|
2440
|
-
error: "record_plan_evidence_gap requires a non-empty description describing the gap",
|
|
2441
|
-
});
|
|
2442
|
-
}
|
|
2443
|
-
const requirementId = typeof entry.requirementId === "string"
|
|
2444
|
-
? entry.requirementId
|
|
2445
|
-
: undefined;
|
|
2446
|
-
if (requirementId &&
|
|
2447
|
-
activeRequirementScope.length > 0 &&
|
|
2448
|
-
!activeRequirementScope.includes(requirementId)) {
|
|
2449
|
-
return planToolReceipt({
|
|
2450
|
-
ok: false,
|
|
2451
|
-
kind: "plan-evidence-gap",
|
|
2452
|
-
error: `record_plan_evidence_gap requirementId "${requirementId}" is outside this session's requirement scope [${activeRequirementScope.join(", ")}]`,
|
|
2453
|
-
});
|
|
2454
|
-
}
|
|
2455
|
-
const result = await adoptPlanFact("plan-evidence-gap", `${attemptId}:record_plan_evidence_gap:${randomUUID()}`, { kind: "plan-evidence-gap", origin: "plan", entry });
|
|
2456
|
-
return planToolReceipt(result);
|
|
2457
|
-
},
|
|
2458
|
-
});
|
|
2459
|
-
const finalizePlanTool = defineTool({
|
|
2460
|
-
name: "finalize_plan",
|
|
2461
|
-
label: "finalize_plan",
|
|
2462
|
-
description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan assembles them from the ledger together with these optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
|
|
2463
|
-
promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
|
|
2464
|
-
parameters: Type.Object({
|
|
2465
|
-
residualRisks: optionalStringArray,
|
|
2466
|
-
realIntegrationGap: optionalString,
|
|
2467
|
-
}, { additionalProperties: false }),
|
|
2468
|
-
async execute(_toolCallId, params) {
|
|
2469
|
-
try {
|
|
2470
|
-
const committed = readCommittedEvents(store, attemptId);
|
|
2471
|
-
// Source fidelity ledger (AC-005/AC-006): inherit the contract
|
|
2472
|
-
// node's declared requirement→fragment provenance so the compiled
|
|
2473
|
-
// canonical contract carries sourceFragmentIds/sourceRefs even
|
|
2474
|
-
// when the plan did not re-declare them. The contract node is the
|
|
2475
|
-
// sole synthesis point; the plan inherits by requirement id.
|
|
2476
|
-
const contractInheritance = await loadContractRequirementInheritance(input.runDir);
|
|
2477
|
-
const missingData = collectFrontendPlanPhaseMissingFacts({ phase: "global-mock-data", requirementIds: input.requirementIds ?? [...contractInheritance.keys()], committedFacts: committed }).filter(f => f.kind === "data-flow");
|
|
2478
|
-
if (missingData.length)
|
|
2479
|
-
return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_DATA_FLOW_INCOMPLETE", error: missingData.map(f => f.reason).join("; ") });
|
|
2480
|
-
const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
|
|
2481
|
-
const patch = {
|
|
2482
|
-
...fragment,
|
|
2483
|
-
...(params?.residualRisks
|
|
2484
|
-
? { residualRisks: params.residualRisks }
|
|
2485
|
-
: {}),
|
|
2486
|
-
...(params?.realIntegrationGap
|
|
2487
|
-
? { realIntegrationGap: params.realIntegrationGap }
|
|
2488
|
-
: {}),
|
|
2489
|
-
};
|
|
2490
|
-
const latestRegistry = [...committed]
|
|
2491
|
-
.reverse()
|
|
2492
|
-
.map((record) => record.fact)
|
|
2493
|
-
.find((fact) => isRecordObject(fact) && fact.kind === "state-registry");
|
|
2494
|
-
if (latestRegistry) {
|
|
2495
|
-
const registryUiStates = new Set(stringList(latestRegistry.uiStateNames));
|
|
2496
|
-
const registryInteractions = new Set(stringList(latestRegistry.interactionNames));
|
|
2497
|
-
const live = collectCanonicalStateFlowNames(committed);
|
|
2498
|
-
const missingUiStates = [...registryUiStates].filter((name) => !live.uiStateNames.has(name));
|
|
2499
|
-
const missingInteractions = [...registryInteractions].filter((name) => !live.interactionNames.has(name));
|
|
2500
|
-
const undeclaredUiStates = [...live.uiStateNames].filter((name) => !registryUiStates.has(name));
|
|
2501
|
-
const undeclaredInteractions = [...live.interactionNames].filter((name) => !registryInteractions.has(name));
|
|
2502
|
-
if (missingUiStates.length > 0 ||
|
|
2503
|
-
missingInteractions.length > 0 ||
|
|
2504
|
-
undeclaredUiStates.length > 0 ||
|
|
2505
|
-
undeclaredInteractions.length > 0) {
|
|
2506
|
-
return planToolReceipt({
|
|
2507
|
-
ok: false,
|
|
2508
|
-
kind: "finalize_plan",
|
|
2509
|
-
error: `finalize_plan UX registry mismatch: every registered name must have one live state-flow entry and every live entry must be registered (missing uiStates: ${missingUiStates.join(", ") || "none"}; missing interactions: ${missingInteractions.join(", ") || "none"}; undeclared uiStates: ${undeclaredUiStates.join(", ") || "none"}; undeclared interactions: ${undeclaredInteractions.join(", ") || "none"})`,
|
|
2510
|
-
});
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
// Front-load the node's compile + policy gates into the finalize
|
|
2514
|
-
// receipt (same pipeline the design-policy shell and the node
|
|
2515
|
-
// self-check run: merge the patch onto the runtime skeleton,
|
|
2516
|
-
// then the full analyze). A failing gate used to burn an entire
|
|
2517
|
-
// attempt per finding (r8/r9: ui-design-coverage, verification
|
|
2518
|
-
// targets, UI-state shape, one attempt each); surfaced here the
|
|
2519
|
-
// model fixes the facts and re-calls finalize_plan in-node.
|
|
2520
|
-
if (input.skeleton && input.sourceBinding) {
|
|
2521
|
-
// Front-load the exact pipeline the design-policy shell and
|
|
2522
|
-
// the node self-check run (patch ⊕ skeleton -> analyze ->
|
|
2523
|
-
// policy pre-checks) into the finalize receipt. Findings
|
|
2524
|
-
// come back as fixable receipt errors instead of burning
|
|
2525
|
-
// an attempt per gate (r8/r9: coverage, verification
|
|
2526
|
-
// targets, UI-state shape each cost a full attempt).
|
|
2527
|
-
const { analyzeFrontendPlanPatchCandidate, applyFrontendContractMergePatch, FrontendContractFailure, PlanPolicyPrecheckFailure, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
2528
|
-
try {
|
|
2529
|
-
const merged = applyFrontendContractMergePatch(input.skeleton, patch);
|
|
2530
|
-
await analyzeFrontendPlanPatchCandidate({
|
|
2531
|
-
runDir: input.runDir,
|
|
2532
|
-
rawContractText: serializeDeterministicJson(merged),
|
|
2533
|
-
sourceBinding: input.sourceBinding,
|
|
2534
|
-
});
|
|
2535
|
-
}
|
|
2536
|
-
catch (error) {
|
|
2537
|
-
if (error instanceof PlanPolicyPrecheckFailure) {
|
|
2538
|
-
// Template the fix: every uncovered interaction / state
|
|
2539
|
-
// maps to a record_component_choice skeleton. Reuse is
|
|
2540
|
-
// never implied: the operator/model must fill an existing
|
|
2541
|
-
// evidencePath or switch the choice to decision=new with
|
|
2542
|
-
// the required frozen source citation.
|
|
2543
|
-
const suggestions = error.findings
|
|
2544
|
-
.filter((finding) => finding.code === "ui-design-coverage-missing" &&
|
|
2545
|
-
finding.path)
|
|
2546
|
-
.map((finding) => ({
|
|
2547
|
-
tool: "record_component_choice",
|
|
2548
|
-
args: {
|
|
2549
|
-
choice: {
|
|
2550
|
-
purpose: finding.path,
|
|
2551
|
-
component: "<name the existing or new component>",
|
|
2552
|
-
decision: "reuse-existing",
|
|
2553
|
-
evidencePath: "<existing repo file that proves this reuse>",
|
|
2554
|
-
},
|
|
2555
|
-
},
|
|
2556
|
-
}));
|
|
2557
|
-
const suggestionBlock = suggestions.length > 0
|
|
2558
|
-
? ` Suggested record_* calls (copy, fill component, submit): ${JSON.stringify(suggestions)}`
|
|
2559
|
-
: "";
|
|
2560
|
-
return planToolReceipt({
|
|
2561
|
-
ok: false,
|
|
2562
|
-
kind: "finalize_plan",
|
|
2563
|
-
error: `finalize_plan pre-validation failed (fix the listed plan facts with record_* tools, then call finalize_plan again): ${error.message}${suggestionBlock}`,
|
|
2564
|
-
});
|
|
2565
|
-
}
|
|
2566
|
-
if (!(error instanceof FrontendContractFailure))
|
|
2567
|
-
throw error;
|
|
2568
|
-
return planToolReceipt({
|
|
2569
|
-
ok: false,
|
|
2570
|
-
kind: "finalize_plan",
|
|
2571
|
-
error: `finalize_plan pre-validation failed (fix the listed plan facts with record_* tools, then call finalize_plan again): ${error.message}`,
|
|
2572
|
-
});
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
const patchResult = await adoptPlanFact("target-surface", `${attemptId}:finalize_plan:patch:${randomUUID()}`, { kind: "target-surface", origin: "plan", patch });
|
|
2576
|
-
if (!patchResult.ok) {
|
|
2577
|
-
return planToolReceipt({
|
|
2578
|
-
ok: false,
|
|
2579
|
-
kind: "finalize_plan",
|
|
2580
|
-
error: patchResult.error,
|
|
2581
|
-
code: patchResult.code,
|
|
2582
|
-
});
|
|
2583
|
-
}
|
|
2584
|
-
const terminal = await adoptPlanFact("finalize_plan", `${attemptId}:finalize_plan:terminal:${randomUUID()}`, { kind: "finalize_plan", origin: "plan", patch });
|
|
2585
|
-
return planToolReceipt(terminal);
|
|
2586
|
-
}
|
|
2587
|
-
catch (error) {
|
|
2588
|
-
return planToolReceipt({
|
|
2589
|
-
ok: false,
|
|
2590
|
-
kind: "finalize_plan",
|
|
2591
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2592
|
-
});
|
|
2593
|
-
}
|
|
2594
|
-
},
|
|
2595
|
-
});
|
|
2596
|
-
const adoptStagedFactTool = defineTool({
|
|
2597
|
-
name: "adopt_staged_fact",
|
|
2598
|
-
label: "adopt_staged_fact",
|
|
2599
|
-
description: "Explicitly adopt a quarantined fact from a prior attempt into the committed ledger (idempotent by requestId).",
|
|
2600
|
-
promptSnippet: "Adopt a quarantined fact into the committed ledger.",
|
|
2601
|
-
parameters: Type.Object({
|
|
2602
|
-
requestId: Type.String({}),
|
|
2603
|
-
eventId: Type.String({}),
|
|
2604
|
-
expectedRevision: Type.Number({}),
|
|
2605
|
-
}, { additionalProperties: false }),
|
|
2606
|
-
async execute(_toolCallId, params) {
|
|
2607
|
-
try {
|
|
2608
|
-
const adopted = await adoptStagedFact({
|
|
2609
|
-
store,
|
|
2610
|
-
requestId: typeof params?.requestId === "string" ? params.requestId : "",
|
|
2611
|
-
attemptId,
|
|
2612
|
-
eventId: typeof params?.eventId === "string" ? params.eventId : "",
|
|
2613
|
-
expectedRevision: typeof params?.expectedRevision === "number"
|
|
2614
|
-
? params.expectedRevision
|
|
2615
|
-
: store.revision,
|
|
2616
|
-
});
|
|
2617
|
-
return planToolReceipt({
|
|
2618
|
-
ok: true,
|
|
2619
|
-
kind: "adopt_staged_fact",
|
|
2620
|
-
eventId: adopted.eventId,
|
|
2621
|
-
revision: adopted.revision,
|
|
2622
|
-
error: "",
|
|
2623
|
-
});
|
|
2624
|
-
}
|
|
2625
|
-
catch (error) {
|
|
2626
|
-
return planToolReceipt({
|
|
2627
|
-
ok: false,
|
|
2628
|
-
kind: "adopt_staged_fact",
|
|
2629
|
-
error: error instanceof Error ? error.message : String(error),
|
|
2630
|
-
code: error?.code,
|
|
2631
|
-
});
|
|
2632
|
-
}
|
|
2633
|
-
},
|
|
2634
|
-
});
|
|
2635
|
-
const readPlanFactsTool = defineTool({
|
|
2636
|
-
name: "read_plan_facts",
|
|
2637
|
-
label: "read_plan_facts",
|
|
2638
|
-
description: "Read the LIVE committed plan ledger without repository access. Filter by kind and optional entryId or eventId. Use field (dot path, e.g. entry.requirementIds or uiStates) to page large arrays/strings. offset/limit paginate results; follow nextOffset until null before replacing any full-array fact. expectedRevision pins all pages to one ledger revision; restart if it changes. No writes.",
|
|
2639
|
-
parameters: Type.Object({
|
|
2640
|
-
kind: Type.String(), entryId: Type.Optional(Type.String()), eventId: Type.Optional(Type.String()),
|
|
2641
|
-
field: Type.Optional(Type.String()), offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
2642
|
-
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
|
|
2643
|
-
stringOffset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
2644
|
-
expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
2645
|
-
}, { additionalProperties: false }),
|
|
2646
|
-
async execute(_id, params) {
|
|
2647
|
-
const reply = (details) => ({ content: [{ type: "text", text: JSON.stringify(details) }], details });
|
|
2648
|
-
if (params.expectedRevision !== undefined && params.expectedRevision !== store.revision) {
|
|
2649
|
-
return reply({ ok: false, revision: store.revision, error: "ledger revision changed; restart pagination" });
|
|
2650
|
-
}
|
|
2651
|
-
let records = readCommittedEvents(store, attemptId).filter(record => {
|
|
2652
|
-
const fact = record.fact;
|
|
2653
|
-
return fact.kind === params.kind && (!params.eventId || record.eventId === params.eventId) &&
|
|
2654
|
-
(!params.entryId || fact.entry?.id === params.entryId);
|
|
2655
|
-
});
|
|
2656
|
-
// Full-entry corrections and registries are last-wins. Other kinds
|
|
2657
|
-
// remain an ordered event log (state removals must remain visible).
|
|
2658
|
-
if (params.entryId || params.kind === "state-registry")
|
|
2659
|
-
records = records.slice(-1);
|
|
2660
|
-
let values = records.map(record => ({ eventId: record.eventId, ...record.fact }));
|
|
2661
|
-
if (params.field) {
|
|
2662
|
-
values = records.flatMap(record => {
|
|
2663
|
-
let value = record.fact;
|
|
2664
|
-
for (const key of params.field.split(".")) {
|
|
2665
|
-
if (!value || typeof value !== "object" || !Object.hasOwn(value, key))
|
|
2666
|
-
return [];
|
|
2667
|
-
value = value[key];
|
|
2668
|
-
}
|
|
2669
|
-
return Array.isArray(value) ? value : [value];
|
|
2670
|
-
});
|
|
2671
|
-
}
|
|
2672
|
-
const offset = params.offset ?? 0;
|
|
2673
|
-
if (params.stringOffset !== undefined) {
|
|
2674
|
-
const value = values[offset];
|
|
2675
|
-
if (typeof value !== "string")
|
|
2676
|
-
return reply({ ok: false, error: "stringOffset requires a string field item" });
|
|
2677
|
-
// Unicode code points prevent a page boundary splitting a surrogate pair.
|
|
2678
|
-
const characters = Array.from(value);
|
|
2679
|
-
const chunk = characters.slice(params.stringOffset, params.stringOffset + 1000).join("");
|
|
2680
|
-
const next = params.stringOffset + Array.from(chunk).length;
|
|
2681
|
-
return reply({ ok: true, revision: store.revision, offset, stringOffset: params.stringOffset,
|
|
2682
|
-
chunk, totalCharacters: characters.length, nextStringOffset: next < characters.length ? next : null });
|
|
2683
|
-
}
|
|
2684
|
-
const items = [];
|
|
2685
|
-
let bytes = 0;
|
|
2686
|
-
for (const value of values.slice(offset, offset + (params.limit ?? 10))) {
|
|
2687
|
-
const size = Buffer.byteLength(JSON.stringify(value));
|
|
2688
|
-
if (bytes + size > 12_000)
|
|
2689
|
-
break;
|
|
2690
|
-
bytes += size;
|
|
2691
|
-
items.push(value);
|
|
2692
|
-
}
|
|
2693
|
-
if (!items.length && offset < values.length)
|
|
2694
|
-
return reply({
|
|
2695
|
-
ok: false, revision: store.revision, total: values.length, offset,
|
|
2696
|
-
error: "item exceeds the page budget; select an eventId/entryId and a narrower field path; for a string item set stringOffset: 0 and follow nextStringOffset",
|
|
2697
|
-
fields: typeof values[offset] === "object" && values[offset] !== null ? Object.keys(values[offset]) : [],
|
|
2698
|
-
});
|
|
2699
|
-
return reply({ ok: true, revision: store.revision, total: values.length, offset, items,
|
|
2700
|
-
nextOffset: offset + items.length < values.length ? offset + items.length : null });
|
|
2701
|
-
},
|
|
2702
|
-
});
|
|
2703
|
-
const durable = await createDurableFrontendTools({
|
|
2704
|
-
file: path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), attemptId, store: input.store,
|
|
2705
|
-
setWorkingStore: next => { store = next; },
|
|
2706
|
-
binding: { sourceBinding: input.sourceBinding, skeleton: input.skeleton, requirementIds: input.requirementIds, writeSet: input.writeSetPatterns, declaredUiStateIds: input.declaredUiStateIds, citations: input.componentNewSourceReferences, contractInheritance },
|
|
2707
|
-
tools: [
|
|
2708
|
-
recordRouteSelectionTool,
|
|
2709
|
-
recordComponentChoiceTool,
|
|
2710
|
-
recordStateRegistryTool,
|
|
2711
|
-
recordStateFlowTool,
|
|
2712
|
-
recordDataFlowTool,
|
|
2713
|
-
recordMockApiTool,
|
|
2714
|
-
recordMockEndpointTool,
|
|
2715
|
-
recordDesignDeviationTool,
|
|
2716
|
-
recordDependencyTool,
|
|
2717
|
-
recordPlanRequirementTool,
|
|
2718
|
-
recordPlanGroupCoverageTool,
|
|
2719
|
-
recordPlanVerificationTargetTool,
|
|
2720
|
-
recordPlanEvidenceGapTool,
|
|
2721
|
-
adoptStagedFactTool,
|
|
2722
|
-
finalizePlanTool,
|
|
2723
|
-
],
|
|
2724
|
-
});
|
|
2725
|
-
const durableFinalizePlanTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "finalize_plan");
|
|
2726
|
-
if (!durableFinalizePlanTool)
|
|
2727
|
-
throw new Error("frontend plan durable finalize tool unavailable");
|
|
2728
|
-
return {
|
|
2729
|
-
customTools: [...durable.customTools, { ...readPlanFactsTool, execute: async (...args) => { await durable.flush(); return readPlanFactsTool.execute(...args); } }],
|
|
2730
|
-
adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
|
|
2731
|
-
for (const record of records) {
|
|
2732
|
-
if (record.phase !== "committed")
|
|
2733
|
-
continue;
|
|
2734
|
-
const fact = record.fact;
|
|
2735
|
-
if (!fact || typeof fact !== "object" || Array.isArray(fact))
|
|
2736
|
-
continue;
|
|
2737
|
-
const kind = typeof fact.kind === "string"
|
|
2738
|
-
? fact.kind
|
|
2739
|
-
: "plan-fact";
|
|
2740
|
-
const entry = fact.entry;
|
|
2741
|
-
const identity = (kind === "plan-requirement" || kind === "plan-verification-target") &&
|
|
2742
|
-
typeof entry?.id === "string"
|
|
2743
|
-
? `${kind}:${entry.id}`
|
|
2744
|
-
: undefined;
|
|
2745
|
-
const sourceShardPrefix = `${attemptId}:parallel-merge:${record.attemptId}:`;
|
|
2746
|
-
const requestId = `${sourceShardPrefix}${record.eventId}`;
|
|
2747
|
-
const committed = readCommittedEvents(store, attemptId);
|
|
2748
|
-
const replay = committed.find((candidate) => candidate.requestId === requestId);
|
|
2749
|
-
if (replay) {
|
|
2750
|
-
if (replay.payloadSha256 !== record.payloadSha256) {
|
|
2751
|
-
throw new Error(`frontend plan shard fact merge replay conflict: source event ${record.eventId} changed payload`);
|
|
2752
|
-
}
|
|
2753
|
-
continue;
|
|
2754
|
-
}
|
|
2755
|
-
const explicitReplacement = typeof fact.replaces === "string" &&
|
|
2756
|
-
fact.replaces === entry?.id;
|
|
2757
|
-
const conflicting = committed.find((candidate) => {
|
|
2758
|
-
const candidateFact = candidate.fact;
|
|
2759
|
-
const candidateEntry = candidateFact.entry;
|
|
2760
|
-
const sameSourceShard = candidate.requestId.startsWith(sourceShardPrefix);
|
|
2761
|
-
return (candidateFact.kind === kind &&
|
|
2762
|
-
typeof candidateEntry?.id === "string" &&
|
|
2763
|
-
`${kind}:${candidateEntry.id}` === identity &&
|
|
2764
|
-
!(sameSourceShard && explicitReplacement));
|
|
2765
|
-
});
|
|
2766
|
-
if (conflicting) {
|
|
2767
|
-
// Two shards may honestly emit the same fact (e.g. both
|
|
2768
|
-
// derive the same frozen verification target). Identical
|
|
2769
|
-
// payloads are duplicates to skip; divergent payloads are
|
|
2770
|
-
// a real conflict the ladder must resolve.
|
|
2771
|
-
if (conflicting.payloadSha256 === record.payloadSha256) {
|
|
2772
|
-
continue;
|
|
2773
|
-
}
|
|
2774
|
-
const fromSameSourceShard = conflicting.requestId.startsWith(sourceShardPrefix);
|
|
2775
|
-
if (fromSameSourceShard) {
|
|
2776
|
-
throw new Error(`frontend plan shard fact merge conflict: ${identity} was already committed by another shard with a different payload`);
|
|
2777
|
-
}
|
|
2778
|
-
// Divergent payload from a different source attempt means a
|
|
2779
|
-
// ladder retry re-derived the coverage phase: the fresh
|
|
2780
|
-
// derivation supersedes the stored record.
|
|
2781
|
-
const storeWithRecords = store;
|
|
2782
|
-
storeWithRecords.records = storeWithRecords.records.filter((candidate) => candidate.eventId !== conflicting.eventId);
|
|
2783
|
-
}
|
|
2784
|
-
const result = await adoptPlanFact(kind, requestId, fact);
|
|
2785
|
-
if (!result.ok) {
|
|
2786
|
-
throw new Error(`frontend plan shard fact merge failed: ${result.error}`);
|
|
2787
|
-
}
|
|
2788
|
-
}
|
|
2789
|
-
}),
|
|
2790
|
-
setActiveRequirementScope: (requirementIds) => {
|
|
2791
|
-
activeRequirementScope = [
|
|
2792
|
-
...new Set(requirementIds.filter((id) => id.trim().length > 0)),
|
|
2793
|
-
];
|
|
1209
|
+
}),
|
|
1210
|
+
setActiveRequirementScope: (requirementIds) => {
|
|
1211
|
+
session.activeRequirementScope = [
|
|
1212
|
+
...new Set(requirementIds.filter((id) => id.trim().length > 0)),
|
|
1213
|
+
];
|
|
2794
1214
|
},
|
|
2795
1215
|
flush: durable.flush,
|
|
2796
|
-
finalizePlan: (params) => durableFinalizePlanTool.execute(`${attemptId}:auto-finalize-plan`, {
|
|
1216
|
+
finalizePlan: (params) => durableFinalizePlanTool.execute(`${session.attemptId}:auto-finalize-plan`, {
|
|
2797
1217
|
...(params?.residualRisks
|
|
2798
1218
|
? { residualRisks: [...params.residualRisks] }
|
|
2799
1219
|
: {}),
|
|
@@ -2804,10 +1224,10 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
2804
1224
|
// The wrapper passes this through to the typed tool; finalize itself
|
|
2805
1225
|
// does not inspect the extension context.
|
|
2806
1226
|
{}),
|
|
2807
|
-
committedFactCount: () => readCommittedEvents(store, attemptId).length,
|
|
1227
|
+
committedFactCount: () => readCommittedEvents(session.store, session.attemptId).length,
|
|
2808
1228
|
committedRequirementIds: () => {
|
|
2809
1229
|
const ids = new Set();
|
|
2810
|
-
for (const event of readCommittedEvents(store, attemptId)) {
|
|
1230
|
+
for (const event of readCommittedEvents(session.store, session.attemptId)) {
|
|
2811
1231
|
const fact = event.fact;
|
|
2812
1232
|
if (!fact || fact.kind !== "plan-requirement")
|
|
2813
1233
|
continue;
|
|
@@ -2817,62 +1237,12 @@ export async function createFrontendPlanLedgerTools(input) {
|
|
|
2817
1237
|
}
|
|
2818
1238
|
return ids;
|
|
2819
1239
|
},
|
|
2820
|
-
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
1240
|
+
committedFacts: () => readCommittedEvents(session.store, session.attemptId),
|
|
2821
1241
|
};
|
|
2822
1242
|
}
|
|
2823
|
-
/** Translate derived-patch validation findings into decision-channel
|
|
2824
|
-
* vocabulary. Each zod issue path names a COMPILED patch array, but the model
|
|
2825
|
-
* authored record_* facts — so quote the offending entry's identity and name
|
|
2826
|
-
* the tool that owns it (r14: "uiComponentChoices.0.specReference.section:
|
|
2827
|
-
* Required" is unactionable when the model has never heard of specReference). */
|
|
2828
|
-
export function translateDecisionPatchFindings(message, decision) {
|
|
2829
|
-
const body = message.replace(/^invalid-output:\s*/, "");
|
|
2830
|
-
return body
|
|
2831
|
-
.split(/;\s*/)
|
|
2832
|
-
.map(issue => {
|
|
2833
|
-
const choice = issue.match(/uiComponentChoices\.(\d+)\.(.+)/);
|
|
2834
|
-
if (choice) {
|
|
2835
|
-
const row = decision.reuseDecisions[Number(choice[1])];
|
|
2836
|
-
const base = row
|
|
2837
|
-
? `record_reuse_decision purpose "${row.purpose}" (component ${row.symbol}, decision ${row.decision}): ${issue}`
|
|
2838
|
-
: `record_reuse_decision row ${choice[1]}: ${issue}`;
|
|
2839
|
-
const field = choice[2];
|
|
2840
|
-
return field?.startsWith("specReference")
|
|
2841
|
-
? `${base} — pass specSection (the AC id / task-source section it implements) on that entry, or use decision "reuse-existing"`
|
|
2842
|
-
: base;
|
|
2843
|
-
}
|
|
2844
|
-
const focus = issue.match(/verificationTargets\.(\d+)\.(.+)/);
|
|
2845
|
-
if (focus) {
|
|
2846
|
-
const row = decision.verificationFocus[Number(focus[1])];
|
|
2847
|
-
if (!row)
|
|
2848
|
-
return `record_verification_focus row ${focus[1]}: ${issue}`;
|
|
2849
|
-
return `record_verification_focus ${row.id}: ${issue} — behavior targets derive uiStates from record_state_ownership rows whose owner equals behaviorGroupId "${row.behaviorGroupId}"; record one for this group if missing`;
|
|
2850
|
-
}
|
|
2851
|
-
const state = issue.match(/uiStates\.(\d+)\.(.+)/);
|
|
2852
|
-
if (state) {
|
|
2853
|
-
const row = decision.stateOwnership[Number(state[1])];
|
|
2854
|
-
return row
|
|
2855
|
-
? `record_state_ownership "${row.state}" (owner ${row.owner}): ${issue}`
|
|
2856
|
-
: `record_state_ownership row ${state[1]}: ${issue}`;
|
|
2857
|
-
}
|
|
2858
|
-
const interaction = issue.match(/interactions\.(\d+)\.(.+)/);
|
|
2859
|
-
if (interaction) {
|
|
2860
|
-
const row = decision.dataFlows[Number(interaction[1])];
|
|
2861
|
-
return row
|
|
2862
|
-
? `record_data_flow "${row.interaction}": ${issue}`
|
|
2863
|
-
: `record_data_flow row ${interaction[1]}: ${issue}`;
|
|
2864
|
-
}
|
|
2865
|
-
const requirement = issue.match(/requirements\.(\d+)\.(.+)/);
|
|
2866
|
-
if (requirement) {
|
|
2867
|
-
return `record_module_placement row ${requirement[1]}: ${issue}`;
|
|
2868
|
-
}
|
|
2869
|
-
return issue;
|
|
2870
|
-
})
|
|
2871
|
-
.join("; ");
|
|
2872
|
-
}
|
|
2873
1243
|
/** Read the recovery child's read-only parent decision snapshot (written by
|
|
2874
1244
|
* the recovery continuation). Absent file → undefined (fresh plan). */
|
|
2875
|
-
export async function readParentDecisionSnapshot(runDir, nodeId) {
|
|
1245
|
+
export async function readParentDecisionSnapshot(runDir, nodeId, context) {
|
|
2876
1246
|
const snapshotPath = path.join(runDir, nodeId, "parent-decision-snapshot.json");
|
|
2877
1247
|
let raw;
|
|
2878
1248
|
try {
|
|
@@ -2884,8 +1254,45 @@ export async function readParentDecisionSnapshot(runDir, nodeId) {
|
|
|
2884
1254
|
throw error;
|
|
2885
1255
|
}
|
|
2886
1256
|
const parsed = JSON.parse(raw);
|
|
2887
|
-
if (!Array.isArray(parsed.facts))
|
|
1257
|
+
if (parsed.schemaVersion !== 1 || parsed.schemaId !== "frontend-plan-parent-decision-snapshot-v1" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(parsed.parentRunId) || !/^[a-f0-9]{64}$/.test(parsed.parentLedgerSha256) || !Array.isArray(parsed.facts) || parsed.facts.some(fact => !fact || typeof fact.kind !== "string" || !fact.entry || typeof fact.entry !== "object" || Array.isArray(fact.entry)))
|
|
2888
1258
|
throw new Error(`parent decision snapshot malformed: ${snapshotPath}`);
|
|
1259
|
+
delete parsed.resolvedParentRunDir;
|
|
1260
|
+
if (context) {
|
|
1261
|
+
const { locateDagRun, readDagRunState, readDagRunSpec } = await import("../workflows/dag/lifecycle.js");
|
|
1262
|
+
const state = await readDagRunState(runDir);
|
|
1263
|
+
if (state.frontendRecoveryState?.parentRunId !== parsed.parentRunId || state.frontendRecoveryState.resetRootNodeId !== "frontend-plan-pi")
|
|
1264
|
+
throw new Error("parent decision snapshot recovery lineage mismatch");
|
|
1265
|
+
const located = await locateDagRun(context.workspaceRoot, parsed.parentRunId);
|
|
1266
|
+
if (!located)
|
|
1267
|
+
throw new Error("parent decision snapshot source run unavailable");
|
|
1268
|
+
const parentSpec = await readDagRunSpec(located.runDir);
|
|
1269
|
+
const sourceHash = sha256OfCanonicalJson(context.sourceBinding ?? null);
|
|
1270
|
+
if (parsed.sourceBindingSha256 !== sourceHash || sha256OfCanonicalJson(parentSpec.sourceBinding ?? null) !== sourceHash)
|
|
1271
|
+
throw new Error("parent decision snapshot source binding mismatch");
|
|
1272
|
+
const ledgerPath = path.join(located.runDir, "frontend-plan-pi", "plan-decision-facts.jsonl");
|
|
1273
|
+
const rawLedger = await readFile(ledgerPath, "utf8");
|
|
1274
|
+
if (createHash("sha256").update(rawLedger).digest("hex") !== parsed.parentLedgerSha256)
|
|
1275
|
+
throw new Error("parent decision snapshot ledger hash mismatch");
|
|
1276
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1277
|
+
const { assembleDecisionContractFromFacts } = await import("../workflows/dag/frontend-plan-decision-contract.js");
|
|
1278
|
+
const records = (await readTypedEventStoreFromJsonl(ledgerPath)).filter(record => record.phase === "committed");
|
|
1279
|
+
if (records.some(record => record.payloadSha256 !== sha256OfCanonicalJson(record.fact)))
|
|
1280
|
+
throw new Error("parent decision snapshot ledger payload hash mismatch");
|
|
1281
|
+
const actual = assembleDecisionContractFromFacts(records.map(record => record.fact));
|
|
1282
|
+
if (sha256OfCanonicalJson(assembleDecisionContractFromFacts(parsed.facts)) !== sha256OfCanonicalJson(actual))
|
|
1283
|
+
throw new Error("parent decision snapshot facts differ from bound ledger");
|
|
1284
|
+
const { readCurrentFrontendRepairFindings } = await import("../workflows/dag/rerun-feedback.js");
|
|
1285
|
+
const feedback = await readCurrentFrontendRepairFindings(located.runDir);
|
|
1286
|
+
const feedbackBinding = feedback ? { sourceNodeId: feedback.sourceNodeId, ledgerSha256: feedback.ledgerSha256, terminalEventId: feedback.terminalEventId } : undefined;
|
|
1287
|
+
if (sha256OfCanonicalJson(parsed.feedbackBinding ?? null) !== sha256OfCanonicalJson(feedbackBinding ?? null) || sha256OfCanonicalJson(parsed.designFindings ?? []) !== sha256OfCanonicalJson(feedback?.verdict === "request" ? feedback.findings : []))
|
|
1288
|
+
throw new Error("parent decision snapshot finding source mismatch");
|
|
1289
|
+
if (parsed.parentCanonicalSha256) {
|
|
1290
|
+
const canonical = await readFile(path.join(located.runDir, "contracts/frontend-implementation-contract.json"), "utf8");
|
|
1291
|
+
if (createHash("sha256").update(canonical).digest("hex") !== parsed.parentCanonicalSha256)
|
|
1292
|
+
throw new Error("parent canonical hash mismatch");
|
|
1293
|
+
}
|
|
1294
|
+
parsed.resolvedParentRunDir = located.runDir;
|
|
1295
|
+
}
|
|
2889
1296
|
return parsed;
|
|
2890
1297
|
}
|
|
2891
1298
|
const decisionKindToToolName = (kind) => `record_${kind.replaceAll("-", "_")}`;
|
|
@@ -2895,490 +1302,24 @@ const decisionKindToToolName = (kind) => `record_${kind.replaceAll("-", "_")}`;
|
|
|
2895
1302
|
* stable per-fact callIds make retries replay receipts instead of duplicating. */
|
|
2896
1303
|
export async function replayParentDecisionSnapshot(snapshot, tools) {
|
|
2897
1304
|
const byName = new Map(tools.map((tool) => [tool.name, tool]));
|
|
2898
|
-
for (const [index, fact] of snapshot.facts.entries()) {
|
|
2899
|
-
const toolName = decisionKindToToolName(fact.kind);
|
|
2900
|
-
const tool = byName.get(toolName);
|
|
2901
|
-
if (!tool) {
|
|
2902
|
-
throw new Error(`parent decision replay: no tool for fact kind "${fact.kind}"`);
|
|
2903
|
-
}
|
|
2904
|
-
const receipt = await tool.execute(`parent-replay:${index}`, {
|
|
2905
|
-
entry: fact.entry,
|
|
2906
|
-
});
|
|
2907
|
-
if (receipt.details?.ok !== true) {
|
|
2908
|
-
throw new Error(`parent decision replay failed for ${toolName} (identity ${JSON.stringify(fact.entry.purpose ??
|
|
2909
|
-
fact.entry.state ??
|
|
2910
|
-
fact.entry.interaction ??
|
|
2911
|
-
fact.entry.id ??
|
|
2912
|
-
fact.entry.boundary ??
|
|
2913
|
-
"?")}): ${receipt.details?.error ?? "unknown error"}`);
|
|
2914
|
-
}
|
|
2915
|
-
}
|
|
2916
|
-
}
|
|
2917
|
-
export async function createFrontendPlanDecisionTools(input) {
|
|
2918
|
-
const [{ Type }, { defineTool }, { assembleDecisionContractFromFacts, assembleExperimentPlanContract, buildFrontendPlanRelationshipPatch }] = await Promise.all([
|
|
2919
|
-
import("typebox"),
|
|
2920
|
-
import("@earendil-works/pi-coding-agent"),
|
|
2921
|
-
import("../workflows/dag/frontend-plan-decision-contract.js"),
|
|
2922
|
-
]);
|
|
2923
|
-
const { readCommittedEvents } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2924
|
-
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
2925
|
-
const { createDurableFrontendTools } = await import("../workflows/dag/frontend-durable-tools.js");
|
|
2926
|
-
let store = input.store;
|
|
2927
|
-
const { attemptId, authority } = input;
|
|
2928
|
-
const optionalStringArray = Type.Optional(Type.Array(Type.String({ minLength: 1 })));
|
|
2929
|
-
const optionalString = Type.Optional(Type.String({ minLength: 1 }));
|
|
2930
|
-
// In-session pre-validation of the derived relationship patch. The
|
|
2931
|
-
// post-session bridge runs the identical analysis; running it first inside
|
|
2932
|
-
// finalize_decision turns findings into a bounded in-session correction
|
|
2933
|
-
// (fix facts, finalize again) instead of an attempt burn. Skipped when the
|
|
2934
|
-
// caller does not supply the runtime skeleton/sourceBinding.
|
|
2935
|
-
let requiredDeliverablesCache;
|
|
2936
|
-
const prevalidateDerivedPatch = async () => {
|
|
2937
|
-
if (!input.skeleton || !input.sourceBinding)
|
|
2938
|
-
return { ok: true, canonicalSha256: "", canonical: {} };
|
|
2939
|
-
const committed = readCommittedEvents(store, attemptId);
|
|
2940
|
-
const decision = assembleDecisionContractFromFacts(committed.map(record => record.fact));
|
|
2941
|
-
requiredDeliverablesCache ??= (async () => {
|
|
2942
|
-
const map = new Map();
|
|
2943
|
-
try {
|
|
2944
|
-
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2945
|
-
const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
2946
|
-
for (const record of [...contractRecords].reverse()) {
|
|
2947
|
-
const fact = record.fact;
|
|
2948
|
-
if (!fact || fact.origin !== "contract" || fact.kind !== "required-deliverables")
|
|
2949
|
-
continue;
|
|
2950
|
-
for (const item of Array.isArray(fact.items) ? fact.items : []) {
|
|
2951
|
-
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
2952
|
-
continue;
|
|
2953
|
-
const requirementId = item.requirementId;
|
|
2954
|
-
const file = item.path;
|
|
2955
|
-
if (typeof requirementId !== "string" || typeof file !== "string")
|
|
2956
|
-
continue;
|
|
2957
|
-
const paths = map.get(requirementId) ?? [];
|
|
2958
|
-
paths.push(file);
|
|
2959
|
-
map.set(requirementId, paths);
|
|
2960
|
-
}
|
|
2961
|
-
break;
|
|
2962
|
-
}
|
|
2963
|
-
}
|
|
2964
|
-
catch {
|
|
2965
|
-
// Missing/unreadable contract ledger → no deliverable obligations.
|
|
2966
|
-
}
|
|
2967
|
-
return map;
|
|
2968
|
-
})();
|
|
2969
|
-
const concreteWriteSet = [
|
|
2970
|
-
...new Set([
|
|
2971
|
-
...decision.modulePlacements.flatMap(item => item.paths),
|
|
2972
|
-
...decision.verificationFocus.map(item => item.file),
|
|
2973
|
-
]),
|
|
2974
|
-
];
|
|
2975
|
-
const { applyFrontendContractMergePatch, analyzeFrontendPlanPatchCandidate, FrontendContractFailure, PlanPolicyPrecheckFailure, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
2976
|
-
try {
|
|
2977
|
-
const patch = buildFrontendPlanRelationshipPatch({
|
|
2978
|
-
decision,
|
|
2979
|
-
authority,
|
|
2980
|
-
concreteWriteSet,
|
|
2981
|
-
requiredDeliverables: await requiredDeliverablesCache,
|
|
2982
|
-
});
|
|
2983
|
-
const merged = applyFrontendContractMergePatch(input.skeleton, patch);
|
|
2984
|
-
const analysis = await analyzeFrontendPlanPatchCandidate({
|
|
2985
|
-
runDir: input.runDir,
|
|
2986
|
-
rawContractText: serializeDeterministicJson(merged),
|
|
2987
|
-
sourceBinding: input.sourceBinding,
|
|
2988
|
-
});
|
|
2989
|
-
return {
|
|
2990
|
-
ok: true,
|
|
2991
|
-
canonicalSha256: createHash("sha256")
|
|
2992
|
-
.update(serializeDeterministicJson(analysis.canonical))
|
|
2993
|
-
.digest("hex"),
|
|
2994
|
-
canonical: analysis.canonical,
|
|
2995
|
-
};
|
|
2996
|
-
}
|
|
2997
|
-
catch (error) {
|
|
2998
|
-
if (error instanceof PlanPolicyPrecheckFailure) {
|
|
2999
|
-
// Template the fix: every uncovered interaction/state maps to a
|
|
3000
|
-
// fill-in record_reuse_decision row (the decision channel's
|
|
3001
|
-
// component-choice tool).
|
|
3002
|
-
const suggestions = error.findings
|
|
3003
|
-
.filter(finding => finding.code === "ui-design-coverage-missing" && finding.path)
|
|
3004
|
-
.map(finding => ({
|
|
3005
|
-
tool: "record_reuse_decision",
|
|
3006
|
-
args: {
|
|
3007
|
-
entry: {
|
|
3008
|
-
symbol: "<name the existing or new component>",
|
|
3009
|
-
decision: "reuse-existing",
|
|
3010
|
-
evidence: ["<existing repo file that proves this reuse>"],
|
|
3011
|
-
purpose: finding.path,
|
|
3012
|
-
},
|
|
3013
|
-
},
|
|
3014
|
-
}));
|
|
3015
|
-
const suggestionBlock = suggestions.length > 0
|
|
3016
|
-
? ` Suggested record_* calls (copy, fill component, submit): ${JSON.stringify(suggestions)}`
|
|
3017
|
-
: "";
|
|
3018
|
-
return {
|
|
3019
|
-
ok: false,
|
|
3020
|
-
error: `finalize_decision pre-validation failed (fix the listed plan facts, then call finalize_decision again): ${error.message}${suggestionBlock}`,
|
|
3021
|
-
};
|
|
3022
|
-
}
|
|
3023
|
-
// FrontendContractFailure messages carry zod issue paths and are
|
|
3024
|
-
// translated into record_* vocabulary; the bridge compiler throws
|
|
3025
|
-
// decision-state-unbindable / decision-styling-strategy-conflict
|
|
3026
|
-
// diagnostics that are already model-fixable as written.
|
|
3027
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
3028
|
-
const translated = error instanceof FrontendContractFailure
|
|
3029
|
-
? translateDecisionPatchFindings(message, decision)
|
|
3030
|
-
: message;
|
|
3031
|
-
return {
|
|
3032
|
-
ok: false,
|
|
3033
|
-
error: `finalize_decision pre-validation failed (fix the listed facts with the named record_* tools — resubmit a corrected row with the same identity and replace:true to replace it — then call finalize_decision again): ${translated}`,
|
|
3034
|
-
};
|
|
3035
|
-
}
|
|
3036
|
-
};
|
|
3037
|
-
const planToolReceipt = (details) => ({
|
|
3038
|
-
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
3039
|
-
details,
|
|
3040
|
-
});
|
|
3041
|
-
async function adoptDecisionFact(kind, requestId, fact) {
|
|
3042
|
-
try {
|
|
3043
|
-
const staged = stageTypedEventFact({
|
|
3044
|
-
store,
|
|
3045
|
-
requestId,
|
|
3046
|
-
attemptId,
|
|
3047
|
-
fact: fact,
|
|
3048
|
-
});
|
|
3049
|
-
const committed = await adoptTypedEventFact({
|
|
3050
|
-
store,
|
|
3051
|
-
requestId,
|
|
3052
|
-
attemptId,
|
|
3053
|
-
fact: fact,
|
|
3054
|
-
eventId: staged.eventId,
|
|
3055
|
-
expectedRevision: store.revision,
|
|
3056
|
-
});
|
|
3057
|
-
return { ok: true, kind, eventId: committed.eventId, revision: committed.revision, error: "" };
|
|
3058
|
-
}
|
|
3059
|
-
catch (error) {
|
|
3060
|
-
return {
|
|
3061
|
-
ok: false,
|
|
3062
|
-
kind,
|
|
3063
|
-
code: error?.code,
|
|
3064
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3065
|
-
};
|
|
3066
|
-
}
|
|
3067
|
-
}
|
|
3068
|
-
const record = (name, label, description, entrySchema, gate) => defineTool({
|
|
3069
|
-
name,
|
|
3070
|
-
label,
|
|
3071
|
-
description,
|
|
3072
|
-
promptSnippet: `Record ${label}.`,
|
|
3073
|
-
parameters: Type.Object({ entry: entrySchema }, { additionalProperties: false }),
|
|
3074
|
-
async execute(_toolCallId, params) {
|
|
3075
|
-
const entry = params?.entry;
|
|
3076
|
-
const kind = name.replace("record_", "").replace(/_/g, "-");
|
|
3077
|
-
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
|
3078
|
-
return planToolReceipt({ ok: false, kind, error: `${name} requires a non-empty entry object` });
|
|
3079
|
-
}
|
|
3080
|
-
if (gate) {
|
|
3081
|
-
const rejected = await gate(entry);
|
|
3082
|
-
if (rejected)
|
|
3083
|
-
return rejected;
|
|
3084
|
-
}
|
|
3085
|
-
const result = await adoptDecisionFact(kind, `${attemptId}:${name}:${randomUUID()}`, { kind, origin: "plan", entry });
|
|
3086
|
-
return planToolReceipt(result);
|
|
3087
|
-
},
|
|
3088
|
-
});
|
|
3089
|
-
const recordModulePlacementTool = record("record_module_placement", "module placement", "Record one module placement: which behavior-group/module id maps to which concrete files. Example: {\"entry\": {\"id\": \"page\", \"paths\": [\"src/page.tsx\"]}}", Type.Object({ id: Type.String({ minLength: 1 }), paths: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }) }, { additionalProperties: false }));
|
|
3090
|
-
const recordReuseDecisionTool = record("record_reuse_decision", "reuse decision", "Record one component decision: which component serves which UI state/interaction (purpose). decision \"reuse-existing\" = an existing repo component (evidence[0] = the repo file that proves it); \"specified\" = the frontend spec mandates it; \"new\" = the task source mandates a bounded addition — specified/new require specSection naming the spec/task-source declaration they implement (e.g. an AC id). Set stylingStrategy once (on the first call) when the task mandates a style contract. Resubmit the same purpose with replace:true to correct a row. Example: {\"entry\": {\"symbol\": \"Spinner\", \"decision\": \"reuse-existing\", \"evidence\": [\"src/ui/Spinner.tsx\"], \"purpose\": \"loading\"}} or {\"entry\": {\"symbol\": \"SmokeCounter\", \"decision\": \"new\", \"evidence\": [\"需求.md\"], \"purpose\": \"increment\", \"specSection\": \"AC-FE-002\", \"stylingStrategy\": \"compact, stable, animation-free single card\"}}", Type.Object({
|
|
3091
|
-
symbol: Type.String({ minLength: 1 }),
|
|
3092
|
-
decision: Type.Union([Type.Literal("specified"), Type.Literal("reuse-existing"), Type.Literal("new")]),
|
|
3093
|
-
evidence: Type.Array(Type.String()),
|
|
3094
|
-
specSection: Type.Optional(Type.String({ minLength: 1 })),
|
|
3095
|
-
purpose: Type.String({ minLength: 1 }),
|
|
3096
|
-
covers: optionalStringArray,
|
|
3097
|
-
rationale: optionalString,
|
|
3098
|
-
stylingStrategy: optionalString,
|
|
3099
|
-
}, { additionalProperties: false }), async (entry) => {
|
|
3100
|
-
// specified/new compile into a canonical specReference whose .section
|
|
3101
|
-
// the contract schema mandates; without the section the row can only
|
|
3102
|
-
// fail at finalize, so demand it here where the model can still act.
|
|
3103
|
-
if (entry.decision === "specified") {
|
|
3104
|
-
const evidenceList = Array.isArray(entry.evidence) ? entry.evidence : [];
|
|
3105
|
-
const evidencePath = typeof evidenceList[0] === "string" ? evidenceList[0] : "";
|
|
3106
|
-
const candidates = input.componentSpecCandidatePaths ?? [];
|
|
3107
|
-
if (candidates.length === 0) {
|
|
3108
|
-
return planToolReceipt({
|
|
3109
|
-
ok: false,
|
|
3110
|
-
kind: "reuse-decision",
|
|
3111
|
-
error: `record_reuse_decision specified-requires-spec-candidates: decision "specified" means a declared component spec mandates this choice, but this task declares no component spec candidates — use decision "new" (task-source-mandated addition, evidence[0] = the task-source file) or "reuse-existing"`,
|
|
3112
|
-
});
|
|
3113
|
-
}
|
|
3114
|
-
if (evidencePath && !candidates.includes(evidencePath)) {
|
|
3115
|
-
return planToolReceipt({
|
|
3116
|
-
ok: false,
|
|
3117
|
-
kind: "reuse-decision",
|
|
3118
|
-
error: `record_reuse_decision specified-spec-reference-outside-candidates: evidence[0] "${evidencePath}" is not a declared component spec candidate; declared candidates: [${candidates.join(", ")}]`,
|
|
3119
|
-
});
|
|
3120
|
-
}
|
|
3121
|
-
}
|
|
3122
|
-
if ((entry.decision === "specified" || entry.decision === "new") &&
|
|
3123
|
-
!(typeof entry.specSection === "string" && entry.specSection.trim())) {
|
|
3124
|
-
return planToolReceipt({
|
|
3125
|
-
ok: false,
|
|
3126
|
-
kind: "reuse-decision",
|
|
3127
|
-
error: `record_reuse_decision spec-section-required: decision "${entry.decision}" must name the spec/task-source declaration it implements via specSection (e.g. an AC id); use decision "reuse-existing" for existing repo conventions. Example: {"entry": {"symbol": "SmokeCounter", "decision": "new", "evidence": ["src/components/SmokeCounter.tsx"], "purpose": "render the counter card", "specSection": "AC-FE-001"}}`,
|
|
3128
|
-
});
|
|
3129
|
-
}
|
|
3130
|
-
return undefined;
|
|
3131
|
-
});
|
|
3132
|
-
const recordStateOwnershipTool = record("record_state_ownership", "state ownership", "Record one UI state and its owner, applicability and expected behavior. owner must equal the behaviorGroupId of the record_verification_focus entries it supports; every behavior group referenced by a verification focus needs at least one state row or finalize fails. Resubmit the same state name with replace:true to correct a row. Example: {\"entry\": {\"state\": \"loading\", \"owner\": \"page\", \"applicable\": true, \"expectedBehavior\": \"render loading\"}}", Type.Object({
|
|
3133
|
-
state: Type.String({ minLength: 1 }),
|
|
3134
|
-
owner: Type.String({ minLength: 1 }),
|
|
3135
|
-
applicable: Type.Boolean(),
|
|
3136
|
-
expectedBehavior: optionalString,
|
|
3137
|
-
notApplicableReason: optionalString,
|
|
3138
|
-
}, { additionalProperties: false }));
|
|
3139
|
-
const frozenInteractionIds = authority.interactionIds ?? [];
|
|
3140
|
-
const recordDataFlowTool = record("record_data_flow", "data flow", "Record one interaction data flow: source behavior group, trigger and expected behavior. One row per interaction name; resubmit the same interaction name to replace it. When a frozen interaction id vocabulary is declared, the interaction name MUST be one of the declared ids — wrong-named records are rejected at submission and stale ones are retracted via retract_data_flow. Example: {\"entry\": {\"interaction\": \"load\", \"source\": \"page\", \"trigger\": \"submit query\", \"expectedBehavior\": \"show loading then results\"}}", Type.Object({
|
|
3141
|
-
interaction: Type.String({ minLength: 1 }),
|
|
3142
|
-
source: Type.String({ minLength: 1 }),
|
|
3143
|
-
trigger: Type.String({ minLength: 1 }),
|
|
3144
|
-
expectedBehavior: Type.String({ minLength: 1 }),
|
|
3145
|
-
}, { additionalProperties: false }), frozenInteractionIds.length > 0
|
|
3146
|
-
? async (entry) => {
|
|
3147
|
-
const name = typeof entry.interaction === "string" ? entry.interaction : "";
|
|
3148
|
-
if (name && !frozenInteractionIds.includes(name)) {
|
|
3149
|
-
return planToolReceipt({
|
|
3150
|
-
ok: false,
|
|
3151
|
-
kind: "data-flow",
|
|
3152
|
-
code: "INTERACTION_ID_NOT_DECLARED",
|
|
3153
|
-
error: `interaction "${name}" is not in the frozen vocabulary; allowed: [${frozenInteractionIds.join(", ")}]. Submit the declared id. To remove an already-recorded wrong interaction, call retract_data_flow with {"interaction": "${name}"}.`,
|
|
3154
|
-
});
|
|
3155
|
-
}
|
|
3156
|
-
return undefined;
|
|
3157
|
-
}
|
|
3158
|
-
: undefined);
|
|
3159
|
-
const retractDataFlowTool = defineTool({
|
|
3160
|
-
name: "retract_data_flow",
|
|
3161
|
-
label: "retract data flow",
|
|
3162
|
-
description: "Retract a previously recorded interaction data flow (e.g. one recorded under a wrong interaction id). The retracted record and every fact keyed to that interaction name stop participating in the compiled plan. Declared vocabulary ids cannot be retracted. Example: {\"interaction\": \"increment-counter\", \"reason\": \"recorded under a wrong id\"}",
|
|
3163
|
-
promptSnippet: "Retract one interaction data flow by interaction name.",
|
|
3164
|
-
parameters: Type.Object({
|
|
3165
|
-
interaction: Type.String({ minLength: 1 }),
|
|
3166
|
-
reason: Type.Optional(Type.String({ minLength: 1 })),
|
|
3167
|
-
}, { additionalProperties: false }),
|
|
3168
|
-
async execute(_toolCallId, params) {
|
|
3169
|
-
const interaction = typeof params?.interaction === "string" ? params.interaction : "";
|
|
3170
|
-
if (!interaction) {
|
|
3171
|
-
return planToolReceipt({ ok: false, kind: "data-flow", error: "retract_data_flow requires a non-empty interaction" });
|
|
3172
|
-
}
|
|
3173
|
-
if (frozenInteractionIds.includes(interaction)) {
|
|
3174
|
-
return planToolReceipt({ ok: false, kind: "data-flow", error: `interaction "${interaction}" is a declared vocabulary id and cannot be retracted` });
|
|
3175
|
-
}
|
|
3176
|
-
const result = await adoptDecisionFact("data-flow", `${attemptId}:retract_data_flow:${randomUUID()}`, { kind: "data-flow", origin: "plan", entry: { interaction, retracted: true, ...(typeof params?.reason === "string" ? { reason: params.reason } : {}) } });
|
|
3177
|
-
return planToolReceipt(result);
|
|
3178
|
-
},
|
|
3179
|
-
});
|
|
3180
|
-
const recordApiMockBoundaryTool = record("record_api_mock_boundary", "API/Mock boundary", "Record one API boundary and its mode (real/mock/not-needed) with evidence. The boundary is the stable identity: resubmit the same boundary with replace:true to change its mode (e.g. mock→real); the old decision is replaced, not appended. Real/mock endpoints require fixture (the test fixture path) and consumer (the implementation file that calls the API) — design policy rejects endpoints without them. Example: {\"entry\": {\"boundary\": \"GET /items\", \"mode\": \"mock\", \"evidence\": \"fixture only\", \"fixture\": \"test/fixtures/items.ts\", \"consumer\": \"src/page.tsx\"}}", Type.Object({
|
|
3181
|
-
boundary: Type.String({ minLength: 1 }),
|
|
3182
|
-
mode: Type.Union([Type.Literal("real"), Type.Literal("mock"), Type.Literal("not-needed")]),
|
|
3183
|
-
evidence: Type.String({ minLength: 1 }),
|
|
3184
|
-
fixture: optionalString,
|
|
3185
|
-
consumer: optionalString,
|
|
3186
|
-
}, { additionalProperties: false }), async (entry) => {
|
|
3187
|
-
// Design policy hard-requires fixture + consumer on every derived
|
|
3188
|
-
// endpoint (non-not-needed strategies); demand them here where the
|
|
3189
|
-
// model can still act instead of failing the compile post-session.
|
|
3190
|
-
if ((entry.mode === "real" || entry.mode === "mock") &&
|
|
3191
|
-
(!(typeof entry.fixture === "string" && entry.fixture.trim()) ||
|
|
3192
|
-
!(typeof entry.consumer === "string" && entry.consumer.trim()))) {
|
|
3193
|
-
return planToolReceipt({
|
|
3194
|
-
ok: false,
|
|
3195
|
-
kind: "api-mock-boundary",
|
|
3196
|
-
error: `record_api_mock_boundary fixture-and-consumer-required: ${entry.mode} boundary "${entry.boundary}" needs fixture (the test fixture path) and consumer (the implementation file that calls the API). Example: {"entry": {"boundary": "${entry.boundary}", "mode": "${entry.mode}", "evidence": "<why>", "fixture": "test/fixtures/items.ts", "consumer": "src/page.tsx"}}`,
|
|
3197
|
-
});
|
|
3198
|
-
}
|
|
3199
|
-
return undefined;
|
|
3200
|
-
});
|
|
3201
|
-
const recordVerificationFocusTool = record("record_verification_focus", "verification focus", "Record one behavior-group verification focus: which test file/command proves which behavior group at what evidence level. Every behaviorGroupId must have at least one record_state_ownership row whose owner equals it — declare one UI state per behavior group before finalizing. Example: {\"entry\": {\"id\": \"VT-1\", \"behaviorGroupId\": \"page\", \"file\": \"test/page.test.tsx\", \"commandId\": \"test\", \"evidenceLevel\": \"mounted\"}}", Type.Object({
|
|
3202
|
-
id: Type.String({ minLength: 1 }),
|
|
3203
|
-
behaviorGroupId: Type.String({ minLength: 1 }),
|
|
3204
|
-
file: Type.String({ minLength: 1 }),
|
|
3205
|
-
commandId: Type.String({ minLength: 1 }),
|
|
3206
|
-
evidenceLevel: Type.Union([Type.Literal("unit"), Type.Literal("mounted"), Type.Literal("real-integration")]),
|
|
3207
|
-
boundary: optionalString,
|
|
3208
|
-
}, { additionalProperties: false }), async (entry) => {
|
|
3209
|
-
// Mirror the relationship record_plan_verification_target boundary so
|
|
3210
|
-
// protocol errors surface in-node (bounded correction) instead of
|
|
3211
|
-
// burning attempts on an immutable committed fact that finalize rejects.
|
|
3212
|
-
// Duplicate/identity enforcement lives in the durable wrapper (FACT_IDENTITY_CONFLICT
|
|
3213
|
-
// unless replace:true) and the compile collapses same-id records to the
|
|
3214
|
-
// latest replacement, so no duplicate gate is needed here.
|
|
3215
|
-
const verificationCommandId = typeof entry.commandId === "string" ? entry.commandId.trim() : "";
|
|
3216
|
-
if (!verificationCommandId) {
|
|
3217
|
-
return planToolReceipt({
|
|
3218
|
-
ok: false,
|
|
3219
|
-
kind: "verification-focus",
|
|
3220
|
-
error: `record_verification_focus entry.commandId is required (received ${JSON.stringify(entry.commandId ?? null)}); pick one id from the frozen command directory in your prompt`,
|
|
3221
|
-
});
|
|
3222
|
-
}
|
|
3223
|
-
const { deriveFrontendVerifyCommandDirectoryFromRun, isFrontendTestFilePath } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
3224
|
-
const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
|
|
3225
|
-
if (verifyDirectory.length > 0) {
|
|
3226
|
-
const directoryEntry = verifyDirectory.find((candidate) => candidate.commandId === verificationCommandId);
|
|
3227
|
-
if (!directoryEntry) {
|
|
3228
|
-
return planToolReceipt({
|
|
3229
|
-
ok: false,
|
|
3230
|
-
kind: "verification-focus",
|
|
3231
|
-
error: `record_verification_focus verification-target-unknown-command: unknown commandId "${verificationCommandId}"; available frozen commands: [${verifyDirectory.map((candidate) => `${candidate.commandId} (${candidate.mode}: ${candidate.label})`).join(", ")}]`,
|
|
3232
|
-
});
|
|
3233
|
-
}
|
|
3234
|
-
if (directoryEntry.mode === "behavior" &&
|
|
3235
|
-
typeof entry.file === "string" &&
|
|
3236
|
-
!isFrontendTestFilePath(entry.file)) {
|
|
3237
|
-
return planToolReceipt({
|
|
3238
|
-
ok: false,
|
|
3239
|
-
kind: "verification-focus",
|
|
3240
|
-
error: `record_verification_focus verification-target-phase-mismatch: behavior command "${directoryEntry.label}" (${directoryEntry.commandId}) must bind a test file (__tests__/, tests?/, e2e/, cypress/, *.test.*, *.spec.*, *.cy.*); received file "${entry.file}"`,
|
|
3241
|
-
});
|
|
3242
|
-
}
|
|
1305
|
+
for (const [index, fact] of snapshot.facts.entries()) {
|
|
1306
|
+
const toolName = decisionKindToToolName(fact.kind);
|
|
1307
|
+
const tool = byName.get(toolName);
|
|
1308
|
+
if (!tool) {
|
|
1309
|
+
throw new Error(`parent decision replay: no tool for fact kind "${fact.kind}"`);
|
|
3243
1310
|
}
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
const ownershipRows = committedLedger.filter((event) => event.fact.kind === "state-ownership");
|
|
3255
|
-
const hasInteractionFacts = committedLedger.some((event) => event.fact.kind === "data-flow");
|
|
3256
|
-
if ((ownershipRows.length > 0 || hasInteractionFacts) &&
|
|
3257
|
-
!ownershipRows.some((event) => event.fact.entry?.owner ===
|
|
3258
|
-
entry.behaviorGroupId)) {
|
|
3259
|
-
return planToolReceipt({
|
|
3260
|
-
ok: false,
|
|
3261
|
-
kind: "verification-focus",
|
|
3262
|
-
error: `record_verification_focus undeclared-behavior-group-state: behavior group "${entry.behaviorGroupId}" (verification target ${entry.id}) has no record_state_ownership row; finalize derives each behavior target's UI states from ownership rows whose owner equals the behaviorGroupId, so record at least one for this group: {"state": "<name>", "owner": "${entry.behaviorGroupId}", "applicable": true, "expectedBehavior": "<what the state does>"}`,
|
|
3263
|
-
});
|
|
1311
|
+
const receipt = await tool.execute(`parent-replay:${index}`, {
|
|
1312
|
+
entry: fact.entry,
|
|
1313
|
+
});
|
|
1314
|
+
if (receipt.details?.ok !== true) {
|
|
1315
|
+
throw new Error(`parent decision replay failed for ${toolName} (identity ${JSON.stringify(fact.entry.purpose ??
|
|
1316
|
+
fact.entry.state ??
|
|
1317
|
+
fact.entry.interaction ??
|
|
1318
|
+
fact.entry.id ??
|
|
1319
|
+
fact.entry.boundary ??
|
|
1320
|
+
"?")}): ${receipt.details?.error ?? "unknown error"}`);
|
|
3264
1321
|
}
|
|
3265
|
-
|
|
3266
|
-
});
|
|
3267
|
-
const recordDependencyTool = record("record_dependency", "dependency", "Record one dependency name. Example: {\"entry\": {\"name\": \"none\"}}", Type.Object({ name: Type.String({ minLength: 1 }) }, { additionalProperties: false }));
|
|
3268
|
-
const finalizeDecisionTool = defineTool({
|
|
3269
|
-
name: "finalize_decision",
|
|
3270
|
-
label: "finalize_decision",
|
|
3271
|
-
description: "Compile the committed decision facts into a decision contract, pre-validate the derived canonical contract, and commit the terminal. Fails closed on any safety finding or validation finding; correct the reported record_* facts and call finalize_decision again.",
|
|
3272
|
-
promptSnippet: "Compile and finalize the committed decision facts.",
|
|
3273
|
-
parameters: Type.Object({}, { additionalProperties: false }),
|
|
3274
|
-
async execute(_toolCallId) {
|
|
3275
|
-
const committed = readCommittedEvents(store, attemptId);
|
|
3276
|
-
const decision = assembleDecisionContractFromFacts(committed.map(record => record.fact));
|
|
3277
|
-
const expectedWriteSet = [
|
|
3278
|
-
...decision.modulePlacements.flatMap(item => item.paths),
|
|
3279
|
-
...decision.verificationFocus.map(item => item.file),
|
|
3280
|
-
];
|
|
3281
|
-
const derived = assembleExperimentPlanContract({
|
|
3282
|
-
decision,
|
|
3283
|
-
authority,
|
|
3284
|
-
concreteWriteSet: expectedWriteSet,
|
|
3285
|
-
});
|
|
3286
|
-
if (derived.findings.length > 0) {
|
|
3287
|
-
return planToolReceipt({
|
|
3288
|
-
ok: false,
|
|
3289
|
-
kind: "finalize_decision",
|
|
3290
|
-
code: "DECISION_SAFETY_FINDINGS",
|
|
3291
|
-
error: `decision safety projection failed: ${derived.findings.join("; ")}`,
|
|
3292
|
-
});
|
|
3293
|
-
}
|
|
3294
|
-
const prevalidation = await prevalidateDerivedPatch();
|
|
3295
|
-
if (!prevalidation.ok) {
|
|
3296
|
-
return planToolReceipt({
|
|
3297
|
-
ok: false,
|
|
3298
|
-
kind: "finalize_decision",
|
|
3299
|
-
code: "DECISION_PREVALIDATION_FAILED",
|
|
3300
|
-
error: prevalidation.error,
|
|
3301
|
-
});
|
|
3302
|
-
}
|
|
3303
|
-
// Recovery-mode guard: a repair child that finalizes a plan identical
|
|
3304
|
-
// to its parent's has not performed the repair the findings demand —
|
|
3305
|
-
// the identical plan is exactly what admission rejected. Both hashes
|
|
3306
|
-
// use the same deterministic serializer over the canonical contract,
|
|
3307
|
-
// so only a real semantic change passes.
|
|
3308
|
-
// Recovery-mode guard: a repair child must demonstrably close the
|
|
3309
|
-
// parent findings before the run re-enters design review.
|
|
3310
|
-
if (input.parentDecisionSnapshot) {
|
|
3311
|
-
const { readParentCanonical, parentCanonicalContentSha256 } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
3312
|
-
const { extractRepairAssertions, evaluateRepairClosure, decisionIdentitySha256 } = await import("../workflows/dag/frontend-plan-decision-contract.js");
|
|
3313
|
-
const parentRunDir = path.join(input.runDir, "..", input.parentDecisionSnapshot.parentRunId);
|
|
3314
|
-
const parentCanonical = await readParentCanonical(parentRunDir);
|
|
3315
|
-
// 1. identical-plan guard. Decisions, not prose: a rationale-only
|
|
3316
|
-
// rewrite is not a repair (smoke r27 cleared a raw byte-hash guard
|
|
3317
|
-
// by editing two rationale strings and changing nothing else). The
|
|
3318
|
-
// byte hash stays as the fallback when the parent canonical cannot
|
|
3319
|
-
// be read.
|
|
3320
|
-
const parentSha = await parentCanonicalContentSha256(parentRunDir);
|
|
3321
|
-
const parentDecisions = parentCanonical
|
|
3322
|
-
? decisionIdentitySha256(parentCanonical)
|
|
3323
|
-
: undefined;
|
|
3324
|
-
const childDecisions = decisionIdentitySha256(prevalidation.canonical);
|
|
3325
|
-
if (parentDecisions
|
|
3326
|
-
? parentDecisions === childDecisions
|
|
3327
|
-
: parentSha !== undefined && parentSha === prevalidation.canonicalSha256) {
|
|
3328
|
-
return planToolReceipt({
|
|
3329
|
-
ok: false,
|
|
3330
|
-
kind: "finalize_decision",
|
|
3331
|
-
code: "DECISION_REPAIR_NO_CHANGE",
|
|
3332
|
-
error: "recovery finalize blocked: every structured decision row is identical to the parent run's (rationale-only edits do not count as a repair), but the review findings require changes. Correct the flagged rows (same identity, replace:true), then call finalize_decision again.",
|
|
3333
|
-
});
|
|
3334
|
-
}
|
|
3335
|
-
// 2. per-finding closure: every deterministic assertion extracted
|
|
3336
|
-
// from the parent findings must hold on the child canonical.
|
|
3337
|
-
const findings = input.parentDecisionSnapshot.designFindings ?? [];
|
|
3338
|
-
if (parentCanonical && findings.length > 0) {
|
|
3339
|
-
const assertions = extractRepairAssertions(findings, parentCanonical);
|
|
3340
|
-
const closure = evaluateRepairClosure(assertions, prevalidation.canonical, parentCanonical);
|
|
3341
|
-
if (!closure.closed) {
|
|
3342
|
-
return planToolReceipt({
|
|
3343
|
-
ok: false,
|
|
3344
|
-
kind: "finalize_decision",
|
|
3345
|
-
code: "DECISION_REPAIR_NOT_CLOSED",
|
|
3346
|
-
error: `recovery finalize blocked: ${closure.unmet.length} finding(s) are still not closed — ${closure.unmet.join("; ")}. Correct the flagged rows (same identity, replace:true), then call finalize_decision again.`,
|
|
3347
|
-
});
|
|
3348
|
-
}
|
|
3349
|
-
}
|
|
3350
|
-
}
|
|
3351
|
-
const result = await adoptDecisionFact("finalize_decision", `${attemptId}:finalize_decision:${randomUUID()}`, { kind: "finalize_decision", origin: "plan", findings: [] });
|
|
3352
|
-
return planToolReceipt(result);
|
|
3353
|
-
},
|
|
3354
|
-
});
|
|
3355
|
-
const durable = await createDurableFrontendTools({
|
|
3356
|
-
file: path.join(input.runDir, input.nodeId, "plan-decision-facts.jsonl"),
|
|
3357
|
-
attemptId,
|
|
3358
|
-
store: input.store,
|
|
3359
|
-
setWorkingStore: next => { store = next; },
|
|
3360
|
-
binding: { authority },
|
|
3361
|
-
tools: [
|
|
3362
|
-
recordModulePlacementTool,
|
|
3363
|
-
recordReuseDecisionTool,
|
|
3364
|
-
recordStateOwnershipTool,
|
|
3365
|
-
recordDataFlowTool,
|
|
3366
|
-
retractDataFlowTool,
|
|
3367
|
-
recordApiMockBoundaryTool,
|
|
3368
|
-
recordVerificationFocusTool,
|
|
3369
|
-
recordDependencyTool,
|
|
3370
|
-
finalizeDecisionTool,
|
|
3371
|
-
],
|
|
3372
|
-
});
|
|
3373
|
-
const durableFinalizeDecisionTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "finalize_decision");
|
|
3374
|
-
if (!durableFinalizeDecisionTool)
|
|
3375
|
-
throw new Error("frontend plan decision durable finalize tool unavailable");
|
|
3376
|
-
return {
|
|
3377
|
-
customTools: durable.customTools,
|
|
3378
|
-
flush: durable.flush,
|
|
3379
|
-
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
3380
|
-
finalizeDecision: () => durableFinalizeDecisionTool.execute(`${attemptId}:auto-finalize-decision`, {}, undefined, undefined, {}),
|
|
3381
|
-
};
|
|
1322
|
+
}
|
|
3382
1323
|
}
|
|
3383
1324
|
/**
|
|
3384
1325
|
* Decision → relationship bridge: after a successful decision `finalize`
|
|
@@ -3396,606 +1337,97 @@ export async function createFrontendPlanDecisionTools(input) {
|
|
|
3396
1337
|
* the exact validation the relationship finalize receipt runs (merge onto the
|
|
3397
1338
|
* runtime skeleton + analyzeFrontendPlanPatchCandidate). When the derived
|
|
3398
1339
|
* canonical fails, this returns a fixable error and the executor turns it into
|
|
3399
|
-
* an invalid-output so the node retry ladder restarts the session with the
|
|
3400
|
-
* diagnostics instead of writing a ledger that R1 would reject.
|
|
3401
|
-
*/
|
|
3402
|
-
export async function bridgeFrontendPlanDecisionToRelationshipLedger(input) {
|
|
3403
|
-
const { assembleDecisionContractFromFacts, buildFrontendPlanRelationshipPatch, } = await import("../workflows/dag/frontend-plan-decision-contract.js");
|
|
3404
|
-
const committed = input.facts
|
|
3405
|
-
.filter(record => record.phase === "committed")
|
|
3406
|
-
.map(record => record.fact);
|
|
3407
|
-
const decision = assembleDecisionContractFromFacts(committed);
|
|
3408
|
-
const concreteWriteSet = [
|
|
3409
|
-
...new Set([
|
|
3410
|
-
...decision.modulePlacements.flatMap(item => item.paths),
|
|
3411
|
-
...decision.verificationFocus.map(item => item.file),
|
|
3412
|
-
]),
|
|
3413
|
-
];
|
|
3414
|
-
// Contract-declared deliverable obligations (the same ledger the analyzer
|
|
3415
|
-
// extracts `requiredDeliverables` from): bind each path to its requirement
|
|
3416
|
-
// so the derived canonical contract plans every contract-mandated file.
|
|
3417
|
-
const requiredDeliverables = new Map();
|
|
3418
|
-
try {
|
|
3419
|
-
const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
3420
|
-
for (const record of [...contractRecords].reverse()) {
|
|
3421
|
-
const fact = record.fact;
|
|
3422
|
-
if (!fact || fact.origin !== "contract" || fact.kind !== "required-deliverables")
|
|
3423
|
-
continue;
|
|
3424
|
-
for (const item of Array.isArray(fact.items) ? fact.items : []) {
|
|
3425
|
-
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
3426
|
-
continue;
|
|
3427
|
-
const requirementId = item.requirementId;
|
|
3428
|
-
const file = item.path;
|
|
3429
|
-
if (typeof requirementId !== "string" || typeof file !== "string")
|
|
3430
|
-
continue;
|
|
3431
|
-
const paths = requiredDeliverables.get(requirementId) ?? [];
|
|
3432
|
-
paths.push(file);
|
|
3433
|
-
requiredDeliverables.set(requirementId, paths);
|
|
3434
|
-
}
|
|
3435
|
-
break;
|
|
3436
|
-
}
|
|
3437
|
-
}
|
|
3438
|
-
catch {
|
|
3439
|
-
// Missing/unreadable contract ledger → no deliverable obligations to merge.
|
|
3440
|
-
}
|
|
3441
|
-
if (!input.skeleton || !input.sourceBinding) {
|
|
3442
|
-
return { ok: false, error: "frontend decision bridge requires the runtime skeleton and sourceBinding" };
|
|
3443
|
-
}
|
|
3444
|
-
let patch;
|
|
3445
|
-
try {
|
|
3446
|
-
const { analyzeFrontendPlanPatchCandidate, applyFrontendContractMergePatch, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
3447
|
-
patch = buildFrontendPlanRelationshipPatch({
|
|
3448
|
-
decision,
|
|
3449
|
-
authority: input.authority,
|
|
3450
|
-
concreteWriteSet,
|
|
3451
|
-
requiredDeliverables,
|
|
3452
|
-
});
|
|
3453
|
-
const merged = applyFrontendContractMergePatch(input.skeleton, patch);
|
|
3454
|
-
await analyzeFrontendPlanPatchCandidate({
|
|
3455
|
-
runDir: input.runDir,
|
|
3456
|
-
rawContractText: serializeDeterministicJson(merged),
|
|
3457
|
-
sourceBinding: input.sourceBinding,
|
|
3458
|
-
});
|
|
3459
|
-
}
|
|
3460
|
-
catch (error) {
|
|
3461
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
3462
|
-
return {
|
|
3463
|
-
ok: false,
|
|
3464
|
-
// This path should be rare now that finalize_decision pre-validates
|
|
3465
|
-
// the same patch in-session; keep the retry-prompt diagnostics in
|
|
3466
|
-
// decision-channel vocabulary regardless.
|
|
3467
|
-
error: `frontend decision → relationship patch failed pre-validation (correct the reported decision facts, then the retry ladder restarts the session): ${translateDecisionPatchFindings(raw, decision)}`,
|
|
3468
|
-
};
|
|
3469
|
-
}
|
|
3470
|
-
const { createTypedEventStore, stageTypedEventRecord, commitTypedEventRecord, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
3471
|
-
const file = path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl");
|
|
3472
|
-
const store = createTypedEventStore();
|
|
3473
|
-
const adopt = (kind, patchValue, revision) => {
|
|
3474
|
-
const eventId = `decision-bridge:${input.attemptId}:${kind}`;
|
|
3475
|
-
const fact = { kind, origin: "plan", patch: patchValue };
|
|
3476
|
-
stageTypedEventRecord(store, {
|
|
3477
|
-
eventId,
|
|
3478
|
-
requestId: eventId,
|
|
3479
|
-
attemptId: input.attemptId,
|
|
3480
|
-
fact,
|
|
3481
|
-
});
|
|
3482
|
-
return commitTypedEventRecord(store, eventId, revision);
|
|
3483
|
-
};
|
|
3484
|
-
adopt("target-surface", patch, 1);
|
|
3485
|
-
adopt("finalize_plan", patch, 2);
|
|
3486
|
-
// The ledger is derived wholesale from the committed decision facts, so a
|
|
3487
|
-
// re-run rewrites it atomically (no incremental append semantics needed).
|
|
3488
|
-
await writeTypedEventStoreJsonl(file, store.records.filter(record => record.phase === "committed"));
|
|
3489
|
-
return { ok: true, patch };
|
|
3490
|
-
}
|
|
3491
|
-
function isRecordObject(value) {
|
|
3492
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3493
|
-
}
|
|
3494
|
-
/** A contract fact is source-mapped when it carries a non-empty `sourceSpan`
|
|
3495
|
-
* (object or string), a non-empty `sourceRefs` array, or a non-empty `source`
|
|
3496
|
-
* string. Anything else is an unmapped source segment (AC-001). */
|
|
3497
|
-
function contractFactSourceSpan(fact) {
|
|
3498
|
-
if (isRecordObject(fact.sourceSpan) || typeof fact.sourceSpan === "string") {
|
|
3499
|
-
return fact.sourceSpan;
|
|
3500
|
-
}
|
|
3501
|
-
if (Array.isArray(fact.sourceRefs) && fact.sourceRefs.length > 0) {
|
|
3502
|
-
return fact.sourceRefs;
|
|
3503
|
-
}
|
|
3504
|
-
if (Array.isArray(fact.sourceFragmentIds) &&
|
|
3505
|
-
fact.sourceFragmentIds.some((value) => typeof value === "string" && value.trim().length > 0)) {
|
|
3506
|
-
return fact.sourceFragmentIds;
|
|
3507
|
-
}
|
|
3508
|
-
if (typeof fact.source === "string" && fact.source.trim().length > 0) {
|
|
3509
|
-
return fact.source;
|
|
3510
|
-
}
|
|
3511
|
-
return undefined;
|
|
3512
|
-
}
|
|
3513
|
-
/**
|
|
3514
|
-
* A+B (AC-001): deterministically assemble the read-only `frontend-task-contract-vNext`
|
|
3515
|
-
* audit artifact from committed contract facts. It never rewrites the original
|
|
3516
|
-
* task source: unmapped requirement/constraint segments are listed explicitly
|
|
3517
|
-
* so Plan/Implement/Review keep binding to the raw source, not the model's
|
|
3518
|
-
* summarized contract. The blocked disposition is projected through the frozen
|
|
3519
|
-
* `mapContractBlockedOwner` mapping (AC-002).
|
|
3520
|
-
*/
|
|
3521
|
-
export function buildFrontendTaskContractVNext(records) {
|
|
3522
|
-
const facts = records
|
|
3523
|
-
.filter((record) => record.phase === "committed")
|
|
3524
|
-
.map((record) => record.fact)
|
|
3525
|
-
.filter(isRecordObject);
|
|
3526
|
-
const finalized = facts.find((fact) => fact.kind === "contract-finalized");
|
|
3527
|
-
const disposition = typeof finalized?.disposition === "string" ? finalized.disposition : null;
|
|
3528
|
-
const blockingOwner = typeof finalized?.blockingOwner === "string"
|
|
3529
|
-
? finalized.blockingOwner
|
|
3530
|
-
: null;
|
|
3531
|
-
const projected = mapContractBlockedOwner({
|
|
3532
|
-
disposition: disposition ?? "",
|
|
3533
|
-
blockingOwner: blockingOwner ?? undefined,
|
|
3534
|
-
});
|
|
3535
|
-
const byKind = (kind) => facts.filter((fact) => fact.kind === kind);
|
|
3536
|
-
const requirements = byKind("requirement");
|
|
3537
|
-
const unmappedSourceSegments = requirements
|
|
3538
|
-
.filter((fact) => contractFactSourceSpan(fact) === undefined)
|
|
3539
|
-
.map((fact) => ({
|
|
3540
|
-
kind: "requirement",
|
|
3541
|
-
id: typeof fact.id === "string"
|
|
3542
|
-
? fact.id
|
|
3543
|
-
: typeof fact.text === "string"
|
|
3544
|
-
? fact.text
|
|
3545
|
-
: undefined,
|
|
3546
|
-
}))
|
|
3547
|
-
.filter((segment) => segment.id !== undefined);
|
|
3548
|
-
return {
|
|
3549
|
-
schemaVersion: 1,
|
|
3550
|
-
schemaId: "frontend-task-contract-vNext",
|
|
3551
|
-
disposition,
|
|
3552
|
-
blockingOwner,
|
|
3553
|
-
blockedOwner: projected === "not-blocked" ? null : projected,
|
|
3554
|
-
requirements,
|
|
3555
|
-
constraints: byKind("constraint"),
|
|
3556
|
-
evidenceExpectations: byKind("evidence-expectation"),
|
|
3557
|
-
deliverableDeclarations: byKind("required-deliverables").at(-1)?.items ?? [],
|
|
3558
|
-
handoffIntents: byKind("handoff-intent"),
|
|
3559
|
-
openQuestions: byKind("open-question"),
|
|
3560
|
-
splitProposals: byKind("split-proposal"),
|
|
3561
|
-
unmappedSourceSegments,
|
|
3562
|
-
};
|
|
3563
|
-
}
|
|
3564
|
-
/**
|
|
3565
|
-
* A+B: `frontend-contract-pi` incremental contract tools. The `record_*` tools
|
|
3566
|
-
* commit origin=contract facts and `finalize_contract` commits the terminal
|
|
3567
|
-
* disposition (ready | ready-with-assumptions | blocked + blockingOwner).
|
|
3568
|
-
*/
|
|
3569
|
-
export async function createFrontendContractTools(input) {
|
|
3570
|
-
const [{ Type }, { defineTool }] = await Promise.all([
|
|
3571
|
-
import("typebox"),
|
|
3572
|
-
import("@earendil-works/pi-coding-agent"),
|
|
3573
|
-
]);
|
|
3574
|
-
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
3575
|
-
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
3576
|
-
let store = input.store;
|
|
3577
|
-
const attemptId = input.attemptId;
|
|
3578
|
-
let activeScope = null;
|
|
3579
|
-
const committedRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement").map(r => String(r.fact.id)));
|
|
3580
|
-
const completedScopeRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "contract-scope-completed").flatMap(r => Array.isArray(r.fact.requirementIds) ? r.fact.requirementIds.filter((id) => typeof id === "string") : []));
|
|
3581
|
-
const receipt = (details) => ({
|
|
3582
|
-
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
3583
|
-
details,
|
|
3584
|
-
});
|
|
3585
|
-
async function adoptContractFact(kind, fact) {
|
|
3586
|
-
try {
|
|
3587
|
-
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
3588
|
-
const staged = stageTypedEventFact({
|
|
3589
|
-
store,
|
|
3590
|
-
requestId,
|
|
3591
|
-
attemptId,
|
|
3592
|
-
fact: fact,
|
|
3593
|
-
});
|
|
3594
|
-
const committed = await adoptTypedEventFact({
|
|
3595
|
-
store,
|
|
3596
|
-
requestId,
|
|
3597
|
-
attemptId,
|
|
3598
|
-
fact: fact,
|
|
3599
|
-
eventId: staged.eventId,
|
|
3600
|
-
expectedRevision: store.revision,
|
|
3601
|
-
});
|
|
3602
|
-
return {
|
|
3603
|
-
ok: true,
|
|
3604
|
-
kind,
|
|
3605
|
-
eventId: committed.eventId,
|
|
3606
|
-
revision: committed.revision,
|
|
3607
|
-
error: "",
|
|
3608
|
-
};
|
|
3609
|
-
}
|
|
3610
|
-
catch (error) {
|
|
3611
|
-
return {
|
|
3612
|
-
ok: false,
|
|
3613
|
-
kind,
|
|
3614
|
-
code: error?.code,
|
|
3615
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3616
|
-
};
|
|
3617
|
-
}
|
|
3618
|
-
}
|
|
3619
|
-
const recordKinds = {
|
|
3620
|
-
record_constraint: "constraint",
|
|
3621
|
-
record_handoff_intent: "handoff-intent",
|
|
3622
|
-
record_open_question: "open-question",
|
|
3623
|
-
record_split_proposal: "split-proposal",
|
|
3624
|
-
};
|
|
3625
|
-
const recordTools = Object.entries(recordKinds).map(([name, kind]) => defineTool({
|
|
3626
|
-
name,
|
|
3627
|
-
label: name,
|
|
3628
|
-
description: `Commit an origin=contract ${kind} fact. IMPORTANT: submit incrementally — batch up to 5 record_* calls per message, starting from the FIRST message; never attempt to emit the whole contract in one response (a single large dump will be truncated and rejected). Every message must make progress by committing at least one record_* fact.`,
|
|
3629
|
-
promptSnippet: `Commit 1-5 origin=contract ${kind} facts (up to 5 per message).`,
|
|
3630
|
-
parameters: Type.Object({
|
|
3631
|
-
text: Type.String({ minLength: 1 }),
|
|
3632
|
-
requirementIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
3633
|
-
sourceFragmentIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
|
|
3634
|
-
...(kind === "constraint" ? { category: Type.Optional(Type.Enum({ constraint: "constraint", "non-goal": "non-goal", risk: "risk" })) } : {}),
|
|
3635
|
-
...(kind === "handoff-intent" ? { taskKind: Type.Literal("frontend-test"), blocking: Type.Optional(Type.Boolean()) } : {}),
|
|
3636
|
-
}, { additionalProperties: false }),
|
|
3637
|
-
async execute(_toolCallId, params) {
|
|
3638
|
-
const data = params;
|
|
3639
|
-
if (!data.text?.trim())
|
|
3640
|
-
return receipt({ ok: false, code: "TOOL_SCHEMA_INVALID", error: `${name}: text must be non-empty` });
|
|
3641
|
-
const knownFragments = new Set([...(input.canonicalRequirements?.values() ?? [])].flatMap(r => r.sourceFragmentIds));
|
|
3642
|
-
if (data.requirementIds?.some(id => !input.canonicalRequirements?.has(id)) || data.sourceFragmentIds?.some(id => !knownFragments.has(id)))
|
|
3643
|
-
return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `${name}: reference is outside the frozen source inventory` });
|
|
3644
|
-
const result = await adoptContractFact(kind, {
|
|
3645
|
-
...(params ?? {}),
|
|
3646
|
-
kind,
|
|
3647
|
-
origin: "contract",
|
|
3648
|
-
});
|
|
3649
|
-
return receipt(result);
|
|
3650
|
-
},
|
|
3651
|
-
}));
|
|
3652
|
-
// record_requirement is runtime-owned by design: the requirement TEXT and
|
|
3653
|
-
// sourceFragmentIds come from the frozen source-fidelity ledger, never from
|
|
3654
|
-
// model-authored prose. The model only names the canonical id it confirms.
|
|
3655
|
-
// This closes the free-shape hole (additionalProperties:true accepted
|
|
3656
|
-
// `statement` rewrites and JSON-stringified `sourceFragmentIds` arrays,
|
|
3657
|
-
// which then reached the planner as empty text + dead fragment bindings).
|
|
3658
|
-
const recordRequirementTool = defineTool({
|
|
3659
|
-
name: "record_requirement",
|
|
3660
|
-
label: "record_requirement",
|
|
3661
|
-
description: 'Confirm one canonical ledger requirement (origin=contract requirement fact). Pass the canonical id and optional execution:{groupId,kind,summary} only. Reuse a group only when its behavior and all permission/threshold/error conditions agree; retain separate groups for differences. Constraints/exclusions do not require invented UI. The canonical id is listed in the <frontend_contract_input> inventory, e.g. {"id": "AC-001"} — the runtime commits the authoritative text and sourceFragmentIds from the frozen ledger. Never pass text/statement/sourceFragmentIds yourself: free-form rewrites and JSON-stringified fragment arrays are rejected. IMPORTANT: batch up to 5 record_* calls per message, starting from the FIRST message.',
|
|
3662
|
-
promptSnippet: "Confirm 1-5 canonical requirements by id (up to 5 per message).",
|
|
3663
|
-
parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }), execution: Type.Optional(Type.Object({ groupId: Type.String({ minLength: 1 }), kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]), summary: Type.String({ minLength: 1 }) }, { additionalProperties: false })) }, { additionalProperties: false }),
|
|
3664
|
-
async execute(_toolCallId, params) {
|
|
3665
|
-
const id = typeof params?.id === "string" ? params.id.trim() : "";
|
|
3666
|
-
if (!id) {
|
|
3667
|
-
return receipt({
|
|
3668
|
-
ok: false,
|
|
3669
|
-
kind: "requirement",
|
|
3670
|
-
error: "record_requirement requires the canonical requirement id",
|
|
3671
|
-
});
|
|
3672
|
-
}
|
|
3673
|
-
if (activeScope && !activeScope.has(id))
|
|
3674
|
-
return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `requirement ${id} is outside the complete input scope of this session` });
|
|
3675
|
-
const canonical = input.canonicalRequirements?.get(id);
|
|
3676
|
-
if (!canonical) {
|
|
3677
|
-
const known = [...(input.canonicalRequirements?.keys() ?? [])];
|
|
3678
|
-
return receipt({
|
|
3679
|
-
ok: false,
|
|
3680
|
-
kind: "requirement",
|
|
3681
|
-
error: `record_requirement id "${id}" is not a canonical ledger requirement; canonical ids are: ${known.join(", ") || "(none)"}`,
|
|
3682
|
-
});
|
|
3683
|
-
}
|
|
3684
|
-
if (params.execution) {
|
|
3685
|
-
try {
|
|
3686
|
-
collectFrontendExecutionGroups([...readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement" && r.fact.id !== id).map(r => ({ id: String(r.fact.id), execution: r.fact.execution })), { id, execution: params.execution }]);
|
|
3687
|
-
}
|
|
3688
|
-
catch (error) {
|
|
3689
|
-
return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: String(error) });
|
|
3690
|
-
}
|
|
3691
|
-
}
|
|
3692
|
-
const result = await adoptContractFact("requirement", {
|
|
3693
|
-
kind: "requirement",
|
|
3694
|
-
origin: "contract",
|
|
3695
|
-
disposition: "explicit",
|
|
3696
|
-
id,
|
|
3697
|
-
text: canonical.text,
|
|
3698
|
-
sourceFragmentIds: canonical.sourceFragmentIds,
|
|
3699
|
-
...(params.execution ? { execution: params.execution } : {}),
|
|
3700
|
-
});
|
|
3701
|
-
return receipt(result);
|
|
3702
|
-
},
|
|
3703
|
-
});
|
|
3704
|
-
// Requirement identity/text/source are runtime-owned and seeded before the
|
|
3705
|
-
// model starts. Execution grouping is still a model decision, so expose it
|
|
3706
|
-
// as a small typed update instead of forcing the model to re-submit the same
|
|
3707
|
-
// requirement just to attach execution metadata.
|
|
3708
|
-
const recordRequirementExecutionTool = defineTool({
|
|
3709
|
-
name: "record_requirement_execution",
|
|
3710
|
-
label: "record_requirement_execution",
|
|
3711
|
-
description: "Attach execution ownership to already-confirmed canonical requirements.",
|
|
3712
|
-
promptSnippet: "Record execution group metadata for confirmed requirements.",
|
|
3713
|
-
parameters: Type.Object({
|
|
3714
|
-
requirementIds: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, uniqueItems: true }),
|
|
3715
|
-
execution: Type.Object({
|
|
3716
|
-
groupId: Type.String({ minLength: 1 }),
|
|
3717
|
-
kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]),
|
|
3718
|
-
summary: Type.String({ minLength: 1 }),
|
|
3719
|
-
}, { additionalProperties: false }),
|
|
3720
|
-
}, { additionalProperties: false }),
|
|
3721
|
-
async execute(callId, params) {
|
|
3722
|
-
const ids = params.requirementIds;
|
|
3723
|
-
if (activeScope && ids.some((id) => !activeScope?.has(id)))
|
|
3724
|
-
return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "execution metadata references a requirement outside the active contract scope" });
|
|
3725
|
-
const canonical = input.canonicalRequirements;
|
|
3726
|
-
const unknown = ids.filter((id) => !canonical?.has(id));
|
|
3727
|
-
if (unknown.length > 0)
|
|
3728
|
-
return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `record_requirement_execution references unknown canonical requirements: ${unknown.join(", ")}` });
|
|
3729
|
-
const execution = frontendExecutionSchema.parse(params.execution);
|
|
3730
|
-
const existing = readCommittedEvents(store, attemptId)
|
|
3731
|
-
.filter((record) => record.fact.kind === "requirement")
|
|
3732
|
-
.map((record) => ({ id: String(record.fact.id), execution: record.fact.execution }))
|
|
3733
|
-
.filter((unit) => !ids.includes(unit.id));
|
|
3734
|
-
try {
|
|
3735
|
-
collectFrontendExecutionGroups([...existing, ...ids.map((id) => ({ id, execution }))]);
|
|
3736
|
-
}
|
|
3737
|
-
catch (error) {
|
|
3738
|
-
return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: error instanceof Error ? error.message : String(error) });
|
|
3739
|
-
}
|
|
3740
|
-
let result = { ok: true };
|
|
3741
|
-
for (const id of ids) {
|
|
3742
|
-
const requirement = canonical.get(id);
|
|
3743
|
-
result = await adoptContractFact("requirement", { kind: "requirement", origin: "contract", disposition: "explicit", id, text: requirement.text, sourceFragmentIds: requirement.sourceFragmentIds, execution });
|
|
3744
|
-
if (result.ok !== true)
|
|
3745
|
-
return receipt(result);
|
|
3746
|
-
}
|
|
3747
|
-
return receipt({ ...result, requestId: callId });
|
|
3748
|
-
},
|
|
3749
|
-
});
|
|
3750
|
-
// Authoritative UI state declarations: the contract node extracts the
|
|
3751
|
-
// PRD/reference UI-state table into structured facts so the planner binds
|
|
3752
|
-
// uiStates to declared ids instead of inventing names (dogfood
|
|
3753
|
-
// dag-1788504923861-0f7b17a9: 10 invented list-visibility variants, 0 of
|
|
3754
|
-
// the 7 PRD states).
|
|
3755
|
-
const recordUiStateTool = defineTool({
|
|
3756
|
-
name: "record_ui_state",
|
|
3757
|
-
label: "record_ui_state",
|
|
3758
|
-
description: 'Declare ONE authoritative UI state extracted from the task source\'s UI-state table (origin=contract ui-state-declaration fact). Call once per declared state, exactly as the source names it: {"id": "<state id from the source table>", "trigger": "<when this state applies>", "observableOutcome": "<what the user can observe>"}. The planner must bind these ids later; do not rename or invent states.',
|
|
3759
|
-
promptSnippet: "Declare 1-5 authoritative UI states (up to 5 per message).",
|
|
3760
|
-
parameters: Type.Object({
|
|
3761
|
-
id: Type.String({ description: "UI state id exactly as the source table declares it" }),
|
|
3762
|
-
trigger: Type.String({ description: "When this state applies" }),
|
|
3763
|
-
observableOutcome: Type.String({ description: "Observable result for the user" }),
|
|
3764
|
-
}, { additionalProperties: false }),
|
|
3765
|
-
async execute(_toolCallId, params) {
|
|
3766
|
-
const id = typeof params?.id === "string" ? params.id.trim() : "";
|
|
3767
|
-
const trigger = typeof params?.trigger === "string" ? params.trigger.trim() : "";
|
|
3768
|
-
const observableOutcome = typeof params?.observableOutcome === "string"
|
|
3769
|
-
? params.observableOutcome.trim()
|
|
3770
|
-
: "";
|
|
3771
|
-
if (!id || !trigger || !observableOutcome) {
|
|
3772
|
-
return receipt({
|
|
3773
|
-
ok: false,
|
|
3774
|
-
kind: "ui-state-declaration",
|
|
3775
|
-
error: "record_ui_state requires non-empty id, trigger, and observableOutcome",
|
|
3776
|
-
});
|
|
3777
|
-
}
|
|
3778
|
-
const result = await adoptContractFact("ui-state-declaration", {
|
|
3779
|
-
kind: "ui-state-declaration",
|
|
3780
|
-
origin: "contract",
|
|
3781
|
-
id,
|
|
3782
|
-
trigger,
|
|
3783
|
-
observableOutcome,
|
|
3784
|
-
});
|
|
3785
|
-
return receipt(result);
|
|
3786
|
-
},
|
|
3787
|
-
});
|
|
3788
|
-
const evidenceStatus = Type.Enum({
|
|
3789
|
-
required: "required", optional: "optional", "not-applicable": "not-applicable",
|
|
3790
|
-
});
|
|
3791
|
-
const recordEvidenceExpectationTool = defineTool({
|
|
3792
|
-
name: "record_evidence_expectation",
|
|
3793
|
-
label: "record_evidence_expectation",
|
|
3794
|
-
description: 'Record evidence requirements as {requirementId:"<canonical id>",evidence:{static:"required|optional|not-applicable",behavior:"required|optional|not-applicable",mock:"required|optional|not-applicable","real-integration":"required|optional|not-applicable"}}. Declare at least one lane; later calls replace only the lanes they name.',
|
|
3795
|
-
parameters: Type.Object({
|
|
3796
|
-
requirementId: Type.String(),
|
|
3797
|
-
evidence: Type.Object({
|
|
3798
|
-
static: Type.Optional(evidenceStatus),
|
|
3799
|
-
behavior: Type.Optional(evidenceStatus),
|
|
3800
|
-
mock: Type.Optional(evidenceStatus),
|
|
3801
|
-
"real-integration": Type.Optional(evidenceStatus),
|
|
3802
|
-
}, { additionalProperties: false }),
|
|
3803
|
-
}, { additionalProperties: false }),
|
|
3804
|
-
async execute(_toolCallId, params) {
|
|
3805
|
-
try {
|
|
3806
|
-
const fact = frontendEvidenceExpectationSchema.parse(params);
|
|
3807
|
-
if (!input.canonicalRequirements?.has(fact.requirementId)) {
|
|
3808
|
-
throw new Error(`unknown canonical requirement ${fact.requirementId}`);
|
|
3809
|
-
}
|
|
3810
|
-
return receipt(await adoptContractFact("evidence-expectation", {
|
|
3811
|
-
kind: "evidence-expectation", origin: "contract", ...fact,
|
|
3812
|
-
}));
|
|
3813
|
-
}
|
|
3814
|
-
catch (error) {
|
|
3815
|
-
return receipt({
|
|
3816
|
-
ok: false, kind: "evidence-expectation",
|
|
3817
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3818
|
-
});
|
|
3819
|
-
}
|
|
3820
|
-
},
|
|
3821
|
-
});
|
|
3822
|
-
const recordRequiredDeliverablesTool = defineTool({
|
|
3823
|
-
name: "record_required_deliverables",
|
|
3824
|
-
label: "record_required_deliverables",
|
|
3825
|
-
description: 'Declare the complete source-required file deliverables as {items:[{path,requirementId,sourceFragmentId}]}. Interpret obligations from the original source, including lists/tables: permissions (allowedPaths/only allowed to modify), prohibitions, examples and read-only references are NOT delivery obligations. Each path must appear exactly in its frozen requirement-bound source fragment. Submit {items:[]} explicitly if no files are mandatory. Each call appends complete source-bound items; replace:true explicitly replaces the inventory. Required before finalize_contract ready.',
|
|
3826
|
-
parameters: Type.Object({ replace: Type.Optional(Type.Boolean()), items: Type.Array(Type.Object({
|
|
3827
|
-
path: Type.String(), requirementId: Type.String(), sourceFragmentId: Type.String(),
|
|
3828
|
-
}, { additionalProperties: false })) }, { additionalProperties: false }),
|
|
3829
|
-
async execute(_toolCallId, params) {
|
|
3830
|
-
try {
|
|
3831
|
-
const declaration = validateFrontendRequiredDeliverables({ items: params.items }, input.canonicalRequirements ?? new Map());
|
|
3832
|
-
const previous = params.replace ? [] : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "required-deliverables").at(-1)?.fact.items;
|
|
3833
|
-
const items = [...new Map([...(Array.isArray(previous) ? previous : []), ...declaration.items].map(item => [JSON.stringify(item), item])).values()];
|
|
3834
|
-
return receipt(await adoptContractFact("required-deliverables", {
|
|
3835
|
-
kind: "required-deliverables", origin: "contract", items,
|
|
3836
|
-
}));
|
|
3837
|
-
}
|
|
3838
|
-
catch (error) {
|
|
3839
|
-
return receipt({
|
|
3840
|
-
ok: false, kind: "required-deliverables",
|
|
3841
|
-
error: error instanceof Error ? error.message : String(error),
|
|
3842
|
-
});
|
|
3843
|
-
}
|
|
3844
|
-
},
|
|
3845
|
-
});
|
|
3846
|
-
// OpenSpec selection committed as individual typed facts (one path per
|
|
3847
|
-
// call) so a large candidate set never exceeds a single model output
|
|
3848
|
-
// budget: each tool call carries exactly one {path, disposition,
|
|
3849
|
-
// rationale} row and the ledger accumulates them across calls. Only
|
|
3850
|
-
// positive classifications (required | relevant) are legal; unmentioned
|
|
3851
|
-
// candidates default to irrelevant at the prewrite gate.
|
|
3852
|
-
const recordOpenspecSelectionTool = defineTool({
|
|
3853
|
-
name: "record_openspec_selection",
|
|
3854
|
-
label: "record_openspec_selection",
|
|
3855
|
-
description: "Commit one OpenSpec candidate classification (origin=contract openspec-selection fact). Call once per path you actually use or consult: required (must be read and cited) or relevant (informs planning). Never call it for irrelevant candidates — unmentioned candidates default to irrelevant. You may call it many times; one row per call.",
|
|
3856
|
-
promptSnippet: "Commit one OpenSpec candidate classification (required | relevant); one path per call; skip irrelevant candidates.",
|
|
3857
|
-
parameters: Type.Object({
|
|
3858
|
-
path: Type.String({
|
|
3859
|
-
description: "Repo-relative candidate spec path, e.g. openspec/project-specs/ui/ucp-components-md/AdvancedSearch.md",
|
|
3860
|
-
}),
|
|
3861
|
-
disposition: Type.Enum({
|
|
3862
|
-
required: "required",
|
|
3863
|
-
relevant: "relevant",
|
|
3864
|
-
}),
|
|
3865
|
-
rationale: Type.String({}),
|
|
3866
|
-
}, { additionalProperties: false }),
|
|
3867
|
-
async execute(_toolCallId, params) {
|
|
3868
|
-
const path = typeof params?.path === "string" ? params.path : "";
|
|
3869
|
-
const disposition = params?.disposition;
|
|
3870
|
-
const rationale = typeof params?.rationale === "string" ? params.rationale : "";
|
|
3871
|
-
if (!path || !disposition || !rationale.trim()) {
|
|
3872
|
-
return receipt({
|
|
3873
|
-
ok: false,
|
|
3874
|
-
kind: "openspec-selection",
|
|
3875
|
-
error: "record_openspec_selection requires non-empty path, disposition (required|relevant), and rationale",
|
|
3876
|
-
});
|
|
3877
|
-
}
|
|
3878
|
-
const result = await adoptContractFact("openspec-selection", {
|
|
3879
|
-
kind: "openspec-selection",
|
|
3880
|
-
origin: "contract",
|
|
3881
|
-
path,
|
|
3882
|
-
disposition,
|
|
3883
|
-
rationale,
|
|
3884
|
-
});
|
|
3885
|
-
return receipt(result);
|
|
3886
|
-
},
|
|
3887
|
-
});
|
|
3888
|
-
const finalizeContractTool = defineTool({
|
|
3889
|
-
name: "finalize_contract",
|
|
3890
|
-
label: "finalize_contract",
|
|
3891
|
-
description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
|
|
3892
|
-
promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
|
|
3893
|
-
parameters: Type.Object({
|
|
3894
|
-
disposition: Type.Enum({
|
|
3895
|
-
ready: "ready",
|
|
3896
|
-
"ready-with-assumptions": "ready-with-assumptions",
|
|
3897
|
-
blocked: "blocked",
|
|
3898
|
-
}),
|
|
3899
|
-
blockingOwner: Type.Optional(Type.Enum({
|
|
3900
|
-
"blocked-human": "blocked-human",
|
|
3901
|
-
"blocked-external": "blocked-external",
|
|
3902
|
-
})),
|
|
3903
|
-
assumptions: Type.Optional(Type.Array(Type.String({}))),
|
|
3904
|
-
summary: Type.Optional(Type.String({})),
|
|
3905
|
-
}, { additionalProperties: false }),
|
|
3906
|
-
async execute(_toolCallId, params) {
|
|
3907
|
-
const disposition = params?.disposition;
|
|
3908
|
-
const blockingOwner = params?.blockingOwner;
|
|
3909
|
-
if (disposition === "blocked" && !blockingOwner) {
|
|
3910
|
-
return receipt({
|
|
3911
|
-
ok: false,
|
|
3912
|
-
kind: "finalize_contract",
|
|
3913
|
-
error: "blocked disposition requires blockingOwner (blocked-human | blocked-external)",
|
|
3914
|
-
});
|
|
3915
|
-
}
|
|
3916
|
-
if (disposition !== "blocked" && input.canonicalRequirements?.size &&
|
|
3917
|
-
!readCommittedEvents(store, attemptId).some((record) => record.fact.kind === "required-deliverables")) {
|
|
3918
|
-
return receipt({
|
|
3919
|
-
ok: false, kind: "finalize_contract",
|
|
3920
|
-
error: "call record_required_deliverables with the complete source-bound inventory (or items:[] when none) before finalizing",
|
|
3921
|
-
});
|
|
3922
|
-
}
|
|
3923
|
-
const missing = [...(input.canonicalRequirements?.keys() ?? [])].filter(id => !committedRequirementIds().has(id) || (activeScope !== null && !completedScopeRequirementIds().has(id)));
|
|
3924
|
-
if (disposition !== "blocked" && missing.length)
|
|
3925
|
-
return receipt({ ok: false, code: "CONTRACT_REQUIREMENT_COVERAGE_MISSING", error: `Confirm all complete source obligations before finalizing: ${missing.join(", ")}` });
|
|
3926
|
-
const blockedOwner = mapContractBlockedOwner({
|
|
3927
|
-
disposition: disposition ?? "",
|
|
3928
|
-
blockingOwner,
|
|
3929
|
-
});
|
|
3930
|
-
const result = await adoptContractFact("contract-finalized", {
|
|
3931
|
-
kind: "contract-finalized",
|
|
3932
|
-
origin: "contract",
|
|
3933
|
-
disposition,
|
|
3934
|
-
...(blockingOwner ? { blockingOwner } : {}),
|
|
3935
|
-
...(blockedOwner !== "not-blocked" ? { blockedOwner } : {}),
|
|
3936
|
-
...(Array.isArray(params?.assumptions)
|
|
3937
|
-
? { assumptions: params.assumptions }
|
|
3938
|
-
: {}),
|
|
3939
|
-
...(typeof params?.summary === "string"
|
|
3940
|
-
? { summary: params.summary }
|
|
3941
|
-
: {}),
|
|
3942
|
-
});
|
|
3943
|
-
return receipt(result);
|
|
3944
|
-
},
|
|
3945
|
-
});
|
|
3946
|
-
const completeScopeTool = defineTool({
|
|
3947
|
-
name: "complete_contract_scope", label: "complete_contract_scope",
|
|
3948
|
-
description: "After recording all requirements AND their evidence, constraints, questions and deliverables for this session, mark the scope complete. Confirming an ID alone does not complete its analysis. Do this before finalize_contract.",
|
|
3949
|
-
parameters: Type.Object({ requirementIds: Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }) }, { additionalProperties: false }),
|
|
3950
|
-
async execute(_id, params) {
|
|
3951
|
-
const ids = params.requirementIds;
|
|
3952
|
-
if (activeScope === null || ids.length !== activeScope.size || ids.some(id => !activeScope?.has(id) || !committedRequirementIds().has(id)))
|
|
3953
|
-
return receipt({ ok: false, code: "CONTRACT_SCOPE_INCOMPLETE", error: "Complete exactly the active scope after confirming all its obligations" });
|
|
3954
|
-
return receipt(await adoptContractFact("contract-scope-completed", { kind: "contract-scope-completed", origin: "contract", requirementIds: ids }));
|
|
3955
|
-
},
|
|
3956
|
-
});
|
|
3957
|
-
const durable = await createDurableFrontendTools({
|
|
3958
|
-
file: path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), attemptId, store: input.store,
|
|
3959
|
-
setWorkingStore: next => { store = next; }, binding: { canonicalRequirements: input.canonicalRequirements, sourceDigest: input.sourceDigest },
|
|
3960
|
-
tools: [
|
|
3961
|
-
...recordTools,
|
|
3962
|
-
recordRequirementTool,
|
|
3963
|
-
recordRequirementExecutionTool,
|
|
3964
|
-
recordEvidenceExpectationTool,
|
|
3965
|
-
recordUiStateTool,
|
|
3966
|
-
recordRequiredDeliverablesTool,
|
|
3967
|
-
recordOpenspecSelectionTool,
|
|
3968
|
-
completeScopeTool,
|
|
3969
|
-
finalizeContractTool,
|
|
3970
|
-
],
|
|
3971
|
-
});
|
|
3972
|
-
const durableRecordRequirementTool = durable.customTools.find((tool) => typeof tool === "object" && tool !== null && tool.name === "record_requirement");
|
|
3973
|
-
if (!durableRecordRequirementTool)
|
|
3974
|
-
throw new Error("frontend contract durable requirement tool unavailable");
|
|
3975
|
-
return {
|
|
3976
|
-
customTools: durable.customTools,
|
|
3977
|
-
inputRequirements: () => [...(input.canonicalRequirements ?? [])].map(([id, value]) => ({ id, text: value.text, sourceFragmentIds: [...value.sourceFragmentIds] })),
|
|
3978
|
-
seedCanonicalRequirements: async () => {
|
|
3979
|
-
for (const id of input.canonicalRequirements?.keys() ?? []) {
|
|
3980
|
-
if (committedRequirementIds().has(id))
|
|
1340
|
+
* an invalid-output so the node retry ladder restarts the session with the
|
|
1341
|
+
* diagnostics instead of writing a ledger that R1 would reject.
|
|
1342
|
+
*/
|
|
1343
|
+
export async function bridgeFrontendPlanDecisionToRelationshipLedger(input) {
|
|
1344
|
+
const { assembleDecisionContractFromFacts, buildFrontendPlanRelationshipPatch, } = await import("../workflows/dag/frontend-plan-decision-contract.js");
|
|
1345
|
+
const committed = input.facts
|
|
1346
|
+
.filter(record => record.phase === "committed")
|
|
1347
|
+
.map(record => record.fact);
|
|
1348
|
+
const decision = assembleDecisionContractFromFacts(committed);
|
|
1349
|
+
const concreteWriteSet = [
|
|
1350
|
+
...new Set([
|
|
1351
|
+
...decision.modulePlacements.flatMap(item => item.paths),
|
|
1352
|
+
...decision.verificationFocus.map(item => item.file),
|
|
1353
|
+
]),
|
|
1354
|
+
];
|
|
1355
|
+
// Contract-declared deliverable obligations (the same ledger the analyzer
|
|
1356
|
+
// extracts `requiredDeliverables` from): bind each path to its requirement
|
|
1357
|
+
// so the derived canonical contract plans every contract-mandated file.
|
|
1358
|
+
const requiredDeliverables = new Map();
|
|
1359
|
+
try {
|
|
1360
|
+
const contractRecords = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
1361
|
+
for (const record of [...contractRecords].reverse()) {
|
|
1362
|
+
const fact = record.fact;
|
|
1363
|
+
if (!fact || fact.origin !== "contract" || fact.kind !== "required-deliverables")
|
|
1364
|
+
continue;
|
|
1365
|
+
for (const item of Array.isArray(fact.items) ? fact.items : []) {
|
|
1366
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
3981
1367
|
continue;
|
|
3982
|
-
const
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
1368
|
+
const requirementId = item.requirementId;
|
|
1369
|
+
const file = item.path;
|
|
1370
|
+
if (typeof requirementId !== "string" || typeof file !== "string")
|
|
1371
|
+
continue;
|
|
1372
|
+
const paths = requiredDeliverables.get(requirementId) ?? [];
|
|
1373
|
+
paths.push(file);
|
|
1374
|
+
requiredDeliverables.set(requirementId, paths);
|
|
3986
1375
|
}
|
|
3987
|
-
|
|
3988
|
-
}
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
1376
|
+
break;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
catch {
|
|
1380
|
+
// Missing/unreadable contract ledger → no deliverable obligations to merge.
|
|
1381
|
+
}
|
|
1382
|
+
if (!input.skeleton || !input.sourceBinding) {
|
|
1383
|
+
return { ok: false, error: "frontend decision bridge requires the runtime skeleton and sourceBinding" };
|
|
1384
|
+
}
|
|
1385
|
+
let patch;
|
|
1386
|
+
try {
|
|
1387
|
+
const { analyzeFrontendPlanPatchCandidate, applyFrontendContractMergePatch, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
1388
|
+
patch = buildFrontendPlanRelationshipPatch({
|
|
1389
|
+
decision,
|
|
1390
|
+
authority: input.authority,
|
|
1391
|
+
concreteWriteSet,
|
|
1392
|
+
requiredDeliverables,
|
|
1393
|
+
});
|
|
1394
|
+
const merged = applyFrontendContractMergePatch(input.skeleton, patch);
|
|
1395
|
+
await analyzeFrontendPlanPatchCandidate({
|
|
1396
|
+
runDir: input.runDir,
|
|
1397
|
+
rawContractText: serializeDeterministicJson(merged),
|
|
1398
|
+
sourceBinding: input.sourceBinding,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
catch (error) {
|
|
1402
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
1403
|
+
return {
|
|
1404
|
+
ok: false,
|
|
1405
|
+
// This path should be rare now that finalize_decision pre-validates
|
|
1406
|
+
// the same patch in-session; keep the retry-prompt diagnostics in
|
|
1407
|
+
// decision-channel vocabulary regardless.
|
|
1408
|
+
error: `frontend decision → relationship patch failed pre-validation (correct the reported decision facts, then the retry ladder restarts the session): ${translateDecisionPatchFindings(raw, decision)}`,
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
const { createTypedEventStore, stageTypedEventRecord, commitTypedEventRecord, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1412
|
+
const file = path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl");
|
|
1413
|
+
const store = createTypedEventStore();
|
|
1414
|
+
const adopt = (kind, patchValue, revision) => {
|
|
1415
|
+
const eventId = `decision-bridge:${input.attemptId}:${kind}`;
|
|
1416
|
+
const fact = { kind, origin: "plan", patch: patchValue };
|
|
1417
|
+
stageTypedEventRecord(store, {
|
|
1418
|
+
eventId,
|
|
1419
|
+
requestId: eventId,
|
|
1420
|
+
attemptId: input.attemptId,
|
|
1421
|
+
fact,
|
|
1422
|
+
});
|
|
1423
|
+
return commitTypedEventRecord(store, eventId, revision);
|
|
3998
1424
|
};
|
|
1425
|
+
adopt("target-surface", patch, 1);
|
|
1426
|
+
adopt("finalize_plan", patch, 2);
|
|
1427
|
+
// The ledger is derived wholesale from the committed decision facts, so a
|
|
1428
|
+
// re-run rewrites it atomically (no incremental append semantics needed).
|
|
1429
|
+
await writeTypedEventStoreJsonl(file, store.records.filter(record => record.phase === "committed"));
|
|
1430
|
+
return { ok: true, patch };
|
|
3999
1431
|
}
|
|
4000
1432
|
async function resolveFrontendScoutSourceDeclaredPaths(input) {
|
|
4001
1433
|
const binding = input.spec.sourceBinding;
|
|
@@ -4032,270 +1464,6 @@ async function resolveFrontendScoutSourceDeclaredPaths(input) {
|
|
|
4032
1464
|
}
|
|
4033
1465
|
return [...declared].sort();
|
|
4034
1466
|
}
|
|
4035
|
-
/**
|
|
4036
|
-
* A+B: `frontend-scout-pi` incremental evidence tools (origin=scout). Runtime
|
|
4037
|
-
* enriches committed target-surface / design-evidence facts with hash/section/
|
|
4038
|
-
* freshness from real read events; the tool itself never trusts model self-report.
|
|
4039
|
-
*/
|
|
4040
|
-
export async function createFrontendScoutEvidenceTools(input) {
|
|
4041
|
-
const [{ Type }, { defineTool }] = await Promise.all([
|
|
4042
|
-
import("typebox"),
|
|
4043
|
-
import("@earendil-works/pi-coding-agent"),
|
|
4044
|
-
]);
|
|
4045
|
-
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4046
|
-
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
4047
|
-
let store = input.store;
|
|
4048
|
-
const attemptId = input.attemptId;
|
|
4049
|
-
const stringArray = Type.Array(Type.String({}));
|
|
4050
|
-
const optionalString = Type.Optional(Type.String({}));
|
|
4051
|
-
const scoutCompleteness = Type.Union([
|
|
4052
|
-
Type.Literal("complete"),
|
|
4053
|
-
Type.Literal("blocked"),
|
|
4054
|
-
]);
|
|
4055
|
-
const receipt = (details) => ({
|
|
4056
|
-
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
4057
|
-
details,
|
|
4058
|
-
});
|
|
4059
|
-
const sourceDeclaredPaths = (input.sourceDeclaredPaths ?? []).map((value) => value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""));
|
|
4060
|
-
const hasSourceDeclarations = input.sourceDeclaredPaths !== undefined;
|
|
4061
|
-
let activeScope;
|
|
4062
|
-
const scopeIdentity = (ids) => createHash("sha256").update(JSON.stringify([...ids].sort())).digest("hex");
|
|
4063
|
-
const latestScopes = () => {
|
|
4064
|
-
const byRequirement = new Map();
|
|
4065
|
-
for (const record of readCommittedEvents(store, attemptId))
|
|
4066
|
-
if (record.fact.kind === "scout-scope" && Array.isArray(record.fact.requirementIds)) {
|
|
4067
|
-
for (const id of record.fact.requirementIds)
|
|
4068
|
-
if (typeof id === "string")
|
|
4069
|
-
byRequirement.set(id, record.fact);
|
|
4070
|
-
}
|
|
4071
|
-
return byRequirement;
|
|
4072
|
-
};
|
|
4073
|
-
const isSourceDeclared = (candidate) => {
|
|
4074
|
-
const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
4075
|
-
return sourceDeclaredPaths.some((declared) => declared === normalized || declared.startsWith(`${normalized}/`));
|
|
4076
|
-
};
|
|
4077
|
-
// A+B (AC-003): runtime enriches declared scout paths with hash/freshness
|
|
4078
|
-
// from the real filesystem. The model's self-reported path list is never
|
|
4079
|
-
// trusted for content identity; a missing file fails closed to fresh=false
|
|
4080
|
-
// with a zero hash instead of inventing content.
|
|
4081
|
-
const enrichScoutPathEvidence = async (paths) => {
|
|
4082
|
-
if (!input.workspaceRoot)
|
|
4083
|
-
return [];
|
|
4084
|
-
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
4085
|
-
const evidence = [];
|
|
4086
|
-
for (const relative of new Set(paths)) {
|
|
4087
|
-
const absolute = path.resolve(workspaceRoot, relative);
|
|
4088
|
-
if (absolute !== workspaceRoot &&
|
|
4089
|
-
!absolute.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
4090
|
-
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false, sourceDeclared: isSourceDeclared(relative) });
|
|
4091
|
-
continue;
|
|
4092
|
-
}
|
|
4093
|
-
try {
|
|
4094
|
-
// Directories are legitimate named targets (greenfield smoke: the
|
|
4095
|
-
// page directory exists while the files inside it are to be
|
|
4096
|
-
// created). readFile on a directory throws EISDIR, which used to
|
|
4097
|
-
// mark every directory path fresh=false and structurally fail the
|
|
4098
|
-
// freshness gate for create-new surfaces. stat() first: a directory
|
|
4099
|
-
// counts as fresh existence evidence; its content hash is a stable
|
|
4100
|
-
// directory marker since there is no single file content to hash.
|
|
4101
|
-
const info = await stat(absolute);
|
|
4102
|
-
if (info.isDirectory()) {
|
|
4103
|
-
evidence.push({
|
|
4104
|
-
path: relative,
|
|
4105
|
-
sha256: createHash("sha256").update(`directory:${relative}`).digest("hex"),
|
|
4106
|
-
fresh: true,
|
|
4107
|
-
sourceDeclared: isSourceDeclared(relative),
|
|
4108
|
-
});
|
|
4109
|
-
continue;
|
|
4110
|
-
}
|
|
4111
|
-
const bytes = await readFile(absolute);
|
|
4112
|
-
evidence.push({
|
|
4113
|
-
path: relative,
|
|
4114
|
-
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
4115
|
-
fresh: true,
|
|
4116
|
-
sourceDeclared: isSourceDeclared(relative),
|
|
4117
|
-
});
|
|
4118
|
-
}
|
|
4119
|
-
catch {
|
|
4120
|
-
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false, sourceDeclared: isSourceDeclared(relative) });
|
|
4121
|
-
}
|
|
4122
|
-
}
|
|
4123
|
-
return evidence;
|
|
4124
|
-
};
|
|
4125
|
-
async function adoptScoutFact(kind, fact) {
|
|
4126
|
-
try {
|
|
4127
|
-
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
4128
|
-
const staged = stageTypedEventFact({
|
|
4129
|
-
store,
|
|
4130
|
-
requestId,
|
|
4131
|
-
attemptId,
|
|
4132
|
-
fact: fact,
|
|
4133
|
-
});
|
|
4134
|
-
const committed = await adoptTypedEventFact({
|
|
4135
|
-
store,
|
|
4136
|
-
requestId,
|
|
4137
|
-
attemptId,
|
|
4138
|
-
fact: fact,
|
|
4139
|
-
eventId: staged.eventId,
|
|
4140
|
-
expectedRevision: store.revision,
|
|
4141
|
-
});
|
|
4142
|
-
return {
|
|
4143
|
-
ok: true,
|
|
4144
|
-
kind,
|
|
4145
|
-
eventId: committed.eventId,
|
|
4146
|
-
revision: committed.revision,
|
|
4147
|
-
error: "",
|
|
4148
|
-
};
|
|
4149
|
-
}
|
|
4150
|
-
catch (error) {
|
|
4151
|
-
return {
|
|
4152
|
-
ok: false,
|
|
4153
|
-
kind,
|
|
4154
|
-
code: error?.code,
|
|
4155
|
-
error: error instanceof Error ? error.message : String(error),
|
|
4156
|
-
};
|
|
4157
|
-
}
|
|
4158
|
-
}
|
|
4159
|
-
const recordTargetSurfaceTool = defineTool({
|
|
4160
|
-
name: "record_target_surface",
|
|
4161
|
-
label: "record_target_surface",
|
|
4162
|
-
description: "Commit an origin=scout target-surface fact with complete/blocked discovery status. A complete surface needs a proven target path and no unresolved paths; blocked surfaces name the unresolved paths instead of guessing. Example: {\"completeness\": \"complete\", \"entrypoint\": \"<file>\", \"implementationPaths\": [\"<dir or file>\"], \"testPaths\": [\"<file>\"], \"allowedPathConflicts\": [], \"unresolvedPaths\": []}",
|
|
4163
|
-
promptSnippet: "Commit an origin=scout target-surface fact.",
|
|
4164
|
-
parameters: Type.Object({
|
|
4165
|
-
scopeId: Type.Optional(Type.String({ minLength: 1 })),
|
|
4166
|
-
completeness: scoutCompleteness,
|
|
4167
|
-
entrypoint: optionalString,
|
|
4168
|
-
routeOrMount: optionalString,
|
|
4169
|
-
implementationPaths: stringArray,
|
|
4170
|
-
proposedPaths: Type.Optional(stringArray),
|
|
4171
|
-
testPaths: stringArray,
|
|
4172
|
-
dataSource: optionalString,
|
|
4173
|
-
allowedPathConflicts: stringArray,
|
|
4174
|
-
unresolvedPaths: stringArray,
|
|
4175
|
-
}, { additionalProperties: false }),
|
|
4176
|
-
async execute(_toolCallId, params) {
|
|
4177
|
-
if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
|
|
4178
|
-
return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
|
|
4179
|
-
const implementationPaths = params?.implementationPaths ?? [];
|
|
4180
|
-
const proposedPaths = params?.proposedPaths ?? [];
|
|
4181
|
-
const testPaths = params?.testPaths ?? [];
|
|
4182
|
-
const pathEvidence = await enrichScoutPathEvidence([
|
|
4183
|
-
...(params?.entrypoint ? [params.entrypoint] : []),
|
|
4184
|
-
...implementationPaths,
|
|
4185
|
-
...testPaths,
|
|
4186
|
-
]);
|
|
4187
|
-
for (const proposedPath of proposedPaths) {
|
|
4188
|
-
if (!pathEvidence.some((item) => item.path === proposedPath)) {
|
|
4189
|
-
pathEvidence.push({ path: proposedPath, sha256: "0".repeat(64), fresh: false, sourceDeclared: false, proposed: true });
|
|
4190
|
-
}
|
|
4191
|
-
}
|
|
4192
|
-
const surface = {
|
|
4193
|
-
kind: "target-surface",
|
|
4194
|
-
origin: "scout",
|
|
4195
|
-
completeness: params?.completeness ?? "blocked",
|
|
4196
|
-
entrypoint: params?.entrypoint ?? "",
|
|
4197
|
-
routeOrMount: params?.routeOrMount ?? "",
|
|
4198
|
-
implementationPaths,
|
|
4199
|
-
...(proposedPaths.length > 0 ? { proposedPaths } : {}),
|
|
4200
|
-
testPaths,
|
|
4201
|
-
dataSource: params?.dataSource ?? "",
|
|
4202
|
-
allowedPathConflicts: params?.allowedPathConflicts ?? [],
|
|
4203
|
-
unresolvedPaths: params?.unresolvedPaths ?? [],
|
|
4204
|
-
...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}),
|
|
4205
|
-
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
4206
|
-
};
|
|
4207
|
-
if (activeScope) {
|
|
4208
|
-
const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-committed-facts.js");
|
|
4209
|
-
if (surface.completeness === "complete") {
|
|
4210
|
-
const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
|
|
4211
|
-
if (!check.ok)
|
|
4212
|
-
return receipt({ ok: false, code: "SCOUT_SCOPE_INCOMPLETE", error: check.reason });
|
|
4213
|
-
}
|
|
4214
|
-
const saved = await adoptScoutFact("scout-scope", { kind: "scout-scope", origin: "scout", id: params.scopeId, requirementIds: activeScope, surface });
|
|
4215
|
-
if (!saved.ok)
|
|
4216
|
-
return receipt(saved);
|
|
4217
|
-
const current = latestScopes();
|
|
4218
|
-
if (input.requirementIds?.every(id => current.get(id)?.surface?.completeness === "complete")) {
|
|
4219
|
-
const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
|
|
4220
|
-
const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
|
|
4221
|
-
const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
|
|
4222
|
-
return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], proposedPaths: union("proposedPaths"), testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
|
|
4223
|
-
}
|
|
4224
|
-
return receipt(saved);
|
|
4225
|
-
}
|
|
4226
|
-
const result = await adoptScoutFact("target-surface", surface);
|
|
4227
|
-
return receipt(result);
|
|
4228
|
-
},
|
|
4229
|
-
});
|
|
4230
|
-
const recordDesignEvidenceTool = defineTool({
|
|
4231
|
-
name: "record_design_evidence",
|
|
4232
|
-
label: "record_design_evidence",
|
|
4233
|
-
description: "Commit an origin=scout design-evidence fact (source, paths, conflicts). Example: {\"source\": \"<source>\", \"paths\": [\"<file>\"], \"conflicts\": []}",
|
|
4234
|
-
promptSnippet: "Commit an origin=scout design-evidence fact.",
|
|
4235
|
-
parameters: Type.Object({ source: Type.String({}), paths: stringArray, conflicts: stringArray }, { additionalProperties: false }),
|
|
4236
|
-
async execute(_toolCallId, params) {
|
|
4237
|
-
const paths = params?.paths ?? [];
|
|
4238
|
-
const pathEvidence = await enrichScoutPathEvidence(paths);
|
|
4239
|
-
const result = await adoptScoutFact("design-evidence", {
|
|
4240
|
-
kind: "design-evidence",
|
|
4241
|
-
origin: "scout",
|
|
4242
|
-
source: params?.source ?? "",
|
|
4243
|
-
paths,
|
|
4244
|
-
conflicts: params?.conflicts ?? [],
|
|
4245
|
-
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
4246
|
-
});
|
|
4247
|
-
return receipt(result);
|
|
4248
|
-
},
|
|
4249
|
-
});
|
|
4250
|
-
const durable = await createDurableFrontendTools({
|
|
4251
|
-
file: path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), attemptId, store: input.store,
|
|
4252
|
-
setWorkingStore: next => { store = next; }, binding: { requirementIds: input.requirementIds, sourceDeclaredPaths: input.sourceDeclaredPaths, sourceDigest: input.sourceDigest, workspaceRoot: input.workspaceRoot },
|
|
4253
|
-
tools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
|
|
4254
|
-
validateRestored: async (records) => {
|
|
4255
|
-
if (!input.workspaceRoot)
|
|
4256
|
-
return;
|
|
4257
|
-
for (const record of records) {
|
|
4258
|
-
const evidence = record.fact.kind === "scout-scope" ? record.fact.surface?.pathEvidence : record.fact.pathEvidence;
|
|
4259
|
-
if (!Array.isArray(evidence))
|
|
4260
|
-
continue;
|
|
4261
|
-
for (const previous of evidence) {
|
|
4262
|
-
if (!isRecordObject(previous) || typeof previous.path !== "string")
|
|
4263
|
-
throw Error("scout path evidence is malformed");
|
|
4264
|
-
const current = (await enrichScoutPathEvidence([previous.path]))[0];
|
|
4265
|
-
if (!current || current.sha256 !== previous.sha256 || current.fresh !== previous.fresh)
|
|
4266
|
-
throw Error(`scout evidence drift: ${previous.path}; refresh Scout before reusing facts`);
|
|
4267
|
-
}
|
|
4268
|
-
}
|
|
4269
|
-
},
|
|
4270
|
-
});
|
|
4271
|
-
return {
|
|
4272
|
-
...durable,
|
|
4273
|
-
adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
|
|
4274
|
-
for (const record of records) {
|
|
4275
|
-
if (record.phase !== "committed")
|
|
4276
|
-
continue;
|
|
4277
|
-
const fact = record.fact;
|
|
4278
|
-
if (!fact || typeof fact !== "object" || Array.isArray(fact))
|
|
4279
|
-
continue;
|
|
4280
|
-
const result = await adoptScoutFact(typeof fact.kind === "string"
|
|
4281
|
-
? fact.kind
|
|
4282
|
-
: "scout-fact", fact);
|
|
4283
|
-
if (!result.ok) {
|
|
4284
|
-
throw new Error(`frontend scout shard fact merge failed: ${result.error}`);
|
|
4285
|
-
}
|
|
4286
|
-
}
|
|
4287
|
-
}),
|
|
4288
|
-
setActiveScope: ids => {
|
|
4289
|
-
if (!ids.length || ids.some(id => !input.requirementIds?.includes(id)))
|
|
4290
|
-
throw Error("FRONTEND_INPUT_SCOPE_VIOLATION");
|
|
4291
|
-
activeScope = [...new Set(ids)];
|
|
4292
|
-
return scopeIdentity(activeScope);
|
|
4293
|
-
},
|
|
4294
|
-
completedRequirementIds: () => new Set([...latestScopes()].filter(([, fact]) => fact.surface?.completeness === "complete").map(([id]) => id)),
|
|
4295
|
-
completedScopeFacts: () => [...new Set(latestScopes().values())].filter(f => f.surface?.completeness === "complete"),
|
|
4296
|
-
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
4297
|
-
};
|
|
4298
|
-
}
|
|
4299
1467
|
export function buildDagPiUserMessage(task, persona, step) {
|
|
4300
1468
|
const role = task.role ?? "unspecified";
|
|
4301
1469
|
const writePolicy = task.writePolicy ?? "read-only (default)";
|
|
@@ -4355,27 +1523,6 @@ export function buildDagPiUserMessage(task, persona, step) {
|
|
|
4355
1523
|
"Do not wrap the output in code fences and do not add conversational preamble.",
|
|
4356
1524
|
].join(" ");
|
|
4357
1525
|
}
|
|
4358
|
-
export function resolveDagPiModelConfig(modelReference, options) {
|
|
4359
|
-
const separatorIndex = modelReference.indexOf("/");
|
|
4360
|
-
const qualified = separatorIndex >= 0;
|
|
4361
|
-
const provider = qualified
|
|
4362
|
-
? modelReference.slice(0, separatorIndex)
|
|
4363
|
-
: (DAG_PI_MODEL_PROVIDERS[modelReference] ?? DEFAULT_DAG_PI_PROVIDER);
|
|
4364
|
-
const model = qualified
|
|
4365
|
-
? modelReference.slice(separatorIndex + 1)
|
|
4366
|
-
: modelReference;
|
|
4367
|
-
if (!provider || !model) {
|
|
4368
|
-
throw new Error(`invalid DAG Pi model reference "${modelReference}": expected non-empty provider/model`);
|
|
4369
|
-
}
|
|
4370
|
-
const explicitThinking = options?.thinking?.trim();
|
|
4371
|
-
const thinking = explicitThinking ??
|
|
4372
|
-
(provider === "wizard-local" && model === "gpt-5.5" ? "low" : undefined);
|
|
4373
|
-
return {
|
|
4374
|
-
provider,
|
|
4375
|
-
model,
|
|
4376
|
-
...(thinking ? { thinking } : {}),
|
|
4377
|
-
};
|
|
4378
|
-
}
|
|
4379
1526
|
const SUMMARY_STDOUT_MAX = 4_000;
|
|
4380
1527
|
const SUMMARY_STDERR_MAX = 2_000;
|
|
4381
1528
|
export function buildPiPromptRedactedMarkdown(prompt) {
|
|
@@ -4660,184 +1807,6 @@ const FRONTEND_PLAN_SEGMENTS = [
|
|
|
4660
1807
|
].join(" "),
|
|
4661
1808
|
},
|
|
4662
1809
|
];
|
|
4663
|
-
function committedFactFromPlanRecord(value) {
|
|
4664
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
4665
|
-
return undefined;
|
|
4666
|
-
const record = value;
|
|
4667
|
-
if (record.phase !== undefined && record.phase !== "committed")
|
|
4668
|
-
return undefined;
|
|
4669
|
-
const fact = record.fact;
|
|
4670
|
-
return fact && typeof fact === "object" && !Array.isArray(fact)
|
|
4671
|
-
? fact
|
|
4672
|
-
: typeof record.kind === "string"
|
|
4673
|
-
? record
|
|
4674
|
-
: undefined;
|
|
4675
|
-
}
|
|
4676
|
-
function planFactStringList(value) {
|
|
4677
|
-
if (!Array.isArray(value))
|
|
4678
|
-
return [];
|
|
4679
|
-
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
4680
|
-
}
|
|
4681
|
-
function planFactScopeIntersects(fact, requirementIds) {
|
|
4682
|
-
return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
|
|
4683
|
-
}
|
|
4684
|
-
/** Compute the authoritative coverage queue from the committed plan ledger. */
|
|
4685
|
-
export function collectFrontendPlanMissingFacts(input) {
|
|
4686
|
-
const requirements = new Map();
|
|
4687
|
-
const standaloneEvidenceGaps = new Set();
|
|
4688
|
-
const verificationTargetIds = new Set();
|
|
4689
|
-
const verificationTargetRequirements = new Map();
|
|
4690
|
-
for (const value of input.committedFacts) {
|
|
4691
|
-
const fact = committedFactFromPlanRecord(value);
|
|
4692
|
-
if (!fact || fact.origin !== "plan")
|
|
4693
|
-
continue;
|
|
4694
|
-
if (fact.kind === "plan-requirement" && fact.entry && typeof fact.entry === "object") {
|
|
4695
|
-
const entry = fact.entry;
|
|
4696
|
-
if (typeof entry.id === "string" && entry.id.trim())
|
|
4697
|
-
requirements.set(entry.id, entry);
|
|
4698
|
-
}
|
|
4699
|
-
if (fact.kind === "plan-verification-target" && fact.entry && typeof fact.entry === "object") {
|
|
4700
|
-
const entry = fact.entry;
|
|
4701
|
-
const id = entry.id;
|
|
4702
|
-
if (typeof id === "string" && id.trim()) {
|
|
4703
|
-
verificationTargetIds.add(id);
|
|
4704
|
-
verificationTargetRequirements.set(id, new Set(Array.isArray(entry.requirementIds)
|
|
4705
|
-
? entry.requirementIds.filter((value) => typeof value === "string")
|
|
4706
|
-
: []));
|
|
4707
|
-
}
|
|
4708
|
-
}
|
|
4709
|
-
if (fact.kind === "plan-evidence-gap" && fact.entry && typeof fact.entry === "object") {
|
|
4710
|
-
const entry = fact.entry;
|
|
4711
|
-
const requirementId = entry.requirementId;
|
|
4712
|
-
const description = entry.description;
|
|
4713
|
-
if (typeof requirementId === "string" && requirementId.trim() && typeof description === "string" && description.trim()) {
|
|
4714
|
-
standaloneEvidenceGaps.add(requirementId);
|
|
4715
|
-
}
|
|
4716
|
-
}
|
|
4717
|
-
}
|
|
4718
|
-
const missing = [];
|
|
4719
|
-
for (const id of input.requirementIds) {
|
|
4720
|
-
const entry = requirements.get(id);
|
|
4721
|
-
if (!entry) {
|
|
4722
|
-
missing.push({
|
|
4723
|
-
kind: "plan-requirement",
|
|
4724
|
-
id,
|
|
4725
|
-
requirementIds: [id],
|
|
4726
|
-
reason: `requirement ${id} has no committed plan-requirement fact`,
|
|
4727
|
-
});
|
|
4728
|
-
continue;
|
|
4729
|
-
}
|
|
4730
|
-
// Verification targets are the single authoritative direction. The
|
|
4731
|
-
// legacy requirement-side list is accepted only as a fallback while
|
|
4732
|
-
// resuming older ledgers; new plans derive it from target.requirementIds.
|
|
4733
|
-
const derivedTargetIds = [...verificationTargetRequirements.entries()]
|
|
4734
|
-
.filter(([, requirementIds]) => requirementIds.has(id))
|
|
4735
|
-
.map(([targetId]) => targetId);
|
|
4736
|
-
const legacyTargetIds = Array.isArray(entry.verificationTargetIds)
|
|
4737
|
-
? entry.verificationTargetIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
4738
|
-
: [];
|
|
4739
|
-
const targetIds = derivedTargetIds.length > 0 ? derivedTargetIds : legacyTargetIds;
|
|
4740
|
-
const gap = entry.evidenceGap && typeof entry.evidenceGap === "object"
|
|
4741
|
-
? entry.evidenceGap
|
|
4742
|
-
: undefined;
|
|
4743
|
-
const hasEvidenceGap = (typeof gap?.description === "string" && gap.description.trim().length > 0) ||
|
|
4744
|
-
standaloneEvidenceGaps.has(id);
|
|
4745
|
-
if (targetIds.length === 0 && !hasEvidenceGap) {
|
|
4746
|
-
missing.push({
|
|
4747
|
-
kind: "plan-verification-target",
|
|
4748
|
-
requirementIds: [id],
|
|
4749
|
-
reason: `requirement ${id} declares neither a verification target nor a non-empty evidenceGap`,
|
|
4750
|
-
});
|
|
4751
|
-
continue;
|
|
4752
|
-
}
|
|
4753
|
-
for (const targetId of targetIds) {
|
|
4754
|
-
if (!verificationTargetIds.has(targetId) ||
|
|
4755
|
-
!verificationTargetRequirements.get(targetId)?.has(id)) {
|
|
4756
|
-
missing.push({
|
|
4757
|
-
kind: "plan-verification-target",
|
|
4758
|
-
id: targetId,
|
|
4759
|
-
requirementIds: [id],
|
|
4760
|
-
reason: `requirement ${id} references verification target ${targetId}, but that target is not committed`,
|
|
4761
|
-
});
|
|
4762
|
-
}
|
|
4763
|
-
}
|
|
4764
|
-
}
|
|
4765
|
-
return missing;
|
|
4766
|
-
}
|
|
4767
|
-
/** Completeness checks for phases whose facts are committed incrementally. */
|
|
4768
|
-
export function collectFrontendPlanPhaseMissingFacts(input) {
|
|
4769
|
-
const facts = input.committedFacts
|
|
4770
|
-
.map(committedFactFromPlanRecord)
|
|
4771
|
-
.filter((fact) => Boolean(fact && fact.origin === "plan"));
|
|
4772
|
-
if (input.phase === "ux-registry") {
|
|
4773
|
-
return facts.some((fact) => fact.kind === "state-registry")
|
|
4774
|
-
? []
|
|
4775
|
-
: [
|
|
4776
|
-
{
|
|
4777
|
-
kind: "state-registry",
|
|
4778
|
-
requirementIds: [...input.requirementIds],
|
|
4779
|
-
reason: "global UX vocabulary phase has no committed state-registry fact",
|
|
4780
|
-
},
|
|
4781
|
-
];
|
|
4782
|
-
}
|
|
4783
|
-
if (input.phase === "ux-local") {
|
|
4784
|
-
const missing = [];
|
|
4785
|
-
// Evaluate each behaviour requirement independently. A fact scoped to AC-1
|
|
4786
|
-
// must not accidentally satisfy AC-2 merely because both ids share one
|
|
4787
|
-
// UX session; shared facts remain valid when they explicitly list both ids.
|
|
4788
|
-
for (const requirementId of input.requirementIds) {
|
|
4789
|
-
if (!input.behaviorRequiredRequirementIds?.includes(requirementId))
|
|
4790
|
-
continue;
|
|
4791
|
-
const scopedFacts = facts.filter((fact) => planFactScopeIntersects(fact, new Set([requirementId])));
|
|
4792
|
-
const hasChoice = scopedFacts.some((fact) => fact.kind === "component-choice" &&
|
|
4793
|
-
Array.isArray(fact.uiComponentChoices) &&
|
|
4794
|
-
fact.uiComponentChoices.length > 0);
|
|
4795
|
-
const canonicalStateFlow = collectCanonicalStateFlowNames(scopedFacts);
|
|
4796
|
-
const hasStateFlow = canonicalStateFlow.uiStateNames.size > 0 ||
|
|
4797
|
-
canonicalStateFlow.interactionNames.size > 0;
|
|
4798
|
-
if (!hasChoice) {
|
|
4799
|
-
missing.push({
|
|
4800
|
-
kind: "component-choice",
|
|
4801
|
-
requirementIds: [requirementId],
|
|
4802
|
-
reason: "behaviour-required UX slice has no committed component-choice fact",
|
|
4803
|
-
});
|
|
4804
|
-
}
|
|
4805
|
-
if (!hasStateFlow) {
|
|
4806
|
-
missing.push({
|
|
4807
|
-
kind: "state-flow",
|
|
4808
|
-
requirementIds: [requirementId],
|
|
4809
|
-
reason: "behaviour-required UX slice has no committed state-flow fact",
|
|
4810
|
-
});
|
|
4811
|
-
}
|
|
4812
|
-
}
|
|
4813
|
-
return missing;
|
|
4814
|
-
}
|
|
4815
|
-
const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
|
|
4816
|
-
const allInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
|
|
4817
|
-
const liveInteractions = new Set([...collectCanonicalStateFlowNames(facts.filter(f => !planFactStringList(f.scopeRequirementIds).length || planFactScopeIntersects(f, new Set(input.requirementIds)))).interactionNames].filter(name => allInteractions.has(name)));
|
|
4818
|
-
const coveredInteractions = new Set(facts
|
|
4819
|
-
.filter((fact) => fact.kind === "data-flow")
|
|
4820
|
-
.flatMap((fact) => planFactStringList(fact.interactions)));
|
|
4821
|
-
const missing = [];
|
|
4822
|
-
if (!hasMockApi) {
|
|
4823
|
-
missing.push({
|
|
4824
|
-
kind: "mock-api",
|
|
4825
|
-
requirementIds: [...input.requirementIds],
|
|
4826
|
-
reason: "global Mock/data phase has no committed mock-api fact",
|
|
4827
|
-
});
|
|
4828
|
-
}
|
|
4829
|
-
for (const interaction of liveInteractions) {
|
|
4830
|
-
if (coveredInteractions.has(interaction))
|
|
4831
|
-
continue;
|
|
4832
|
-
missing.push({
|
|
4833
|
-
kind: "data-flow",
|
|
4834
|
-
id: interaction,
|
|
4835
|
-
requirementIds: [...input.requirementIds],
|
|
4836
|
-
reason: `interaction ${interaction} has no committed data-flow fact`,
|
|
4837
|
-
});
|
|
4838
|
-
}
|
|
4839
|
-
return missing;
|
|
4840
|
-
}
|
|
4841
1810
|
/** Estimate calls conservatively: requirement + one VT, with a second VT
|
|
4842
1811
|
* reserved for behaviour-required requirements. Explicit declarations win. */
|
|
4843
1812
|
export function estimateFrontendPlanRequirementRecordCalls(fact) {
|
|
@@ -5914,9 +2883,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
5914
2883
|
}
|
|
5915
2884
|
}
|
|
5916
2885
|
const work = [...domains.values()];
|
|
5917
|
-
|
|
2886
|
+
// Work-unit packing and full provider envelope capacity have separate budgets.
|
|
5918
2887
|
return packFrontendInputUnits(work, {
|
|
5919
|
-
targetBytes:
|
|
2888
|
+
targetBytes: input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES,
|
|
5920
2889
|
maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits ?? 4,
|
|
5921
2890
|
maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
|
|
5922
2891
|
cost: (group) => group.estimatedCalls,
|
|
@@ -6170,7 +3139,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
6170
3139
|
};
|
|
6171
3140
|
const customTools = input.segmentCustomTools(atomicFocus ? new Set([atomicTools[atomicFocus.kind]]) : session.toolNames);
|
|
6172
3141
|
const result = await observeFrontendSession({
|
|
6173
|
-
...input.observation, phase: `plan/${session.id}`, scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
|
|
3142
|
+
...input.observation, phase: `plan/${session.id}`, dispatchReason: (session.retryCount ?? 0) > 0 || !!session.missingFacts?.length || /capacity|recovery|split/.test(session.id) ? "correction" : "initial", scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
|
|
6174
3143
|
prompt, userMessage: input.sessionOptions.userMessage, customTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
|
|
6175
3144
|
artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocationCount}.json` : undefined,
|
|
6176
3145
|
}, async (observer) => {
|
|
@@ -6530,7 +3499,12 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
6530
3499
|
optionalPlanFields.realIntegrationGap = fact.realIntegrationGap;
|
|
6531
3500
|
}
|
|
6532
3501
|
}
|
|
6533
|
-
let
|
|
3502
|
+
let finalizationCount = 0;
|
|
3503
|
+
const finalize = () => observeFrontendFinalization({
|
|
3504
|
+
...input.observation,
|
|
3505
|
+
artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-runtime-finalize-${++finalizationCount}.json` : undefined,
|
|
3506
|
+
}, () => input.finalizePlan(optionalPlanFields));
|
|
3507
|
+
let finalizeResult = await finalize();
|
|
6534
3508
|
const finalizeDetails = finalizeResult && typeof finalizeResult === "object"
|
|
6535
3509
|
? (finalizeResult.details ?? finalizeResult)
|
|
6536
3510
|
: finalizeResult;
|
|
@@ -6551,20 +3525,30 @@ export async function runFrontendPlanSegmentedSessions(input) {
|
|
|
6551
3525
|
].filter(Boolean).join("\n\n");
|
|
6552
3526
|
input.setActiveRequirementScope?.([]);
|
|
6553
3527
|
const correctionTools = input.segmentCustomTools(null);
|
|
6554
|
-
const correction = await
|
|
6555
|
-
...input.
|
|
6556
|
-
prompt: correctionPrompt,
|
|
6557
|
-
|
|
3528
|
+
const correction = await observeFrontendSession({
|
|
3529
|
+
...input.observation, phase: "plan/finalize-correction", dispatchReason: "correction",
|
|
3530
|
+
scopeIds: input.requirementIds, prompt: correctionPrompt, userMessage: input.sessionOptions.userMessage,
|
|
3531
|
+
customTools: correctionTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
|
|
3532
|
+
artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-finalize-correction.json` : undefined,
|
|
3533
|
+
}, async (observer) => {
|
|
3534
|
+
const result = await input.piStepFn({
|
|
3535
|
+
...input.sessionOptions, onAttemptObservation: observer, prompt: correctionPrompt,
|
|
3536
|
+
writerToolPolicy: { requireSdk: true, customTools: correctionTools },
|
|
3537
|
+
});
|
|
3538
|
+
try {
|
|
3539
|
+
await input.flushLedger();
|
|
3540
|
+
lastDurableCount = input.committedFactCount();
|
|
3541
|
+
}
|
|
3542
|
+
catch { /* node-level flush retries below */ }
|
|
3543
|
+
return result;
|
|
6558
3544
|
});
|
|
6559
|
-
try {
|
|
6560
|
-
await input.flushLedger();
|
|
6561
|
-
}
|
|
6562
|
-
catch { /* node-level flush retries below */ }
|
|
6563
3545
|
accumulated = accumulated
|
|
6564
3546
|
? combineSequentialPiResults(accumulated, correction)
|
|
6565
3547
|
: correction;
|
|
3548
|
+
if (!correction.ok && !["invalid-output", "empty-output", "success"].includes(correction.failureCategory))
|
|
3549
|
+
return { ...accumulated, ok: false, failureCategory: correction.failureCategory };
|
|
6566
3550
|
if (correction.ok) {
|
|
6567
|
-
finalizeResult = await
|
|
3551
|
+
finalizeResult = await finalize();
|
|
6568
3552
|
}
|
|
6569
3553
|
const retriedDetails = finalizeResult && typeof finalizeResult === "object"
|
|
6570
3554
|
? (finalizeResult.details ?? finalizeResult)
|
|
@@ -6657,7 +3641,7 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
6657
3641
|
toolName: "record_target_surface",
|
|
6658
3642
|
instruction: [
|
|
6659
3643
|
"PARALLEL SCOUT SHARD — target surface only.",
|
|
6660
|
-
"Inspect routes, entrypoints, implementation ownership, data source, and
|
|
3644
|
+
"Inspect routes, entrypoints, implementation ownership, data source, and existing test entrypoints. Inspect project source/config first; do not recursively explore dependency internals merely to reconfirm standard test-runner behavior. If a concrete required capability cannot be established from project evidence, report the precise gap.",
|
|
6661
3645
|
"Call record_target_surface exactly once with the complete runtime-evidenced surface. Do not call record_design_evidence.",
|
|
6662
3646
|
].join(" "),
|
|
6663
3647
|
},
|
|
@@ -6666,12 +3650,16 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
6666
3650
|
toolName: "record_design_evidence",
|
|
6667
3651
|
instruction: [
|
|
6668
3652
|
"PARALLEL SCOUT SHARD — design evidence only.",
|
|
6669
|
-
"Inspect
|
|
3653
|
+
"Inspect project-owned frontend framework, styling/theme conventions, reusable components, and relevant design/spec files. The surface shard owns test-runner/environment discovery; do not duplicate its Vitest/happy-dom investigation. Stop discovery once the design evidence is sufficient and commit it.",
|
|
6670
3654
|
"Call record_design_evidence for the evidence you actually read. Do not call record_target_surface.",
|
|
6671
3655
|
].join(" "),
|
|
6672
3656
|
},
|
|
6673
3657
|
];
|
|
6674
3658
|
const outcomes = await Promise.all(shards.map(async (shard) => {
|
|
3659
|
+
const cacheKey = JSON.stringify([input.runDir, input.nodeId, shard.id, input.sessionOptions.modelConfig, input.observation?.model, input.observation?.sourceDigest, input.sourceDeclaredPaths]);
|
|
3660
|
+
const cached = input.completedShards?.get(cacheKey);
|
|
3661
|
+
if (cached)
|
|
3662
|
+
return { shard, facts: cached.facts, result: { ...cached.result, durationMs: 0, tokensUsed: 0, parsedEvents: 0, attemptedModels: [], fallbackUsed: false } };
|
|
6675
3663
|
let shardTools;
|
|
6676
3664
|
let shardResult;
|
|
6677
3665
|
try {
|
|
@@ -6714,7 +3702,12 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
6714
3702
|
},
|
|
6715
3703
|
}));
|
|
6716
3704
|
await shardTools.flush();
|
|
6717
|
-
|
|
3705
|
+
const facts = shardTools.committedFacts();
|
|
3706
|
+
const expectedKind = shard.id === "design" ? "design-evidence" : "target-surface";
|
|
3707
|
+
if (shardResult.ok && facts.some(record => { const fact = record.fact; return fact.kind === expectedKind && (shard.id !== "surface" || fact.completeness === "complete"); })) {
|
|
3708
|
+
input.completedShards?.set(cacheKey, { result: structuredClone(shardResult), facts: structuredClone(facts) });
|
|
3709
|
+
}
|
|
3710
|
+
return { shard, result: shardResult, tools: shardTools, facts };
|
|
6718
3711
|
}
|
|
6719
3712
|
catch (error) {
|
|
6720
3713
|
const crashMessage = `frontend scout parallel shard ${shard.id} crashed: ${error instanceof Error ? error.message : String(error)}`;
|
|
@@ -6754,8 +3747,8 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
6754
3747
|
const ordered = [...outcomes].sort((left, right) => left.shard.id.localeCompare(right.shard.id));
|
|
6755
3748
|
try {
|
|
6756
3749
|
for (const outcome of ordered) {
|
|
6757
|
-
if (outcome.result.ok
|
|
6758
|
-
await input.mainTools.adoptCommittedFacts(outcome.tools
|
|
3750
|
+
if (outcome.result.ok) {
|
|
3751
|
+
await input.mainTools.adoptCommittedFacts(outcome.facts ?? outcome.tools?.committedFacts() ?? []);
|
|
6759
3752
|
}
|
|
6760
3753
|
}
|
|
6761
3754
|
}
|
|
@@ -6782,7 +3775,7 @@ async function runFrontendScoutParallelSessions(input) {
|
|
|
6782
3775
|
stderr: `${aggregate.stderr}\nfrontend scout parallel shard failed: ${failed.shard.id}`.trim(),
|
|
6783
3776
|
};
|
|
6784
3777
|
}
|
|
6785
|
-
const committedKinds = new Set(ordered.flatMap((outcome) => (outcome.tools?.committedFacts() ?? [])
|
|
3778
|
+
const committedKinds = new Set(ordered.flatMap((outcome) => (outcome.facts ?? outcome.tools?.committedFacts() ?? [])
|
|
6786
3779
|
.map((record) => record.fact?.kind)
|
|
6787
3780
|
.filter((kind) => typeof kind === "string")));
|
|
6788
3781
|
const missing = ["target-surface", "design-evidence"].filter((kind) => !committedKinds.has(kind));
|
|
@@ -6987,7 +3980,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
6987
3980
|
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
6988
3981
|
const store = createTypedEventStore();
|
|
6989
3982
|
reviewTerminalTools = await createFrontendReviewTerminalTools({
|
|
6990
|
-
inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
|
|
3983
|
+
inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES, input.cwd),
|
|
6991
3984
|
inputDigest: reviewInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
|
|
6992
3985
|
attemptId: `${meta.runId}:${input.task.id}`,
|
|
6993
3986
|
store,
|
|
@@ -7144,7 +4137,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
7144
4137
|
if (useDecisionPlan) {
|
|
7145
4138
|
// Recovery child: read the parent snapshot before the tools so
|
|
7146
4139
|
// the replay and the identical-plan finalize guard share it.
|
|
7147
|
-
parentDecisionSnapshot = await readParentDecisionSnapshot(meta.runDir, input.task.id);
|
|
4140
|
+
parentDecisionSnapshot = await readParentDecisionSnapshot(meta.runDir, input.task.id, { workspaceRoot: input.cwd, sourceBinding: meta.spec.sourceBinding });
|
|
7148
4141
|
}
|
|
7149
4142
|
decisionPlanTools = await createFrontendPlanDecisionTools({
|
|
7150
4143
|
attemptId: `${meta.runId}:${input.task.id}`,
|
|
@@ -7334,6 +4327,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
7334
4327
|
scoutEvidenceTools &&
|
|
7335
4328
|
input.task.complexity !== "LOW") {
|
|
7336
4329
|
result = await runFrontendScoutParallelSessions({
|
|
4330
|
+
completedShards: input.scoutCompletedShards,
|
|
7337
4331
|
piStepFn,
|
|
7338
4332
|
sessionOptions: piSessionOptions,
|
|
7339
4333
|
basePrompt: input.prompt,
|
|
@@ -7412,46 +4406,86 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
7412
4406
|
return base;
|
|
7413
4407
|
})()
|
|
7414
4408
|
: input.prompt;
|
|
7415
|
-
|
|
7416
|
-
|
|
7417
|
-
|
|
7418
|
-
|
|
4409
|
+
let decisionDurableCount = decisionPlanTools.committedFacts().length;
|
|
4410
|
+
const decisionResult = await observeFrontendSession({
|
|
4411
|
+
artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}-decision.json`),
|
|
4412
|
+
phase: "plan/decision", dispatchReason: "initial",
|
|
4413
|
+
runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId,
|
|
4414
|
+
attempt: input.attempt ?? 1, model: input.model,
|
|
4415
|
+
sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
|
|
4416
|
+
prompt: decisionPrompt, userMessage: piSessionOptions.userMessage, customTools: decisionPlanTools.customTools,
|
|
4417
|
+
committedCount: () => decisionPlanTools.committedFacts().length,
|
|
4418
|
+
durableCommittedCount: () => decisionDurableCount,
|
|
4419
|
+
}, async (observer) => {
|
|
4420
|
+
const result = await piStepFn({
|
|
4421
|
+
...piSessionOptions, frontendPlanControl: decisionPlanTools.planControl, onAttemptObservation: observer, prompt: decisionPrompt,
|
|
4422
|
+
writerToolPolicy: { requireSdk: true, customTools: decisionPlanTools.customTools },
|
|
4423
|
+
});
|
|
4424
|
+
try {
|
|
4425
|
+
await decisionPlanTools.flush();
|
|
4426
|
+
decisionDurableCount = decisionPlanTools.committedFacts().length;
|
|
4427
|
+
}
|
|
4428
|
+
catch { /* node-level flush retries below */ }
|
|
4429
|
+
return result;
|
|
7419
4430
|
});
|
|
7420
|
-
|
|
7421
|
-
|
|
4431
|
+
if ((!decisionResult.ok && !["empty-output", "invalid-output", "unknown"].includes(decisionResult.failureCategory)) || decisionPlanTools.planControl.readStop()) {
|
|
4432
|
+
const stopped = decisionPlanTools.planControl.readStop();
|
|
4433
|
+
result = decisionResult.ok && stopped ? { ...decisionResult, ok: false, failureCategory: "invalid-output", stderr: `${stopped.code}: ${stopped.error}` } : decisionResult;
|
|
7422
4434
|
}
|
|
7423
|
-
|
|
7424
|
-
|
|
7425
|
-
|
|
7426
|
-
|
|
7427
|
-
|
|
7428
|
-
|
|
7429
|
-
|
|
7430
|
-
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7435
|
-
|
|
7436
|
-
|
|
7437
|
-
|
|
7438
|
-
|
|
7439
|
-
|
|
7440
|
-
|
|
7441
|
-
|
|
7442
|
-
|
|
7443
|
-
|
|
7444
|
-
|
|
7445
|
-
|
|
7446
|
-
|
|
4435
|
+
else {
|
|
4436
|
+
const finalizeResult = await observeFrontendFinalization({
|
|
4437
|
+
artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}-runtime-finalize.json`),
|
|
4438
|
+
runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1,
|
|
4439
|
+
}, () => decisionPlanTools.finalizeDecision());
|
|
4440
|
+
const finalizeDetails = finalizeResult && typeof finalizeResult === "object"
|
|
4441
|
+
? (finalizeResult.details ?? finalizeResult)
|
|
4442
|
+
: finalizeResult;
|
|
4443
|
+
if (finalizeDetails && typeof finalizeDetails === "object" && finalizeDetails.ok === true) {
|
|
4444
|
+
// Bridge the finalized decisions into the relationship-shaped plan
|
|
4445
|
+
// ledger. The node-level R1 self-check and all downstream shells
|
|
4446
|
+
// compile ONLY plan-typed-facts.jsonl; without this write the whole
|
|
4447
|
+
// decision run fails "frontend plan ledger missing" at R1 on every
|
|
4448
|
+
// attempt. Validation failures surface as invalid-output so the
|
|
4449
|
+
// retry ladder restarts the session with the diagnostics.
|
|
4450
|
+
try {
|
|
4451
|
+
const bridge = await bridgeFrontendPlanDecisionToRelationshipLedger({
|
|
4452
|
+
runDir: meta.runDir,
|
|
4453
|
+
nodeId: input.task.id,
|
|
4454
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
4455
|
+
facts: decisionPlanTools.committedFacts(),
|
|
4456
|
+
authority: decisionPlanAuthority,
|
|
4457
|
+
skeleton: input.task.structuredContractOutput?.skeleton,
|
|
4458
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
4459
|
+
});
|
|
4460
|
+
if (!bridge.ok) {
|
|
4461
|
+
throw new Error(bridge.error);
|
|
4462
|
+
}
|
|
4463
|
+
result = decisionResult;
|
|
4464
|
+
}
|
|
4465
|
+
catch (error) {
|
|
4466
|
+
result = {
|
|
4467
|
+
ok: false,
|
|
4468
|
+
stdout: decisionResult.stdout ?? "",
|
|
4469
|
+
stderr: `${decisionResult.stderr ?? ""}\n${error instanceof Error ? error.message : String(error)}`.trim(),
|
|
4470
|
+
failureCategory: "invalid-output",
|
|
4471
|
+
durationMs: Date.now() - started,
|
|
4472
|
+
modelDisplay: decisionResult.modelDisplay,
|
|
4473
|
+
parsedEvents: decisionResult.parsedEvents,
|
|
4474
|
+
timedOut: decisionResult.timedOut,
|
|
4475
|
+
attemptedModels: decisionResult.attemptedModels,
|
|
4476
|
+
fallbackUsed: decisionResult.fallbackUsed,
|
|
4477
|
+
tokensUsed: decisionResult.tokensUsed,
|
|
4478
|
+
assistantText: decisionResult.assistantText ?? "",
|
|
4479
|
+
command: decisionResult.command ?? [],
|
|
4480
|
+
exitCode: decisionResult.exitCode,
|
|
4481
|
+
};
|
|
7447
4482
|
}
|
|
7448
|
-
result = decisionResult;
|
|
7449
4483
|
}
|
|
7450
|
-
|
|
4484
|
+
else {
|
|
7451
4485
|
result = {
|
|
7452
4486
|
ok: false,
|
|
7453
4487
|
stdout: decisionResult.stdout ?? "",
|
|
7454
|
-
stderr: `${decisionResult.stderr ?? ""}\
|
|
4488
|
+
stderr: `${decisionResult.stderr ?? ""}\nfrontend decision finalize failed: ${String(finalizeDetails?.error ?? "decision finalize rejected")}`.trim(),
|
|
7455
4489
|
failureCategory: "invalid-output",
|
|
7456
4490
|
durationMs: Date.now() - started,
|
|
7457
4491
|
modelDisplay: decisionResult.modelDisplay,
|
|
@@ -7466,24 +4500,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
7466
4500
|
};
|
|
7467
4501
|
}
|
|
7468
4502
|
}
|
|
7469
|
-
else {
|
|
7470
|
-
result = {
|
|
7471
|
-
ok: false,
|
|
7472
|
-
stdout: decisionResult.stdout ?? "",
|
|
7473
|
-
stderr: `${decisionResult.stderr ?? ""}\nfrontend decision finalize failed: ${String(finalizeDetails?.error ?? "decision finalize rejected")}`.trim(),
|
|
7474
|
-
failureCategory: "invalid-output",
|
|
7475
|
-
durationMs: Date.now() - started,
|
|
7476
|
-
modelDisplay: decisionResult.modelDisplay,
|
|
7477
|
-
parsedEvents: decisionResult.parsedEvents,
|
|
7478
|
-
timedOut: decisionResult.timedOut,
|
|
7479
|
-
attemptedModels: decisionResult.attemptedModels,
|
|
7480
|
-
fallbackUsed: decisionResult.fallbackUsed,
|
|
7481
|
-
tokensUsed: decisionResult.tokensUsed,
|
|
7482
|
-
assistantText: decisionResult.assistantText ?? "",
|
|
7483
|
-
command: decisionResult.command ?? [],
|
|
7484
|
-
exitCode: decisionResult.exitCode,
|
|
7485
|
-
};
|
|
7486
|
-
}
|
|
7487
4503
|
}
|
|
7488
4504
|
else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
|
|
7489
4505
|
// Frontend-only split: independent coverage map sessions feed a single
|
|
@@ -7496,14 +4512,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
7496
4512
|
try {
|
|
7497
4513
|
const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-committed-facts.js");
|
|
7498
4514
|
const contractFacts = await readCommittedOriginFacts(meta.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
|
|
7499
|
-
//
|
|
7500
|
-
//
|
|
7501
|
-
//
|
|
7502
|
-
|
|
7503
|
-
|
|
7504
|
-
|
|
7505
|
-
planRequirementIds = collectFrontendPlanRequirementIds(contractFacts);
|
|
7506
|
-
for (const fact of resolveFrontendContractRequirements(contractFacts.map((record) => record.fact))) {
|
|
4515
|
+
// Read current requirements, not append-only revisions introduced when
|
|
4516
|
+
// Contract attaches execution metadata. Coverage and costs must share
|
|
4517
|
+
// the same identity projection as the rendered Plan input.
|
|
4518
|
+
const requirements = resolveFrontendContractRequirements(contractFacts.map(record => record.fact));
|
|
4519
|
+
planRequirementIds = requirements.map(fact => fact.id);
|
|
4520
|
+
for (const fact of requirements) {
|
|
7507
4521
|
if (fact.evidence.behavior === "required")
|
|
7508
4522
|
behaviorRequiredRequirementIds.push(fact.id);
|
|
7509
4523
|
planRequirementCosts.set(fact.id, estimateFrontendPlanRequirementRecordCalls(fact));
|
|
@@ -7529,7 +4543,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
7529
4543
|
contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
|
|
7530
4544
|
},
|
|
7531
4545
|
piStepFn,
|
|
7532
|
-
sessionOptions: options.sessionOptions,
|
|
4546
|
+
sessionOptions: { ...options.sessionOptions, frontendPlanControl: options.ledgerTools.planControl },
|
|
7533
4547
|
basePrompt: options.basePrompt ?? input.prompt,
|
|
7534
4548
|
attempt: input.attempt ?? 1,
|
|
7535
4549
|
committedFactCount: () => options.ledgerTools.committedFactCount(),
|