@tea-agent/loop-agent 0.42.0-next.1 → 0.42.0-next.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +321 -1
- package/dist/application/dag/generate-task-dag.js +75 -19
- package/dist/application/dag/run-dag.js +41 -0
- package/dist/application/task-lifecycle/advance.js +24 -5
- package/dist/application/task-lifecycle/observe.js +171 -17
- package/dist/application/task-lifecycle/plan-transitions.js +42 -7
- package/dist/application/task-lifecycle/recommendations.js +13 -2
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +7 -0
- package/dist/cli/program.js +6 -1
- package/dist/commands/client-recovery.js +3 -0
- package/dist/commands/dag-follow-up.js +138 -0
- package/dist/commands/dag-rerun-task.js +2 -0
- package/dist/commands/init.js +27 -1
- package/dist/commands/task-advance.js +19 -0
- package/dist/executors/dag-pi-executor.js +4010 -78
- package/dist/executors/pi-executor.js +15 -4
- package/dist/executors/pi-extension-resolver.js +14 -2
- package/dist/executors/pi-read-budget-policy.js +239 -0
- package/dist/executors/shell-executor.js +978 -188
- package/dist/executors/shell-write-guard.js +7 -0
- package/dist/infrastructure/console/operation-store.js +226 -6
- package/dist/shared/dag-failure-category.js +12 -0
- package/dist/shared/openspec-spec.js +70 -4
- package/dist/shared/operator/capabilities.js +7 -0
- package/dist/task/config-types.js +105 -5
- package/dist/task/contract/adopt.js +4 -0
- package/dist/task/contract/import-revision.js +4 -0
- package/dist/task/contract/project.js +3 -0
- package/dist/task/contract/schema.js +2 -1
- package/dist/task/frontend-project-capability.js +203 -20
- package/dist/task/runtime.js +5 -2
- package/dist/task/source-prepare/build-draft.js +3 -3
- package/dist/task/source-prepare/fragment-inventory.js +64 -25
- package/dist/task/source-prepare/prepare.js +126 -21
- package/dist/task/source-prepare/semantic-intake.js +6 -2
- package/dist/task/source-references.js +22 -1
- package/dist/worker/console/chat/chat-ui-policy.js +4 -2
- package/dist/worker/console/chat/model-resolver.js +7 -4
- package/dist/worker/console/chat/pi-runtime.js +258 -11
- package/dist/worker/console/chat/repo-browser.js +10 -3
- package/dist/worker/console/chat/routes.js +165 -71
- package/dist/worker/console/chat/sdd-data-alignment.js +222 -0
- package/dist/worker/console/chat/session-catalog.js +2 -0
- package/dist/worker/console/chat/session-store.js +159 -70
- package/dist/worker/console/chat/shortcuts.js +19 -3
- package/dist/worker/console/chat/turn-process.js +111 -56
- package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
- package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
- package/dist/worker/console/index.js +3 -0
- package/dist/worker/console/operator-actions.js +169 -7
- package/dist/worker/console/pi-readiness.js +3 -2
- package/dist/worker/console/server.js +23 -1
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-DZZ8m3PO.js → abnfDiagram-N423BO3Z-CcS17TBr.js} +1 -1
- package/dist/worker/console/static/assets/{arc-D6PvaVd-.js → arc-COptKq2S.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-B_OTOiI8.js → architectureDiagram-T3A2C74G-h4LKMHjP.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-Bv6rqHBg.js → blockDiagram-VBNYF7ZC-COA1MOH0.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-B8eHr0oz.js → c4Diagram-5PPSVZJV-DJUf0QPm.js} +1 -1
- package/dist/worker/console/static/assets/channel-DdBCaOJ6.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-DYwR0im2.js → chunk-2GRJ4B5K-mtWfKrUX.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-D2WPGqXt.js → chunk-2Q5K7J3B-B8pXsxDQ.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-CQISJ_I7.js → chunk-5RXB4S5H-ipKzByl1.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-C0o2Du1e.js → chunk-5VM5RSS4-DYi3Ald_.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-4f8kr-U7.js → chunk-6Q2QTUOP-DQtGYoty.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-D_OWvXzX.js → chunk-GF5L2VYU-BU0qS2YV.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-CasdPz5X.js → chunk-JWPE2WC7-DVH9dIGE.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-Cj8lRrla.js → chunk-KBJHAD2P-Dt60SmgM.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-CUvD1FSd.js → chunk-RYQCIY6F-BoaUGuEY.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CqLw_eqB.js → chunk-XXDRQBXY-_WHFDTp2.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DZFra1GO.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DZFra1GO.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-ku-WqJPx.js → cose-bilkent-JH36ORCC-BxkbTIRd.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-Bq_PYDhl.js → cynefin-VYW2F7L2-Bf2UVnoG.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BiGZV641.js → cynefinDiagram-MW4NZA55-Bo23q1J_.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-C4JRlglF.js → dagre-VZM6K2ZE-Dd_UU49i.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-B0dNeWYG.js → diagram-7IWD3JNH-ebUa1a9y.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-Cj35YvYM.js → diagram-B4RE2ZJO-BucthU8r.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-uzQoJ2-8.js → diagram-LBJQPF4R-BgqNpAkm.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D1a-Buoz.js → diagram-Q27KOJAE-DC4q5NGa.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-XjRrLRSs.js → diagram-UB23O5K3-CBqCaMeb.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-Da_O24cW.js → ebnfDiagram-BXEA7PRR-CGtnbQ3-.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-BLJ8jrYU.js → erDiagram-JOGREHBK-CG3LUao5.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-B9GrLjM0.js → flowDiagram-UKHOOZJN-D2ZVgoFS.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CeJ0TqiK.js → ganttDiagram-PKOTCBZU-DoYFZiKt.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Bm2eNNFX.js → gitGraphDiagram-DS77QQ5N-CKoP1s6j.js} +1 -1
- package/dist/worker/console/static/assets/index-CAZ2fC_X.css +1 -0
- package/dist/worker/console/static/assets/index-vbTcFnFs.js +449 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DB26i3d8.js → infoDiagram-6WML65LV-Duofv8p2.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-BOGRyeZT.js → ishikawaDiagram-WSZJBQD7-D2nlCkA1.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-CZRoBO_6.js → journeyDiagram-NVQOT4AX-Dd4IHdus.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-qlWhJyoB.js → kanban-definition-27J2QSJJ-Bi20AOdt.js} +1 -1
- package/dist/worker/console/static/assets/{linear-CfUiDDB3.js → linear-D59YJ9kB.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BQe6fpqj.js → mermaid.core-Bl13LpNa.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-zFzWHw64.js → mindmap-definition-FAOFIHXS-C6DLP5eY.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-DFGUqjZO.js → pegDiagram-VL7TDLO6-BjO4cy64.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-BB5i0l8Z.js → pieDiagram-7S7Q4E2Y-D7i2qZTG.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-obbVZ_X5.js → quadrantDiagram-CIZ2JOQS-Dx8o_h0y.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-BwdaNzc1.js → railroadDiagram-AXF67PYL-B55orI_t.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CkTJaugh.js → requirementDiagram-LRYGKXZP-zWaehp6f.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-BwJ-hIgq.js → sankeyDiagram-W5VNT64P-CcA-pjvD.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-BzSgmm7w.js → sequenceDiagram-SI44F4Z6-BOFbzNHI.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-Bn3oj5Q1.js → sizeCapture-X5ZJPWSS-BjAejah1.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-dxhGtL8K.js → stateDiagram-OKZ733FA-DXYwxJwZ.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Clg3V9t1.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-BLpEInya.js → swimlanes-SLNWSIFB-OYiOch8n.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-DX1dxAAW.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-ChJGYcXy.js → timeline-definition-Z64GVDOM-cZfH3nmU.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-DK-qjRer.js → vennDiagram-T6HMQDX7-BDE7b3E1.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-3qeaWAg-.js → wardleyDiagram-T6FBY63Y-kyyGy9WJ.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CootlyP9.js → xychartDiagram-ELKLHX3M-CJj6VTog.js} +1 -1
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/console/static-src/active-run-badge.js +17 -0
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +5 -5
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +19 -8
- package/dist/worker/console/static-src/operator-chat/input-history.js +8 -6
- package/dist/worker/console/static-src/operator-chat/open-preview-in-browser.js +15 -7
- package/dist/worker/console/static-src/operator-chat/runtime-snapshot-store.js +10 -0
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +258 -23
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +112 -4
- package/dist/worker/console/static-src/operator-chat/useComposer.js +13 -4
- package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +29 -6
- package/dist/worker/console/static-src/operator-chat/useRuntimeSnapshot.js +8 -2
- package/dist/worker/console/static-src/shell/console-update-reload.js +36 -9
- package/dist/worker/console/static-src/shell/workspace-route.js +11 -0
- package/dist/worker/console/workspace-context.js +115 -1
- package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
- package/dist/worker/observe/routes.js +4 -0
- package/dist/worker/observe/static/constants.js +22 -22
- package/dist/worker/observe/static/dag-context-reason-labels.js +19 -0
- package/dist/worker/observe/static/dag-helpers.js +4 -2
- package/dist/worker/observe/static/dag-history-labels.js +1 -0
- package/dist/worker/observe/static/dag-inspector-humanize.d.ts +16 -0
- package/dist/worker/observe/static/dag-inspector-humanize.js +339 -0
- package/dist/worker/observe/static/dag-node-purpose.js +13 -8
- package/dist/worker/observe/static/index.html +4 -4
- package/dist/worker/observe/static/inspect-workspace.js +23 -0
- package/dist/worker/observe/static/kpi.js +2 -2
- package/dist/worker/observe/static/operator-chrome.d.ts +10 -2
- package/dist/worker/observe/static/operator-chrome.js +37 -23
- package/dist/worker/observe/static/prompt-restart-candidates.js +4 -2
- package/dist/worker/observe/static/relations.js +7 -7
- package/dist/worker/observe/static/router.d.ts +12 -1
- package/dist/worker/observe/static/router.js +48 -6
- package/dist/worker/observe/static/run-processing.js +4 -2
- package/dist/worker/observe/static/shell-chrome.js +2 -2
- package/dist/worker/observe/static/state.js +5 -0
- package/dist/worker/observe/static/styles.css +231 -0
- package/dist/worker/observe/static/views/dag-graph.js +1 -1
- package/dist/worker/observe/static/views/dag-inspector.js +336 -181
- package/dist/worker/observe/static/views/dag.js +26 -14
- package/dist/worker/observe/static/views/dags.js +3 -1
- package/dist/worker/observe/static/views/dashboard.js +14 -6
- package/dist/worker/observe/static/views/pool.js +7 -2
- package/dist/worker/observe/static/views/run.js +12 -3
- package/dist/worker/observe/static/views/session-timeline.js +74 -13
- package/dist/worker/observe/static/views/task.js +7 -2
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +215 -28
- package/dist/workflows/dag/backend-test-markdown-workflow.js +13 -1
- package/dist/workflows/dag/backend-test-plan-protocol.js +210 -0
- package/dist/workflows/dag/backend-test-scenario-param.js +172 -25
- package/dist/workflows/dag/backend-test-scenario-partitions.js +62 -1
- package/dist/workflows/dag/backend-test-writer-completeness.js +104 -12
- package/dist/workflows/dag/contract-validator-registrations.js +1 -2
- package/dist/workflows/dag/dag-retry-schema.js +138 -0
- package/dist/workflows/dag/frontend-closeout.js +221 -0
- package/dist/workflows/dag/frontend-design-policy.js +400 -0
- package/dist/workflows/dag/frontend-human-decision.js +182 -0
- package/dist/workflows/dag/frontend-implementation-contract.js +1237 -192
- package/dist/workflows/dag/frontend-plan-render.js +2 -1
- package/dist/workflows/dag/frontend-prewrite-gate.js +256 -350
- package/dist/workflows/dag/frontend-provider-capability-matrix.js +159 -0
- package/dist/workflows/dag/frontend-recovery-capsule.js +455 -0
- package/dist/workflows/dag/frontend-recovery-controller.js +226 -0
- package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +21 -10
- package/dist/workflows/dag/frontend-recovery-run.js +166 -34
- package/dist/workflows/dag/frontend-repair.js +1 -432
- package/dist/workflows/dag/frontend-review-context.js +261 -15
- package/dist/workflows/dag/frontend-review-findings.js +270 -0
- package/dist/workflows/dag/frontend-shadow-dual-write.js +941 -0
- package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
- package/dist/workflows/dag/frontend-shape-facts.js +419 -0
- package/dist/workflows/dag/frontend-shape.js +435 -0
- package/dist/workflows/dag/frontend-source-fidelity-ledger.js +108 -0
- package/dist/workflows/dag/frontend-split-application-service.js +203 -0
- package/dist/workflows/dag/frontend-split-orchestrator.js +899 -0
- package/dist/workflows/dag/frontend-typed-event-store.js +452 -0
- package/dist/workflows/dag/frontend-typed-event-transaction.js +180 -0
- package/dist/workflows/dag/frontend-verification-trace.js +252 -25
- package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
- package/dist/workflows/dag/frontend-writer-admission.js +319 -0
- package/dist/workflows/dag/frontend-writer-status.js +256 -0
- package/dist/workflows/dag/init-hybrid.js +1031 -564
- package/dist/workflows/dag/interrupt-request.js +7 -0
- package/dist/workflows/dag/node-execution.js +854 -4
- package/dist/workflows/dag/prompt.js +70 -3
- package/dist/workflows/dag/recovery-lease.js +170 -0
- package/dist/workflows/dag/report.js +37 -1
- package/dist/workflows/dag/rerun-feedback.js +315 -1
- package/dist/workflows/dag/rerun-plan.js +17 -6
- package/dist/workflows/dag/rerun-run.js +44 -6
- package/dist/workflows/dag/rerun-task.js +251 -13
- package/dist/workflows/dag/retry-policy.js +230 -104
- package/dist/workflows/dag/runner.js +580 -124
- package/dist/workflows/dag/scheduler.js +133 -20
- package/dist/workflows/dag/types.js +244 -16
- package/dist/workflows/dag/validate.js +28 -12
- package/docs/examples/README.md +5 -0
- package/docs/init-surface.manifest.json +30 -12
- package/docs/skills/vetted-skill-registry.md +4 -2
- package/docs/templates/README.md +2 -0
- package/docs/templates/agent-dag-report.schema.json +8 -2
- package/docs/templates/agent-dag.schema.json +1 -1
- package/docs/templates/backend-test-dag.json +25 -20
- package/docs/templates/frontend-implementation-contract.schema.json +4 -1
- package/docs/templates/frontend-implementation-dag.json +89 -0
- package/docs/templates/spec-registry.schema.json +45 -0
- package/harness.json +1 -1
- package/package.json +4 -3
- package/skills/frontend-bounded-implement/SKILL.md +15 -14
- package/skills/frontend-bounded-implement/references/code-standards.md +19 -0
- package/skills/frontend-contract/SKILL.md +23 -0
- package/skills/frontend-contract/references/contract-protocol.md +34 -0
- package/skills/frontend-design-review/SKILL.md +22 -41
- package/skills/frontend-plan/SKILL.md +26 -0
- package/skills/frontend-plan/references/decision-contract.md +37 -0
- package/skills/frontend-plan/references/design-decisions.md +17 -0
- package/skills/frontend-review/SKILL.md +20 -15
- package/skills/frontend-review/references/review-findings.md +6 -7
- package/skills/frontend-scout/SKILL.md +25 -0
- package/skills/frontend-scout/references/design-evidence.md +16 -0
- package/skills/frontend-scout/references/scout-evidence.md +23 -0
- package/skills/frontend-verification/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +1 -0
- package/skills/loop-agent/references/hybrid-dag.md +2 -2
- package/dist/worker/console/static/assets/channel-BU5gOilw.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DIzKJGHr.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DIzKJGHr.js +0 -1
- package/dist/worker/console/static/assets/index-H9rFJiGL.css +0 -1
- package/dist/worker/console/static/assets/index-xwu9GxEc.js +0 -451
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cld2qK9v.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-TIHpiT7w.js +0 -8
- package/skills/frontend-implementation/SKILL.md +0 -52
- package/skills/frontend-implementation/references/code-standards.md +0 -33
- package/skills/frontend-implementation/references/design-spec.md +0 -56
- package/skills/frontend-implementation/references/node-contracts.md +0 -31
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
3
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
5
|
+
import { writeJsonAtomic } from "../infrastructure/harness/atomic-write.js";
|
|
6
|
+
import { mapContractBlockedOwner } from "../workflows/dag/frontend-human-decision.js";
|
|
7
|
+
import { routeFrontendProviderCapability } from "../workflows/dag/frontend-provider-capability-matrix.js";
|
|
5
8
|
import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
|
|
6
9
|
import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
|
|
7
10
|
import { buildPiWriterToolPolicyContext, createPiReaderCustomTools, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
|
|
11
|
+
import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
|
|
8
12
|
import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
|
|
9
13
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
14
|
+
import { parseLedgerJson } from "../task/source-prepare/ledger.js";
|
|
10
15
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
11
16
|
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
12
17
|
import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
18
|
+
import { pathMatchesPattern } from "../shared/git-progress.js";
|
|
19
|
+
import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
20
|
+
import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
15
21
|
import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
|
|
22
|
+
import { assessBackendTestPlanProtocol } from "../workflows/dag/backend-test-plan-protocol.js";
|
|
16
23
|
import { frontendTestLayoutFromSpec } from "../workflows/dag/frontend-test-layout.js";
|
|
17
24
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
18
25
|
import { writeEffectiveContextReceipt } from "../workflows/dag/context-receipt.js";
|
|
@@ -25,6 +32,47 @@ import { writeEffectiveContextReceipt } from "../workflows/dag/context-receipt.j
|
|
|
25
32
|
* recoverable partial-write-set (incomplete-write-set) upgrade.
|
|
26
33
|
*/
|
|
27
34
|
export const WRITER_THINKING_EXHAUSTED_CATEGORY = "writer-thinking-exhausted";
|
|
35
|
+
/**
|
|
36
|
+
* Planner classification mirroring writer-thinking-exhausted: a read-only
|
|
37
|
+
* planning session stopped on length, observed thinking, and committed zero
|
|
38
|
+
* typed facts with no assistant text. By the time this survives the segmented
|
|
39
|
+
* ladder the scope has already been degraded, so the durable fix is a
|
|
40
|
+
* thinking-capped or non-thinking model for the tier — not another replay of
|
|
41
|
+
* the same full-scope prompt.
|
|
42
|
+
*/
|
|
43
|
+
export const PLANNER_THINKING_EXHAUSTED_CATEGORY = "planner-thinking-exhausted";
|
|
44
|
+
export function isPlannerThinkingExhausted(result, committedAnyFacts) {
|
|
45
|
+
if (result.ok)
|
|
46
|
+
return false;
|
|
47
|
+
const evidence = readWriterThinkingExhaustionEvidence(result);
|
|
48
|
+
if (evidence.stopReason !== "length")
|
|
49
|
+
return false;
|
|
50
|
+
if (evidence.thinkingObserved !== true)
|
|
51
|
+
return false;
|
|
52
|
+
if (committedAnyFacts)
|
|
53
|
+
return false;
|
|
54
|
+
if ((result.assistantText ?? "").trim())
|
|
55
|
+
return false;
|
|
56
|
+
// Gateways sometimes relabel a length-stopped stream as `network` or
|
|
57
|
+
// `nonzero-exit`; provider evidence outweighs the transport label.
|
|
58
|
+
if (result.failureCategory &&
|
|
59
|
+
!["empty-output", "network", "nonzero-exit", "unknown"].includes(result.failureCategory))
|
|
60
|
+
return false;
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The writer session burned an excessive token budget (a read-edit-test loop
|
|
65
|
+
* that never converged) and still failed. Distinct from empty-output so the
|
|
66
|
+
* report shows the real cause and recovery recommends a fresh compacted run.
|
|
67
|
+
* Not auto-retried by default; operators may rerun after a model switch.
|
|
68
|
+
*/
|
|
69
|
+
export const WRITER_BUDGET_EXHAUSTED_CATEGORY = "writer-budget-exhausted";
|
|
70
|
+
/** Token ceiling for a single writer node before it is judged budget-exhausted. */
|
|
71
|
+
export const WRITER_TOKEN_BUDGET = 2_000_000;
|
|
72
|
+
export function allowsMissingChangedWriterOutcomeRecovery(task) {
|
|
73
|
+
return (isBackendTestCompletenessRetryCandidate(task) ||
|
|
74
|
+
isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate(task));
|
|
75
|
+
}
|
|
28
76
|
function readWriterThinkingExhaustionEvidence(result) {
|
|
29
77
|
const wider = result;
|
|
30
78
|
return {
|
|
@@ -50,8 +98,6 @@ function readWriterThinkingExhaustionEvidence(result) {
|
|
|
50
98
|
export function isWriterThinkingExhausted(result, mapped, changeManifestChangedFiles) {
|
|
51
99
|
if (mapped.ok)
|
|
52
100
|
return false;
|
|
53
|
-
if (mapped.failureCategory !== "empty-output")
|
|
54
|
-
return false;
|
|
55
101
|
const evidence = readWriterThinkingExhaustionEvidence(result);
|
|
56
102
|
if (evidence.stopReason !== "length")
|
|
57
103
|
return false;
|
|
@@ -59,6 +105,13 @@ export function isWriterThinkingExhausted(result, mapped, changeManifestChangedF
|
|
|
59
105
|
return false;
|
|
60
106
|
if ((evidence.writeToolCallCount ?? 0) !== 0)
|
|
61
107
|
return false;
|
|
108
|
+
// Gateways sometimes classify a length-stopped stream as `network` or
|
|
109
|
+
// `nonzero-exit` because the terminal event is carried in stderr. The
|
|
110
|
+
// provider evidence is stronger than that transport label when no write
|
|
111
|
+
// tool was called and the run produced no diff.
|
|
112
|
+
if (mapped.failureCategory &&
|
|
113
|
+
!["empty-output", "network", "nonzero-exit", "unknown"].includes(mapped.failureCategory))
|
|
114
|
+
return false;
|
|
62
115
|
if (changeManifestChangedFiles === undefined)
|
|
63
116
|
return false;
|
|
64
117
|
if (changeManifestChangedFiles.length !== 0)
|
|
@@ -167,6 +220,14 @@ function resolvePiExecutorStep(persona) {
|
|
|
167
220
|
function isDagPiWriteTask(task) {
|
|
168
221
|
return task.executor === "pi" && task.toolProfile === "write";
|
|
169
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* M4: `frontend-implement-pi` derives its status from mechanical facts instead
|
|
225
|
+
* of the IMPLEMENTATION_OUTCOME first line. Everything else (backend/README/
|
|
226
|
+
* module writers + frontend-repair-pi) keeps the legacy protocol face.
|
|
227
|
+
*/
|
|
228
|
+
export function isFrontendFactsWriter(task) {
|
|
229
|
+
return task.writerOutcomePolicy?.type === "frontend-facts-v1";
|
|
230
|
+
}
|
|
170
231
|
/** Map DAG `piStep` / `role` to a safe workflow step for read-only Pi or bounded write. */
|
|
171
232
|
export function resolveDagPiStepName(task) {
|
|
172
233
|
if (isDagPiWriteTask(task)) {
|
|
@@ -178,38 +239,2080 @@ export function resolveDagPiStepName(task) {
|
|
|
178
239
|
if (task.role) {
|
|
179
240
|
return PI_WRITE_ROLE_TO_STEP[task.role];
|
|
180
241
|
}
|
|
181
|
-
return "implement";
|
|
182
|
-
}
|
|
183
|
-
return resolvePiExecutorStep(resolveDagPiPersona(task));
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
242
|
+
return "implement";
|
|
243
|
+
}
|
|
244
|
+
return resolvePiExecutorStep(resolveDagPiPersona(task));
|
|
245
|
+
}
|
|
246
|
+
/** A+B: `frontend-contract-pi` incremental record tools (origin=contract). */
|
|
247
|
+
export const FRONTEND_CONTRACT_RECORD_TOOL_NAMES = [
|
|
248
|
+
"record_requirement",
|
|
249
|
+
"record_constraint",
|
|
250
|
+
"record_evidence_expectation",
|
|
251
|
+
"record_handoff_intent",
|
|
252
|
+
"record_open_question",
|
|
253
|
+
"record_split_proposal",
|
|
254
|
+
];
|
|
255
|
+
export const FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES = [
|
|
256
|
+
"finalize_contract",
|
|
257
|
+
];
|
|
258
|
+
/** A+B: `frontend-scout-pi` incremental evidence tools (origin=scout). */
|
|
259
|
+
export const FRONTEND_SCOUT_EVIDENCE_TOOL_NAMES = [
|
|
260
|
+
"record_target_surface",
|
|
261
|
+
"record_design_evidence",
|
|
262
|
+
];
|
|
263
|
+
/** A+B: `frontend-plan-pi` incremental record tools (origin=plan). */
|
|
264
|
+
export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
|
|
265
|
+
"record_route_selection",
|
|
266
|
+
"record_component_choice",
|
|
267
|
+
"record_state_flow",
|
|
268
|
+
"record_data_flow",
|
|
269
|
+
"record_mock_api",
|
|
270
|
+
"record_design_deviation",
|
|
271
|
+
"record_dependency",
|
|
272
|
+
"record_plan_requirement",
|
|
273
|
+
"record_plan_verification_target",
|
|
274
|
+
"record_plan_evidence_gap",
|
|
275
|
+
];
|
|
276
|
+
export const FRONTEND_PLAN_TERMINAL_TOOL_NAMES = ["finalize_plan"];
|
|
277
|
+
export const FRONTEND_PLAN_ADOPT_TOOL_NAMES = ["adopt_staged_fact"];
|
|
278
|
+
/** M5: `frontend-review-pi` emits its authoritative terminal verdict through
|
|
279
|
+
* committed typed tools instead of the legacy JSON verdict parse. */
|
|
280
|
+
export function isFrontendReviewTypedTerminalNode(task) {
|
|
281
|
+
return task.id === "frontend-review-pi";
|
|
282
|
+
}
|
|
283
|
+
/** M8: `frontend-design-review-pi` emits its authoritative terminal verdict
|
|
284
|
+
* through committed typed tools (`approve_design` / `request_design_changes`)
|
|
285
|
+
* instead of the legacy first-line `VERDICT: pass|request-revision` text. */
|
|
286
|
+
export function isFrontendDesignTypedTerminalNode(task) {
|
|
287
|
+
return task.id === "frontend-design-review-pi";
|
|
288
|
+
}
|
|
289
|
+
/** A+B: `frontend-contract-pi` submits its contract through incremental typed
|
|
290
|
+
* tools + the `finalize_contract` terminal (origin=contract). */
|
|
291
|
+
export function isFrontendContractTypedNode(task) {
|
|
292
|
+
return task.id === "frontend-contract-pi";
|
|
293
|
+
}
|
|
294
|
+
/** A+B: `frontend-scout-pi` submits target-surface/design-evidence through
|
|
295
|
+
* incremental evidence tools (origin=scout). */
|
|
296
|
+
export function isFrontendScoutEvidenceNode(task) {
|
|
297
|
+
return task.id === "frontend-scout-pi";
|
|
298
|
+
}
|
|
299
|
+
/** A+B: `frontend-plan-pi` records its decision ledger through seven
|
|
300
|
+
* incremental `record_*` tools and closes with `finalize_plan`. */
|
|
301
|
+
export function isFrontendPlanLedgerNode(task) {
|
|
302
|
+
return task.id === "frontend-plan-pi";
|
|
303
|
+
}
|
|
304
|
+
/** Facts-first nodes whose authoritative output is a committed typed terminal
|
|
305
|
+
* fact (not the assistant text). Downstream compilation reads the flushed
|
|
306
|
+
* `<nodeId>/<file>` store and never the node narrative, so a committed
|
|
307
|
+
* terminal means the work is done. */
|
|
308
|
+
const TYPED_TERMINAL_FACT_NODES = {
|
|
309
|
+
"frontend-contract-pi": {
|
|
310
|
+
file: "contract-typed-facts.jsonl",
|
|
311
|
+
kind: "contract-finalized",
|
|
312
|
+
},
|
|
313
|
+
"frontend-plan-pi": {
|
|
314
|
+
file: "plan-typed-facts.jsonl",
|
|
315
|
+
kind: "finalize_plan",
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* Accept a facts-terminal node result whose final assistant text is blank
|
|
320
|
+
* when the typed terminal fact was committed successfully. Small-output
|
|
321
|
+
* models legitimately end after the terminal tool call; without this the
|
|
322
|
+
* empty assistantText fails the node as empty-output, the failure classifier
|
|
323
|
+
* phrase-scans the whole session stream and can mislabel the committed run
|
|
324
|
+
* as rate-limit/network, and the finished ledger is thrown away for a
|
|
325
|
+
* deterministic retry that burns the full prompt budget again. Fail-closed:
|
|
326
|
+
* acceptance requires a committed terminal record from the flushed typed
|
|
327
|
+
* facts store; provider-error attempts (non-empty stderr) are never accepted
|
|
328
|
+
* by the caller.
|
|
329
|
+
*/
|
|
330
|
+
export async function acceptCommittedTypedTerminalFact(runDir, nodeId) {
|
|
331
|
+
const binding = TYPED_TERMINAL_FACT_NODES[nodeId];
|
|
332
|
+
if (!binding)
|
|
333
|
+
return false;
|
|
334
|
+
try {
|
|
335
|
+
const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
|
|
336
|
+
const records = await readCommittedOriginFacts(runDir, nodeId, binding.file);
|
|
337
|
+
return records.some((record) => record.fact.kind === binding.kind);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
export function resolveDagPiToolNames(task) {
|
|
344
|
+
if (isFrontendReviewTypedTerminalNode(task)) {
|
|
345
|
+
return [
|
|
346
|
+
...DAG_PI_READONLY_TOOLS,
|
|
347
|
+
"approve_review",
|
|
348
|
+
"request_review_changes",
|
|
349
|
+
];
|
|
350
|
+
}
|
|
351
|
+
if (isFrontendDesignTypedTerminalNode(task)) {
|
|
352
|
+
return [
|
|
353
|
+
...DAG_PI_READONLY_TOOLS,
|
|
354
|
+
"approve_design",
|
|
355
|
+
"request_design_changes",
|
|
356
|
+
];
|
|
357
|
+
}
|
|
358
|
+
if (isFrontendContractTypedNode(task)) {
|
|
359
|
+
// Contract is an incremental-commit node: the source-fidelity ledger is
|
|
360
|
+
// compiled into the <frontend_contract_input> block (node-execution), so
|
|
361
|
+
// no read tools — mirrors the plan node. Omitting read tools prevents a
|
|
362
|
+
// contract from spending its output budget re-reading the raw source.
|
|
363
|
+
return [
|
|
364
|
+
...FRONTEND_CONTRACT_RECORD_TOOL_NAMES,
|
|
365
|
+
...FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES,
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
if (isFrontendScoutEvidenceNode(task)) {
|
|
369
|
+
return [...DAG_PI_READONLY_TOOLS, ...FRONTEND_SCOUT_EVIDENCE_TOOL_NAMES];
|
|
370
|
+
}
|
|
371
|
+
if (isFrontendPlanLedgerNode(task)) {
|
|
372
|
+
return [
|
|
373
|
+
// Plan is a decision-only node. Contract/scout own source and repository
|
|
374
|
+
// discovery; omitting read tools prevents a planner from spending its
|
|
375
|
+
// output budget reconstructing already-frozen evidence.
|
|
376
|
+
...FRONTEND_PLAN_RECORD_TOOL_NAMES,
|
|
377
|
+
...FRONTEND_PLAN_TERMINAL_TOOL_NAMES,
|
|
378
|
+
...FRONTEND_PLAN_ADOPT_TOOL_NAMES,
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
if ((task.readSet?.length ?? 0) > 0) {
|
|
382
|
+
return isDagPiWriteTask(task) ? ["read", "edit", "write"] : ["read"];
|
|
383
|
+
}
|
|
384
|
+
if (!isDagPiWriteTask(task))
|
|
385
|
+
return [...DAG_PI_READONLY_TOOLS];
|
|
386
|
+
const tools = [...DAG_PI_WRITE_TOOLS];
|
|
387
|
+
// Pi SDK activates custom tools only when they are in this explicit list.
|
|
388
|
+
// Command capabilities (playwright_cli) stay capability-gated; read-only nodes never get command tools.
|
|
389
|
+
if (dagCommandPolicyAllows(task.commandPolicy, "playwright-cli")) {
|
|
390
|
+
tools.push("playwright_cli");
|
|
391
|
+
}
|
|
392
|
+
return tools;
|
|
393
|
+
}
|
|
394
|
+
export const FRONTEND_REVIEW_TERMINAL_TOOL_NAMES = new Set([
|
|
395
|
+
"approve_review",
|
|
396
|
+
"request_review_changes",
|
|
397
|
+
]);
|
|
398
|
+
/**
|
|
399
|
+
* Fail-closed scanner for the two committed review terminal tools. Mirrors
|
|
400
|
+
* M4's `countWriteToolEventsFromSessionEvents` pattern: an unparseable line
|
|
401
|
+
* never counts as a terminal fact, so a corrupt/empty log cannot inflate the
|
|
402
|
+
* typed verdict.
|
|
403
|
+
*/
|
|
404
|
+
export function scanReviewTerminalKindsFromSessionEvents(content) {
|
|
405
|
+
const kinds = [];
|
|
406
|
+
for (const line of content.split("\n")) {
|
|
407
|
+
const trimmed = line.trim();
|
|
408
|
+
if (!trimmed)
|
|
409
|
+
continue;
|
|
410
|
+
let event;
|
|
411
|
+
try {
|
|
412
|
+
event = JSON.parse(trimmed);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
if (event !== null &&
|
|
418
|
+
typeof event === "object" &&
|
|
419
|
+
event.type === "tool_execution_start" &&
|
|
420
|
+
typeof event.toolName === "string" &&
|
|
421
|
+
FRONTEND_REVIEW_TERMINAL_TOOL_NAMES.has(event.toolName)) {
|
|
422
|
+
kinds.push(event.toolName);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return kinds;
|
|
426
|
+
}
|
|
427
|
+
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "ls", "find"]);
|
|
428
|
+
function readEventPath(event) {
|
|
429
|
+
const candidates = [event.path, event.readPath, event.input];
|
|
430
|
+
for (const value of candidates) {
|
|
431
|
+
if (typeof value === "string")
|
|
432
|
+
return value;
|
|
433
|
+
if (value && typeof value === "object") {
|
|
434
|
+
const nested = value;
|
|
435
|
+
for (const key of ["path", "readPath", "file"]) {
|
|
436
|
+
if (typeof nested[key] === "string")
|
|
437
|
+
return nested[key];
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
function eventTimestamp(event) {
|
|
444
|
+
for (const key of ["timestamp", "timestampMs", "ts", "createdAt"]) {
|
|
445
|
+
const value = event[key];
|
|
446
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
447
|
+
return value < 10_000_000_000 ? value * 1000 : value;
|
|
448
|
+
if (typeof value === "string") {
|
|
449
|
+
const parsed = Date.parse(value);
|
|
450
|
+
if (Number.isFinite(parsed))
|
|
451
|
+
return parsed;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return undefined;
|
|
455
|
+
}
|
|
456
|
+
function eventBytes(event, line) {
|
|
457
|
+
const result = event.result ?? event.toolResult ?? event.output;
|
|
458
|
+
if (typeof result === "string")
|
|
459
|
+
return Buffer.byteLength(result);
|
|
460
|
+
if (result !== undefined)
|
|
461
|
+
return Buffer.byteLength(JSON.stringify(result));
|
|
462
|
+
return Buffer.byteLength(line);
|
|
463
|
+
}
|
|
464
|
+
export async function detectNodeReadBudget(input) {
|
|
465
|
+
if (!input.budget)
|
|
466
|
+
return [];
|
|
467
|
+
const sessionEventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
|
|
468
|
+
let content;
|
|
469
|
+
try {
|
|
470
|
+
content = await readFile(sessionEventsPath, "utf8");
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
return [];
|
|
474
|
+
}
|
|
475
|
+
const stats = { files: new Set(), bytes: 0, tools: 0 };
|
|
476
|
+
let firstTs;
|
|
477
|
+
let lastTs;
|
|
478
|
+
for (const line of content.split("\n")) {
|
|
479
|
+
if (!line.trim())
|
|
480
|
+
continue;
|
|
481
|
+
try {
|
|
482
|
+
const event = JSON.parse(line);
|
|
483
|
+
if (event.type !== "tool_execution_start" || typeof event.toolName !== "string" || !READ_ONLY_TOOL_NAMES.has(event.toolName))
|
|
484
|
+
continue;
|
|
485
|
+
stats.tools++;
|
|
486
|
+
const readPath = readEventPath(event);
|
|
487
|
+
if (readPath)
|
|
488
|
+
stats.files.add(readPath);
|
|
489
|
+
stats.bytes += eventBytes(event, line);
|
|
490
|
+
const ts = eventTimestamp(event);
|
|
491
|
+
if (ts !== undefined) {
|
|
492
|
+
firstTs ??= ts;
|
|
493
|
+
lastTs = ts;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
catch { /* ignore malformed telemetry */ }
|
|
497
|
+
}
|
|
498
|
+
if (firstTs !== undefined && lastTs !== undefined)
|
|
499
|
+
stats.elapsedMs = Math.max(0, lastTs - firstTs);
|
|
500
|
+
const issues = [];
|
|
501
|
+
if (stats.files.size > input.budget.maxFiles)
|
|
502
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.files.size} files (budget ${input.budget.maxFiles})`);
|
|
503
|
+
if (stats.bytes > input.budget.maxBytes)
|
|
504
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.bytes} bytes (budget ${input.budget.maxBytes})`);
|
|
505
|
+
if (input.budget.maxMs !== undefined &&
|
|
506
|
+
stats.elapsedMs !== undefined &&
|
|
507
|
+
stats.elapsedMs > input.budget.maxMs)
|
|
508
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.elapsedMs}ms (budget ${input.budget.maxMs}ms)`);
|
|
509
|
+
return issues;
|
|
510
|
+
}
|
|
511
|
+
export const FRONTEND_DESIGN_TERMINAL_TOOL_NAMES = new Set([
|
|
512
|
+
"approve_design",
|
|
513
|
+
"request_design_changes",
|
|
514
|
+
]);
|
|
515
|
+
/**
|
|
516
|
+
* Fail-closed scanner for the two committed design terminal tools. Mirrors the
|
|
517
|
+
* review scanner: an unparseable line never counts as a terminal fact, so a
|
|
518
|
+
* corrupt/empty log cannot inflate the typed verdict.
|
|
519
|
+
*/
|
|
520
|
+
export function scanDesignTerminalKindsFromSessionEvents(content) {
|
|
521
|
+
const kinds = [];
|
|
522
|
+
for (const line of content.split("\n")) {
|
|
523
|
+
const trimmed = line.trim();
|
|
524
|
+
if (!trimmed)
|
|
525
|
+
continue;
|
|
526
|
+
let event;
|
|
527
|
+
try {
|
|
528
|
+
event = JSON.parse(trimmed);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
if (event !== null &&
|
|
534
|
+
typeof event === "object" &&
|
|
535
|
+
event.type === "tool_execution_start" &&
|
|
536
|
+
typeof event.toolName === "string" &&
|
|
537
|
+
FRONTEND_DESIGN_TERMINAL_TOOL_NAMES.has(event.toolName)) {
|
|
538
|
+
kinds.push(event.toolName);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return kinds;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* M5: build the two committed typed review terminal tools (approve_review /
|
|
545
|
+
* request_review_changes). Each tool validates its parameters with the review
|
|
546
|
+
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
547
|
+
* store, and returns a structured receipt. Terminal conflicts (a second
|
|
548
|
+
* terminal commit) are caught inside execute and returned as an error receipt
|
|
549
|
+
* rather than crashing the node.
|
|
550
|
+
*/
|
|
551
|
+
export async function createFrontendReviewTerminalTools(input) {
|
|
552
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
553
|
+
import("typebox"),
|
|
554
|
+
import("@earendil-works/pi-coding-agent"),
|
|
555
|
+
]);
|
|
556
|
+
const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
557
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
558
|
+
const store = input.store;
|
|
559
|
+
const attemptId = input.attemptId;
|
|
560
|
+
const findingSchema = Type.Object({
|
|
561
|
+
severity: Type.String({
|
|
562
|
+
description: "Critical | Important | Minor | Info",
|
|
563
|
+
}),
|
|
564
|
+
file: Type.Optional(Type.String({})),
|
|
565
|
+
line: Type.Optional(Type.Number({})),
|
|
566
|
+
issue: Type.String({}),
|
|
567
|
+
requiredChange: Type.Optional(Type.String({})),
|
|
568
|
+
}, { additionalProperties: false });
|
|
569
|
+
const approveParameters = Type.Object({
|
|
570
|
+
findings: Type.Array(findingSchema, {
|
|
571
|
+
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
572
|
+
}),
|
|
573
|
+
}, { additionalProperties: false });
|
|
574
|
+
const requestParameters = Type.Object({
|
|
575
|
+
issueCategory: Type.Enum({
|
|
576
|
+
"implementation-mismatch": "implementation-mismatch",
|
|
577
|
+
"approved-design-defect": "approved-design-defect",
|
|
578
|
+
"target-surface-defect": "target-surface-defect",
|
|
579
|
+
"contract-requirement-gap": "contract-requirement-gap",
|
|
580
|
+
"unknown": "unknown",
|
|
581
|
+
}, { description: "Typed issue category (five-value enum)" }),
|
|
582
|
+
evidenceRefs: Type.Array(Type.String({}), {
|
|
583
|
+
description: "Evidence refs (paths or artifact ids); at least one",
|
|
584
|
+
}),
|
|
585
|
+
findings: Type.Array(findingSchema, {
|
|
586
|
+
description: "At least one finding",
|
|
587
|
+
}),
|
|
588
|
+
}, { additionalProperties: false });
|
|
589
|
+
async function adoptReviewFact(kind, fact) {
|
|
590
|
+
const requestId = randomUUID();
|
|
591
|
+
try {
|
|
592
|
+
const parsed = kind === "approve_review"
|
|
593
|
+
? approveReviewFactSchema.parse(fact)
|
|
594
|
+
: requestReviewChangesFactSchema.parse(fact);
|
|
595
|
+
const staged = stageTypedEventFact({
|
|
596
|
+
store,
|
|
597
|
+
requestId,
|
|
598
|
+
attemptId,
|
|
599
|
+
fact: parsed,
|
|
600
|
+
});
|
|
601
|
+
const adopted = await adoptTypedEventFact({
|
|
602
|
+
store,
|
|
603
|
+
requestId,
|
|
604
|
+
attemptId,
|
|
605
|
+
fact: parsed,
|
|
606
|
+
eventId: staged.eventId,
|
|
607
|
+
expectedRevision: store.revision,
|
|
608
|
+
});
|
|
609
|
+
return {
|
|
610
|
+
content: [
|
|
611
|
+
{
|
|
612
|
+
type: "text",
|
|
613
|
+
text: JSON.stringify({
|
|
614
|
+
ok: true,
|
|
615
|
+
kind,
|
|
616
|
+
eventId: adopted.eventId,
|
|
617
|
+
revision: adopted.revision,
|
|
618
|
+
}),
|
|
619
|
+
},
|
|
620
|
+
],
|
|
621
|
+
details: {
|
|
622
|
+
ok: true,
|
|
623
|
+
kind,
|
|
624
|
+
eventId: adopted.eventId,
|
|
625
|
+
revision: adopted.revision,
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
const code = error?.code;
|
|
631
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
632
|
+
return {
|
|
633
|
+
content: [
|
|
634
|
+
{
|
|
635
|
+
type: "text",
|
|
636
|
+
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
637
|
+
},
|
|
638
|
+
],
|
|
639
|
+
details: { ok: false, kind, code, error: message },
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const approveReviewTool = defineTool({
|
|
644
|
+
name: "approve_review",
|
|
645
|
+
label: "approve_review",
|
|
646
|
+
description: "Commit the authoritative approve_review terminal fact. Use only when the implementation passes review with no Critical/Important findings.",
|
|
647
|
+
promptSnippet: "Commit the authoritative approve_review terminal verdict (no Critical/Important findings).",
|
|
648
|
+
parameters: approveParameters,
|
|
649
|
+
async execute(_toolCallId, params) {
|
|
650
|
+
return adoptReviewFact("approve_review", {
|
|
651
|
+
kind: "approve_review",
|
|
652
|
+
verdict: "approve_review",
|
|
653
|
+
findings: params?.findings ?? [],
|
|
654
|
+
});
|
|
655
|
+
},
|
|
656
|
+
});
|
|
657
|
+
const requestReviewChangesTool = defineTool({
|
|
658
|
+
name: "request_review_changes",
|
|
659
|
+
label: "request_review_changes",
|
|
660
|
+
description: "Commit the authoritative request_review_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
661
|
+
promptSnippet: "Commit the authoritative request_review_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
662
|
+
parameters: requestParameters,
|
|
663
|
+
async execute(_toolCallId, params) {
|
|
664
|
+
return adoptReviewFact("request_review_changes", {
|
|
665
|
+
kind: "request_review_changes",
|
|
666
|
+
verdict: "request_review_changes",
|
|
667
|
+
issueCategory: params?.issueCategory,
|
|
668
|
+
evidenceRefs: params?.evidenceRefs,
|
|
669
|
+
findings: params?.findings,
|
|
670
|
+
});
|
|
671
|
+
},
|
|
672
|
+
});
|
|
673
|
+
return {
|
|
674
|
+
customTools: [approveReviewTool, requestReviewChangesTool],
|
|
675
|
+
flush: async () => {
|
|
676
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
677
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), committed);
|
|
678
|
+
},
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* M8: build the two committed typed design terminal tools (approve_design /
|
|
683
|
+
* request_design_changes). Each tool validates its parameters with the design
|
|
684
|
+
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
685
|
+
* store, and returns a structured receipt. Terminal conflicts are caught
|
|
686
|
+
* inside execute and returned as an error receipt rather than crashing the
|
|
687
|
+
* node.
|
|
688
|
+
*/
|
|
689
|
+
export async function createFrontendDesignTerminalTools(input) {
|
|
690
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
691
|
+
import("typebox"),
|
|
692
|
+
import("@earendil-works/pi-coding-agent"),
|
|
693
|
+
]);
|
|
694
|
+
const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
695
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
696
|
+
const store = input.store;
|
|
697
|
+
const attemptId = input.attemptId;
|
|
698
|
+
const findingSchema = Type.Object({
|
|
699
|
+
severity: Type.String({
|
|
700
|
+
description: "Critical | Important | Minor | Info",
|
|
701
|
+
}),
|
|
702
|
+
file: Type.Optional(Type.String({})),
|
|
703
|
+
line: Type.Optional(Type.Number({})),
|
|
704
|
+
issue: Type.String({}),
|
|
705
|
+
requiredChange: Type.Optional(Type.String({})),
|
|
706
|
+
}, { additionalProperties: false });
|
|
707
|
+
const approveParameters = Type.Object({
|
|
708
|
+
findings: Type.Array(findingSchema, {
|
|
709
|
+
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
710
|
+
}),
|
|
711
|
+
}, { additionalProperties: false });
|
|
712
|
+
const requestParameters = Type.Object({
|
|
713
|
+
issueCategory: Type.Enum({
|
|
714
|
+
"implementation-mismatch": "implementation-mismatch",
|
|
715
|
+
"approved-design-defect": "approved-design-defect",
|
|
716
|
+
"target-surface-defect": "target-surface-defect",
|
|
717
|
+
"contract-requirement-gap": "contract-requirement-gap",
|
|
718
|
+
"unknown": "unknown",
|
|
719
|
+
}, { description: "Typed issue category (five-value enum)" }),
|
|
720
|
+
evidenceRefs: Type.Array(Type.String({}), {
|
|
721
|
+
description: "Evidence refs (paths or artifact ids); at least one",
|
|
722
|
+
}),
|
|
723
|
+
findings: Type.Array(findingSchema, {
|
|
724
|
+
description: "At least one finding",
|
|
725
|
+
}),
|
|
726
|
+
}, { additionalProperties: false });
|
|
727
|
+
async function adoptDesignFact(kind, fact) {
|
|
728
|
+
const requestId = randomUUID();
|
|
729
|
+
try {
|
|
730
|
+
const parsed = kind === "approve_design"
|
|
731
|
+
? approveDesignFactSchema.parse(fact)
|
|
732
|
+
: requestDesignChangesFactSchema.parse(fact);
|
|
733
|
+
const staged = stageTypedEventFact({
|
|
734
|
+
store,
|
|
735
|
+
requestId,
|
|
736
|
+
attemptId,
|
|
737
|
+
fact: parsed,
|
|
738
|
+
});
|
|
739
|
+
const adopted = await adoptTypedEventFact({
|
|
740
|
+
store,
|
|
741
|
+
requestId,
|
|
742
|
+
attemptId,
|
|
743
|
+
fact: parsed,
|
|
744
|
+
eventId: staged.eventId,
|
|
745
|
+
expectedRevision: store.revision,
|
|
746
|
+
});
|
|
747
|
+
return {
|
|
748
|
+
content: [
|
|
749
|
+
{
|
|
750
|
+
type: "text",
|
|
751
|
+
text: JSON.stringify({
|
|
752
|
+
ok: true,
|
|
753
|
+
kind,
|
|
754
|
+
eventId: adopted.eventId,
|
|
755
|
+
revision: adopted.revision,
|
|
756
|
+
}),
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
details: {
|
|
760
|
+
ok: true,
|
|
761
|
+
kind,
|
|
762
|
+
eventId: adopted.eventId,
|
|
763
|
+
revision: adopted.revision,
|
|
764
|
+
},
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
catch (error) {
|
|
768
|
+
const code = error?.code;
|
|
769
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
770
|
+
return {
|
|
771
|
+
content: [
|
|
772
|
+
{
|
|
773
|
+
type: "text",
|
|
774
|
+
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
775
|
+
},
|
|
776
|
+
],
|
|
777
|
+
details: { ok: false, kind, code, error: message },
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const approveDesignTool = defineTool({
|
|
782
|
+
name: "approve_design",
|
|
783
|
+
label: "approve_design",
|
|
784
|
+
description: "Commit the authoritative approve_design terminal fact. Use only when the plan passes design review with no Critical/Important findings.",
|
|
785
|
+
promptSnippet: "Commit the authoritative approve_design terminal verdict (no Critical/Important findings).",
|
|
786
|
+
parameters: approveParameters,
|
|
787
|
+
async execute(_toolCallId, params) {
|
|
788
|
+
return adoptDesignFact("approve_design", {
|
|
789
|
+
kind: "approve_design",
|
|
790
|
+
verdict: "approve_design",
|
|
791
|
+
findings: params?.findings ?? [],
|
|
792
|
+
});
|
|
793
|
+
},
|
|
794
|
+
});
|
|
795
|
+
const requestDesignChangesTool = defineTool({
|
|
796
|
+
name: "request_design_changes",
|
|
797
|
+
label: "request_design_changes",
|
|
798
|
+
description: "Commit the authoritative request_design_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
799
|
+
promptSnippet: "Commit the authoritative request_design_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
800
|
+
parameters: requestParameters,
|
|
801
|
+
async execute(_toolCallId, params) {
|
|
802
|
+
return adoptDesignFact("request_design_changes", {
|
|
803
|
+
kind: "request_design_changes",
|
|
804
|
+
verdict: "request_design_changes",
|
|
805
|
+
issueCategory: params?.issueCategory,
|
|
806
|
+
evidenceRefs: params?.evidenceRefs,
|
|
807
|
+
findings: params?.findings,
|
|
808
|
+
});
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
return {
|
|
812
|
+
customTools: [approveDesignTool, requestDesignChangesTool],
|
|
813
|
+
flush: async () => {
|
|
814
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
815
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), committed);
|
|
816
|
+
},
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Source fidelity ledger (AC-005/AC-006): load the contract node's committed
|
|
821
|
+
* requirement facts and build a requirement-id → provenance map. The contract
|
|
822
|
+
* node declared sourceFragmentIds/sourceRefs from the ledger's
|
|
823
|
+
* requirement→fragment mapping; the plan inherits them by id so the compiled
|
|
824
|
+
* canonical contract carries authoritative provenance without the plan
|
|
825
|
+
* re-deriving (or fabricating) it. Best-effort: missing/unreadable contract
|
|
826
|
+
* ledger yields an empty map and the plan compiles as before (the design
|
|
827
|
+
* policy shell will then fail closed on missing provenance).
|
|
828
|
+
*/
|
|
829
|
+
async function loadContractRequirementInheritance(runDir) {
|
|
830
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
831
|
+
let records;
|
|
832
|
+
try {
|
|
833
|
+
records = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return new Map();
|
|
837
|
+
}
|
|
838
|
+
const byId = new Map();
|
|
839
|
+
for (const record of records) {
|
|
840
|
+
const fact = record.fact;
|
|
841
|
+
if (!fact || typeof fact !== "object")
|
|
842
|
+
continue;
|
|
843
|
+
const recordFact = fact;
|
|
844
|
+
if (recordFact.origin !== "contract" ||
|
|
845
|
+
recordFact.kind !== "requirement") {
|
|
846
|
+
continue;
|
|
847
|
+
}
|
|
848
|
+
// Contract requirement facts carry id/sourceFragmentIds/sourceRefs on
|
|
849
|
+
// the fact itself (origin=contract, kind=requirement, id, text, ...),
|
|
850
|
+
// not inside an `entry` wrapper.
|
|
851
|
+
const id = typeof recordFact.id === "string" ? recordFact.id : "";
|
|
852
|
+
if (!id)
|
|
853
|
+
continue;
|
|
854
|
+
const sourceFragmentIds = Array.isArray(recordFact.sourceFragmentIds)
|
|
855
|
+
? recordFact.sourceFragmentIds.filter((value) => typeof value === "string")
|
|
856
|
+
: undefined;
|
|
857
|
+
const sourceRefs = Array.isArray(recordFact.sourceRefs)
|
|
858
|
+
? recordFact.sourceRefs.filter((value) => typeof value === "string")
|
|
859
|
+
: undefined;
|
|
860
|
+
if (sourceFragmentIds || sourceRefs) {
|
|
861
|
+
byId.set(id, {
|
|
862
|
+
...(sourceFragmentIds ? { sourceFragmentIds } : {}),
|
|
863
|
+
...(sourceRefs ? { sourceRefs } : {}),
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
return byId;
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Resolve task-source citations from the source-fidelity ledger before the
|
|
871
|
+
* planner starts. The planner names a frozen requirement id and one of its
|
|
872
|
+
* fragment ids; it never needs to re-read a PRD merely to recover a
|
|
873
|
+
* path/section/line triple.
|
|
874
|
+
*/
|
|
875
|
+
async function resolveFrontendPlanNewComponentSourceReferences(input) {
|
|
876
|
+
const binding = input.sourceBinding;
|
|
877
|
+
if (!binding || binding.schemaVersion !== 2)
|
|
878
|
+
return new Map();
|
|
879
|
+
const ledgerPath = binding.ledgerPath;
|
|
880
|
+
const absolutePath = path.resolve(input.cwd, ledgerPath);
|
|
881
|
+
const workspaceRoot = path.resolve(input.cwd);
|
|
882
|
+
if (absolutePath !== workspaceRoot &&
|
|
883
|
+
!absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
884
|
+
return new Map();
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
const ledger = parseLedgerJson(await readFile(absolutePath, "utf8"));
|
|
888
|
+
const fragmentsById = new Map(ledger.fragments.map((fragment) => [fragment.id, fragment]));
|
|
889
|
+
const references = new Map();
|
|
890
|
+
for (const requirement of ledger.canonicalRequirements) {
|
|
891
|
+
const citations = requirement.sourceFragmentIds
|
|
892
|
+
.map((fragmentId) => fragmentsById.get(fragmentId))
|
|
893
|
+
.filter((fragment) => fragment !== undefined)
|
|
894
|
+
.map((fragment) => ({
|
|
895
|
+
fragmentId: fragment.id,
|
|
896
|
+
path: fragment.path,
|
|
897
|
+
section: fragment.headingPath,
|
|
898
|
+
line: fragment.lineRange.start,
|
|
899
|
+
}));
|
|
900
|
+
if (citations.length > 0)
|
|
901
|
+
references.set(requirement.id, citations);
|
|
902
|
+
}
|
|
903
|
+
return references;
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
return new Map();
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* A+B: `frontend-plan-pi` now records its decision ledger through seven
|
|
911
|
+
* incremental `record_*` tools (origin=plan) and closes with exactly one
|
|
912
|
+
* `finalize_plan` terminal. A later attempt may explicitly adopt a quarantined
|
|
913
|
+
* fact via `adopt_staged_fact`. Flush writes `plan-typed-facts.jsonl` for the
|
|
914
|
+
* node validator / compile authority.
|
|
915
|
+
*/
|
|
916
|
+
export async function createFrontendPlanLedgerTools(input) {
|
|
917
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
918
|
+
import("typebox"),
|
|
919
|
+
import("@earendil-works/pi-coding-agent"),
|
|
920
|
+
]);
|
|
921
|
+
const { loadTypedEventStore, readCommittedEvents, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
922
|
+
const { adoptStagedFact, adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
923
|
+
const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
|
|
924
|
+
const store = input.store;
|
|
925
|
+
const attemptId = input.attemptId;
|
|
926
|
+
let activeRequirementScope = [];
|
|
927
|
+
const scopedRequirementIds = () => [...activeRequirementScope];
|
|
928
|
+
// A retry creates a fresh executor-local store, but the plan ledger is the
|
|
929
|
+
// cross-attempt authority. Restore the committed prefix before registering
|
|
930
|
+
// tools; otherwise the first flush of a retry can overwrite facts that the
|
|
931
|
+
// previous attempt had already committed. The on-disk file contains only
|
|
932
|
+
// committed records, so loading it is also fail-closed with respect to
|
|
933
|
+
// staged/quarantined facts.
|
|
934
|
+
const persisted = await loadTypedEventStore(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"));
|
|
935
|
+
if (persisted.records.length > 0) {
|
|
936
|
+
const existingEventIds = new Set(store.records.map((record) => record.eventId));
|
|
937
|
+
for (const record of persisted.records) {
|
|
938
|
+
if (!existingEventIds.has(record.eventId)) {
|
|
939
|
+
store.records.push(record);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
store.revision = Math.max(store.revision, persisted.revision);
|
|
943
|
+
}
|
|
944
|
+
const stringArray = Type.Array(Type.String({}));
|
|
945
|
+
const optionalString = Type.Optional(Type.String({}));
|
|
946
|
+
const optionalStringArray = Type.Optional(stringArray);
|
|
947
|
+
const requirementSchema = Type.Object({
|
|
948
|
+
id: Type.String({}),
|
|
949
|
+
expectedOutcome: Type.Optional(Type.String({
|
|
950
|
+
description: "Optional. When omitted, the runtime derives it from the contract requirement. Prefer omitting it to keep this tool call small.",
|
|
951
|
+
})),
|
|
952
|
+
implementationTargets: stringArray,
|
|
953
|
+
verificationTargetIds: stringArray,
|
|
954
|
+
evidenceGap: Type.Optional(Type.Object({
|
|
955
|
+
requirementId: optionalString,
|
|
956
|
+
description: Type.String({}),
|
|
957
|
+
blocking: Type.Boolean(),
|
|
958
|
+
}, { additionalProperties: false })),
|
|
959
|
+
}, { additionalProperties: false });
|
|
960
|
+
const uiStateSchema = Type.Object({
|
|
961
|
+
name: Type.String({}),
|
|
962
|
+
applicable: Type.Boolean(),
|
|
963
|
+
expectedBehavior: optionalString,
|
|
964
|
+
implementationTargets: optionalStringArray,
|
|
965
|
+
verificationTargetIds: optionalStringArray,
|
|
966
|
+
notApplicableReason: optionalString,
|
|
967
|
+
reason: Type.Optional(Type.String({})),
|
|
968
|
+
}, { additionalProperties: false });
|
|
969
|
+
const interactionSchema = Type.Object({
|
|
970
|
+
name: optionalString,
|
|
971
|
+
id: Type.Optional(Type.String({})),
|
|
972
|
+
trigger: Type.String({}),
|
|
973
|
+
expectedBehavior: Type.String({}),
|
|
974
|
+
implementationTargets: stringArray,
|
|
975
|
+
verificationTargetIds: stringArray,
|
|
976
|
+
}, { additionalProperties: false });
|
|
977
|
+
const mockEndpointSchema = Type.Object({
|
|
978
|
+
method: Type.String({
|
|
979
|
+
description: "GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS",
|
|
980
|
+
}),
|
|
981
|
+
path: Type.String({}),
|
|
982
|
+
fixture: optionalString,
|
|
983
|
+
consumer: optionalString,
|
|
984
|
+
}, { additionalProperties: false });
|
|
985
|
+
const mockApiSchema = Type.Object({
|
|
986
|
+
strategy: Type.Union([
|
|
987
|
+
Type.Literal("native"),
|
|
988
|
+
Type.Literal("browser-intercept"),
|
|
989
|
+
Type.Literal("request-adapter"),
|
|
990
|
+
Type.Literal("not-needed"),
|
|
991
|
+
], {
|
|
992
|
+
description: "native | browser-intercept | request-adapter | not-needed",
|
|
993
|
+
}),
|
|
994
|
+
activation: Type.String({}),
|
|
995
|
+
endpoints: Type.Array(mockEndpointSchema),
|
|
996
|
+
}, { additionalProperties: false });
|
|
997
|
+
const designEvidenceSchema = Type.Object({
|
|
998
|
+
source: Type.String({}),
|
|
999
|
+
paths: stringArray,
|
|
1000
|
+
conflicts: stringArray,
|
|
1001
|
+
}, { additionalProperties: false });
|
|
1002
|
+
// Verification mode is runtime-owned (contract v2): the plan submits a
|
|
1003
|
+
// commandId referencing the frozen command directory, never a type. A
|
|
1004
|
+
// soft Type.String would let the model commit values that only fail at
|
|
1005
|
+
// compile time — keep the reference a required string and validate it
|
|
1006
|
+
// against the directory at the tool boundary below.
|
|
1007
|
+
const verificationTargetScopeSchema = Type.Union([
|
|
1008
|
+
Type.Literal("unit"),
|
|
1009
|
+
Type.Literal("component"),
|
|
1010
|
+
Type.Literal("integration"),
|
|
1011
|
+
]);
|
|
1012
|
+
const verificationTargetSchema = Type.Object({
|
|
1013
|
+
id: Type.String({}),
|
|
1014
|
+
commandId: Type.String({
|
|
1015
|
+
description: "Frozen command directory key (e.g. verify-npm-run-build); the runtime resolves mode and label from it.",
|
|
1016
|
+
}),
|
|
1017
|
+
file: Type.String({}),
|
|
1018
|
+
requirementIds: stringArray,
|
|
1019
|
+
scope: Type.Optional(verificationTargetScopeSchema),
|
|
1020
|
+
uiStates: Type.Optional(Type.Array(Type.String({}), {
|
|
1021
|
+
description: "Optional only at this tool boundary. An omitted value is deterministically recorded as []. Pass an explicit array for new calls.",
|
|
1022
|
+
})),
|
|
1023
|
+
}, { additionalProperties: false });
|
|
1024
|
+
const evidenceGapSchema = Type.Object({
|
|
1025
|
+
requirementId: optionalString,
|
|
1026
|
+
description: Type.String({}),
|
|
1027
|
+
blocking: Type.Boolean(),
|
|
1028
|
+
}, { additionalProperties: false });
|
|
1029
|
+
const uiComponentChoiceSchema = Type.Object({
|
|
1030
|
+
purpose: Type.String({}),
|
|
1031
|
+
component: Type.String({}),
|
|
1032
|
+
decision: Type.Union([
|
|
1033
|
+
Type.Literal("specified"),
|
|
1034
|
+
Type.Literal("reuse-existing"),
|
|
1035
|
+
Type.Literal("new"),
|
|
1036
|
+
], {
|
|
1037
|
+
description: "specified | reuse-existing | new",
|
|
1038
|
+
}),
|
|
1039
|
+
specReference: Type.Optional(Type.Object({
|
|
1040
|
+
path: Type.String({}),
|
|
1041
|
+
section: Type.String({}),
|
|
1042
|
+
line: Type.Optional(Type.Number({})),
|
|
1043
|
+
}, { additionalProperties: false })),
|
|
1044
|
+
rationale: Type.String({}),
|
|
1045
|
+
}, { additionalProperties: false });
|
|
1046
|
+
const stringList = (value) => Array.isArray(value)
|
|
1047
|
+
? value.filter((item) => typeof item === "string")
|
|
1048
|
+
: [];
|
|
1049
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0
|
|
1050
|
+
? value.trim()
|
|
1051
|
+
: undefined;
|
|
1052
|
+
const planToolReceipt = (details) => ({
|
|
1053
|
+
content: [
|
|
1054
|
+
{
|
|
1055
|
+
type: "text",
|
|
1056
|
+
text: JSON.stringify(details),
|
|
1057
|
+
},
|
|
1058
|
+
],
|
|
1059
|
+
details,
|
|
1060
|
+
});
|
|
1061
|
+
async function adoptPlanFact(kind, requestId, fact) {
|
|
1062
|
+
// A+B (AC-005): a provider-capability fact kind reaching the plan ledger
|
|
1063
|
+
// is out of route — it belongs to the shadow provider capability channel,
|
|
1064
|
+
// not the plan decision ledger. Route it through the frozen seven-kind
|
|
1065
|
+
// matrix and fail closed to `unsupported-provider-capability` instead of
|
|
1066
|
+
// silently widening the plan catalog.
|
|
1067
|
+
const capabilityRoute = routeFrontendProviderCapability({ factKind: kind });
|
|
1068
|
+
if (capabilityRoute.ok) {
|
|
1069
|
+
return {
|
|
1070
|
+
ok: false,
|
|
1071
|
+
kind,
|
|
1072
|
+
code: "unsupported-provider-capability",
|
|
1073
|
+
error: `plan ledger cannot adopt provider capability fact kind: ${kind}`,
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
try {
|
|
1077
|
+
const staged = stageTypedEventFact({
|
|
1078
|
+
store,
|
|
1079
|
+
requestId,
|
|
1080
|
+
attemptId,
|
|
1081
|
+
fact: fact,
|
|
1082
|
+
});
|
|
1083
|
+
const committed = await adoptTypedEventFact({
|
|
1084
|
+
store,
|
|
1085
|
+
requestId,
|
|
1086
|
+
attemptId,
|
|
1087
|
+
fact: fact,
|
|
1088
|
+
eventId: staged.eventId,
|
|
1089
|
+
expectedRevision: store.revision,
|
|
1090
|
+
});
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
kind,
|
|
1094
|
+
eventId: committed.eventId,
|
|
1095
|
+
revision: committed.revision,
|
|
1096
|
+
error: "",
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
catch (error) {
|
|
1100
|
+
return {
|
|
1101
|
+
ok: false,
|
|
1102
|
+
kind,
|
|
1103
|
+
code: error?.code,
|
|
1104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1105
|
+
};
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
const recordRouteSelectionTool = defineTool({
|
|
1109
|
+
name: "record_route_selection",
|
|
1110
|
+
label: "record_route_selection",
|
|
1111
|
+
description: "Record the route selection needed by this plan. Repository target surface and file ownership belong to Scout/runtime. Example: {\"routes\": [\"/<route>\"]}",
|
|
1112
|
+
promptSnippet: "Record the selected routes.",
|
|
1113
|
+
parameters: Type.Object({ routes: stringArray }, { additionalProperties: false }),
|
|
1114
|
+
async execute(_toolCallId, params) {
|
|
1115
|
+
const routes = stringList(params?.routes);
|
|
1116
|
+
const result = await adoptPlanFact("target-surface", `${attemptId}:record_route_selection:${randomUUID()}`, { kind: "target-surface", origin: "plan", routes });
|
|
1117
|
+
return planToolReceipt(result);
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
const recordComponentChoiceTool = defineTool({
|
|
1121
|
+
name: "record_component_choice",
|
|
1122
|
+
label: "record_component_choice",
|
|
1123
|
+
description: "Record ONE component choice (origin=plan component-choice fact). Declare every UI purpose's component selection. For decision=new, pass sourceRequirementIds containing the frozen requirement ID(s) that mandate the component and sourceFragmentId selecting one frozen citation listed in the plan checklist; the runtime validates the relation and derives the exact PRD specReference. Do not read the PRD or invent a path/line. decision=reuse-existing is only for components that already exist in the repo (e.g. reusing ActiveRunBadge's styling convention). Omit rationale for reuse-existing; it is optional. Call up to 5 component choices per assistant message (batching reduces API round trips and rate-limit risk); never more than 5 per message. Optionally include stylingStrategy (set it once, on the first call). Example: {\"choice\": {\"purpose\": \"<interaction or UI state name>\", \"component\": \"<component name>\", \"decision\": \"new\"}, \"sourceRequirementIds\": [\"<AC-XXX mandating this component>\"], \"sourceFragmentId\": \"<REQ-SRC-...>\"}",
|
|
1124
|
+
promptSnippet: "Record 1-5 component choices (up to 5 per message).",
|
|
1125
|
+
parameters: Type.Object({
|
|
1126
|
+
choice: uiComponentChoiceSchema,
|
|
1127
|
+
sourceRequirementIds: Type.Optional(stringArray),
|
|
1128
|
+
sourceFragmentId: optionalString,
|
|
1129
|
+
stylingStrategy: optionalString,
|
|
1130
|
+
}, { additionalProperties: false }),
|
|
1131
|
+
async execute(_toolCallId, params) {
|
|
1132
|
+
const rawChoice = params?.choice;
|
|
1133
|
+
if (!isRecordObject(rawChoice)) {
|
|
1134
|
+
return planToolReceipt({
|
|
1135
|
+
ok: false,
|
|
1136
|
+
kind: "component-choice",
|
|
1137
|
+
error: "record_component_choice requires a non-empty choice object",
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
const sourceRequirementIds = stringList(params?.sourceRequirementIds);
|
|
1141
|
+
const sourceFragmentId = nonEmptyString(params?.sourceFragmentId);
|
|
1142
|
+
const choice = { ...rawChoice };
|
|
1143
|
+
if (choice.decision === "new") {
|
|
1144
|
+
if (sourceRequirementIds.length === 0) {
|
|
1145
|
+
return planToolReceipt({
|
|
1146
|
+
ok: false,
|
|
1147
|
+
kind: "component-choice",
|
|
1148
|
+
error: "decision=new requires sourceRequirementIds so runtime can materialize the task-source specReference",
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
if (!sourceFragmentId) {
|
|
1152
|
+
return planToolReceipt({
|
|
1153
|
+
ok: false,
|
|
1154
|
+
kind: "component-choice",
|
|
1155
|
+
error: "decision=new requires sourceFragmentId selecting a frozen task-source citation",
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
const citation = sourceRequirementIds
|
|
1159
|
+
.flatMap((id) => input.componentNewSourceReferences?.get(id) ?? [])
|
|
1160
|
+
.find((candidate) => candidate.fragmentId === sourceFragmentId);
|
|
1161
|
+
if (!citation) {
|
|
1162
|
+
return planToolReceipt({
|
|
1163
|
+
ok: false,
|
|
1164
|
+
kind: "component-choice",
|
|
1165
|
+
error: `decision=new sourceFragmentId ${sourceFragmentId} is not bound to sourceRequirementIds ${sourceRequirementIds.join(", ")}`,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
choice.specReference = {
|
|
1169
|
+
path: citation.path,
|
|
1170
|
+
section: citation.section,
|
|
1171
|
+
...(citation.line !== undefined ? { line: citation.line } : {}),
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
const components = typeof choice.component === "string" ? [choice.component] : [];
|
|
1175
|
+
const result = await adoptPlanFact("component-choice", `${attemptId}:record_component_choice:${randomUUID()}`, {
|
|
1176
|
+
kind: "component-choice",
|
|
1177
|
+
origin: "plan",
|
|
1178
|
+
scopeRequirementIds: scopedRequirementIds(),
|
|
1179
|
+
components,
|
|
1180
|
+
uiComponentChoices: [choice],
|
|
1181
|
+
...(params?.stylingStrategy
|
|
1182
|
+
? { stylingStrategy: params.stylingStrategy }
|
|
1183
|
+
: {}),
|
|
1184
|
+
});
|
|
1185
|
+
// Echo the frozen citation the runtime derived: the model sees the
|
|
1186
|
+
// purpose↔citation mapping it just committed and can re-record the
|
|
1187
|
+
// choice (last-wins per purpose at compile) when it mismatches.
|
|
1188
|
+
const echo = {
|
|
1189
|
+
...result,
|
|
1190
|
+
...(choice.specReference
|
|
1191
|
+
? { derivedSpecReference: choice.specReference }
|
|
1192
|
+
: {}),
|
|
1193
|
+
};
|
|
1194
|
+
return planToolReceipt(echo);
|
|
1195
|
+
},
|
|
1196
|
+
});
|
|
1197
|
+
const recordStateFlowTool = defineTool({
|
|
1198
|
+
name: "record_state_flow",
|
|
1199
|
+
label: "record_state_flow",
|
|
1200
|
+
description: "Record UI states and interactions as an origin=plan state-flow fact. 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>\"]}]}",
|
|
1201
|
+
promptSnippet: "Record the plan state-flow fact.",
|
|
1202
|
+
parameters: Type.Object({
|
|
1203
|
+
uiStates: Type.Array(uiStateSchema),
|
|
1204
|
+
interactions: Type.Array(interactionSchema),
|
|
1205
|
+
removeUiStateNames: Type.Optional(stringArray),
|
|
1206
|
+
removeInteractionNames: Type.Optional(stringArray),
|
|
1207
|
+
}, { additionalProperties: false }),
|
|
1208
|
+
async execute(_toolCallId, params) {
|
|
1209
|
+
const rawUiStates = Array.isArray(params?.uiStates)
|
|
1210
|
+
? params.uiStates
|
|
1211
|
+
: [];
|
|
1212
|
+
const rawInteractions = Array.isArray(params?.interactions)
|
|
1213
|
+
? params.interactions
|
|
1214
|
+
: [];
|
|
1215
|
+
const removeUiStateNames = stringList(params?.removeUiStateNames);
|
|
1216
|
+
const removeInteractionNames = stringList(params?.removeInteractionNames);
|
|
1217
|
+
const uiStates = [];
|
|
1218
|
+
for (const rawState of rawUiStates) {
|
|
1219
|
+
if (!isRecordObject(rawState)) {
|
|
1220
|
+
return planToolReceipt({
|
|
1221
|
+
ok: false,
|
|
1222
|
+
kind: "state-flow",
|
|
1223
|
+
error: "record_state_flow uiStates entries must be objects",
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
const { reason, notApplicableReason, ...state } = rawState;
|
|
1227
|
+
const canonicalReason = nonEmptyString(notApplicableReason);
|
|
1228
|
+
const aliasReason = nonEmptyString(reason);
|
|
1229
|
+
if (canonicalReason !== undefined &&
|
|
1230
|
+
aliasReason !== undefined &&
|
|
1231
|
+
canonicalReason !== aliasReason) {
|
|
1232
|
+
return planToolReceipt({
|
|
1233
|
+
ok: false,
|
|
1234
|
+
kind: "state-flow",
|
|
1235
|
+
error: "record_state_flow uiState has conflicting reason and notApplicableReason values",
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
const resolvedReason = canonicalReason ?? aliasReason;
|
|
1239
|
+
if (state.applicable === false && !resolvedReason) {
|
|
1240
|
+
return planToolReceipt({
|
|
1241
|
+
ok: false,
|
|
1242
|
+
kind: "state-flow",
|
|
1243
|
+
error: "record_state_flow requires non-empty notApplicableReason when uiState.applicable is false",
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
uiStates.push({
|
|
1247
|
+
...state,
|
|
1248
|
+
...(resolvedReason
|
|
1249
|
+
? { notApplicableReason: resolvedReason }
|
|
1250
|
+
: {}),
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
const interactions = [];
|
|
1254
|
+
for (const rawInteraction of rawInteractions) {
|
|
1255
|
+
if (!isRecordObject(rawInteraction)) {
|
|
1256
|
+
return planToolReceipt({
|
|
1257
|
+
ok: false,
|
|
1258
|
+
kind: "state-flow",
|
|
1259
|
+
error: "record_state_flow interactions entries must be objects",
|
|
1260
|
+
});
|
|
1261
|
+
}
|
|
1262
|
+
const { id, name, ...interaction } = rawInteraction;
|
|
1263
|
+
const canonicalName = nonEmptyString(name);
|
|
1264
|
+
const aliasName = nonEmptyString(id);
|
|
1265
|
+
if (canonicalName !== undefined &&
|
|
1266
|
+
aliasName !== undefined &&
|
|
1267
|
+
canonicalName !== aliasName) {
|
|
1268
|
+
return planToolReceipt({
|
|
1269
|
+
ok: false,
|
|
1270
|
+
kind: "state-flow",
|
|
1271
|
+
error: "record_state_flow interaction has conflicting id and name values",
|
|
1272
|
+
});
|
|
1273
|
+
}
|
|
1274
|
+
const resolvedName = canonicalName ?? aliasName;
|
|
1275
|
+
if (!resolvedName) {
|
|
1276
|
+
return planToolReceipt({
|
|
1277
|
+
ok: false,
|
|
1278
|
+
kind: "state-flow",
|
|
1279
|
+
error: "record_state_flow requires non-empty interaction.name (id is accepted only as a legacy alias)",
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
// Interaction -> VT forward references are legal only against
|
|
1283
|
+
// already-committed VT facts. In the segmented flow every VT
|
|
1284
|
+
// commits in the coverage segment before state flows run, so a
|
|
1285
|
+
// dangling reference here is a real defect (r20: *-BEHAVIOR
|
|
1286
|
+
// refs reached the final review untraceable).
|
|
1287
|
+
const interactionVtIds = stringList(interaction.verificationTargetIds);
|
|
1288
|
+
const committedVtIds = new Set(readCommittedEvents(store, attemptId)
|
|
1289
|
+
.map((event) => event.fact)
|
|
1290
|
+
.filter((fact) => fact.kind === "plan-verification-target")
|
|
1291
|
+
.map((fact) => fact.entry
|
|
1292
|
+
?.id)
|
|
1293
|
+
.filter((id) => typeof id === "string"));
|
|
1294
|
+
const unknownVtIds = interactionVtIds.filter((id) => !committedVtIds.has(id));
|
|
1295
|
+
if (unknownVtIds.length > 0) {
|
|
1296
|
+
return planToolReceipt({
|
|
1297
|
+
ok: false,
|
|
1298
|
+
kind: "state-flow",
|
|
1299
|
+
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`,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
interactions.push({ ...interaction, name: resolvedName });
|
|
1303
|
+
}
|
|
1304
|
+
const states = uiStates
|
|
1305
|
+
.map((state) => (typeof state?.name === "string" ? state.name : ""))
|
|
1306
|
+
.filter(Boolean);
|
|
1307
|
+
const result = await adoptPlanFact("state-flow", `${attemptId}:record_state_flow:${randomUUID()}`, {
|
|
1308
|
+
kind: "state-flow",
|
|
1309
|
+
origin: "plan",
|
|
1310
|
+
scopeRequirementIds: scopedRequirementIds(),
|
|
1311
|
+
states,
|
|
1312
|
+
uiStates,
|
|
1313
|
+
interactions,
|
|
1314
|
+
removeUiStateNames,
|
|
1315
|
+
removeInteractionNames,
|
|
1316
|
+
});
|
|
1317
|
+
return planToolReceipt(result);
|
|
1318
|
+
},
|
|
1319
|
+
});
|
|
1320
|
+
const recordDataFlowTool = defineTool({
|
|
1321
|
+
name: "record_data_flow",
|
|
1322
|
+
label: "record_data_flow",
|
|
1323
|
+
description: "Record interaction/endpoint data flow as an origin=plan data-flow fact. Example: {\"interactions\": [\"<interaction name>\"], \"endpoints\": [\"GET <path>\"]}",
|
|
1324
|
+
promptSnippet: "Record the plan data-flow fact.",
|
|
1325
|
+
parameters: Type.Object({ interactions: stringArray, endpoints: stringArray }, { additionalProperties: false }),
|
|
1326
|
+
async execute(_toolCallId, params) {
|
|
1327
|
+
const result = await adoptPlanFact("data-flow", `${attemptId}:record_data_flow:${randomUUID()}`, {
|
|
1328
|
+
kind: "data-flow",
|
|
1329
|
+
origin: "plan",
|
|
1330
|
+
interactions: stringList(params?.interactions),
|
|
1331
|
+
endpoints: stringList(params?.endpoints),
|
|
1332
|
+
});
|
|
1333
|
+
return planToolReceipt(result);
|
|
1334
|
+
},
|
|
1335
|
+
});
|
|
1336
|
+
const recordMockApiTool = defineTool({
|
|
1337
|
+
name: "record_mock_api",
|
|
1338
|
+
label: "record_mock_api",
|
|
1339
|
+
description: "Record the Mock/API strategy as an origin=plan mock-api fact. Example: {\"mockApi\": {\"strategy\": \"not-needed\", \"activation\": \"n/a\", \"endpoints\": []}}",
|
|
1340
|
+
promptSnippet: "Record the plan mock-api fact.",
|
|
1341
|
+
parameters: Type.Object({ mockApi: mockApiSchema }, { additionalProperties: false }),
|
|
1342
|
+
async execute(_toolCallId, params) {
|
|
1343
|
+
const mockApi = params?.mockApi ?? {
|
|
1344
|
+
strategy: "not-needed",
|
|
1345
|
+
activation: "",
|
|
1346
|
+
endpoints: [],
|
|
1347
|
+
};
|
|
1348
|
+
const endpoints = stringList((Array.isArray(mockApi?.endpoints) ? mockApi.endpoints : []).map((endpoint) => `${typeof endpoint?.method === "string" ? endpoint.method : ""} ${typeof endpoint?.path === "string" ? endpoint.path : ""}`.trim()));
|
|
1349
|
+
const result = await adoptPlanFact("mock-api", `${attemptId}:record_mock_api:${randomUUID()}`, {
|
|
1350
|
+
kind: "mock-api",
|
|
1351
|
+
origin: "plan",
|
|
1352
|
+
strategy: typeof mockApi?.strategy === "string" ? mockApi.strategy : "",
|
|
1353
|
+
endpoints,
|
|
1354
|
+
mockApi,
|
|
1355
|
+
});
|
|
1356
|
+
return planToolReceipt(result);
|
|
1357
|
+
},
|
|
1358
|
+
});
|
|
1359
|
+
const recordDesignDeviationTool = defineTool({
|
|
1360
|
+
name: "record_design_deviation",
|
|
1361
|
+
label: "record_design_deviation",
|
|
1362
|
+
description: "Record design evidence conflicts as an origin=plan design-deviation fact. Example: {\"designEvidence\": {\"source\": \"<source>\", \"paths\": [\"<file>\"], \"conflicts\": [\"<conflicting requirement id>\"]}}",
|
|
1363
|
+
promptSnippet: "Record the plan design-deviation fact.",
|
|
1364
|
+
parameters: Type.Object({ designEvidence: designEvidenceSchema }, { additionalProperties: false }),
|
|
1365
|
+
async execute(_toolCallId, params) {
|
|
1366
|
+
const conflicts = stringList(params?.designEvidence?.conflicts);
|
|
1367
|
+
const result = await adoptPlanFact("design-deviation", `${attemptId}:record_design_deviation:${randomUUID()}`, { kind: "design-deviation", origin: "plan", conflicts });
|
|
1368
|
+
return planToolReceipt(result);
|
|
1369
|
+
},
|
|
1370
|
+
});
|
|
1371
|
+
const recordDependencyTool = defineTool({
|
|
1372
|
+
name: "record_dependency",
|
|
1373
|
+
label: "record_dependency",
|
|
1374
|
+
description: "Record the dependency policy as an origin=plan dependency fact. Example: {\"policy\": \"<dependency policy statement>\"}",
|
|
1375
|
+
promptSnippet: "Record the plan dependency fact.",
|
|
1376
|
+
parameters: Type.Object({ policy: Type.String({}) }, { additionalProperties: false }),
|
|
1377
|
+
async execute(_toolCallId, params) {
|
|
1378
|
+
const result = await adoptPlanFact("dependency", `${attemptId}:record_dependency:${randomUUID()}`, {
|
|
1379
|
+
kind: "dependency",
|
|
1380
|
+
origin: "plan",
|
|
1381
|
+
policy: typeof params?.policy === "string" ? params.policy : "",
|
|
1382
|
+
});
|
|
1383
|
+
return planToolReceipt(result);
|
|
1384
|
+
},
|
|
1385
|
+
});
|
|
1386
|
+
// Incremental plan payload records (one entry per call) so requirements,
|
|
1387
|
+
// verification targets, evidence gaps, and implementation steps never have
|
|
1388
|
+
// to be emitted as one large array inside a single finalize_plan call —
|
|
1389
|
+
// they aggregate from the ledger in commit order. Mirrors the contract
|
|
1390
|
+
// node's record_requirement pattern to stay within any model output budget.
|
|
1391
|
+
const recordPlanRequirementTool = defineTool({
|
|
1392
|
+
name: "record_plan_requirement",
|
|
1393
|
+
label: "record_plan_requirement",
|
|
1394
|
+
description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement. For a correction, re-submit the same id with replace=true; the ledger compiles the latest replacement. Entry carries id, implementationTargets, verificationTargetIds, 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>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}}",
|
|
1395
|
+
promptSnippet: "Commit 1-4 plan requirement entries (up to 4 per message).",
|
|
1396
|
+
parameters: Type.Object({
|
|
1397
|
+
entry: requirementSchema,
|
|
1398
|
+
replace: Type.Optional(Type.Boolean()),
|
|
1399
|
+
}, { additionalProperties: false }),
|
|
1400
|
+
async execute(_toolCallId, params) {
|
|
1401
|
+
const rawEntry = params?.entry;
|
|
1402
|
+
if (!isRecordObject(rawEntry)) {
|
|
1403
|
+
return planToolReceipt({
|
|
1404
|
+
ok: false,
|
|
1405
|
+
kind: "plan-requirement",
|
|
1406
|
+
error: "record_plan_requirement requires a non-empty entry object",
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
let entry = rawEntry;
|
|
1410
|
+
// An embedded evidenceGap with a blank description means "no gap":
|
|
1411
|
+
// small-output models emit the slot defensively with description ""
|
|
1412
|
+
// on every requirement. The canonical contract schema requires a
|
|
1413
|
+
// non-empty gap description (min 1 char), so passing the empty slot
|
|
1414
|
+
// through would deterministically fail the plan compile with
|
|
1415
|
+
// invalid-output and burn every retry. Drop the empty slot — the
|
|
1416
|
+
// field is optional and the runtime derives real blocking gaps when
|
|
1417
|
+
// a requirement has no proof.
|
|
1418
|
+
const rawGap = isRecordObject(entry.evidenceGap)
|
|
1419
|
+
? entry.evidenceGap
|
|
1420
|
+
: undefined;
|
|
1421
|
+
if (rawGap &&
|
|
1422
|
+
typeof rawGap.description === "string" &&
|
|
1423
|
+
rawGap.description.trim() === "") {
|
|
1424
|
+
const { evidenceGap: _omittedGap, ...rest } = entry;
|
|
1425
|
+
void _omittedGap;
|
|
1426
|
+
entry = rest;
|
|
1427
|
+
}
|
|
1428
|
+
// Canonical-identity check: a requirement id outside the frozen
|
|
1429
|
+
// canonical list (e.g. a BR-* business rule picked up from the PRD
|
|
1430
|
+
// prose) would commit an immutable fact that finalize's
|
|
1431
|
+
// canonical-coverage gate rejects with no in-node cure. Reject here
|
|
1432
|
+
// and name the allowed ids.
|
|
1433
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
1434
|
+
if (id &&
|
|
1435
|
+
input.requirementIds &&
|
|
1436
|
+
input.requirementIds.length > 0 &&
|
|
1437
|
+
!input.requirementIds.includes(id)) {
|
|
1438
|
+
return planToolReceipt({
|
|
1439
|
+
ok: false,
|
|
1440
|
+
kind: "plan-requirement",
|
|
1441
|
+
error: `record_plan_requirement id "${id}" is not a frozen canonical requirement; canonical ids are: ${input.requirementIds.join(", ")}`,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
// A requirement id is a canonical identity: recording it twice would
|
|
1445
|
+
// compile a duplicate requirements[] entry and fail design review.
|
|
1446
|
+
// Reject duplicates by default; replace=true is the explicit in-node
|
|
1447
|
+
// correction path and the compiler keeps the latest replacement.
|
|
1448
|
+
if (id && params?.replace !== true) {
|
|
1449
|
+
const existing = readCommittedEvents(store, attemptId).find((event) => {
|
|
1450
|
+
const fact = event.fact;
|
|
1451
|
+
if (!fact || fact.kind !== "plan-requirement")
|
|
1452
|
+
return false;
|
|
1453
|
+
const entryFact = fact.entry;
|
|
1454
|
+
return (typeof entryFact?.id === "string" &&
|
|
1455
|
+
entryFact.id === id);
|
|
1456
|
+
});
|
|
1457
|
+
if (existing) {
|
|
1458
|
+
return planToolReceipt({
|
|
1459
|
+
ok: false,
|
|
1460
|
+
kind: "plan-requirement",
|
|
1461
|
+
error: `record_plan_requirement duplicate: requirement ${id} is already recorded; do not record the same requirement id twice`,
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
const result = await adoptPlanFact("plan-requirement", `${attemptId}:record_plan_requirement:${randomUUID()}`, {
|
|
1466
|
+
kind: "plan-requirement",
|
|
1467
|
+
origin: "plan",
|
|
1468
|
+
entry,
|
|
1469
|
+
...(params?.replace === true ? { replaces: id } : {}),
|
|
1470
|
+
});
|
|
1471
|
+
return planToolReceipt(result);
|
|
1472
|
+
},
|
|
1473
|
+
});
|
|
1474
|
+
const recordPlanVerificationTargetTool = defineTool({
|
|
1475
|
+
name: "record_plan_verification_target",
|
|
1476
|
+
label: "record_plan_verification_target",
|
|
1477
|
+
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 need a test file whose describe/it/test title contains the target id). 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 behavior target id is the stable trace token that implementation must place in a real describe/it/test title. 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\": []}}",
|
|
1478
|
+
promptSnippet: "Commit 1-4 plan verification target entries (up to 4 per message).",
|
|
1479
|
+
parameters: Type.Object({
|
|
1480
|
+
entry: verificationTargetSchema,
|
|
1481
|
+
replace: Type.Optional(Type.Boolean()),
|
|
1482
|
+
}, { additionalProperties: false }),
|
|
1483
|
+
async execute(_toolCallId, params) {
|
|
1484
|
+
const rawEntry = params?.entry;
|
|
1485
|
+
if (!isRecordObject(rawEntry)) {
|
|
1486
|
+
return planToolReceipt({
|
|
1487
|
+
ok: false,
|
|
1488
|
+
kind: "plan-verification-target",
|
|
1489
|
+
error: "record_plan_verification_target requires a non-empty entry object",
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1492
|
+
// Contract v2: the plan references a frozen command by commandId;
|
|
1493
|
+
// mode and label are resolved by the runtime from the frozen
|
|
1494
|
+
// command directory (run.json frontend-verify-shell bundle lanes).
|
|
1495
|
+
// Reject unknown ids and behavior commands bound to non-test
|
|
1496
|
+
// files here so the model fixes them in-node instead of the
|
|
1497
|
+
// attempt dying at materialization or at the writer focused-check.
|
|
1498
|
+
const verificationCommandId = typeof rawEntry.commandId === "string"
|
|
1499
|
+
? rawEntry.commandId.trim()
|
|
1500
|
+
: "";
|
|
1501
|
+
if (!verificationCommandId) {
|
|
1502
|
+
return planToolReceipt({
|
|
1503
|
+
ok: false,
|
|
1504
|
+
kind: "plan-verification-target",
|
|
1505
|
+
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`,
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
const { deriveFrontendVerifyCommandDirectoryFromRun, isFrontendTestFilePath, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
1509
|
+
const verifyDirectory = await deriveFrontendVerifyCommandDirectoryFromRun(input.runDir);
|
|
1510
|
+
if (verifyDirectory.length > 0) {
|
|
1511
|
+
const directoryEntry = verifyDirectory.find((entry) => entry.commandId === verificationCommandId);
|
|
1512
|
+
if (!directoryEntry) {
|
|
1513
|
+
return planToolReceipt({
|
|
1514
|
+
ok: false,
|
|
1515
|
+
kind: "plan-verification-target",
|
|
1516
|
+
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(", ")}]`,
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
if (directoryEntry.mode === "behavior" &&
|
|
1520
|
+
typeof rawEntry.file === "string" &&
|
|
1521
|
+
!isFrontendTestFilePath(rawEntry.file)) {
|
|
1522
|
+
return planToolReceipt({
|
|
1523
|
+
ok: false,
|
|
1524
|
+
kind: "plan-verification-target",
|
|
1525
|
+
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}"`,
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// Duplicate-id rejection: committed typed facts are immutable, so
|
|
1530
|
+
// re-recording the same VT id would deadlock the compile by default.
|
|
1531
|
+
// replace=true is the explicit in-node correction path.
|
|
1532
|
+
const vtId = typeof rawEntry.id === "string" ? rawEntry.id : "";
|
|
1533
|
+
if (vtId &&
|
|
1534
|
+
params?.replace !== true &&
|
|
1535
|
+
readCommittedEvents(store, attemptId).some((event) => {
|
|
1536
|
+
const fact = event.fact;
|
|
1537
|
+
if (!fact || fact.kind !== "plan-verification-target")
|
|
1538
|
+
return false;
|
|
1539
|
+
const entryFact = fact.entry;
|
|
1540
|
+
return entryFact?.id === vtId;
|
|
1541
|
+
})) {
|
|
1542
|
+
return planToolReceipt({
|
|
1543
|
+
ok: false,
|
|
1544
|
+
kind: "plan-verification-target",
|
|
1545
|
+
error: `record_plan_verification_target duplicate: verification target ${vtId} is already recorded; use replace=true for an in-node correction`,
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
// WriteSet containment at the boundary: a committed VT fact whose
|
|
1549
|
+
// file is outside the task writeSet is immutable, and the finalize
|
|
1550
|
+
// pre-validation would then fail the whole attempt with no in-node
|
|
1551
|
+
// cure (r17 post-merge). Reject here with the allowed patterns.
|
|
1552
|
+
if (typeof rawEntry.file === "string" &&
|
|
1553
|
+
input.writeSetPatterns &&
|
|
1554
|
+
input.writeSetPatterns.length > 0 &&
|
|
1555
|
+
!input.writeSetPatterns.some((pattern) => pathMatchesPattern(rawEntry.file, pattern))) {
|
|
1556
|
+
return planToolReceipt({
|
|
1557
|
+
ok: false,
|
|
1558
|
+
kind: "plan-verification-target",
|
|
1559
|
+
error: `record_plan_verification_target file is outside the writeSet patterns [${input.writeSetPatterns.join(", ")}]: ${rawEntry.file}; verification targets must point inside the task writeSet`,
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
// uiStates: [] means this verification target is intentionally not
|
|
1563
|
+
// bound to a named UI state. Keep that canonical representation even
|
|
1564
|
+
// when a model omits the optional tool-boundary field.
|
|
1565
|
+
const entryWithoutSymbol = { ...rawEntry };
|
|
1566
|
+
delete entryWithoutSymbol.symbol;
|
|
1567
|
+
const entry = {
|
|
1568
|
+
...entryWithoutSymbol,
|
|
1569
|
+
uiStates: stringList(rawEntry.uiStates),
|
|
1570
|
+
};
|
|
1571
|
+
// Cross-reference integrity at the boundary: the compile gate
|
|
1572
|
+
// rejects verification targets referencing UI states or
|
|
1573
|
+
// requirements that were never declared. Validate against the
|
|
1574
|
+
// facts already committed in this attempt so the model fixes the
|
|
1575
|
+
// reference in-node instead of burning the attempt at compile time
|
|
1576
|
+
// (r7: one full attempt lost to a single unknown UI state name).
|
|
1577
|
+
const committedEvents = readCommittedEvents(store, attemptId);
|
|
1578
|
+
const declaredUiStateNames = collectCanonicalStateFlowNames(committedEvents).uiStateNames;
|
|
1579
|
+
const unknownUiStates = entry.uiStates.filter((name) => !declaredUiStateNames.has(name));
|
|
1580
|
+
if (unknownUiStates.length > 0) {
|
|
1581
|
+
return planToolReceipt({
|
|
1582
|
+
ok: false,
|
|
1583
|
+
kind: "plan-verification-target",
|
|
1584
|
+
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`,
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1587
|
+
const declaredRequirementIds = new Set(committedEvents.flatMap((event) => {
|
|
1588
|
+
const fact = event.fact;
|
|
1589
|
+
if (!fact || fact.kind !== "plan-requirement")
|
|
1590
|
+
return [];
|
|
1591
|
+
const requirementEntry = fact.entry;
|
|
1592
|
+
return typeof requirementEntry?.id === "string"
|
|
1593
|
+
? [requirementEntry.id]
|
|
1594
|
+
: [];
|
|
1595
|
+
}));
|
|
1596
|
+
const unknownRequirementIds = stringList(rawEntry.requirementIds).filter((id) => !declaredRequirementIds.has(id));
|
|
1597
|
+
if (unknownRequirementIds.length > 0) {
|
|
1598
|
+
return planToolReceipt({
|
|
1599
|
+
ok: false,
|
|
1600
|
+
kind: "plan-verification-target",
|
|
1601
|
+
error: `record_plan_verification_target references unknown requirement ids: ${unknownRequirementIds.join(", ")}; record every referenced requirement with record_plan_requirement first`,
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
const result = await adoptPlanFact("plan-verification-target", `${attemptId}:record_plan_verification_target:${randomUUID()}`, {
|
|
1605
|
+
kind: "plan-verification-target",
|
|
1606
|
+
origin: "plan",
|
|
1607
|
+
entry,
|
|
1608
|
+
...(params?.replace === true ? { replaces: vtId } : {}),
|
|
1609
|
+
});
|
|
1610
|
+
return planToolReceipt(result);
|
|
1611
|
+
},
|
|
1612
|
+
});
|
|
1613
|
+
const recordPlanEvidenceGapTool = defineTool({
|
|
1614
|
+
name: "record_plan_evidence_gap",
|
|
1615
|
+
label: "record_plan_evidence_gap",
|
|
1616
|
+
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}}",
|
|
1617
|
+
promptSnippet: "Commit 1-4 plan evidence gap entries (up to 4 per message).",
|
|
1618
|
+
parameters: Type.Object({
|
|
1619
|
+
entry: evidenceGapSchema,
|
|
1620
|
+
}, { additionalProperties: false }),
|
|
1621
|
+
async execute(_toolCallId, params) {
|
|
1622
|
+
const rawEntry = params?.entry;
|
|
1623
|
+
if (!isRecordObject(rawEntry)) {
|
|
1624
|
+
return planToolReceipt({
|
|
1625
|
+
ok: false,
|
|
1626
|
+
kind: "plan-evidence-gap",
|
|
1627
|
+
error: "record_plan_evidence_gap requires a non-empty entry object",
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
const entry = rawEntry;
|
|
1631
|
+
// A standalone evidence gap IS the gap statement: a blank description
|
|
1632
|
+
// would fail the canonical contract schema (min 1 char) after the
|
|
1633
|
+
// whole attempt finished. Reject at the boundary so the model writes
|
|
1634
|
+
// a real description in-node instead of burning the attempt.
|
|
1635
|
+
if (typeof entry.description === "string" &&
|
|
1636
|
+
entry.description.trim() === "") {
|
|
1637
|
+
return planToolReceipt({
|
|
1638
|
+
ok: false,
|
|
1639
|
+
kind: "plan-evidence-gap",
|
|
1640
|
+
error: "record_plan_evidence_gap requires a non-empty description describing the gap",
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
const result = await adoptPlanFact("plan-evidence-gap", `${attemptId}:record_plan_evidence_gap:${randomUUID()}`, { kind: "plan-evidence-gap", origin: "plan", entry });
|
|
1644
|
+
return planToolReceipt(result);
|
|
1645
|
+
},
|
|
1646
|
+
});
|
|
1647
|
+
const finalizePlanTool = defineTool({
|
|
1648
|
+
name: "finalize_plan",
|
|
1649
|
+
label: "finalize_plan",
|
|
1650
|
+
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. Call exactly once.",
|
|
1651
|
+
promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
|
|
1652
|
+
parameters: Type.Object({
|
|
1653
|
+
residualRisks: optionalStringArray,
|
|
1654
|
+
realIntegrationGap: optionalString,
|
|
1655
|
+
}, { additionalProperties: false }),
|
|
1656
|
+
async execute(_toolCallId, params) {
|
|
1657
|
+
try {
|
|
1658
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
1659
|
+
// Source fidelity ledger (AC-005/AC-006): inherit the contract
|
|
1660
|
+
// node's declared requirement→fragment provenance so the compiled
|
|
1661
|
+
// canonical contract carries sourceFragmentIds/sourceRefs even
|
|
1662
|
+
// when the plan did not re-declare them. The contract node is the
|
|
1663
|
+
// sole synthesis point; the plan inherits by requirement id.
|
|
1664
|
+
const contractInheritance = await loadContractRequirementInheritance(input.runDir);
|
|
1665
|
+
const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
|
|
1666
|
+
const patch = {
|
|
1667
|
+
...fragment,
|
|
1668
|
+
...(params?.residualRisks
|
|
1669
|
+
? { residualRisks: params.residualRisks }
|
|
1670
|
+
: {}),
|
|
1671
|
+
...(params?.realIntegrationGap
|
|
1672
|
+
? { realIntegrationGap: params.realIntegrationGap }
|
|
1673
|
+
: {}),
|
|
1674
|
+
};
|
|
1675
|
+
// Front-load the node's compile + policy gates into the finalize
|
|
1676
|
+
// receipt (same pipeline the design-policy shell and the node
|
|
1677
|
+
// self-check run: merge the patch onto the runtime skeleton,
|
|
1678
|
+
// then the full analyze). A failing gate used to burn an entire
|
|
1679
|
+
// attempt per finding (r8/r9: ui-design-coverage, verification
|
|
1680
|
+
// targets, UI-state shape, one attempt each); surfaced here the
|
|
1681
|
+
// model fixes the facts and re-calls finalize_plan in-node.
|
|
1682
|
+
if (input.skeleton && input.sourceBinding) {
|
|
1683
|
+
// Front-load the exact pipeline the design-policy shell and
|
|
1684
|
+
// the node self-check run (patch ⊕ skeleton -> analyze ->
|
|
1685
|
+
// policy pre-checks) into the finalize receipt. Findings
|
|
1686
|
+
// come back as fixable receipt errors instead of burning
|
|
1687
|
+
// an attempt per gate (r8/r9: coverage, verification
|
|
1688
|
+
// targets, UI-state shape each cost a full attempt).
|
|
1689
|
+
const { analyzeFrontendPlanPatchCandidate, applyFrontendContractMergePatch, FrontendContractFailure, PlanPolicyPrecheckFailure, serializeDeterministicJson, } = await import("../workflows/dag/frontend-implementation-contract.js");
|
|
1690
|
+
try {
|
|
1691
|
+
const merged = applyFrontendContractMergePatch(input.skeleton, patch);
|
|
1692
|
+
await analyzeFrontendPlanPatchCandidate({
|
|
1693
|
+
runDir: input.runDir,
|
|
1694
|
+
rawContractText: serializeDeterministicJson(merged),
|
|
1695
|
+
sourceBinding: input.sourceBinding,
|
|
1696
|
+
});
|
|
1697
|
+
}
|
|
1698
|
+
catch (error) {
|
|
1699
|
+
if (error instanceof PlanPolicyPrecheckFailure) {
|
|
1700
|
+
// Template the fix: every uncovered interaction / state
|
|
1701
|
+
// maps to a ready-to-submit record_component_choice
|
|
1702
|
+
// call. One reuse-existing choice covers all
|
|
1703
|
+
// behavioural interactions.
|
|
1704
|
+
const suggestions = error.findings
|
|
1705
|
+
.filter((finding) => finding.code === "ui-design-coverage-missing" &&
|
|
1706
|
+
finding.path)
|
|
1707
|
+
.map((finding) => ({
|
|
1708
|
+
tool: "record_component_choice",
|
|
1709
|
+
args: {
|
|
1710
|
+
choice: {
|
|
1711
|
+
purpose: finding.path,
|
|
1712
|
+
component: "<name the existing or new component>",
|
|
1713
|
+
decision: "reuse-existing",
|
|
1714
|
+
},
|
|
1715
|
+
},
|
|
1716
|
+
}));
|
|
1717
|
+
const suggestionBlock = suggestions.length > 0
|
|
1718
|
+
? ` Suggested record_* calls (copy, fill component, submit): ${JSON.stringify(suggestions)}`
|
|
1719
|
+
: "";
|
|
1720
|
+
return planToolReceipt({
|
|
1721
|
+
ok: false,
|
|
1722
|
+
kind: "finalize_plan",
|
|
1723
|
+
error: `finalize_plan pre-validation failed (fix the listed plan facts with record_* tools, then call finalize_plan again): ${error.message}${suggestionBlock}`,
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
if (!(error instanceof FrontendContractFailure))
|
|
1727
|
+
throw error;
|
|
1728
|
+
return planToolReceipt({
|
|
1729
|
+
ok: false,
|
|
1730
|
+
kind: "finalize_plan",
|
|
1731
|
+
error: `finalize_plan pre-validation failed (fix the listed plan facts with record_* tools, then call finalize_plan again): ${error.message}`,
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
const patchResult = await adoptPlanFact("target-surface", `${attemptId}:finalize_plan:patch:${randomUUID()}`, { kind: "target-surface", origin: "plan", patch });
|
|
1736
|
+
if (!patchResult.ok) {
|
|
1737
|
+
return planToolReceipt({
|
|
1738
|
+
ok: false,
|
|
1739
|
+
kind: "finalize_plan",
|
|
1740
|
+
error: patchResult.error,
|
|
1741
|
+
code: patchResult.code,
|
|
1742
|
+
});
|
|
1743
|
+
}
|
|
1744
|
+
const terminal = await adoptPlanFact("finalize_plan", `${attemptId}:finalize_plan:terminal:${randomUUID()}`, { kind: "finalize_plan", origin: "plan", patch });
|
|
1745
|
+
return planToolReceipt(terminal);
|
|
1746
|
+
}
|
|
1747
|
+
catch (error) {
|
|
1748
|
+
return planToolReceipt({
|
|
1749
|
+
ok: false,
|
|
1750
|
+
kind: "finalize_plan",
|
|
1751
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1752
|
+
});
|
|
1753
|
+
}
|
|
1754
|
+
},
|
|
1755
|
+
});
|
|
1756
|
+
const adoptStagedFactTool = defineTool({
|
|
1757
|
+
name: "adopt_staged_fact",
|
|
1758
|
+
label: "adopt_staged_fact",
|
|
1759
|
+
description: "Explicitly adopt a quarantined fact from a prior attempt into the committed ledger (idempotent by requestId).",
|
|
1760
|
+
promptSnippet: "Adopt a quarantined fact into the committed ledger.",
|
|
1761
|
+
parameters: Type.Object({
|
|
1762
|
+
requestId: Type.String({}),
|
|
1763
|
+
eventId: Type.String({}),
|
|
1764
|
+
expectedRevision: Type.Number({}),
|
|
1765
|
+
}, { additionalProperties: false }),
|
|
1766
|
+
async execute(_toolCallId, params) {
|
|
1767
|
+
try {
|
|
1768
|
+
const adopted = await adoptStagedFact({
|
|
1769
|
+
store,
|
|
1770
|
+
requestId: typeof params?.requestId === "string" ? params.requestId : "",
|
|
1771
|
+
attemptId,
|
|
1772
|
+
eventId: typeof params?.eventId === "string" ? params.eventId : "",
|
|
1773
|
+
expectedRevision: typeof params?.expectedRevision === "number"
|
|
1774
|
+
? params.expectedRevision
|
|
1775
|
+
: store.revision,
|
|
1776
|
+
});
|
|
1777
|
+
return planToolReceipt({
|
|
1778
|
+
ok: true,
|
|
1779
|
+
kind: "adopt_staged_fact",
|
|
1780
|
+
eventId: adopted.eventId,
|
|
1781
|
+
revision: adopted.revision,
|
|
1782
|
+
error: "",
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
catch (error) {
|
|
1786
|
+
return planToolReceipt({
|
|
1787
|
+
ok: false,
|
|
1788
|
+
kind: "adopt_staged_fact",
|
|
1789
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1790
|
+
code: error?.code,
|
|
1791
|
+
});
|
|
1792
|
+
}
|
|
1793
|
+
},
|
|
1794
|
+
});
|
|
1795
|
+
return {
|
|
1796
|
+
customTools: [
|
|
1797
|
+
recordRouteSelectionTool,
|
|
1798
|
+
recordComponentChoiceTool,
|
|
1799
|
+
recordStateFlowTool,
|
|
1800
|
+
recordDataFlowTool,
|
|
1801
|
+
recordMockApiTool,
|
|
1802
|
+
recordDesignDeviationTool,
|
|
1803
|
+
recordDependencyTool,
|
|
1804
|
+
recordPlanRequirementTool,
|
|
1805
|
+
recordPlanVerificationTargetTool,
|
|
1806
|
+
recordPlanEvidenceGapTool,
|
|
1807
|
+
adoptStagedFactTool,
|
|
1808
|
+
finalizePlanTool,
|
|
1809
|
+
],
|
|
1810
|
+
setActiveRequirementScope: (requirementIds) => {
|
|
1811
|
+
activeRequirementScope = [
|
|
1812
|
+
...new Set(requirementIds.filter((id) => id.trim().length > 0)),
|
|
1813
|
+
];
|
|
1814
|
+
},
|
|
1815
|
+
flush: async () => {
|
|
1816
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
1817
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), committed);
|
|
1818
|
+
},
|
|
1819
|
+
committedFactCount: () => readCommittedEvents(store, attemptId).length,
|
|
1820
|
+
committedRequirementIds: () => {
|
|
1821
|
+
const ids = new Set();
|
|
1822
|
+
for (const event of readCommittedEvents(store, attemptId)) {
|
|
1823
|
+
const fact = event.fact;
|
|
1824
|
+
if (!fact || fact.kind !== "plan-requirement")
|
|
1825
|
+
continue;
|
|
1826
|
+
const entryFact = fact.entry;
|
|
1827
|
+
if (typeof entryFact?.id === "string")
|
|
1828
|
+
ids.add(entryFact.id);
|
|
1829
|
+
}
|
|
1830
|
+
return ids;
|
|
1831
|
+
},
|
|
1832
|
+
committedFacts: () => readCommittedEvents(store, attemptId),
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
function isRecordObject(value) {
|
|
1836
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1837
|
+
}
|
|
1838
|
+
/** A contract fact is source-mapped when it carries a non-empty `sourceSpan`
|
|
1839
|
+
* (object or string), a non-empty `sourceRefs` array, or a non-empty `source`
|
|
1840
|
+
* string. Anything else is an unmapped source segment (AC-001). */
|
|
1841
|
+
function contractFactSourceSpan(fact) {
|
|
1842
|
+
if (isRecordObject(fact.sourceSpan) || typeof fact.sourceSpan === "string") {
|
|
1843
|
+
return fact.sourceSpan;
|
|
1844
|
+
}
|
|
1845
|
+
if (Array.isArray(fact.sourceRefs) && fact.sourceRefs.length > 0) {
|
|
1846
|
+
return fact.sourceRefs;
|
|
1847
|
+
}
|
|
1848
|
+
if (typeof fact.source === "string" && fact.source.trim().length > 0) {
|
|
1849
|
+
return fact.source;
|
|
1850
|
+
}
|
|
1851
|
+
return undefined;
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* A+B (AC-001): deterministically assemble the read-only `frontend-task-contract-vNext`
|
|
1855
|
+
* audit artifact from committed contract facts. It never rewrites the original
|
|
1856
|
+
* task source: unmapped requirement/constraint segments are listed explicitly
|
|
1857
|
+
* so Plan/Implement/Review keep binding to the raw source, not the model's
|
|
1858
|
+
* summarized contract. The blocked disposition is projected through the frozen
|
|
1859
|
+
* `mapContractBlockedOwner` mapping (AC-002).
|
|
1860
|
+
*/
|
|
1861
|
+
export function buildFrontendTaskContractVNext(records) {
|
|
1862
|
+
const facts = records
|
|
1863
|
+
.filter((record) => record.phase === "committed")
|
|
1864
|
+
.map((record) => record.fact)
|
|
1865
|
+
.filter(isRecordObject);
|
|
1866
|
+
const finalized = facts.find((fact) => fact.kind === "contract-finalized");
|
|
1867
|
+
const disposition = typeof finalized?.disposition === "string" ? finalized.disposition : null;
|
|
1868
|
+
const blockingOwner = typeof finalized?.blockingOwner === "string"
|
|
1869
|
+
? finalized.blockingOwner
|
|
1870
|
+
: null;
|
|
1871
|
+
const projected = mapContractBlockedOwner({
|
|
1872
|
+
disposition: disposition ?? "",
|
|
1873
|
+
blockingOwner: blockingOwner ?? undefined,
|
|
1874
|
+
});
|
|
1875
|
+
const byKind = (kind) => facts.filter((fact) => fact.kind === kind);
|
|
1876
|
+
const requirements = byKind("requirement");
|
|
1877
|
+
const unmappedSourceSegments = requirements
|
|
1878
|
+
.filter((fact) => contractFactSourceSpan(fact) === undefined)
|
|
1879
|
+
.map((fact) => ({
|
|
1880
|
+
kind: "requirement",
|
|
1881
|
+
id: typeof fact.id === "string"
|
|
1882
|
+
? fact.id
|
|
1883
|
+
: typeof fact.text === "string"
|
|
1884
|
+
? fact.text
|
|
1885
|
+
: undefined,
|
|
1886
|
+
}))
|
|
1887
|
+
.filter((segment) => segment.id !== undefined);
|
|
1888
|
+
return {
|
|
1889
|
+
schemaVersion: 1,
|
|
1890
|
+
schemaId: "frontend-task-contract-vNext",
|
|
1891
|
+
disposition,
|
|
1892
|
+
blockingOwner,
|
|
1893
|
+
blockedOwner: projected === "not-blocked" ? null : projected,
|
|
1894
|
+
requirements,
|
|
1895
|
+
constraints: byKind("constraint"),
|
|
1896
|
+
evidenceExpectations: byKind("evidence-expectation"),
|
|
1897
|
+
handoffIntents: byKind("handoff-intent"),
|
|
1898
|
+
openQuestions: byKind("open-question"),
|
|
1899
|
+
splitProposals: byKind("split-proposal"),
|
|
1900
|
+
unmappedSourceSegments,
|
|
1901
|
+
};
|
|
1902
|
+
}
|
|
1903
|
+
/**
|
|
1904
|
+
* A+B: `frontend-contract-pi` incremental contract tools. Six `record_*` tools
|
|
1905
|
+
* commit origin=contract facts and `finalize_contract` commits the terminal
|
|
1906
|
+
* disposition (ready | ready-with-assumptions | blocked + blockingOwner).
|
|
1907
|
+
*/
|
|
1908
|
+
export async function createFrontendContractTools(input) {
|
|
1909
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
1910
|
+
import("typebox"),
|
|
1911
|
+
import("@earendil-works/pi-coding-agent"),
|
|
1912
|
+
]);
|
|
1913
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1914
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
1915
|
+
const store = input.store;
|
|
1916
|
+
const attemptId = input.attemptId;
|
|
1917
|
+
const receipt = (details) => ({
|
|
1918
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
1919
|
+
details,
|
|
1920
|
+
});
|
|
1921
|
+
async function adoptContractFact(kind, fact) {
|
|
1922
|
+
try {
|
|
1923
|
+
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
1924
|
+
const staged = stageTypedEventFact({
|
|
1925
|
+
store,
|
|
1926
|
+
requestId,
|
|
1927
|
+
attemptId,
|
|
1928
|
+
fact: fact,
|
|
1929
|
+
});
|
|
1930
|
+
const committed = await adoptTypedEventFact({
|
|
1931
|
+
store,
|
|
1932
|
+
requestId,
|
|
1933
|
+
attemptId,
|
|
1934
|
+
fact: fact,
|
|
1935
|
+
eventId: staged.eventId,
|
|
1936
|
+
expectedRevision: store.revision,
|
|
1937
|
+
});
|
|
1938
|
+
return {
|
|
1939
|
+
ok: true,
|
|
1940
|
+
kind,
|
|
1941
|
+
eventId: committed.eventId,
|
|
1942
|
+
revision: committed.revision,
|
|
1943
|
+
error: "",
|
|
1944
|
+
};
|
|
1945
|
+
}
|
|
1946
|
+
catch (error) {
|
|
1947
|
+
return {
|
|
1948
|
+
ok: false,
|
|
1949
|
+
kind,
|
|
1950
|
+
code: error?.code,
|
|
1951
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1952
|
+
};
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
const recordKinds = {
|
|
1956
|
+
record_requirement: "requirement",
|
|
1957
|
+
record_constraint: "constraint",
|
|
1958
|
+
record_evidence_expectation: "evidence-expectation",
|
|
1959
|
+
record_handoff_intent: "handoff-intent",
|
|
1960
|
+
record_open_question: "open-question",
|
|
1961
|
+
record_split_proposal: "split-proposal",
|
|
1962
|
+
};
|
|
1963
|
+
const recordTools = Object.entries(recordKinds).map(([name, kind]) => defineTool({
|
|
1964
|
+
name,
|
|
1965
|
+
label: name,
|
|
1966
|
+
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.`,
|
|
1967
|
+
promptSnippet: `Commit 1-5 origin=contract ${kind} facts (up to 5 per message).`,
|
|
1968
|
+
parameters: Type.Object({}, { additionalProperties: true }),
|
|
1969
|
+
async execute(_toolCallId, params) {
|
|
1970
|
+
const result = await adoptContractFact(kind, {
|
|
1971
|
+
kind,
|
|
1972
|
+
origin: "contract",
|
|
1973
|
+
...(params ?? {}),
|
|
1974
|
+
});
|
|
1975
|
+
return receipt(result);
|
|
1976
|
+
},
|
|
1977
|
+
}));
|
|
1978
|
+
// OpenSpec selection committed as individual typed facts (one path per
|
|
1979
|
+
// call) so a large candidate set never exceeds a single model output
|
|
1980
|
+
// budget: each tool call carries exactly one {path, disposition,
|
|
1981
|
+
// rationale} row and the ledger accumulates them across calls. Only
|
|
1982
|
+
// positive classifications (required | relevant) are legal; unmentioned
|
|
1983
|
+
// candidates default to irrelevant at the prewrite gate.
|
|
1984
|
+
const recordOpenspecSelectionTool = defineTool({
|
|
1985
|
+
name: "record_openspec_selection",
|
|
1986
|
+
label: "record_openspec_selection",
|
|
1987
|
+
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.",
|
|
1988
|
+
promptSnippet: "Commit one OpenSpec candidate classification (required | relevant); one path per call; skip irrelevant candidates.",
|
|
1989
|
+
parameters: Type.Object({
|
|
1990
|
+
path: Type.String({
|
|
1991
|
+
description: "Repo-relative candidate spec path, e.g. openspec/project-specs/ui/ucp-components-md/AdvancedSearch.md",
|
|
1992
|
+
}),
|
|
1993
|
+
disposition: Type.Enum({
|
|
1994
|
+
required: "required",
|
|
1995
|
+
relevant: "relevant",
|
|
1996
|
+
}),
|
|
1997
|
+
rationale: Type.String({}),
|
|
1998
|
+
}, { additionalProperties: false }),
|
|
1999
|
+
async execute(_toolCallId, params) {
|
|
2000
|
+
const path = typeof params?.path === "string" ? params.path : "";
|
|
2001
|
+
const disposition = params?.disposition;
|
|
2002
|
+
const rationale = typeof params?.rationale === "string" ? params.rationale : "";
|
|
2003
|
+
if (!path || !disposition || !rationale.trim()) {
|
|
2004
|
+
return receipt({
|
|
2005
|
+
ok: false,
|
|
2006
|
+
kind: "openspec-selection",
|
|
2007
|
+
error: "record_openspec_selection requires non-empty path, disposition (required|relevant), and rationale",
|
|
2008
|
+
});
|
|
2009
|
+
}
|
|
2010
|
+
const result = await adoptContractFact("openspec-selection", {
|
|
2011
|
+
kind: "openspec-selection",
|
|
2012
|
+
origin: "contract",
|
|
2013
|
+
path,
|
|
2014
|
+
disposition,
|
|
2015
|
+
rationale,
|
|
2016
|
+
});
|
|
2017
|
+
return receipt(result);
|
|
2018
|
+
},
|
|
2019
|
+
});
|
|
2020
|
+
const finalizeContractTool = defineTool({
|
|
2021
|
+
name: "finalize_contract",
|
|
2022
|
+
label: "finalize_contract",
|
|
2023
|
+
description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). Call exactly once.",
|
|
2024
|
+
promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
|
|
2025
|
+
parameters: Type.Object({
|
|
2026
|
+
disposition: Type.Enum({
|
|
2027
|
+
ready: "ready",
|
|
2028
|
+
"ready-with-assumptions": "ready-with-assumptions",
|
|
2029
|
+
blocked: "blocked",
|
|
2030
|
+
}),
|
|
2031
|
+
blockingOwner: Type.Optional(Type.Enum({
|
|
2032
|
+
"blocked-human": "blocked-human",
|
|
2033
|
+
"blocked-external": "blocked-external",
|
|
2034
|
+
})),
|
|
2035
|
+
assumptions: Type.Optional(Type.Array(Type.String({}))),
|
|
2036
|
+
summary: Type.Optional(Type.String({})),
|
|
2037
|
+
}, { additionalProperties: false }),
|
|
2038
|
+
async execute(_toolCallId, params) {
|
|
2039
|
+
const disposition = params?.disposition;
|
|
2040
|
+
const blockingOwner = params?.blockingOwner;
|
|
2041
|
+
if (disposition === "blocked" && !blockingOwner) {
|
|
2042
|
+
return receipt({
|
|
2043
|
+
ok: false,
|
|
2044
|
+
kind: "finalize_contract",
|
|
2045
|
+
error: "blocked disposition requires blockingOwner (blocked-human | blocked-external)",
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
const blockedOwner = mapContractBlockedOwner({
|
|
2049
|
+
disposition: disposition ?? "",
|
|
2050
|
+
blockingOwner,
|
|
2051
|
+
});
|
|
2052
|
+
const result = await adoptContractFact("contract-finalized", {
|
|
2053
|
+
kind: "contract-finalized",
|
|
2054
|
+
origin: "contract",
|
|
2055
|
+
disposition,
|
|
2056
|
+
...(blockingOwner ? { blockingOwner } : {}),
|
|
2057
|
+
...(blockedOwner !== "not-blocked" ? { blockedOwner } : {}),
|
|
2058
|
+
...(Array.isArray(params?.assumptions)
|
|
2059
|
+
? { assumptions: params.assumptions }
|
|
2060
|
+
: {}),
|
|
2061
|
+
...(typeof params?.summary === "string"
|
|
2062
|
+
? { summary: params.summary }
|
|
2063
|
+
: {}),
|
|
2064
|
+
});
|
|
2065
|
+
return receipt(result);
|
|
2066
|
+
},
|
|
2067
|
+
});
|
|
2068
|
+
return {
|
|
2069
|
+
customTools: [
|
|
2070
|
+
...recordTools,
|
|
2071
|
+
recordOpenspecSelectionTool,
|
|
2072
|
+
finalizeContractTool,
|
|
2073
|
+
],
|
|
2074
|
+
flush: async () => {
|
|
2075
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
2076
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), committed);
|
|
2077
|
+
await writeJsonAtomic(path.join(input.runDir, input.nodeId, "frontend-task-contract-vNext.json"), buildFrontendTaskContractVNext(committed));
|
|
2078
|
+
},
|
|
2079
|
+
};
|
|
2080
|
+
}
|
|
2081
|
+
async function resolveFrontendScoutSourceDeclaredPaths(input) {
|
|
2082
|
+
const binding = input.spec.sourceBinding;
|
|
2083
|
+
if (!binding?.sources?.length)
|
|
2084
|
+
return undefined;
|
|
2085
|
+
const declared = new Set();
|
|
2086
|
+
const pathToken = /(?:\.?\.?\/)?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?/g;
|
|
2087
|
+
const writerPaths = input.spec.tasks
|
|
2088
|
+
.filter((task) => task.executor === "pi" && task.toolProfile === "write")
|
|
2089
|
+
.flatMap((task) => task.allowedPaths ?? []);
|
|
2090
|
+
const prefixes = writerPaths
|
|
2091
|
+
.map((pattern) => pattern.replaceAll("\\", "/").replace(/^\.\//, ""))
|
|
2092
|
+
.map((pattern) => pattern.slice(0, pattern.search(/[!*?[{]/) >= 0 ? pattern.search(/[!*?[{]/) : pattern.length))
|
|
2093
|
+
.map((prefix) => prefix.slice(0, prefix.lastIndexOf("/") + 1))
|
|
2094
|
+
.filter(Boolean);
|
|
2095
|
+
for (const source of binding.sources) {
|
|
2096
|
+
try {
|
|
2097
|
+
const text = await readFile(path.resolve(input.cwd, source.path), "utf8");
|
|
2098
|
+
for (const match of text.matchAll(pathToken)) {
|
|
2099
|
+
const token = match[0].replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
2100
|
+
if (!token || token.startsWith("http") || token.startsWith("api/"))
|
|
2101
|
+
continue;
|
|
2102
|
+
declared.add(token);
|
|
2103
|
+
for (const prefix of prefixes) {
|
|
2104
|
+
if (!token.startsWith(prefix) && !token.startsWith(".harness/"))
|
|
2105
|
+
declared.add(`${prefix}${token}`);
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
catch {
|
|
2110
|
+
// The source binding itself remains the authority; an unreadable source
|
|
2111
|
+
// produces an empty declaration set, so future test paths fail closed.
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
return [...declared].sort();
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* A+B: `frontend-scout-pi` incremental evidence tools (origin=scout). Runtime
|
|
2118
|
+
* enriches committed target-surface / design-evidence facts with hash/section/
|
|
2119
|
+
* freshness from real read events; the tool itself never trusts model self-report.
|
|
2120
|
+
*/
|
|
2121
|
+
export async function createFrontendScoutEvidenceTools(input) {
|
|
2122
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
2123
|
+
import("typebox"),
|
|
2124
|
+
import("@earendil-works/pi-coding-agent"),
|
|
2125
|
+
]);
|
|
2126
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2127
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
2128
|
+
const store = input.store;
|
|
2129
|
+
const attemptId = input.attemptId;
|
|
2130
|
+
const stringArray = Type.Array(Type.String({}));
|
|
2131
|
+
const optionalString = Type.Optional(Type.String({}));
|
|
2132
|
+
const scoutCompleteness = Type.Union([
|
|
2133
|
+
Type.Literal("complete"),
|
|
2134
|
+
Type.Literal("blocked"),
|
|
2135
|
+
]);
|
|
2136
|
+
const receipt = (details) => ({
|
|
2137
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
2138
|
+
details,
|
|
2139
|
+
});
|
|
2140
|
+
const sourceDeclaredPaths = (input.sourceDeclaredPaths ?? []).map((value) => value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""));
|
|
2141
|
+
const hasSourceDeclarations = input.sourceDeclaredPaths !== undefined;
|
|
2142
|
+
const isSourceDeclared = (candidate) => {
|
|
2143
|
+
const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
2144
|
+
return sourceDeclaredPaths.some((declared) => declared === normalized || declared.startsWith(`${normalized}/`));
|
|
2145
|
+
};
|
|
2146
|
+
// A+B (AC-003): runtime enriches declared scout paths with hash/freshness
|
|
2147
|
+
// from the real filesystem. The model's self-reported path list is never
|
|
2148
|
+
// trusted for content identity; a missing file fails closed to fresh=false
|
|
2149
|
+
// with a zero hash instead of inventing content.
|
|
2150
|
+
const enrichScoutPathEvidence = async (paths) => {
|
|
2151
|
+
if (!input.workspaceRoot)
|
|
2152
|
+
return [];
|
|
2153
|
+
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
2154
|
+
const evidence = [];
|
|
2155
|
+
for (const relative of new Set(paths)) {
|
|
2156
|
+
const absolute = path.resolve(workspaceRoot, relative);
|
|
2157
|
+
if (absolute !== workspaceRoot &&
|
|
2158
|
+
!absolute.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
2159
|
+
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false, sourceDeclared: isSourceDeclared(relative) });
|
|
2160
|
+
continue;
|
|
2161
|
+
}
|
|
2162
|
+
try {
|
|
2163
|
+
// Directories are legitimate named targets (greenfield smoke: the
|
|
2164
|
+
// page directory exists while the files inside it are to be
|
|
2165
|
+
// created). readFile on a directory throws EISDIR, which used to
|
|
2166
|
+
// mark every directory path fresh=false and structurally fail the
|
|
2167
|
+
// freshness gate for create-new surfaces. stat() first: a directory
|
|
2168
|
+
// counts as fresh existence evidence; its content hash is a stable
|
|
2169
|
+
// directory marker since there is no single file content to hash.
|
|
2170
|
+
const info = await stat(absolute);
|
|
2171
|
+
if (info.isDirectory()) {
|
|
2172
|
+
evidence.push({
|
|
2173
|
+
path: relative,
|
|
2174
|
+
sha256: createHash("sha256").update(`directory:${relative}`).digest("hex"),
|
|
2175
|
+
fresh: true,
|
|
2176
|
+
sourceDeclared: isSourceDeclared(relative),
|
|
2177
|
+
});
|
|
2178
|
+
continue;
|
|
2179
|
+
}
|
|
2180
|
+
const bytes = await readFile(absolute);
|
|
2181
|
+
evidence.push({
|
|
2182
|
+
path: relative,
|
|
2183
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
2184
|
+
fresh: true,
|
|
2185
|
+
sourceDeclared: isSourceDeclared(relative),
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
catch {
|
|
2189
|
+
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false, sourceDeclared: isSourceDeclared(relative) });
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return evidence;
|
|
2193
|
+
};
|
|
2194
|
+
async function adoptScoutFact(kind, fact) {
|
|
2195
|
+
try {
|
|
2196
|
+
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
2197
|
+
const staged = stageTypedEventFact({
|
|
2198
|
+
store,
|
|
2199
|
+
requestId,
|
|
2200
|
+
attemptId,
|
|
2201
|
+
fact: fact,
|
|
2202
|
+
});
|
|
2203
|
+
const committed = await adoptTypedEventFact({
|
|
2204
|
+
store,
|
|
2205
|
+
requestId,
|
|
2206
|
+
attemptId,
|
|
2207
|
+
fact: fact,
|
|
2208
|
+
eventId: staged.eventId,
|
|
2209
|
+
expectedRevision: store.revision,
|
|
2210
|
+
});
|
|
2211
|
+
return {
|
|
2212
|
+
ok: true,
|
|
2213
|
+
kind,
|
|
2214
|
+
eventId: committed.eventId,
|
|
2215
|
+
revision: committed.revision,
|
|
2216
|
+
error: "",
|
|
2217
|
+
};
|
|
2218
|
+
}
|
|
2219
|
+
catch (error) {
|
|
2220
|
+
return {
|
|
2221
|
+
ok: false,
|
|
2222
|
+
kind,
|
|
2223
|
+
code: error?.code,
|
|
2224
|
+
error: error instanceof Error ? error.message : String(error),
|
|
2225
|
+
};
|
|
2226
|
+
}
|
|
196
2227
|
}
|
|
197
|
-
|
|
2228
|
+
const recordTargetSurfaceTool = defineTool({
|
|
2229
|
+
name: "record_target_surface",
|
|
2230
|
+
label: "record_target_surface",
|
|
2231
|
+
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\": []}",
|
|
2232
|
+
promptSnippet: "Commit an origin=scout target-surface fact.",
|
|
2233
|
+
parameters: Type.Object({
|
|
2234
|
+
completeness: scoutCompleteness,
|
|
2235
|
+
entrypoint: optionalString,
|
|
2236
|
+
routeOrMount: optionalString,
|
|
2237
|
+
implementationPaths: stringArray,
|
|
2238
|
+
testPaths: stringArray,
|
|
2239
|
+
dataSource: optionalString,
|
|
2240
|
+
allowedPathConflicts: stringArray,
|
|
2241
|
+
unresolvedPaths: stringArray,
|
|
2242
|
+
}, { additionalProperties: false }),
|
|
2243
|
+
async execute(_toolCallId, params) {
|
|
2244
|
+
const implementationPaths = params?.implementationPaths ?? [];
|
|
2245
|
+
const testPaths = params?.testPaths ?? [];
|
|
2246
|
+
const pathEvidence = await enrichScoutPathEvidence([
|
|
2247
|
+
...(params?.entrypoint ? [params.entrypoint] : []),
|
|
2248
|
+
...implementationPaths,
|
|
2249
|
+
...testPaths,
|
|
2250
|
+
]);
|
|
2251
|
+
const result = await adoptScoutFact("target-surface", {
|
|
2252
|
+
kind: "target-surface",
|
|
2253
|
+
origin: "scout",
|
|
2254
|
+
completeness: params?.completeness ?? "blocked",
|
|
2255
|
+
entrypoint: params?.entrypoint ?? "",
|
|
2256
|
+
routeOrMount: params?.routeOrMount ?? "",
|
|
2257
|
+
implementationPaths,
|
|
2258
|
+
testPaths,
|
|
2259
|
+
dataSource: params?.dataSource ?? "",
|
|
2260
|
+
allowedPathConflicts: params?.allowedPathConflicts ?? [],
|
|
2261
|
+
unresolvedPaths: params?.unresolvedPaths ?? [],
|
|
2262
|
+
...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}),
|
|
2263
|
+
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
2264
|
+
});
|
|
2265
|
+
return receipt(result);
|
|
2266
|
+
},
|
|
2267
|
+
});
|
|
2268
|
+
const recordDesignEvidenceTool = defineTool({
|
|
2269
|
+
name: "record_design_evidence",
|
|
2270
|
+
label: "record_design_evidence",
|
|
2271
|
+
description: "Commit an origin=scout design-evidence fact (source, paths, conflicts). Example: {\"source\": \"<source>\", \"paths\": [\"<file>\"], \"conflicts\": []}",
|
|
2272
|
+
promptSnippet: "Commit an origin=scout design-evidence fact.",
|
|
2273
|
+
parameters: Type.Object({ source: Type.String({}), paths: stringArray, conflicts: stringArray }, { additionalProperties: false }),
|
|
2274
|
+
async execute(_toolCallId, params) {
|
|
2275
|
+
const paths = params?.paths ?? [];
|
|
2276
|
+
const pathEvidence = await enrichScoutPathEvidence(paths);
|
|
2277
|
+
const result = await adoptScoutFact("design-evidence", {
|
|
2278
|
+
kind: "design-evidence",
|
|
2279
|
+
origin: "scout",
|
|
2280
|
+
source: params?.source ?? "",
|
|
2281
|
+
paths,
|
|
2282
|
+
conflicts: params?.conflicts ?? [],
|
|
2283
|
+
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
2284
|
+
});
|
|
2285
|
+
return receipt(result);
|
|
2286
|
+
},
|
|
2287
|
+
});
|
|
2288
|
+
return {
|
|
2289
|
+
customTools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
|
|
2290
|
+
flush: async () => {
|
|
2291
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
2292
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), committed);
|
|
2293
|
+
},
|
|
2294
|
+
};
|
|
198
2295
|
}
|
|
199
2296
|
export function buildDagPiUserMessage(task, persona, step) {
|
|
200
2297
|
const role = task.role ?? "unspecified";
|
|
201
2298
|
const writePolicy = task.writePolicy ?? "read-only (default)";
|
|
202
2299
|
if (isDagPiWriteTask(task)) {
|
|
203
|
-
const outcomeInstruction = task
|
|
2300
|
+
const outcomeInstruction = isFrontendFactsWriter(task)
|
|
204
2301
|
? [
|
|
205
|
-
"
|
|
206
|
-
task.writerOutcomePolicy.requireChangedFiles
|
|
207
|
-
? "This generation node requires a non-empty bounded diff; already-satisfied cannot complete it successfully."
|
|
208
|
-
: undefined,
|
|
2302
|
+
"Your implementation status is derived by the executor from mechanical facts (persisted write-tool events, run delta, write guard, requirement coverage, focused-check failures) — never from an IMPLEMENTATION_OUTCOME first line. Do not emit an IMPLEMENTATION_OUTCOME first line. Make real write/edit tool calls that persist files to disk: a response-only change is an empty diff that fails this node. If the contract is already satisfied, prove it with concrete target/verification evidence; a bare self-report cannot authorize an empty implementation.",
|
|
209
2303
|
]
|
|
210
2304
|
.filter((value) => Boolean(value))
|
|
211
2305
|
.join(" ")
|
|
212
|
-
:
|
|
2306
|
+
: task.writerOutcomePolicy
|
|
2307
|
+
? [
|
|
2308
|
+
"The first non-empty response line must be exactly IMPLEMENTATION_OUTCOME: changed, IMPLEMENTATION_OUTCOME: already-satisfied, or IMPLEMENTATION_OUTCOME: blocked. The runner measures your diff mechanically from git status snapshots taken before and after this node: code pasted into the response text is NOT an implementation and yields an empty diff. Use changed only after actually calling write/edit tools that persist files to disk; use already-satisfied only when the contract is already met and no file changed; use blocked when implementation cannot proceed. Claiming changed without a persisted diff fails this node as invalid-output.",
|
|
2309
|
+
task.writerOutcomePolicy.requireChangedFiles
|
|
2310
|
+
? "This generation node requires a non-empty bounded diff; already-satisfied cannot complete it successfully."
|
|
2311
|
+
: undefined,
|
|
2312
|
+
]
|
|
2313
|
+
.filter((value) => Boolean(value))
|
|
2314
|
+
.join(" ")
|
|
2315
|
+
: undefined;
|
|
213
2316
|
return [
|
|
214
2317
|
`You are executing hybrid DAG node "${task.id}" (role=${role}, piStep=${step}, writePolicy=${writePolicy}).`,
|
|
215
2318
|
"The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
|
|
@@ -399,30 +2502,1350 @@ async function resolveValidatedFrontendBaseUrlFromContext(workspaceRoot, runDir)
|
|
|
399
2502
|
break;
|
|
400
2503
|
}
|
|
401
2504
|
}
|
|
402
|
-
if (!rawBaseUrl || !/environmentProbe\s*[:=]\s*reachable/i.test(markdown)) {
|
|
403
|
-
throw new Error("browser-command-capability-unavailable: frontend context URL is missing or not environment-validated");
|
|
404
|
-
}
|
|
405
|
-
let parsed;
|
|
406
|
-
try {
|
|
407
|
-
parsed = new URL(rawBaseUrl);
|
|
408
|
-
}
|
|
409
|
-
catch {
|
|
410
|
-
throw new Error("browser-command-capability-unavailable: invalid frontend context origin");
|
|
411
|
-
}
|
|
412
|
-
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
413
|
-
parsed.username ||
|
|
414
|
-
parsed.password ||
|
|
415
|
-
parsed.search ||
|
|
416
|
-
parsed.hash ||
|
|
417
|
-
/(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
|
|
418
|
-
throw new Error("browser-command-capability-unavailable: unsafe frontend context origin");
|
|
2505
|
+
if (!rawBaseUrl || !/environmentProbe\s*[:=]\s*reachable/i.test(markdown)) {
|
|
2506
|
+
throw new Error("browser-command-capability-unavailable: frontend context URL is missing or not environment-validated");
|
|
2507
|
+
}
|
|
2508
|
+
let parsed;
|
|
2509
|
+
try {
|
|
2510
|
+
parsed = new URL(rawBaseUrl);
|
|
2511
|
+
}
|
|
2512
|
+
catch {
|
|
2513
|
+
throw new Error("browser-command-capability-unavailable: invalid frontend context origin");
|
|
2514
|
+
}
|
|
2515
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
2516
|
+
parsed.username ||
|
|
2517
|
+
parsed.password ||
|
|
2518
|
+
parsed.search ||
|
|
2519
|
+
parsed.hash ||
|
|
2520
|
+
/(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
|
|
2521
|
+
throw new Error("browser-command-capability-unavailable: unsafe frontend context origin");
|
|
2522
|
+
}
|
|
2523
|
+
return parsed.toString();
|
|
2524
|
+
}
|
|
2525
|
+
const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
|
|
2526
|
+
readGitStatusPorcelain,
|
|
2527
|
+
recoverRootNulArtifact,
|
|
2528
|
+
};
|
|
2529
|
+
/**
|
|
2530
|
+
* M5 shadow pass for `frontend-review-pi`: extract the committed typed terminal
|
|
2531
|
+
* facts from session events, parse the legacy JSON verdict from the response
|
|
2532
|
+
* text, compare them (audit-only), and persist the audit artifact. Missing
|
|
2533
|
+
* typed terminal facts fail the node closed (AC-001); a shadow mismatch never
|
|
2534
|
+
* blocks the node.
|
|
2535
|
+
*/
|
|
2536
|
+
async function runFrontendReviewTerminalShadow(input) {
|
|
2537
|
+
// A provider/executor failure (in particular context-overflow) is already
|
|
2538
|
+
// authoritative. Do not rewrite it to review-terminal-missing merely
|
|
2539
|
+
// because no terminal tool could be submitted after the failed call.
|
|
2540
|
+
if (!input.mapped.ok)
|
|
2541
|
+
return input.mapped;
|
|
2542
|
+
const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
|
|
2543
|
+
const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
|
|
2544
|
+
const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
|
|
2545
|
+
let typedKinds = [];
|
|
2546
|
+
try {
|
|
2547
|
+
const content = await readFile(sessionEventsPath, "utf8");
|
|
2548
|
+
typedKinds = scanReviewTerminalKindsFromSessionEvents(content);
|
|
2549
|
+
}
|
|
2550
|
+
catch {
|
|
2551
|
+
// Missing/unreadable session log → fail-closed at zero terminal facts.
|
|
2552
|
+
typedKinds = [];
|
|
2553
|
+
}
|
|
2554
|
+
let legacyVerdict;
|
|
2555
|
+
try {
|
|
2556
|
+
const parsed = parseJsonReviewVerdict(input.mapped.assistantText ?? input.mapped.stdout);
|
|
2557
|
+
if (parsed.ok)
|
|
2558
|
+
legacyVerdict = parsed.verdict;
|
|
2559
|
+
}
|
|
2560
|
+
catch {
|
|
2561
|
+
legacyVerdict = undefined;
|
|
2562
|
+
}
|
|
2563
|
+
let comparison;
|
|
2564
|
+
try {
|
|
2565
|
+
comparison = compareTypedReviewToLegacyJsonVerdict({
|
|
2566
|
+
typedKinds,
|
|
2567
|
+
legacyVerdict,
|
|
2568
|
+
});
|
|
2569
|
+
}
|
|
2570
|
+
catch (error) {
|
|
2571
|
+
comparison = {
|
|
2572
|
+
typedVerdict: undefined,
|
|
2573
|
+
legacyVerdict: undefined,
|
|
2574
|
+
match: false,
|
|
2575
|
+
reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2576
|
+
};
|
|
2577
|
+
}
|
|
2578
|
+
// Audit-only flush + artifact. Neither blocks the node.
|
|
2579
|
+
try {
|
|
2580
|
+
await input.tools?.flush?.();
|
|
2581
|
+
}
|
|
2582
|
+
catch {
|
|
2583
|
+
// best-effort
|
|
2584
|
+
}
|
|
2585
|
+
try {
|
|
2586
|
+
await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
|
|
2587
|
+
schemaVersion: 1,
|
|
2588
|
+
nodeId: input.task.id,
|
|
2589
|
+
typedKinds,
|
|
2590
|
+
legacyVerdict,
|
|
2591
|
+
comparison,
|
|
2592
|
+
});
|
|
2593
|
+
}
|
|
2594
|
+
catch {
|
|
2595
|
+
// best-effort audit artifact
|
|
2596
|
+
}
|
|
2597
|
+
if (typedKinds.length === 0) {
|
|
2598
|
+
return {
|
|
2599
|
+
...input.mapped,
|
|
2600
|
+
ok: false,
|
|
2601
|
+
failureCategory: "review-terminal-missing",
|
|
2602
|
+
stderr: [
|
|
2603
|
+
input.mapped.stderr,
|
|
2604
|
+
"frontend review typed terminal fact missing: no approve_review/request_review_changes tool call was committed",
|
|
2605
|
+
]
|
|
2606
|
+
.filter(Boolean)
|
|
2607
|
+
.join("\n\n"),
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
return input.mapped;
|
|
2611
|
+
}
|
|
2612
|
+
/**
|
|
2613
|
+
* M8 shadow pass for `frontend-design-review-pi`: extract the committed typed
|
|
2614
|
+
* design terminal facts from session events, flush them, and persist the audit
|
|
2615
|
+
* artifact. The design review's legacy output was a first-line
|
|
2616
|
+
* `VERDICT: pass|request-revision` text protocol (not a JSON verdict), so
|
|
2617
|
+
* there is no JSON equivalence comparison here. Missing typed terminal facts
|
|
2618
|
+
* fail the node closed; writer admission later reads the committed
|
|
2619
|
+
* `design-typed-facts.jsonl` as the only authoritative verdict.
|
|
2620
|
+
*/
|
|
2621
|
+
async function runFrontendDesignTerminalShadow(input) {
|
|
2622
|
+
// See the review counterpart above: a failed provider call cannot be
|
|
2623
|
+
// diagnosed as an omitted terminal tool call.
|
|
2624
|
+
if (!input.mapped.ok)
|
|
2625
|
+
return input.mapped;
|
|
2626
|
+
const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
|
|
2627
|
+
let typedKinds = [];
|
|
2628
|
+
try {
|
|
2629
|
+
const content = await readFile(sessionEventsPath, "utf8");
|
|
2630
|
+
typedKinds = scanDesignTerminalKindsFromSessionEvents(content);
|
|
2631
|
+
}
|
|
2632
|
+
catch {
|
|
2633
|
+
// Missing/unreadable session log → fail-closed at zero terminal facts.
|
|
2634
|
+
typedKinds = [];
|
|
2635
|
+
}
|
|
2636
|
+
// Audit-only flush + artifact. Neither blocks the node.
|
|
2637
|
+
try {
|
|
2638
|
+
await input.tools?.flush?.();
|
|
2639
|
+
}
|
|
2640
|
+
catch {
|
|
2641
|
+
// best-effort
|
|
2642
|
+
}
|
|
2643
|
+
try {
|
|
2644
|
+
await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
|
|
2645
|
+
schemaVersion: 1,
|
|
2646
|
+
nodeId: input.task.id,
|
|
2647
|
+
typedKinds,
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
catch {
|
|
2651
|
+
// best-effort audit artifact
|
|
2652
|
+
}
|
|
2653
|
+
if (typedKinds.length === 0) {
|
|
2654
|
+
return {
|
|
2655
|
+
...input.mapped,
|
|
2656
|
+
ok: false,
|
|
2657
|
+
failureCategory: "design-terminal-missing",
|
|
2658
|
+
stderr: [
|
|
2659
|
+
input.mapped.stderr,
|
|
2660
|
+
"frontend design typed terminal fact missing: no approve_design/request_design_changes tool call was committed",
|
|
2661
|
+
]
|
|
2662
|
+
.filter(Boolean)
|
|
2663
|
+
.join("\n\n"),
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
return input.mapped;
|
|
2667
|
+
}
|
|
2668
|
+
/** Tool subsets for the frontend plan phases. Local UX decisions and global
|
|
2669
|
+
* policies intentionally have different sessions and different tool sets. */
|
|
2670
|
+
const FRONTEND_PLAN_SEGMENTS = [
|
|
2671
|
+
{
|
|
2672
|
+
id: "coverage",
|
|
2673
|
+
toolNames: new Set([
|
|
2674
|
+
"record_plan_requirement",
|
|
2675
|
+
"record_plan_verification_target",
|
|
2676
|
+
"record_plan_evidence_gap",
|
|
2677
|
+
"adopt_staged_fact",
|
|
2678
|
+
]),
|
|
2679
|
+
instruction: [
|
|
2680
|
+
"PLAN PHASE — requirement coverage only.",
|
|
2681
|
+
"Your ONLY job: for every frozen requirement, emit record_plan_requirement (requirement → implementation files) and record_plan_verification_target facts (verification target bound to requirement ids and files). Group related requirements under one non-static behavior target when one observable test behavior proves them together; do not mechanically create one target per requirement. A non-static target id is the stable machine trace token; never submit prose as a symbol. Do NOT record components, UI states, mock, dependency, or routes — a follow-up session owns those.",
|
|
2682
|
+
"If a requirement genuinely cannot have a verification target, record a non-empty record_plan_evidence_gap. Do not call finalize_plan; it is not available in this phase.",
|
|
2683
|
+
].join(" "),
|
|
2684
|
+
},
|
|
2685
|
+
{
|
|
2686
|
+
id: "ux-local",
|
|
2687
|
+
toolNames: new Set([
|
|
2688
|
+
"record_component_choice",
|
|
2689
|
+
"record_state_flow",
|
|
2690
|
+
"adopt_staged_fact",
|
|
2691
|
+
]),
|
|
2692
|
+
instruction: [
|
|
2693
|
+
"PLAN PHASE — requirement-local UX decisions.",
|
|
2694
|
+
"Requirements and verification targets are already committed in the ledger (do NOT re-record them; duplicates are rejected). For ONLY this requirement slice, record component choices and UI state flow. Keep these facts for this slice together. Cross-cutting data flow belongs to the global Mock/data phase. Do not record routes, Mock/API policy, dependencies, or design deviations here.",
|
|
2695
|
+
"Do not call finalize_plan; it is not available in this phase.",
|
|
2696
|
+
].join(" "),
|
|
2697
|
+
},
|
|
2698
|
+
{
|
|
2699
|
+
id: "global-route",
|
|
2700
|
+
toolNames: new Set(["record_route_selection", "adopt_staged_fact"]),
|
|
2701
|
+
instruction: [
|
|
2702
|
+
"PLAN PHASE — global route decision.",
|
|
2703
|
+
"Use the Scout target surface and record only the selected route(s). Do not record requirement-local UX, Mock/data, dependency, or deviation facts. Do not call finalize_plan.",
|
|
2704
|
+
].join(" "),
|
|
2705
|
+
},
|
|
2706
|
+
{
|
|
2707
|
+
id: "global-mock-data",
|
|
2708
|
+
toolNames: new Set(["record_data_flow", "record_mock_api", "adopt_staged_fact"]),
|
|
2709
|
+
instruction: [
|
|
2710
|
+
"PLAN PHASE — global Mock/API and data policy.",
|
|
2711
|
+
"Record the cross-cutting interaction-to-endpoint data flow and Mock/API strategy only. Keep this decision set separate from route, component, state, dependency, and deviation facts. Do not call finalize_plan.",
|
|
2712
|
+
].join(" "),
|
|
2713
|
+
},
|
|
2714
|
+
{
|
|
2715
|
+
id: "global-dependency-deviation",
|
|
2716
|
+
toolNames: new Set(["record_dependency", "record_design_deviation", "adopt_staged_fact"]),
|
|
2717
|
+
instruction: [
|
|
2718
|
+
"PLAN PHASE — global dependency and design-deviation policy.",
|
|
2719
|
+
"Record only dependency policy and design-evidence conflicts. Do not record route, Mock/data, or requirement-local UX facts. Do not call finalize_plan.",
|
|
2720
|
+
].join(" "),
|
|
2721
|
+
},
|
|
2722
|
+
{
|
|
2723
|
+
id: "finalize",
|
|
2724
|
+
toolNames: null,
|
|
2725
|
+
instruction: [
|
|
2726
|
+
"PLAN PHASE — finalize.",
|
|
2727
|
+
"All record_* tools are available only for a narrowly named correction if the finalize receipt reports missing or invalid facts. Otherwise call finalize_plan exactly once with no extra fields.",
|
|
2728
|
+
].join(" "),
|
|
2729
|
+
},
|
|
2730
|
+
];
|
|
2731
|
+
function committedFactFromPlanRecord(value) {
|
|
2732
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2733
|
+
return undefined;
|
|
2734
|
+
const record = value;
|
|
2735
|
+
if (record.phase !== undefined && record.phase !== "committed")
|
|
2736
|
+
return undefined;
|
|
2737
|
+
const fact = record.fact;
|
|
2738
|
+
return fact && typeof fact === "object" && !Array.isArray(fact)
|
|
2739
|
+
? fact
|
|
2740
|
+
: typeof record.kind === "string"
|
|
2741
|
+
? record
|
|
2742
|
+
: undefined;
|
|
2743
|
+
}
|
|
2744
|
+
function planFactStringList(value) {
|
|
2745
|
+
if (!Array.isArray(value))
|
|
2746
|
+
return [];
|
|
2747
|
+
return value.filter((item) => typeof item === "string" && item.trim().length > 0);
|
|
2748
|
+
}
|
|
2749
|
+
function planFactScopeIntersects(fact, requirementIds) {
|
|
2750
|
+
return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
|
|
2751
|
+
}
|
|
2752
|
+
/** Apply state-flow replacement/removal semantics exactly as the plan compiler does. */
|
|
2753
|
+
function collectCanonicalStateFlowNames(committedFacts) {
|
|
2754
|
+
const uiStateNames = new Set();
|
|
2755
|
+
const interactionNames = new Set();
|
|
2756
|
+
for (const value of committedFacts) {
|
|
2757
|
+
const fact = committedFactFromPlanRecord(value);
|
|
2758
|
+
if (!fact || fact.origin !== "plan" || fact.kind !== "state-flow")
|
|
2759
|
+
continue;
|
|
2760
|
+
for (const name of planFactStringList(fact.removeUiStateNames)) {
|
|
2761
|
+
uiStateNames.delete(name);
|
|
2762
|
+
}
|
|
2763
|
+
for (const name of planFactStringList(fact.removeInteractionNames)) {
|
|
2764
|
+
interactionNames.delete(name);
|
|
2765
|
+
}
|
|
2766
|
+
for (const state of Array.isArray(fact.uiStates) ? fact.uiStates : []) {
|
|
2767
|
+
if (!state || typeof state !== "object" || Array.isArray(state))
|
|
2768
|
+
continue;
|
|
2769
|
+
const name = state.name;
|
|
2770
|
+
if (typeof name === "string" && name.trim())
|
|
2771
|
+
uiStateNames.add(name);
|
|
2772
|
+
}
|
|
2773
|
+
for (const interaction of Array.isArray(fact.interactions)
|
|
2774
|
+
? fact.interactions
|
|
2775
|
+
: []) {
|
|
2776
|
+
if (!interaction || typeof interaction !== "object" || Array.isArray(interaction)) {
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
const name = interaction.name;
|
|
2780
|
+
if (typeof name === "string" && name.trim())
|
|
2781
|
+
interactionNames.add(name);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
return { uiStateNames, interactionNames };
|
|
2785
|
+
}
|
|
2786
|
+
/** Compute the authoritative coverage queue from the committed plan ledger. */
|
|
2787
|
+
export function collectFrontendPlanMissingFacts(input) {
|
|
2788
|
+
const requirements = new Map();
|
|
2789
|
+
const standaloneEvidenceGaps = new Set();
|
|
2790
|
+
const verificationTargetIds = new Set();
|
|
2791
|
+
const verificationTargetRequirements = new Map();
|
|
2792
|
+
for (const value of input.committedFacts) {
|
|
2793
|
+
const fact = committedFactFromPlanRecord(value);
|
|
2794
|
+
if (!fact || fact.origin !== "plan")
|
|
2795
|
+
continue;
|
|
2796
|
+
if (fact.kind === "plan-requirement" && fact.entry && typeof fact.entry === "object") {
|
|
2797
|
+
const entry = fact.entry;
|
|
2798
|
+
if (typeof entry.id === "string" && entry.id.trim())
|
|
2799
|
+
requirements.set(entry.id, entry);
|
|
2800
|
+
}
|
|
2801
|
+
if (fact.kind === "plan-verification-target" && fact.entry && typeof fact.entry === "object") {
|
|
2802
|
+
const entry = fact.entry;
|
|
2803
|
+
const id = entry.id;
|
|
2804
|
+
if (typeof id === "string" && id.trim()) {
|
|
2805
|
+
verificationTargetIds.add(id);
|
|
2806
|
+
verificationTargetRequirements.set(id, new Set(Array.isArray(entry.requirementIds)
|
|
2807
|
+
? entry.requirementIds.filter((value) => typeof value === "string")
|
|
2808
|
+
: []));
|
|
2809
|
+
}
|
|
2810
|
+
}
|
|
2811
|
+
if (fact.kind === "plan-evidence-gap" && fact.entry && typeof fact.entry === "object") {
|
|
2812
|
+
const entry = fact.entry;
|
|
2813
|
+
const requirementId = entry.requirementId;
|
|
2814
|
+
const description = entry.description;
|
|
2815
|
+
if (typeof requirementId === "string" && requirementId.trim() && typeof description === "string" && description.trim()) {
|
|
2816
|
+
standaloneEvidenceGaps.add(requirementId);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
}
|
|
2820
|
+
const missing = [];
|
|
2821
|
+
for (const id of input.requirementIds) {
|
|
2822
|
+
const entry = requirements.get(id);
|
|
2823
|
+
if (!entry) {
|
|
2824
|
+
missing.push({
|
|
2825
|
+
kind: "plan-requirement",
|
|
2826
|
+
id,
|
|
2827
|
+
requirementIds: [id],
|
|
2828
|
+
reason: `requirement ${id} has no committed plan-requirement fact`,
|
|
2829
|
+
});
|
|
2830
|
+
continue;
|
|
2831
|
+
}
|
|
2832
|
+
const targetIds = Array.isArray(entry.verificationTargetIds)
|
|
2833
|
+
? entry.verificationTargetIds.filter((value) => typeof value === "string" && value.trim().length > 0)
|
|
2834
|
+
: [];
|
|
2835
|
+
const gap = entry.evidenceGap && typeof entry.evidenceGap === "object"
|
|
2836
|
+
? entry.evidenceGap
|
|
2837
|
+
: undefined;
|
|
2838
|
+
const hasEvidenceGap = (typeof gap?.description === "string" && gap.description.trim().length > 0) ||
|
|
2839
|
+
standaloneEvidenceGaps.has(id);
|
|
2840
|
+
if (targetIds.length === 0 && !hasEvidenceGap) {
|
|
2841
|
+
missing.push({
|
|
2842
|
+
kind: "plan-verification-target",
|
|
2843
|
+
requirementIds: [id],
|
|
2844
|
+
reason: `requirement ${id} declares neither a verification target nor a non-empty evidenceGap`,
|
|
2845
|
+
});
|
|
2846
|
+
continue;
|
|
2847
|
+
}
|
|
2848
|
+
for (const targetId of targetIds) {
|
|
2849
|
+
if (!verificationTargetIds.has(targetId) ||
|
|
2850
|
+
!verificationTargetRequirements.get(targetId)?.has(id)) {
|
|
2851
|
+
missing.push({
|
|
2852
|
+
kind: "plan-verification-target",
|
|
2853
|
+
id: targetId,
|
|
2854
|
+
requirementIds: [id],
|
|
2855
|
+
reason: `requirement ${id} references verification target ${targetId}, but that target is not committed`,
|
|
2856
|
+
});
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
return missing;
|
|
2861
|
+
}
|
|
2862
|
+
/** Completeness checks for phases whose facts are committed incrementally. */
|
|
2863
|
+
export function collectFrontendPlanPhaseMissingFacts(input) {
|
|
2864
|
+
const facts = input.committedFacts
|
|
2865
|
+
.map(committedFactFromPlanRecord)
|
|
2866
|
+
.filter((fact) => Boolean(fact && fact.origin === "plan"));
|
|
2867
|
+
if (input.phase === "ux-local") {
|
|
2868
|
+
const needsUx = input.requirementIds.some((id) => input.behaviorRequiredRequirementIds?.includes(id));
|
|
2869
|
+
if (!needsUx)
|
|
2870
|
+
return [];
|
|
2871
|
+
const requirementSlice = new Set(input.requirementIds);
|
|
2872
|
+
const scopedFacts = facts.filter((fact) => planFactScopeIntersects(fact, requirementSlice));
|
|
2873
|
+
const hasChoice = scopedFacts.some((fact) => fact.kind === "component-choice" &&
|
|
2874
|
+
Array.isArray(fact.uiComponentChoices) &&
|
|
2875
|
+
fact.uiComponentChoices.length > 0);
|
|
2876
|
+
const canonicalStateFlow = collectCanonicalStateFlowNames(scopedFacts);
|
|
2877
|
+
const hasStateFlow = canonicalStateFlow.uiStateNames.size > 0 ||
|
|
2878
|
+
canonicalStateFlow.interactionNames.size > 0;
|
|
2879
|
+
const missing = [];
|
|
2880
|
+
if (!hasChoice) {
|
|
2881
|
+
missing.push({
|
|
2882
|
+
kind: "component-choice",
|
|
2883
|
+
requirementIds: [...input.requirementIds],
|
|
2884
|
+
reason: "behaviour-required UX slice has no committed component-choice fact",
|
|
2885
|
+
});
|
|
2886
|
+
}
|
|
2887
|
+
if (!hasStateFlow) {
|
|
2888
|
+
missing.push({
|
|
2889
|
+
kind: "state-flow",
|
|
2890
|
+
requirementIds: [...input.requirementIds],
|
|
2891
|
+
reason: "behaviour-required UX slice has no committed state-flow fact",
|
|
2892
|
+
});
|
|
2893
|
+
}
|
|
2894
|
+
return missing;
|
|
2895
|
+
}
|
|
2896
|
+
const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
|
|
2897
|
+
const liveInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
|
|
2898
|
+
const coveredInteractions = new Set(facts
|
|
2899
|
+
.filter((fact) => fact.kind === "data-flow")
|
|
2900
|
+
.flatMap((fact) => planFactStringList(fact.interactions)));
|
|
2901
|
+
const missing = [];
|
|
2902
|
+
if (!hasMockApi) {
|
|
2903
|
+
missing.push({
|
|
2904
|
+
kind: "mock-api",
|
|
2905
|
+
requirementIds: [...input.requirementIds],
|
|
2906
|
+
reason: "global Mock/data phase has no committed mock-api fact",
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
for (const interaction of liveInteractions) {
|
|
2910
|
+
if (coveredInteractions.has(interaction))
|
|
2911
|
+
continue;
|
|
2912
|
+
missing.push({
|
|
2913
|
+
kind: "data-flow",
|
|
2914
|
+
id: interaction,
|
|
2915
|
+
requirementIds: [...input.requirementIds],
|
|
2916
|
+
reason: `interaction ${interaction} has no committed data-flow fact`,
|
|
2917
|
+
});
|
|
2918
|
+
}
|
|
2919
|
+
return missing;
|
|
2920
|
+
}
|
|
2921
|
+
/** Estimate calls conservatively: requirement + one VT, with a second VT
|
|
2922
|
+
* reserved for behaviour-required requirements. Explicit declarations win. */
|
|
2923
|
+
export function estimateFrontendPlanRequirementRecordCalls(fact) {
|
|
2924
|
+
const record = fact && typeof fact === "object" && !Array.isArray(fact)
|
|
2925
|
+
? fact
|
|
2926
|
+
: {};
|
|
2927
|
+
const declaredTargetCount = Array.isArray(record.verificationTargetIds)
|
|
2928
|
+
? record.verificationTargetIds.filter((value) => typeof value === "string" && value.trim()).length
|
|
2929
|
+
: Array.isArray(record.verificationTargets)
|
|
2930
|
+
? record.verificationTargets.length
|
|
2931
|
+
: 0;
|
|
2932
|
+
const evidence = record.evidence && typeof record.evidence === "object"
|
|
2933
|
+
? record.evidence
|
|
2934
|
+
: undefined;
|
|
2935
|
+
const targetCount = Math.max(declaredTargetCount, evidence?.behavior === "required" ? 2 : 1);
|
|
2936
|
+
return 1 + targetCount;
|
|
2937
|
+
}
|
|
2938
|
+
/** Pack requirements without splitting one requirement across sessions. */
|
|
2939
|
+
export function batchFrontendPlanRequirements(input) {
|
|
2940
|
+
const batches = [];
|
|
2941
|
+
let current = [];
|
|
2942
|
+
let currentCost = 0;
|
|
2943
|
+
for (const id of input.requirementIds) {
|
|
2944
|
+
const cost = Math.max(1, input.requirementCosts?.get(id) ?? 2);
|
|
2945
|
+
if (current.length > 0 &&
|
|
2946
|
+
(currentCost + cost > input.maxEstimatedRecordCalls ||
|
|
2947
|
+
(input.maxRequirements !== undefined &&
|
|
2948
|
+
current.length >= input.maxRequirements))) {
|
|
2949
|
+
batches.push(current);
|
|
2950
|
+
current = [];
|
|
2951
|
+
currentCost = 0;
|
|
2952
|
+
}
|
|
2953
|
+
current.push(id);
|
|
2954
|
+
currentCost += cost;
|
|
2955
|
+
}
|
|
2956
|
+
if (current.length > 0)
|
|
2957
|
+
batches.push(current);
|
|
2958
|
+
return batches;
|
|
2959
|
+
}
|
|
2960
|
+
const FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS = 10;
|
|
2961
|
+
const FRONTEND_PLAN_UX_LOCAL_MAX_RECORD_CALLS = 6;
|
|
2962
|
+
// A large requirement set creates both coverage and UX-local shards. Keep a
|
|
2963
|
+
// safety bound, but do not let the old 32-session ceiling skip finalize for a
|
|
2964
|
+
// legitimate large plan.
|
|
2965
|
+
const FRONTEND_PLAN_BATCH_MAX_SESSIONS = 128;
|
|
2966
|
+
const FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS = 8;
|
|
2967
|
+
const FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS = 24;
|
|
2968
|
+
function compactPromptString(value, maxChars) {
|
|
2969
|
+
if (typeof value !== "string" || value.trim().length === 0)
|
|
2970
|
+
return undefined;
|
|
2971
|
+
const normalized = value.trim();
|
|
2972
|
+
return normalized.length <= maxChars
|
|
2973
|
+
? normalized
|
|
2974
|
+
: `${normalized.slice(0, maxChars - 1)}…`;
|
|
2975
|
+
}
|
|
2976
|
+
function compactPromptStringArray(value, maxEntries = 12, maxChars = 180) {
|
|
2977
|
+
if (!Array.isArray(value))
|
|
2978
|
+
return [];
|
|
2979
|
+
return value
|
|
2980
|
+
.map((item) => compactPromptString(item, maxChars))
|
|
2981
|
+
.filter((item) => item !== undefined)
|
|
2982
|
+
.slice(0, maxEntries);
|
|
2983
|
+
}
|
|
2984
|
+
function countFrontendPlanTargetSurfaces(basePrompt) {
|
|
2985
|
+
const match = /<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/.exec(basePrompt);
|
|
2986
|
+
if (!match)
|
|
2987
|
+
return undefined;
|
|
2988
|
+
for (const line of match[0].split(/\r?\n/)) {
|
|
2989
|
+
try {
|
|
2990
|
+
const payload = JSON.parse(line);
|
|
2991
|
+
if (Array.isArray(payload.targetSurface))
|
|
2992
|
+
return payload.targetSurface.length;
|
|
2993
|
+
}
|
|
2994
|
+
catch {
|
|
2995
|
+
// surrounding lines are prose
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
return undefined;
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* Deterministically extract repository file paths that frozen verification
|
|
3002
|
+
* commands operate on (`--config <file>`, `node --check <file>`). A frozen
|
|
3003
|
+
* command whose referenced file is outside the planner's verification targets
|
|
3004
|
+
* can never run: the admission writeSet derives from those targets, so the
|
|
3005
|
+
* writer is not authorized to create the file and verify-shell fails ~20
|
|
3006
|
+
* minutes later. Surfacing the gap as plan missing-facts lets the coverage or
|
|
3007
|
+
* compact session record the missing verification target inside the same
|
|
3008
|
+
* attempt instead.
|
|
3009
|
+
*/
|
|
3010
|
+
export function collectFrontendVerificationCommandFiles(basePrompt) {
|
|
3011
|
+
const files = new Set();
|
|
3012
|
+
const configRe = /--config\s+([\w@./-]+\.(?:js|mjs|cjs|ts|json))/g;
|
|
3013
|
+
const checkRe = /node\s+--check\s+([\w@./-]+\.(?:js|mjs|cjs))/g;
|
|
3014
|
+
for (const re of [configRe, checkRe]) {
|
|
3015
|
+
for (const match of basePrompt.matchAll(re)) {
|
|
3016
|
+
const file = match[1];
|
|
3017
|
+
if (file && file.includes("/"))
|
|
3018
|
+
files.add(file);
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
return [...files].sort();
|
|
3022
|
+
}
|
|
3023
|
+
/**
|
|
3024
|
+
* Remove the repeated full planner input from a coverage batch. The normal
|
|
3025
|
+
* plan prompt already contains a bounded JSON handoff, but repeating all
|
|
3026
|
+
* requirements and the upstream preview once per batch still makes the
|
|
3027
|
+
* provider spend its output budget reasoning about work owned by other
|
|
3028
|
+
* sessions. If the handoff is absent, malformed, or missing a requested id,
|
|
3029
|
+
* preserve the old prompt as a safe compatibility fallback rather than
|
|
3030
|
+
* silently giving the model an incomplete requirement.
|
|
3031
|
+
*/
|
|
3032
|
+
export function compactFrontendPlanPromptForRequirementSlice(basePrompt, requirementIds, options = {}) {
|
|
3033
|
+
const slice = [...new Set(requirementIds.filter((id) => id.trim().length > 0))];
|
|
3034
|
+
if (slice.length === 0)
|
|
3035
|
+
return basePrompt;
|
|
3036
|
+
const planInputPattern = /<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/;
|
|
3037
|
+
const planInputMatch = planInputPattern.exec(basePrompt);
|
|
3038
|
+
if (!planInputMatch)
|
|
3039
|
+
return basePrompt;
|
|
3040
|
+
const blockLines = planInputMatch[0].split(/\r?\n/);
|
|
3041
|
+
let payload;
|
|
3042
|
+
for (const line of blockLines) {
|
|
3043
|
+
try {
|
|
3044
|
+
const parsed = JSON.parse(line);
|
|
3045
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
3046
|
+
payload = parsed;
|
|
3047
|
+
break;
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
catch {
|
|
3051
|
+
// The surrounding block contains prose; only its JSON line is data.
|
|
3052
|
+
}
|
|
3053
|
+
}
|
|
3054
|
+
if (!payload || !Array.isArray(payload.requirements))
|
|
3055
|
+
return basePrompt;
|
|
3056
|
+
const requirementsById = new Map();
|
|
3057
|
+
for (const value of payload.requirements) {
|
|
3058
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3059
|
+
continue;
|
|
3060
|
+
const requirement = value;
|
|
3061
|
+
if (typeof requirement.id === "string") {
|
|
3062
|
+
requirementsById.set(requirement.id, requirement);
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
const requirements = slice.map((id) => requirementsById.get(id));
|
|
3066
|
+
if (requirements.some((requirement) => requirement === undefined)) {
|
|
3067
|
+
return basePrompt;
|
|
3068
|
+
}
|
|
3069
|
+
const compactRequirements = requirements.map((requirement) => ({
|
|
3070
|
+
id: compactPromptString(requirement.id, 80),
|
|
3071
|
+
...(options.includeRequirementText !== false && compactPromptString(requirement.text, 240)
|
|
3072
|
+
? { text: compactPromptString(requirement.text, 240) }
|
|
3073
|
+
: {}),
|
|
3074
|
+
sourceFragmentIds: compactPromptStringArray(requirement.sourceFragmentIds, 20, 80),
|
|
3075
|
+
}));
|
|
3076
|
+
const sliceSet = new Set(slice);
|
|
3077
|
+
const compactVerificationTargets = options.includeVerificationTargets === false
|
|
3078
|
+
? []
|
|
3079
|
+
: Array.isArray(payload.verificationTargets)
|
|
3080
|
+
? payload.verificationTargets.flatMap((value) => {
|
|
3081
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3082
|
+
return [];
|
|
3083
|
+
const target = value;
|
|
3084
|
+
const targetRequirementIds = compactPromptStringArray(target.requirementIds, 20, 80);
|
|
3085
|
+
const relatedRequirementIds = targetRequirementIds.filter((id) => sliceSet.has(id));
|
|
3086
|
+
if (relatedRequirementIds.length === 0)
|
|
3087
|
+
return [];
|
|
3088
|
+
return [
|
|
3089
|
+
{
|
|
3090
|
+
...(compactPromptString(target.id, 80)
|
|
3091
|
+
? { id: compactPromptString(target.id, 80) }
|
|
3092
|
+
: {}),
|
|
3093
|
+
...(compactPromptString(target.commandId, 80)
|
|
3094
|
+
? { commandId: compactPromptString(target.commandId, 80) }
|
|
3095
|
+
: {}),
|
|
3096
|
+
...(compactPromptString(target.commandLabel, 180)
|
|
3097
|
+
? { commandLabel: compactPromptString(target.commandLabel, 180) }
|
|
3098
|
+
: {}),
|
|
3099
|
+
...(compactPromptString(target.file, 180)
|
|
3100
|
+
? { file: compactPromptString(target.file, 180) }
|
|
3101
|
+
: {}),
|
|
3102
|
+
requirementIds: relatedRequirementIds,
|
|
3103
|
+
uiStates: compactPromptStringArray(target.uiStates, 12, 100),
|
|
3104
|
+
},
|
|
3105
|
+
];
|
|
3106
|
+
})
|
|
3107
|
+
: [];
|
|
3108
|
+
const compactTargetSurface = Array.isArray(payload.targetSurface)
|
|
3109
|
+
? payload.targetSurface.flatMap((value) => {
|
|
3110
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3111
|
+
return [];
|
|
3112
|
+
const surface = value;
|
|
3113
|
+
return [
|
|
3114
|
+
{
|
|
3115
|
+
...(compactPromptString(surface.completeness, 32)
|
|
3116
|
+
? { completeness: compactPromptString(surface.completeness, 32) }
|
|
3117
|
+
: {}),
|
|
3118
|
+
...(compactPromptString(surface.entrypoint, 180)
|
|
3119
|
+
? { entrypoint: compactPromptString(surface.entrypoint, 180) }
|
|
3120
|
+
: {}),
|
|
3121
|
+
...(compactPromptString(surface.routeOrMount, 180)
|
|
3122
|
+
? { routeOrMount: compactPromptString(surface.routeOrMount, 180) }
|
|
3123
|
+
: {}),
|
|
3124
|
+
implementationPaths: compactPromptStringArray(surface.implementationPaths),
|
|
3125
|
+
testPaths: compactPromptStringArray(surface.testPaths),
|
|
3126
|
+
...(compactPromptString(surface.dataSource, 180)
|
|
3127
|
+
? { dataSource: compactPromptString(surface.dataSource, 180) }
|
|
3128
|
+
: {}),
|
|
3129
|
+
allowedPathConflicts: compactPromptStringArray(surface.allowedPathConflicts),
|
|
3130
|
+
unresolvedPaths: compactPromptStringArray(surface.unresolvedPaths),
|
|
3131
|
+
},
|
|
3132
|
+
];
|
|
3133
|
+
})
|
|
3134
|
+
: [];
|
|
3135
|
+
const compactDesignEvidence = options.includeDesignEvidence === true &&
|
|
3136
|
+
Array.isArray(payload.designEvidence)
|
|
3137
|
+
? payload.designEvidence.flatMap((value) => {
|
|
3138
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3139
|
+
return [];
|
|
3140
|
+
const evidence = value;
|
|
3141
|
+
return [{
|
|
3142
|
+
...(compactPromptString(evidence.source, 180)
|
|
3143
|
+
? { source: compactPromptString(evidence.source, 180) }
|
|
3144
|
+
: {}),
|
|
3145
|
+
paths: compactPromptStringArray(evidence.paths, 12, 180),
|
|
3146
|
+
conflicts: compactPromptStringArray(evidence.conflicts, 24, 180),
|
|
3147
|
+
}];
|
|
3148
|
+
})
|
|
3149
|
+
: [];
|
|
3150
|
+
const compactPayload = {
|
|
3151
|
+
requirements: compactRequirements,
|
|
3152
|
+
...(compactTargetSurface.length > 0
|
|
3153
|
+
? { targetSurface: compactTargetSurface }
|
|
3154
|
+
: {}),
|
|
3155
|
+
...(compactVerificationTargets.length > 0
|
|
3156
|
+
? { verificationTargets: compactVerificationTargets }
|
|
3157
|
+
: {}),
|
|
3158
|
+
...(compactDesignEvidence.length > 0
|
|
3159
|
+
? { designEvidence: compactDesignEvidence }
|
|
3160
|
+
: {}),
|
|
3161
|
+
};
|
|
3162
|
+
const compactBlock = [
|
|
3163
|
+
"<frontend_plan_input>",
|
|
3164
|
+
`Committed Contract/Scout facts for ONLY the current requirement slice (${slice.join(", ")}); do not infer or record requirements outside this slice.`,
|
|
3165
|
+
JSON.stringify(compactPayload),
|
|
3166
|
+
"Do not read upstream artifacts, task sources, or repository files. If this slice cannot support a decision, record a genuine evidence gap.",
|
|
3167
|
+
"</frontend_plan_input>",
|
|
3168
|
+
].join("\n");
|
|
3169
|
+
let compactPrompt = basePrompt.replace(planInputPattern, compactBlock);
|
|
3170
|
+
compactPrompt = compactPrompt.replace(/<upstream_context>[\s\S]*?<\/upstream_context>/, "<upstream_context>\n(full upstream prose omitted; use only the slice-specific typed facts above)\n</upstream_context>");
|
|
3171
|
+
compactPrompt = compactPrompt.replace(/Cover each frozen requirement ID exactly once:[^\n]*/, `Cover ONLY the current requirement slice: ${slice.join(", ")}. Record each slice requirement exactly once.`);
|
|
3172
|
+
const checklistPattern = /<plan_review_checklist>([\s\S]*?)<\/plan_review_checklist>/;
|
|
3173
|
+
const checklistMatch = checklistPattern.exec(compactPrompt);
|
|
3174
|
+
if (checklistMatch) {
|
|
3175
|
+
if (options.includeChecklist === false) {
|
|
3176
|
+
compactPrompt = compactPrompt.replace(checklistPattern, "");
|
|
3177
|
+
return compactPrompt;
|
|
3178
|
+
}
|
|
3179
|
+
const checklist = checklistMatch[1]
|
|
3180
|
+
.split(/\r?\n/)
|
|
3181
|
+
.filter((line) => {
|
|
3182
|
+
const requirementLine = /^Requirements requiring behavioural coverage:\s*(.*)$/.exec(line.trim());
|
|
3183
|
+
if (requirementLine) {
|
|
3184
|
+
return false;
|
|
3185
|
+
}
|
|
3186
|
+
const citationId = /^([^\s]+)\s+\+/.exec(line.trim())?.[1];
|
|
3187
|
+
return citationId === undefined || sliceSet.has(citationId);
|
|
3188
|
+
})
|
|
3189
|
+
.concat([`Requirements requiring behavioural coverage: ${slice.join(", ") || "(none)"}`])
|
|
3190
|
+
.join("\n");
|
|
3191
|
+
compactPrompt = compactPrompt.replace(checklistPattern, `<plan_review_checklist>${checklist}</plan_review_checklist>`);
|
|
3192
|
+
}
|
|
3193
|
+
return compactPrompt;
|
|
3194
|
+
}
|
|
3195
|
+
function compactFrontendPlanLedgerContext(input) {
|
|
3196
|
+
const slice = new Set(input.requirementIds);
|
|
3197
|
+
const allowed = new Set(input.kinds);
|
|
3198
|
+
const scopedKinds = new Set(input.scopedKinds ?? []);
|
|
3199
|
+
const compactFacts = [];
|
|
3200
|
+
for (const value of input.committedFacts) {
|
|
3201
|
+
const fact = committedFactFromPlanRecord(value);
|
|
3202
|
+
if (!fact || fact.origin !== "plan" || typeof fact.kind !== "string" || !allowed.has(fact.kind))
|
|
3203
|
+
continue;
|
|
3204
|
+
if (scopedKinds.has(fact.kind) && !planFactScopeIntersects(fact, slice)) {
|
|
3205
|
+
continue;
|
|
3206
|
+
}
|
|
3207
|
+
const entry = fact.entry && typeof fact.entry === "object" && !Array.isArray(fact.entry)
|
|
3208
|
+
? fact.entry
|
|
3209
|
+
: undefined;
|
|
3210
|
+
if (fact.kind === "plan-requirement") {
|
|
3211
|
+
if (!entry || typeof entry.id !== "string" || !slice.has(entry.id))
|
|
3212
|
+
continue;
|
|
3213
|
+
compactFacts.push({
|
|
3214
|
+
kind: fact.kind,
|
|
3215
|
+
entry: {
|
|
3216
|
+
id: compactPromptString(entry.id, 80),
|
|
3217
|
+
implementationTargets: compactPromptStringArray(entry.implementationTargets, 12, 180),
|
|
3218
|
+
verificationTargetIds: compactPromptStringArray(entry.verificationTargetIds, 12, 80),
|
|
3219
|
+
...(compactPromptString(entry.expectedOutcome, 240)
|
|
3220
|
+
? { expectedOutcome: compactPromptString(entry.expectedOutcome, 240) }
|
|
3221
|
+
: {}),
|
|
3222
|
+
...(entry.evidenceGap && typeof entry.evidenceGap === "object"
|
|
3223
|
+
? { evidenceGap: entry.evidenceGap }
|
|
3224
|
+
: {}),
|
|
3225
|
+
},
|
|
3226
|
+
});
|
|
3227
|
+
continue;
|
|
3228
|
+
}
|
|
3229
|
+
if (fact.kind === "plan-verification-target") {
|
|
3230
|
+
if (!entry)
|
|
3231
|
+
continue;
|
|
3232
|
+
const requirementIds = compactPromptStringArray(entry.requirementIds, 20, 80);
|
|
3233
|
+
if (!requirementIds.some((id) => slice.has(id)))
|
|
3234
|
+
continue;
|
|
3235
|
+
compactFacts.push({
|
|
3236
|
+
kind: fact.kind,
|
|
3237
|
+
entry: {
|
|
3238
|
+
id: compactPromptString(entry.id, 80),
|
|
3239
|
+
commandId: compactPromptString(entry.commandId, 80),
|
|
3240
|
+
file: compactPromptString(entry.file, 180),
|
|
3241
|
+
requirementIds,
|
|
3242
|
+
uiStates: compactPromptStringArray(entry.uiStates, 12, 100),
|
|
3243
|
+
},
|
|
3244
|
+
});
|
|
3245
|
+
continue;
|
|
3246
|
+
}
|
|
3247
|
+
if (fact.kind === "component-choice") {
|
|
3248
|
+
const choices = Array.isArray(fact.uiComponentChoices)
|
|
3249
|
+
? fact.uiComponentChoices.flatMap((choice) => {
|
|
3250
|
+
if (!choice || typeof choice !== "object" || Array.isArray(choice))
|
|
3251
|
+
return [];
|
|
3252
|
+
const item = choice;
|
|
3253
|
+
return [{
|
|
3254
|
+
purpose: compactPromptString(item.purpose, 120),
|
|
3255
|
+
component: compactPromptString(item.component, 120),
|
|
3256
|
+
decision: compactPromptString(item.decision, 40),
|
|
3257
|
+
}];
|
|
3258
|
+
}).slice(0, 24)
|
|
3259
|
+
: [];
|
|
3260
|
+
if (choices.length > 0)
|
|
3261
|
+
compactFacts.push({ kind: fact.kind, uiComponentChoices: choices });
|
|
3262
|
+
continue;
|
|
3263
|
+
}
|
|
3264
|
+
if (fact.kind === "state-flow") {
|
|
3265
|
+
compactFacts.push({
|
|
3266
|
+
kind: fact.kind,
|
|
3267
|
+
uiStates: Array.isArray(fact.uiStates) ? fact.uiStates.slice(0, 24) : [],
|
|
3268
|
+
interactions: Array.isArray(fact.interactions) ? fact.interactions.slice(0, 24) : [],
|
|
3269
|
+
removeUiStateNames: compactPromptStringArray(fact.removeUiStateNames, 24, 100),
|
|
3270
|
+
removeInteractionNames: compactPromptStringArray(fact.removeInteractionNames, 24, 100),
|
|
3271
|
+
});
|
|
3272
|
+
continue;
|
|
3273
|
+
}
|
|
3274
|
+
if (fact.kind === "data-flow") {
|
|
3275
|
+
compactFacts.push({
|
|
3276
|
+
kind: fact.kind,
|
|
3277
|
+
interactions: compactPromptStringArray(fact.interactions, 32, 120),
|
|
3278
|
+
endpoints: compactPromptStringArray(fact.endpoints, 32, 180),
|
|
3279
|
+
});
|
|
3280
|
+
continue;
|
|
3281
|
+
}
|
|
3282
|
+
if (fact.kind === "mock-api") {
|
|
3283
|
+
const mockApi = fact.mockApi && typeof fact.mockApi === "object" && !Array.isArray(fact.mockApi)
|
|
3284
|
+
? fact.mockApi
|
|
3285
|
+
: {};
|
|
3286
|
+
compactFacts.push({
|
|
3287
|
+
kind: fact.kind,
|
|
3288
|
+
mockApi: {
|
|
3289
|
+
strategy: compactPromptString(mockApi.strategy, 40),
|
|
3290
|
+
activation: compactPromptString(mockApi.activation, 180),
|
|
3291
|
+
endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints.slice(0, 24) : [],
|
|
3292
|
+
},
|
|
3293
|
+
});
|
|
3294
|
+
continue;
|
|
3295
|
+
}
|
|
3296
|
+
if (fact.kind === "design-deviation") {
|
|
3297
|
+
compactFacts.push({ kind: fact.kind, conflicts: compactPromptStringArray(fact.conflicts, 32, 180) });
|
|
3298
|
+
continue;
|
|
3299
|
+
}
|
|
3300
|
+
if (fact.kind === "dependency") {
|
|
3301
|
+
compactFacts.push({ kind: fact.kind, policy: compactPromptString(fact.policy, 300) });
|
|
3302
|
+
continue;
|
|
3303
|
+
}
|
|
3304
|
+
if (fact.kind === "target-surface") {
|
|
3305
|
+
compactFacts.push({ kind: fact.kind, routes: compactPromptStringArray(fact.routes, 24, 180) });
|
|
3306
|
+
}
|
|
3307
|
+
}
|
|
3308
|
+
if (compactFacts.length === 0)
|
|
3309
|
+
return "";
|
|
3310
|
+
const priorityFacts = compactFacts.filter((fact) => fact.kind === "plan-requirement" || fact.kind === "plan-verification-target");
|
|
3311
|
+
const otherFacts = compactFacts.filter((fact) => fact.kind !== "plan-requirement" && fact.kind !== "plan-verification-target");
|
|
3312
|
+
const boundedFacts = [
|
|
3313
|
+
...priorityFacts.slice(0, 64),
|
|
3314
|
+
...otherFacts.slice(-32),
|
|
3315
|
+
].slice(0, 96);
|
|
3316
|
+
return [
|
|
3317
|
+
"<frontend_plan_ledger>",
|
|
3318
|
+
"Committed plan facts from earlier sessions. Treat these as authoritative; correct them only with the allowed replacement/removal fields.",
|
|
3319
|
+
JSON.stringify(boundedFacts),
|
|
3320
|
+
"</frontend_plan_ledger>",
|
|
3321
|
+
].join("\n");
|
|
3322
|
+
}
|
|
3323
|
+
export async function runFrontendPlanSegmentedSessions(input) {
|
|
3324
|
+
const queue = [];
|
|
3325
|
+
const coverageSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "coverage");
|
|
3326
|
+
const uxSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-local");
|
|
3327
|
+
const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
|
|
3328
|
+
const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
|
|
3329
|
+
const allRequirementIds = input.requirementIds ?? [];
|
|
3330
|
+
const buildPhasePrompt = (segment, missing = []) => {
|
|
3331
|
+
const compact = allRequirementIds.length > 0
|
|
3332
|
+
? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
|
|
3333
|
+
includeRequirementText: segment.id === "global-mock-data" || segment.id === "global-dependency-deviation",
|
|
3334
|
+
includeVerificationTargets: false,
|
|
3335
|
+
includeDesignEvidence: segment.id === "global-dependency-deviation" || segment.id === "finalize",
|
|
3336
|
+
includeChecklist: false,
|
|
3337
|
+
})
|
|
3338
|
+
: input.basePrompt;
|
|
3339
|
+
const kinds = segment.id === "global-route"
|
|
3340
|
+
? ["target-surface"]
|
|
3341
|
+
: segment.id === "global-mock-data"
|
|
3342
|
+
? ["plan-requirement", "plan-verification-target", "state-flow", "data-flow", "mock-api"]
|
|
3343
|
+
: segment.id === "global-dependency-deviation"
|
|
3344
|
+
? ["dependency", "design-deviation"]
|
|
3345
|
+
: ["plan-requirement", "plan-verification-target", "component-choice", "state-flow", "data-flow", "mock-api", "design-deviation", "dependency", "target-surface"];
|
|
3346
|
+
const ledger = input.committedFacts
|
|
3347
|
+
? compactFrontendPlanLedgerContext({
|
|
3348
|
+
committedFacts: input.committedFacts(),
|
|
3349
|
+
requirementIds: allRequirementIds,
|
|
3350
|
+
kinds,
|
|
3351
|
+
})
|
|
3352
|
+
: "";
|
|
3353
|
+
return [
|
|
3354
|
+
compact,
|
|
3355
|
+
segment.instruction,
|
|
3356
|
+
ledger,
|
|
3357
|
+
...(missing.length > 0
|
|
3358
|
+
? [
|
|
3359
|
+
"MISSING-FACT QUEUE: repair ONLY these items, then re-check the phase:",
|
|
3360
|
+
...missing.map((item) => `- ${item.kind}${item.id ? ` ${item.id}` : ""} for ${item.requirementIds.join(", ")}: ${item.reason}`),
|
|
3361
|
+
]
|
|
3362
|
+
: []),
|
|
3363
|
+
].filter(Boolean).join("\n\n");
|
|
3364
|
+
};
|
|
3365
|
+
const buildCoveragePrompt = (slice, missing = []) => [
|
|
3366
|
+
compactFrontendPlanPromptForRequirementSlice(input.basePrompt, slice),
|
|
3367
|
+
coverageSegment.instruction,
|
|
3368
|
+
`COVERAGE BATCH: process ONLY these requirements in this session: ${slice.join(", ")}. Other requirements are handled by separate sessions; do not record them.`,
|
|
3369
|
+
...(missing.length > 0
|
|
3370
|
+
? [
|
|
3371
|
+
"MISSING-FACT QUEUE: the previous session did not establish complete coverage. Repair ONLY these items, then re-check the slice:",
|
|
3372
|
+
...missing.map((item) => `- ${item.kind}${item.id ? ` ${item.id}` : ""} for ${item.requirementIds.join(", ")}: ${item.reason}`),
|
|
3373
|
+
]
|
|
3374
|
+
: []),
|
|
3375
|
+
].join("\n\n");
|
|
3376
|
+
const buildUxPrompt = (slice, missing = []) => {
|
|
3377
|
+
const ledger = input.committedFacts
|
|
3378
|
+
? compactFrontendPlanLedgerContext({
|
|
3379
|
+
committedFacts: input.committedFacts(),
|
|
3380
|
+
requirementIds: slice,
|
|
3381
|
+
kinds: ["plan-requirement", "plan-verification-target", "component-choice", "state-flow"],
|
|
3382
|
+
scopedKinds: ["component-choice", "state-flow"],
|
|
3383
|
+
})
|
|
3384
|
+
: "";
|
|
3385
|
+
return [
|
|
3386
|
+
compactFrontendPlanPromptForRequirementSlice(input.basePrompt, slice),
|
|
3387
|
+
uxSegment.instruction,
|
|
3388
|
+
`UX LOCAL BATCH: process ONLY these requirements: ${slice.join(", ")}.`,
|
|
3389
|
+
ledger,
|
|
3390
|
+
...(missing.length > 0
|
|
3391
|
+
? [
|
|
3392
|
+
"MISSING-FACT QUEUE: repair ONLY these items, then re-check this UX slice:",
|
|
3393
|
+
...missing.map((item) => `- ${item.kind}${item.id ? ` ${item.id}` : ""} for ${item.requirementIds.join(", ")}: ${item.reason}`),
|
|
3394
|
+
]
|
|
3395
|
+
: []),
|
|
3396
|
+
].filter(Boolean).join("\n\n");
|
|
3397
|
+
};
|
|
3398
|
+
const buildCompactLocalPrompt = (missing = []) => {
|
|
3399
|
+
const ledger = input.committedFacts
|
|
3400
|
+
? compactFrontendPlanLedgerContext({
|
|
3401
|
+
committedFacts: input.committedFacts(),
|
|
3402
|
+
requirementIds: allRequirementIds,
|
|
3403
|
+
kinds: [
|
|
3404
|
+
"plan-requirement",
|
|
3405
|
+
"plan-verification-target",
|
|
3406
|
+
"component-choice",
|
|
3407
|
+
"state-flow",
|
|
3408
|
+
],
|
|
3409
|
+
})
|
|
3410
|
+
: "";
|
|
3411
|
+
return [
|
|
3412
|
+
compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds),
|
|
3413
|
+
"PLAN PHASE — compact local planning for a small frontend request.",
|
|
3414
|
+
"For every listed requirement, record plan-requirement and verification-target facts, then record the component-choice and state-flow facts needed by the observable UX. Do not read the repository or task source; use only the committed input above. Do not call finalize_plan in this session.",
|
|
3415
|
+
"TOOL-FIRST: your first assistant actions must be record_* tool calls, at most 2-3 facts per message. Do not draft the whole analysis before recording; if a fact is uncertain, record it with an evidence gap instead of reasoning longer.",
|
|
3416
|
+
ledger,
|
|
3417
|
+
...(missing.length > 0
|
|
3418
|
+
? [
|
|
3419
|
+
"MISSING-FACT QUEUE: repair ONLY these items, then re-check the compact local phase:",
|
|
3420
|
+
...missing.map((item) => `- ${item.kind}${item.id ? ` ${item.id}` : ""} for ${item.requirementIds.join(", ")}: ${item.reason}`),
|
|
3421
|
+
]
|
|
3422
|
+
: []),
|
|
3423
|
+
].filter(Boolean).join("\n\n");
|
|
3424
|
+
};
|
|
3425
|
+
const compactFinalizeInstruction = "This is a small-request compact pass. Reconcile the committed local facts with route, data-flow, Mock/API, dependency and deviation policy, then call finalize_plan exactly once.";
|
|
3426
|
+
const buildCompactFinalizePrompt = (missing = []) => [buildPhasePrompt(finalizeSegment, missing), compactFinalizeInstruction].join("\n\n");
|
|
3427
|
+
const mapPlannerExhaustion = (r, committedAnyFacts) => isPlannerThinkingExhausted(r, committedAnyFacts)
|
|
3428
|
+
? {
|
|
3429
|
+
...r,
|
|
3430
|
+
failureCategory: PLANNER_THINKING_EXHAUSTED_CATEGORY,
|
|
3431
|
+
stderr: `${r.stderr}\n${PLANNER_THINKING_EXHAUSTED_CATEGORY}: stopReason=length, thinking observed, 0 typed facts committed; the batch ladder degraded the scope without converging — set thinking=off for this tier or switch to a non-thinking model`.trim(),
|
|
3432
|
+
}
|
|
3433
|
+
: r;
|
|
3434
|
+
// An empty list means "ledger unreadable / unknown" and falls back to one
|
|
3435
|
+
// unscoped coverage session; a non-empty list enables deterministic sharding.
|
|
3436
|
+
const requirementIdsProvided = input.requirementIds !== undefined && input.requirementIds.length > 0;
|
|
3437
|
+
const pending = (input.requirementIds ?? []).filter((id) => !input.committedRequirementIds?.().has(id));
|
|
3438
|
+
const initialMissing = requirementIdsProvided && input.committedFacts
|
|
3439
|
+
? collectFrontendPlanMissingFacts({
|
|
3440
|
+
requirementIds: input.requirementIds,
|
|
3441
|
+
committedFacts: input.committedFacts(),
|
|
3442
|
+
})
|
|
3443
|
+
: [];
|
|
3444
|
+
const incompleteRequirementIds = new Set(initialMissing.flatMap((item) => item.requirementIds));
|
|
3445
|
+
const coverageWorkIds = (input.requirementIds ?? []).filter((id) => pending.includes(id) || incompleteRequirementIds.has(id));
|
|
3446
|
+
const estimatedCalls = (input.requirementIds ?? []).reduce((total, id) => total + Math.max(1, input.requirementCosts?.get(id) ?? 2), 0);
|
|
3447
|
+
const targetSurfaceCount = countFrontendPlanTargetSurfaces(input.basePrompt);
|
|
3448
|
+
// Small, single-surface requests do not benefit from six isolated Pi
|
|
3449
|
+
// sessions. Keep the typed ledger as the authority, but let one local
|
|
3450
|
+
// session establish requirement/UX facts and one final session establish
|
|
3451
|
+
// cross-cutting policy + finalize. The old sharded ladder remains available
|
|
3452
|
+
// for larger plans and for the unscoped compatibility path.
|
|
3453
|
+
const useCompactSmallPlan = requirementIdsProvided &&
|
|
3454
|
+
input.compactSmallPlan === true &&
|
|
3455
|
+
input.requirementCosts !== undefined &&
|
|
3456
|
+
estimatedCalls > 12 &&
|
|
3457
|
+
(input.requirementIds?.length ?? 0) <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
|
|
3458
|
+
estimatedCalls <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
|
|
3459
|
+
targetSurfaceCount === 1;
|
|
3460
|
+
if (useCompactSmallPlan) {
|
|
3461
|
+
const compactLocalTools = new Set([
|
|
3462
|
+
"record_plan_requirement",
|
|
3463
|
+
"record_plan_verification_target",
|
|
3464
|
+
"record_plan_evidence_gap",
|
|
3465
|
+
"record_component_choice",
|
|
3466
|
+
"record_state_flow",
|
|
3467
|
+
"adopt_staged_fact",
|
|
3468
|
+
]);
|
|
3469
|
+
queue.push({
|
|
3470
|
+
id: "compact-local",
|
|
3471
|
+
toolNames: compactLocalTools,
|
|
3472
|
+
requirementSlice: [...allRequirementIds],
|
|
3473
|
+
prompt: buildCompactLocalPrompt(),
|
|
3474
|
+
});
|
|
3475
|
+
queue.push({
|
|
3476
|
+
id: "finalize",
|
|
3477
|
+
toolNames: null,
|
|
3478
|
+
prompt: buildCompactFinalizePrompt(),
|
|
3479
|
+
});
|
|
3480
|
+
}
|
|
3481
|
+
else if (!requirementIdsProvided) {
|
|
3482
|
+
queue.push({
|
|
3483
|
+
id: "coverage",
|
|
3484
|
+
toolNames: coverageSegment.toolNames,
|
|
3485
|
+
prompt: `${input.basePrompt}\n\n${coverageSegment.instruction}`,
|
|
3486
|
+
});
|
|
3487
|
+
}
|
|
3488
|
+
else if (coverageWorkIds.length > 0) {
|
|
3489
|
+
const coverageBatches = batchFrontendPlanRequirements({
|
|
3490
|
+
requirementIds: coverageWorkIds,
|
|
3491
|
+
maxEstimatedRecordCalls: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
|
|
3492
|
+
maxRequirements: 4,
|
|
3493
|
+
requirementCosts: input.requirementCosts,
|
|
3494
|
+
});
|
|
3495
|
+
coverageBatches.forEach((slice, batchIndex) => queue.push({
|
|
3496
|
+
id: `coverage-batch-${batchIndex + 1}`,
|
|
3497
|
+
toolNames: coverageSegment.toolNames,
|
|
3498
|
+
coverageSlice: slice,
|
|
3499
|
+
coverageOnly: true,
|
|
3500
|
+
prompt: buildCoveragePrompt(slice),
|
|
3501
|
+
}));
|
|
3502
|
+
}
|
|
3503
|
+
if (useCompactSmallPlan) {
|
|
3504
|
+
// Compact mode already queued both sessions above.
|
|
3505
|
+
}
|
|
3506
|
+
else {
|
|
3507
|
+
const uxCosts = new Map((input.requirementIds ?? []).map((id) => [
|
|
3508
|
+
id,
|
|
3509
|
+
Math.max(2, Math.min(3, input.requirementCosts?.get(id) ?? 2)),
|
|
3510
|
+
]));
|
|
3511
|
+
const uxSlices = requirementIdsProvided
|
|
3512
|
+
? batchFrontendPlanRequirements({
|
|
3513
|
+
requirementIds: input.requirementIds,
|
|
3514
|
+
maxEstimatedRecordCalls: FRONTEND_PLAN_UX_LOCAL_MAX_RECORD_CALLS,
|
|
3515
|
+
maxRequirements: 3,
|
|
3516
|
+
requirementCosts: uxCosts,
|
|
3517
|
+
})
|
|
3518
|
+
: [[]];
|
|
3519
|
+
uxSlices.forEach((slice, batchIndex) => queue.push({
|
|
3520
|
+
id: `ux-local-${batchIndex + 1}`,
|
|
3521
|
+
toolNames: uxSegment.toolNames,
|
|
3522
|
+
...(slice.length > 0 ? { requirementSlice: slice } : {}),
|
|
3523
|
+
prompt: slice.length > 0
|
|
3524
|
+
? buildUxPrompt(slice)
|
|
3525
|
+
: buildPhasePrompt(uxSegment),
|
|
3526
|
+
}));
|
|
3527
|
+
for (const segment of FRONTEND_PLAN_SEGMENTS) {
|
|
3528
|
+
if (["coverage", "ux-local", "finalize"].includes(segment.id))
|
|
3529
|
+
continue;
|
|
3530
|
+
queue.push({
|
|
3531
|
+
id: segment.id,
|
|
3532
|
+
toolNames: segment.toolNames,
|
|
3533
|
+
prompt: buildPhasePrompt(segment),
|
|
3534
|
+
});
|
|
3535
|
+
}
|
|
3536
|
+
queue.push({
|
|
3537
|
+
id: finalizeSegment.id,
|
|
3538
|
+
toolNames: finalizeSegment.toolNames,
|
|
3539
|
+
prompt: buildPhasePrompt(finalizeSegment),
|
|
3540
|
+
});
|
|
3541
|
+
}
|
|
3542
|
+
let last;
|
|
3543
|
+
let index = 0;
|
|
3544
|
+
let invocationCount = 0;
|
|
3545
|
+
while (index < queue.length) {
|
|
3546
|
+
const session = queue[index];
|
|
3547
|
+
const remaining = (session.coverageOnly ? session.coverageSlice ?? [] : []).filter((id) => !input.committedRequirementIds?.().has(id));
|
|
3548
|
+
const preexistingMissing = session.coverageOnly && session.coverageSlice && input.committedFacts
|
|
3549
|
+
? collectFrontendPlanMissingFacts({
|
|
3550
|
+
requirementIds: session.coverageSlice,
|
|
3551
|
+
committedFacts: input.committedFacts(),
|
|
3552
|
+
})
|
|
3553
|
+
: [];
|
|
3554
|
+
// Resume/earlier-batch commits may already cover this slice.
|
|
3555
|
+
if (session.coverageOnly &&
|
|
3556
|
+
session.coverageSlice &&
|
|
3557
|
+
remaining.length === 0 &&
|
|
3558
|
+
preexistingMissing.length === 0) {
|
|
3559
|
+
index += 1;
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
let prompt = session.prompt;
|
|
3563
|
+
if (session.coverageOnly && session.coverageSlice) {
|
|
3564
|
+
const promptSlice = remaining.length > 0 ? remaining : session.coverageSlice;
|
|
3565
|
+
prompt = buildCoveragePrompt(promptSlice, session.missingFacts ?? preexistingMissing);
|
|
3566
|
+
}
|
|
3567
|
+
else if (session.id === "compact-local") {
|
|
3568
|
+
prompt = buildCompactLocalPrompt(session.missingFacts);
|
|
3569
|
+
}
|
|
3570
|
+
else if (session.id.startsWith("ux-local-")) {
|
|
3571
|
+
// Build this at execution time: coverage facts are committed by the
|
|
3572
|
+
// preceding sessions and must be visible to the UX-local model.
|
|
3573
|
+
prompt = session.requirementSlice
|
|
3574
|
+
? buildUxPrompt(session.requirementSlice, session.missingFacts)
|
|
3575
|
+
: buildPhasePrompt(uxSegment, session.missingFacts);
|
|
3576
|
+
}
|
|
3577
|
+
else {
|
|
3578
|
+
// Global phases and finalize also consume the latest committed ledger;
|
|
3579
|
+
// constructing their prompt only when the session starts prevents a
|
|
3580
|
+
// stale queue entry from dropping facts written by earlier phases.
|
|
3581
|
+
const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id);
|
|
3582
|
+
if (segment) {
|
|
3583
|
+
prompt =
|
|
3584
|
+
session.id === "finalize" && useCompactSmallPlan
|
|
3585
|
+
? buildCompactFinalizePrompt(session.missingFacts)
|
|
3586
|
+
: buildPhasePrompt(segment, session.missingFacts);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
const committedBefore = input.committedFactCount();
|
|
3590
|
+
input.setActiveRequirementScope?.(session.id === "compact-local" || session.id.startsWith("ux-local-")
|
|
3591
|
+
? session.requirementSlice ?? []
|
|
3592
|
+
: []);
|
|
3593
|
+
if (invocationCount >= FRONTEND_PLAN_BATCH_MAX_SESSIONS)
|
|
3594
|
+
break;
|
|
3595
|
+
invocationCount += 1;
|
|
3596
|
+
const customTools = input.segmentCustomTools(session.toolNames);
|
|
3597
|
+
const result = await input.piStepFn({
|
|
3598
|
+
...input.sessionOptions,
|
|
3599
|
+
prompt,
|
|
3600
|
+
...(customTools.length > 0
|
|
3601
|
+
? {
|
|
3602
|
+
writerToolPolicy: {
|
|
3603
|
+
requireSdk: true,
|
|
3604
|
+
customTools,
|
|
3605
|
+
},
|
|
3606
|
+
}
|
|
3607
|
+
: {}),
|
|
3608
|
+
});
|
|
3609
|
+
last = result;
|
|
3610
|
+
try {
|
|
3611
|
+
await input.flushLedger();
|
|
3612
|
+
}
|
|
3613
|
+
catch {
|
|
3614
|
+
// best-effort: the node-level flush runs again after the attempt
|
|
3615
|
+
}
|
|
3616
|
+
const committedAfter = input.committedFactCount();
|
|
3617
|
+
const committedFactsOnlySuccess = session.id !== "finalize" &&
|
|
3618
|
+
!(result.assistantText ?? "").trim() &&
|
|
3619
|
+
!result.stderr.trim() &&
|
|
3620
|
+
!result.timedOut &&
|
|
3621
|
+
committedAfter > committedBefore;
|
|
3622
|
+
const missingCoverage = session.coverageOnly && session.coverageSlice && input.committedFacts
|
|
3623
|
+
? collectFrontendPlanMissingFacts({
|
|
3624
|
+
requirementIds: session.coverageSlice,
|
|
3625
|
+
committedFacts: input.committedFacts(),
|
|
3626
|
+
})
|
|
3627
|
+
: [];
|
|
3628
|
+
const isCompactLocalSession = session.id === "compact-local";
|
|
3629
|
+
const isUxLocalSession = session.id.startsWith("ux-local-");
|
|
3630
|
+
const missingPhase = isCompactLocalSession && input.committedFacts
|
|
3631
|
+
? [
|
|
3632
|
+
...collectFrontendPlanMissingFacts({
|
|
3633
|
+
requirementIds: allRequirementIds,
|
|
3634
|
+
committedFacts: input.committedFacts(),
|
|
3635
|
+
}),
|
|
3636
|
+
...collectFrontendPlanPhaseMissingFacts({
|
|
3637
|
+
phase: "ux-local",
|
|
3638
|
+
requirementIds: allRequirementIds,
|
|
3639
|
+
committedFacts: input.committedFacts(),
|
|
3640
|
+
behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
|
|
3641
|
+
}),
|
|
3642
|
+
]
|
|
3643
|
+
: isUxLocalSession && session.requirementSlice && input.committedFacts
|
|
3644
|
+
? collectFrontendPlanPhaseMissingFacts({
|
|
3645
|
+
phase: "ux-local",
|
|
3646
|
+
requirementIds: session.requirementSlice,
|
|
3647
|
+
committedFacts: input.committedFacts(),
|
|
3648
|
+
behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
|
|
3649
|
+
})
|
|
3650
|
+
: session.id === "global-mock-data" && allRequirementIds.length > 0 && input.committedFacts
|
|
3651
|
+
? collectFrontendPlanPhaseMissingFacts({
|
|
3652
|
+
phase: "global-mock-data",
|
|
3653
|
+
requirementIds: allRequirementIds,
|
|
3654
|
+
committedFacts: input.committedFacts(),
|
|
3655
|
+
})
|
|
3656
|
+
: [];
|
|
3657
|
+
const missingPhaseFacts = [...missingCoverage, ...missingPhase];
|
|
3658
|
+
// Frozen verification commands must operate on files the planner has
|
|
3659
|
+
// committed verification targets for; otherwise the admission writeSet
|
|
3660
|
+
// cannot authorize the file and verify-shell deterministically fails.
|
|
3661
|
+
const verificationCommandFiles = collectFrontendVerificationCommandFiles(input.basePrompt);
|
|
3662
|
+
if (verificationCommandFiles.length > 0 && allRequirementIds.length > 0 && input.committedFacts) {
|
|
3663
|
+
const coveredVerificationFiles = new Set(input
|
|
3664
|
+
.committedFacts()
|
|
3665
|
+
.map(committedFactFromPlanRecord)
|
|
3666
|
+
.filter((fact) => Boolean(fact && fact.origin === "plan" && fact.kind === "plan-verification-target"))
|
|
3667
|
+
.map((fact) => fact.entry?.file)
|
|
3668
|
+
.filter((file) => typeof file === "string"));
|
|
3669
|
+
for (const file of verificationCommandFiles) {
|
|
3670
|
+
if (coveredVerificationFiles.has(file))
|
|
3671
|
+
continue;
|
|
3672
|
+
missingPhaseFacts.push({
|
|
3673
|
+
kind: "plan-verification-target",
|
|
3674
|
+
id: file,
|
|
3675
|
+
requirementIds: allRequirementIds.slice(0, 1),
|
|
3676
|
+
reason: `frozen verification command references ${file} but no committed verification target covers it; record a static verification target for this file so it joins the writeSet`,
|
|
3677
|
+
});
|
|
3678
|
+
}
|
|
3679
|
+
}
|
|
3680
|
+
const promptForMissingPhase = (missing) => session.coverageOnly
|
|
3681
|
+
? buildCoveragePrompt(session.coverageSlice ?? [], missing)
|
|
3682
|
+
: isCompactLocalSession
|
|
3683
|
+
? buildCompactLocalPrompt(missing)
|
|
3684
|
+
: session.id === "finalize" && useCompactSmallPlan
|
|
3685
|
+
? buildCompactFinalizePrompt(missing)
|
|
3686
|
+
: isUxLocalSession
|
|
3687
|
+
? buildUxPrompt(session.requirementSlice ?? [], missing)
|
|
3688
|
+
: buildPhasePrompt(globalMockDataSegment, missing);
|
|
3689
|
+
if (result.ok) {
|
|
3690
|
+
if (missingPhaseFacts.length === 0) {
|
|
3691
|
+
index += 1;
|
|
3692
|
+
continue;
|
|
3693
|
+
}
|
|
3694
|
+
if ((session.retryCount ?? 0) < 2) {
|
|
3695
|
+
queue[index] = {
|
|
3696
|
+
...session,
|
|
3697
|
+
retryCount: (session.retryCount ?? 0) + 1,
|
|
3698
|
+
missingFacts: missingPhaseFacts,
|
|
3699
|
+
prompt: promptForMissingPhase(missingPhaseFacts),
|
|
3700
|
+
};
|
|
3701
|
+
continue;
|
|
3702
|
+
}
|
|
3703
|
+
return {
|
|
3704
|
+
...result,
|
|
3705
|
+
ok: false,
|
|
3706
|
+
stderr: `${result.stderr}\nfrontend plan completeness check failed: ${missingPhaseFacts.map((item) => item.reason).join("; ")}`.trim(),
|
|
3707
|
+
failureCategory: "invalid-output",
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
if (committedFactsOnlySuccess) {
|
|
3711
|
+
// The session died but banked facts: keep the progress and move on.
|
|
3712
|
+
if (missingPhaseFacts.length > 0 && (session.retryCount ?? 0) < 2) {
|
|
3713
|
+
queue[index] = {
|
|
3714
|
+
...session,
|
|
3715
|
+
retryCount: (session.retryCount ?? 0) + 1,
|
|
3716
|
+
missingFacts: missingPhaseFacts,
|
|
3717
|
+
prompt: promptForMissingPhase(missingPhaseFacts),
|
|
3718
|
+
};
|
|
3719
|
+
continue;
|
|
3720
|
+
}
|
|
3721
|
+
if (missingPhaseFacts.length > 0) {
|
|
3722
|
+
return {
|
|
3723
|
+
...result,
|
|
3724
|
+
ok: false,
|
|
3725
|
+
stderr: `frontend plan completeness check failed: ${missingPhaseFacts.map((item) => item.reason).join("; ")}`,
|
|
3726
|
+
failureCategory: "invalid-output",
|
|
3727
|
+
};
|
|
3728
|
+
}
|
|
3729
|
+
index += 1;
|
|
3730
|
+
continue;
|
|
3731
|
+
}
|
|
3732
|
+
// A length-stopped, fact-less compact session is the planner variant of
|
|
3733
|
+
// writer-thinking-exhausted. Give the same scope exactly one tool-first
|
|
3734
|
+
// retry before the split below re-batches the requirements, because one
|
|
3735
|
+
// reinforced full-scope pass is cheaper than re-planning split halves.
|
|
3736
|
+
const plannerThinkingBurn = isCompactLocalSession &&
|
|
3737
|
+
committedAfter === committedBefore &&
|
|
3738
|
+
!(result.assistantText ?? "").trim() &&
|
|
3739
|
+
!result.stderr.trim() &&
|
|
3740
|
+
!result.timedOut &&
|
|
3741
|
+
readWriterThinkingExhaustionEvidence(result).stopReason === "length";
|
|
3742
|
+
if (plannerThinkingBurn && (session.retryCount ?? 0) < 1) {
|
|
3743
|
+
queue[index] = {
|
|
3744
|
+
...session,
|
|
3745
|
+
retryCount: (session.retryCount ?? 0) + 1,
|
|
3746
|
+
prompt: buildCompactLocalPrompt(),
|
|
3747
|
+
};
|
|
3748
|
+
continue;
|
|
3749
|
+
}
|
|
3750
|
+
// Option 5: a multi-requirement coverage batch that failed with ZERO
|
|
3751
|
+
// new facts and no provider stderr is the upfront-reasoning burn —
|
|
3752
|
+
// halve the slice and retry instead of failing the attempt. The compact
|
|
3753
|
+
// local session participates through its requirementSlice so a
|
|
3754
|
+
// thinking-burned small plan degrades into smaller batched sessions
|
|
3755
|
+
// instead of replaying one full-scope prompt until the repair budget
|
|
3756
|
+
// runs out.
|
|
3757
|
+
const coverageSlice = session.coverageOnly
|
|
3758
|
+
? session.coverageSlice
|
|
3759
|
+
: isCompactLocalSession
|
|
3760
|
+
? session.requirementSlice
|
|
3761
|
+
: undefined;
|
|
3762
|
+
const zeroProgressBurn = coverageSlice !== undefined &&
|
|
3763
|
+
coverageSlice.length > 1 &&
|
|
3764
|
+
committedAfter === committedBefore &&
|
|
3765
|
+
!(result.assistantText ?? "").trim() &&
|
|
3766
|
+
!result.stderr.trim() &&
|
|
3767
|
+
!result.timedOut;
|
|
3768
|
+
if (zeroProgressBurn && coverageSlice) {
|
|
3769
|
+
const half = Math.ceil(coverageSlice.length / 2);
|
|
3770
|
+
const firstSlice = coverageSlice.slice(0, half);
|
|
3771
|
+
const secondSlice = coverageSlice.slice(half);
|
|
3772
|
+
if (isCompactLocalSession) {
|
|
3773
|
+
// The split halves leave compact mode: continue them as ordinary
|
|
3774
|
+
// coverage sessions so every downstream ladder branch applies.
|
|
3775
|
+
queue.splice(index, 1, {
|
|
3776
|
+
...session,
|
|
3777
|
+
id: `coverage-compact-split-1`,
|
|
3778
|
+
coverageOnly: true,
|
|
3779
|
+
coverageSlice: firstSlice,
|
|
3780
|
+
requirementSlice: firstSlice,
|
|
3781
|
+
prompt: buildCoveragePrompt(firstSlice),
|
|
3782
|
+
}, {
|
|
3783
|
+
...session,
|
|
3784
|
+
id: `coverage-compact-split-2`,
|
|
3785
|
+
coverageOnly: true,
|
|
3786
|
+
coverageSlice: secondSlice,
|
|
3787
|
+
requirementSlice: secondSlice,
|
|
3788
|
+
prompt: buildCoveragePrompt(secondSlice),
|
|
3789
|
+
});
|
|
3790
|
+
continue;
|
|
3791
|
+
}
|
|
3792
|
+
queue.splice(index, 1, {
|
|
3793
|
+
...session,
|
|
3794
|
+
coverageSlice: firstSlice,
|
|
3795
|
+
prompt: buildCoveragePrompt(firstSlice),
|
|
3796
|
+
}, {
|
|
3797
|
+
...session,
|
|
3798
|
+
coverageSlice: secondSlice,
|
|
3799
|
+
prompt: buildCoveragePrompt(secondSlice),
|
|
3800
|
+
});
|
|
3801
|
+
continue;
|
|
3802
|
+
}
|
|
3803
|
+
if ((session.coverageOnly || isUxLocalSession || session.id === "global-mock-data") &&
|
|
3804
|
+
missingPhaseFacts.length > 0 &&
|
|
3805
|
+
!result.stderr.trim() &&
|
|
3806
|
+
!result.timedOut &&
|
|
3807
|
+
(session.retryCount ?? 0) < 2) {
|
|
3808
|
+
queue[index] = {
|
|
3809
|
+
...session,
|
|
3810
|
+
retryCount: (session.retryCount ?? 0) + 1,
|
|
3811
|
+
missingFacts: missingPhaseFacts,
|
|
3812
|
+
prompt: promptForMissingPhase(missingPhaseFacts),
|
|
3813
|
+
};
|
|
3814
|
+
continue;
|
|
3815
|
+
}
|
|
3816
|
+
return mapPlannerExhaustion(result, committedAfter > committedBefore);
|
|
3817
|
+
}
|
|
3818
|
+
if (index < queue.length) {
|
|
3819
|
+
return {
|
|
3820
|
+
...(last ?? {
|
|
3821
|
+
ok: false,
|
|
3822
|
+
stdout: "",
|
|
3823
|
+
stderr: "",
|
|
3824
|
+
assistantText: "",
|
|
3825
|
+
command: [],
|
|
3826
|
+
durationMs: 0,
|
|
3827
|
+
exitCode: null,
|
|
3828
|
+
failureCategory: "invalid-output",
|
|
3829
|
+
modelDisplay: "unknown",
|
|
3830
|
+
parsedEvents: 0,
|
|
3831
|
+
timedOut: false,
|
|
3832
|
+
attemptedModels: [],
|
|
3833
|
+
fallbackUsed: false,
|
|
3834
|
+
tokensUsed: 0,
|
|
3835
|
+
}),
|
|
3836
|
+
ok: false,
|
|
3837
|
+
stderr: `${last?.stderr ?? ""}\nfrontend plan segmentation exceeded the ${FRONTEND_PLAN_BATCH_MAX_SESSIONS}-session safety limit before finalize`.trim(),
|
|
3838
|
+
failureCategory: "invalid-output",
|
|
3839
|
+
};
|
|
419
3840
|
}
|
|
420
|
-
return
|
|
3841
|
+
return mapPlannerExhaustion(last ?? {
|
|
3842
|
+
ok: false,
|
|
3843
|
+
stdout: "",
|
|
3844
|
+
stderr: "frontend plan segmentation produced no session",
|
|
3845
|
+
failureCategory: "empty-output",
|
|
3846
|
+
durationMs: 0,
|
|
3847
|
+
}, false);
|
|
421
3848
|
}
|
|
422
|
-
const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
|
|
423
|
-
readGitStatusPorcelain,
|
|
424
|
-
recoverRootNulArtifact,
|
|
425
|
-
};
|
|
426
3849
|
export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
|
|
427
3850
|
const started = Date.now();
|
|
428
3851
|
const persona = resolveDagPiPersona(input.task);
|
|
@@ -512,6 +3935,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
512
3935
|
}
|
|
513
3936
|
: undefined;
|
|
514
3937
|
let writerToolPolicy;
|
|
3938
|
+
let reviewTerminalTools;
|
|
3939
|
+
let designTerminalTools;
|
|
3940
|
+
let planLedgerTools;
|
|
3941
|
+
let contractTools;
|
|
3942
|
+
let scoutEvidenceTools;
|
|
3943
|
+
let readBudgetTools;
|
|
515
3944
|
const commandPolicy = resolveDagCommandPolicy(input.task.commandPolicy);
|
|
516
3945
|
const allowsPlaywrightCli = dagCommandPolicyAllows(input.task.commandPolicy, "playwright-cli");
|
|
517
3946
|
let playwrightToolContext;
|
|
@@ -595,6 +4024,167 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
595
4024
|
};
|
|
596
4025
|
}
|
|
597
4026
|
}
|
|
4027
|
+
if (isFrontendReviewTypedTerminalNode(input.task)) {
|
|
4028
|
+
try {
|
|
4029
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4030
|
+
const store = createTypedEventStore();
|
|
4031
|
+
reviewTerminalTools = await createFrontendReviewTerminalTools({
|
|
4032
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
4033
|
+
store,
|
|
4034
|
+
runDir: meta.runDir,
|
|
4035
|
+
nodeId: input.task.id,
|
|
4036
|
+
});
|
|
4037
|
+
writerToolPolicy = {
|
|
4038
|
+
requireSdk: true,
|
|
4039
|
+
customTools: reviewTerminalTools.customTools,
|
|
4040
|
+
};
|
|
4041
|
+
}
|
|
4042
|
+
catch (error) {
|
|
4043
|
+
return {
|
|
4044
|
+
ok: false,
|
|
4045
|
+
stdout: "",
|
|
4046
|
+
stderr: `pi review terminal tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
4047
|
+
failureCategory: "tool-policy",
|
|
4048
|
+
durationMs: Date.now() - started,
|
|
4049
|
+
};
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
if (isFrontendDesignTypedTerminalNode(input.task)) {
|
|
4053
|
+
try {
|
|
4054
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4055
|
+
const store = createTypedEventStore();
|
|
4056
|
+
designTerminalTools = await createFrontendDesignTerminalTools({
|
|
4057
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
4058
|
+
store,
|
|
4059
|
+
runDir: meta.runDir,
|
|
4060
|
+
nodeId: input.task.id,
|
|
4061
|
+
});
|
|
4062
|
+
writerToolPolicy = {
|
|
4063
|
+
requireSdk: true,
|
|
4064
|
+
customTools: designTerminalTools.customTools,
|
|
4065
|
+
};
|
|
4066
|
+
}
|
|
4067
|
+
catch (error) {
|
|
4068
|
+
return {
|
|
4069
|
+
ok: false,
|
|
4070
|
+
stdout: "",
|
|
4071
|
+
stderr: `pi design terminal tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
4072
|
+
failureCategory: "tool-policy",
|
|
4073
|
+
durationMs: Date.now() - started,
|
|
4074
|
+
};
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
if (isFrontendContractTypedNode(input.task)) {
|
|
4078
|
+
try {
|
|
4079
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4080
|
+
const store = createTypedEventStore();
|
|
4081
|
+
contractTools = await createFrontendContractTools({
|
|
4082
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
4083
|
+
store,
|
|
4084
|
+
runDir: meta.runDir,
|
|
4085
|
+
nodeId: input.task.id,
|
|
4086
|
+
});
|
|
4087
|
+
writerToolPolicy = {
|
|
4088
|
+
requireSdk: true,
|
|
4089
|
+
customTools: contractTools.customTools,
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
4092
|
+
catch (error) {
|
|
4093
|
+
return {
|
|
4094
|
+
ok: false,
|
|
4095
|
+
stdout: "",
|
|
4096
|
+
stderr: `pi contract tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
4097
|
+
failureCategory: "tool-policy",
|
|
4098
|
+
durationMs: Date.now() - started,
|
|
4099
|
+
};
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
if (isFrontendScoutEvidenceNode(input.task)) {
|
|
4103
|
+
try {
|
|
4104
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4105
|
+
const store = createTypedEventStore();
|
|
4106
|
+
scoutEvidenceTools = await createFrontendScoutEvidenceTools({
|
|
4107
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
4108
|
+
store,
|
|
4109
|
+
runDir: meta.runDir,
|
|
4110
|
+
nodeId: input.task.id,
|
|
4111
|
+
workspaceRoot: input.cwd,
|
|
4112
|
+
sourceDeclaredPaths: await resolveFrontendScoutSourceDeclaredPaths({
|
|
4113
|
+
cwd: input.cwd,
|
|
4114
|
+
spec: meta.spec,
|
|
4115
|
+
}),
|
|
4116
|
+
});
|
|
4117
|
+
writerToolPolicy = {
|
|
4118
|
+
requireSdk: true,
|
|
4119
|
+
customTools: scoutEvidenceTools.customTools,
|
|
4120
|
+
};
|
|
4121
|
+
}
|
|
4122
|
+
catch (error) {
|
|
4123
|
+
return {
|
|
4124
|
+
ok: false,
|
|
4125
|
+
stdout: "",
|
|
4126
|
+
stderr: `pi scout evidence tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
4127
|
+
failureCategory: "tool-policy",
|
|
4128
|
+
durationMs: Date.now() - started,
|
|
4129
|
+
};
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
if (isFrontendPlanLedgerNode(input.task)) {
|
|
4133
|
+
try {
|
|
4134
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
4135
|
+
const store = createTypedEventStore();
|
|
4136
|
+
planLedgerTools = await createFrontendPlanLedgerTools({
|
|
4137
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
4138
|
+
store,
|
|
4139
|
+
runDir: meta.runDir,
|
|
4140
|
+
nodeId: input.task.id,
|
|
4141
|
+
skeleton: input.task.structuredContractOutput?.skeleton,
|
|
4142
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
4143
|
+
writeSetPatterns: input.task.writeSet,
|
|
4144
|
+
componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
|
|
4145
|
+
cwd: input.cwd,
|
|
4146
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
4147
|
+
}),
|
|
4148
|
+
});
|
|
4149
|
+
writerToolPolicy = {
|
|
4150
|
+
requireSdk: true,
|
|
4151
|
+
customTools: planLedgerTools.customTools,
|
|
4152
|
+
};
|
|
4153
|
+
}
|
|
4154
|
+
catch (error) {
|
|
4155
|
+
return {
|
|
4156
|
+
ok: false,
|
|
4157
|
+
stdout: "",
|
|
4158
|
+
stderr: `pi plan ledger tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
4159
|
+
failureCategory: "tool-policy",
|
|
4160
|
+
durationMs: Date.now() - started,
|
|
4161
|
+
};
|
|
4162
|
+
}
|
|
4163
|
+
}
|
|
4164
|
+
if (input.task.readBudget) {
|
|
4165
|
+
try {
|
|
4166
|
+
readBudgetTools = await createPiReadBudgetCustomTools({
|
|
4167
|
+
repoRoot: input.cwd,
|
|
4168
|
+
budget: input.task.readBudget,
|
|
4169
|
+
});
|
|
4170
|
+
writerToolPolicy = {
|
|
4171
|
+
requireSdk: true,
|
|
4172
|
+
customTools: [
|
|
4173
|
+
...(writerToolPolicy?.customTools ?? []),
|
|
4174
|
+
...readBudgetTools.customTools,
|
|
4175
|
+
],
|
|
4176
|
+
};
|
|
4177
|
+
}
|
|
4178
|
+
catch (error) {
|
|
4179
|
+
return {
|
|
4180
|
+
ok: false,
|
|
4181
|
+
stdout: "",
|
|
4182
|
+
stderr: `pi read budget policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
4183
|
+
failureCategory: "tool-policy",
|
|
4184
|
+
durationMs: Date.now() - started,
|
|
4185
|
+
};
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
598
4188
|
let result;
|
|
599
4189
|
try {
|
|
600
4190
|
// Phase 5 (P1-9): capture the writeSet baseline BEFORE the writer provider
|
|
@@ -603,8 +4193,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
603
4193
|
// hard fail (no baseline → no rollback → no recovery). Dynamic import avoids
|
|
604
4194
|
// an executor ↔ scheduler static cycle.
|
|
605
4195
|
if (isWriteTask &&
|
|
606
|
-
|
|
607
|
-
input.task.id === "frontend-repair-pi") &&
|
|
4196
|
+
input.task.id === "frontend-implement-pi" &&
|
|
608
4197
|
(input.task.writeSet?.length ?? 0) > 0) {
|
|
609
4198
|
try {
|
|
610
4199
|
const { captureFrontendWriterAttemptIntent } = await import("../workflows/dag/frontend-writer-recovery.js");
|
|
@@ -667,10 +4256,9 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
667
4256
|
: [],
|
|
668
4257
|
activeTools: toolNames,
|
|
669
4258
|
});
|
|
670
|
-
|
|
4259
|
+
const piSessionOptions = {
|
|
671
4260
|
attachedFiles: [],
|
|
672
4261
|
modelConfig,
|
|
673
|
-
prompt: input.prompt,
|
|
674
4262
|
repoRoot: input.cwd,
|
|
675
4263
|
step,
|
|
676
4264
|
toolNames,
|
|
@@ -683,7 +4271,6 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
683
4271
|
...(input.task.contextBudget
|
|
684
4272
|
? { contextBudget: input.task.contextBudget }
|
|
685
4273
|
: {}),
|
|
686
|
-
...(writerToolPolicy ? { writerToolPolicy } : {}),
|
|
687
4274
|
...(piExtensionPaths && piExtensionPaths.length > 0
|
|
688
4275
|
? { piExtensionPaths }
|
|
689
4276
|
: {}),
|
|
@@ -696,7 +4283,82 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
696
4283
|
bridgeActivity(activity.kind, activity.at);
|
|
697
4284
|
}
|
|
698
4285
|
: undefined,
|
|
699
|
-
}
|
|
4286
|
+
};
|
|
4287
|
+
if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
|
|
4288
|
+
// Frontend-only split: sequential sessions with independent
|
|
4289
|
+
// output budgets (coverage -> UX decisions -> finalize), mirroring the
|
|
4290
|
+
// backend-test template's module sharding. Every other template keeps
|
|
4291
|
+
// the single-session path below.
|
|
4292
|
+
let planRequirementIds = [];
|
|
4293
|
+
const planRequirementCosts = new Map();
|
|
4294
|
+
const behaviorRequiredRequirementIds = [];
|
|
4295
|
+
try {
|
|
4296
|
+
const { readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
|
|
4297
|
+
const contractFacts = await readCommittedOriginFacts(meta.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl");
|
|
4298
|
+
// Only requirement facts: the contract ledger also carries
|
|
4299
|
+
// constraints (CON-*), evidence expectations (EV-*), handoff
|
|
4300
|
+
// intents (HND-*), open questions (OQ-*) and split proposals
|
|
4301
|
+
// (SPLIT-*) that all have ids — feeding those into the coverage
|
|
4302
|
+
// batches made the model record non-frozen plan-requirement ids
|
|
4303
|
+
// that finalize's canonical-coverage gate then rejected (r-ext2).
|
|
4304
|
+
planRequirementIds = contractFacts
|
|
4305
|
+
.filter((record) => record.fact
|
|
4306
|
+
?.kind === "requirement")
|
|
4307
|
+
.map((record) => record.fact?.id)
|
|
4308
|
+
.filter((id) => typeof id === "string");
|
|
4309
|
+
for (const record of contractFacts) {
|
|
4310
|
+
const fact = record.fact;
|
|
4311
|
+
if (fact?.kind !== "requirement" || typeof fact.id !== "string")
|
|
4312
|
+
continue;
|
|
4313
|
+
const evidence = fact.evidence;
|
|
4314
|
+
if (evidence?.behavior === "required")
|
|
4315
|
+
behaviorRequiredRequirementIds.push(fact.id);
|
|
4316
|
+
planRequirementCosts.set(fact.id, estimateFrontendPlanRequirementRecordCalls(fact));
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
catch {
|
|
4320
|
+
// Unreadable ledger falls back to a single coverage session.
|
|
4321
|
+
}
|
|
4322
|
+
result = await runFrontendPlanSegmentedSessions({
|
|
4323
|
+
piStepFn,
|
|
4324
|
+
sessionOptions: piSessionOptions,
|
|
4325
|
+
basePrompt: input.prompt,
|
|
4326
|
+
attempt: input.attempt ?? 1,
|
|
4327
|
+
committedFactCount: () => planLedgerTools.committedFactCount(),
|
|
4328
|
+
requirementIds: planRequirementIds,
|
|
4329
|
+
requirementCosts: planRequirementCosts,
|
|
4330
|
+
compactSmallPlan: true,
|
|
4331
|
+
committedRequirementIds: () => planLedgerTools.committedRequirementIds(),
|
|
4332
|
+
committedFacts: () => planLedgerTools.committedFacts(),
|
|
4333
|
+
behaviorRequiredRequirementIds,
|
|
4334
|
+
setActiveRequirementScope: (requirementIds) => planLedgerTools.setActiveRequirementScope(requirementIds),
|
|
4335
|
+
segmentCustomTools: (toolNames) => toolNames === null
|
|
4336
|
+
? planLedgerTools.customTools
|
|
4337
|
+
: planLedgerTools.customTools.filter((tool) => typeof tool === "object" &&
|
|
4338
|
+
tool !== null &&
|
|
4339
|
+
toolNames.has(tool.name)),
|
|
4340
|
+
flushLedger: () => planLedgerTools.flush(),
|
|
4341
|
+
});
|
|
4342
|
+
}
|
|
4343
|
+
else {
|
|
4344
|
+
result = await piStepFn({
|
|
4345
|
+
...piSessionOptions,
|
|
4346
|
+
prompt: input.prompt,
|
|
4347
|
+
...(writerToolPolicy ? { writerToolPolicy } : {}),
|
|
4348
|
+
});
|
|
4349
|
+
}
|
|
4350
|
+
try {
|
|
4351
|
+
const verificationCommandFiles = collectFrontendVerificationCommandFiles(input.prompt);
|
|
4352
|
+
const debugPath = path.join(meta.runDir, input.task.id, "verification-command-coverage.json");
|
|
4353
|
+
await import("node:fs/promises").then(({ writeFile, mkdir }) => mkdir(path.dirname(debugPath), { recursive: true }).then(() => writeFile(debugPath, `${JSON.stringify({
|
|
4354
|
+
schemaVersion: 1,
|
|
4355
|
+
basePromptChars: input.prompt.length,
|
|
4356
|
+
verificationCommandFiles,
|
|
4357
|
+
}, null, 2)}\n`)));
|
|
4358
|
+
}
|
|
4359
|
+
catch {
|
|
4360
|
+
// best-effort diagnostic breadcrumb
|
|
4361
|
+
}
|
|
700
4362
|
}
|
|
701
4363
|
catch (error) {
|
|
702
4364
|
if (playwrightToolContext) {
|
|
@@ -734,10 +4396,171 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
734
4396
|
if (input.task.id === "generate-backend-md-plan-pi" && result.assistantText?.trim()) {
|
|
735
4397
|
await writeTextArtifactFile(path.join(meta.runDir, input.task.id, "plan.md"), result.assistantText.trim() + "\n");
|
|
736
4398
|
}
|
|
737
|
-
const mapped = mapPiResultToDagNodeResult(result, input.task
|
|
738
|
-
?
|
|
739
|
-
: input.task.
|
|
4399
|
+
const mapped = mapPiResultToDagNodeResult(result, isFrontendFactsWriter(input.task)
|
|
4400
|
+
? undefined
|
|
4401
|
+
: input.task.writerOutcomePolicy
|
|
4402
|
+
? WRITER_OUTCOME_PROTOCOL_LINE
|
|
4403
|
+
: input.task.firstProtocolLine);
|
|
4404
|
+
if (input.task.id === "generate-backend-md-plan-pi") {
|
|
4405
|
+
if (mapped.stopReason === "length") {
|
|
4406
|
+
return {
|
|
4407
|
+
...mapped,
|
|
4408
|
+
ok: false,
|
|
4409
|
+
failureCategory: STRUCTURED_OUTPUT_RETRY_CATEGORY,
|
|
4410
|
+
stderr: [
|
|
4411
|
+
mapped.stderr,
|
|
4412
|
+
"backend-test Markdown plan was truncated before its required protocol could be trusted: stopReason=length; retry with the final artifact first",
|
|
4413
|
+
]
|
|
4414
|
+
.filter(Boolean)
|
|
4415
|
+
.join("\n"),
|
|
4416
|
+
};
|
|
4417
|
+
}
|
|
4418
|
+
if (mapped.ok) {
|
|
4419
|
+
const protocol = assessBackendTestPlanProtocol(mapped.assistantText || mapped.stdout);
|
|
4420
|
+
if (!protocol.ok) {
|
|
4421
|
+
return {
|
|
4422
|
+
...mapped,
|
|
4423
|
+
ok: false,
|
|
4424
|
+
failureCategory: "invalid-output",
|
|
4425
|
+
stderr: [
|
|
4426
|
+
mapped.stderr,
|
|
4427
|
+
`backend-test Markdown plan protocol invalid: ${protocol.issues.map((issue) => `${issue.code}: ${issue.detail}`).join("; ")}`,
|
|
4428
|
+
]
|
|
4429
|
+
.filter(Boolean)
|
|
4430
|
+
.join("\n"),
|
|
4431
|
+
};
|
|
4432
|
+
}
|
|
4433
|
+
}
|
|
4434
|
+
}
|
|
4435
|
+
// A node-specific budget is enforced for every frontend reader that opts in,
|
|
4436
|
+
// including design/final review. Earlier code only checked contract, scout,
|
|
4437
|
+
// and plan nodes, leaving the two largest review sessions unbounded.
|
|
4438
|
+
// A soft-limit node has already stopped repository access in its SDK tool
|
|
4439
|
+
// guard. Session events still include the rejected tool attempt, so using
|
|
4440
|
+
// those telemetry events as a second budget gate would wrongly turn a
|
|
4441
|
+
// successful plan into read-burst.
|
|
4442
|
+
const readBudgetIssues = input.task.readBudget?.onExhaustion === "return-guidance"
|
|
4443
|
+
? []
|
|
4444
|
+
: [
|
|
4445
|
+
...(readBudgetTools?.issues() ?? []),
|
|
4446
|
+
...(await detectNodeReadBudget({
|
|
4447
|
+
runDir: meta.runDir,
|
|
4448
|
+
nodeId: input.task.id,
|
|
4449
|
+
budget: input.task.readBudget,
|
|
4450
|
+
})),
|
|
4451
|
+
];
|
|
4452
|
+
if (readBudgetIssues.length > 0) {
|
|
4453
|
+
const hasTypedFrontendTerminal = isFrontendReviewTypedTerminalNode(input.task) ||
|
|
4454
|
+
isFrontendDesignTypedTerminalNode(input.task);
|
|
4455
|
+
// A stale/generated DAG may still carry the legacy read budget. Once a
|
|
4456
|
+
// design/review node has a successful typed terminal, telemetry is
|
|
4457
|
+
// diagnostic only; the terminal shadow below still fails closed when the
|
|
4458
|
+
// fact is missing or conflicting.
|
|
4459
|
+
if (mapped.ok && hasTypedFrontendTerminal) {
|
|
4460
|
+
// Continue to the typed terminal shadow validation below.
|
|
4461
|
+
}
|
|
4462
|
+
else {
|
|
4463
|
+
// Read-budget telemetry is diagnostic only when the provider/executor
|
|
4464
|
+
// has already failed. In particular, keep context-overflow so
|
|
4465
|
+
// node-execution selects its compact retry envelope instead of treating
|
|
4466
|
+
// the attempt as a generic read-burst retry.
|
|
4467
|
+
if (!mapped.ok) {
|
|
4468
|
+
return {
|
|
4469
|
+
...mapped,
|
|
4470
|
+
stderr: [mapped.stderr, ...readBudgetIssues]
|
|
4471
|
+
.filter(Boolean)
|
|
4472
|
+
.join("\n\n"),
|
|
4473
|
+
};
|
|
4474
|
+
}
|
|
4475
|
+
return {
|
|
4476
|
+
...mapped,
|
|
4477
|
+
ok: false,
|
|
4478
|
+
failureCategory: "read-burst",
|
|
4479
|
+
stderr: [mapped.stderr, ...readBudgetIssues]
|
|
4480
|
+
.filter(Boolean)
|
|
4481
|
+
.join("\n\n"),
|
|
4482
|
+
};
|
|
4483
|
+
}
|
|
4484
|
+
}
|
|
4485
|
+
if (isFrontendReviewTypedTerminalNode(input.task)) {
|
|
4486
|
+
return await runFrontendReviewTerminalShadow({
|
|
4487
|
+
task: input.task,
|
|
4488
|
+
meta,
|
|
4489
|
+
mapped,
|
|
4490
|
+
tools: reviewTerminalTools,
|
|
4491
|
+
});
|
|
4492
|
+
}
|
|
4493
|
+
if (isFrontendDesignTypedTerminalNode(input.task)) {
|
|
4494
|
+
return await runFrontendDesignTerminalShadow({
|
|
4495
|
+
task: input.task,
|
|
4496
|
+
meta,
|
|
4497
|
+
mapped,
|
|
4498
|
+
tools: designTerminalTools,
|
|
4499
|
+
});
|
|
4500
|
+
}
|
|
4501
|
+
if (isFrontendContractTypedNode(input.task)) {
|
|
4502
|
+
try {
|
|
4503
|
+
await contractTools?.flush?.();
|
|
4504
|
+
}
|
|
4505
|
+
catch {
|
|
4506
|
+
// best-effort flush
|
|
4507
|
+
}
|
|
4508
|
+
}
|
|
4509
|
+
if (isFrontendScoutEvidenceNode(input.task)) {
|
|
4510
|
+
try {
|
|
4511
|
+
await scoutEvidenceTools?.flush?.();
|
|
4512
|
+
}
|
|
4513
|
+
catch {
|
|
4514
|
+
// best-effort flush
|
|
4515
|
+
}
|
|
4516
|
+
if (mapped.ok) {
|
|
4517
|
+
const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
|
|
4518
|
+
const scoutFacts = await readCommittedOriginFacts(meta.runDir, input.task.id, "scout-typed-facts.jsonl");
|
|
4519
|
+
const completeness = checkCommittedOriginFacts({
|
|
4520
|
+
records: scoutFacts,
|
|
4521
|
+
origin: "scout",
|
|
4522
|
+
});
|
|
4523
|
+
if (!completeness.ok) {
|
|
4524
|
+
return {
|
|
4525
|
+
...mapped,
|
|
4526
|
+
ok: false,
|
|
4527
|
+
failureCategory: "invalid-output",
|
|
4528
|
+
stderr: [mapped.stderr, completeness.reason]
|
|
4529
|
+
.filter(Boolean)
|
|
4530
|
+
.join("\n\n"),
|
|
4531
|
+
};
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
}
|
|
4535
|
+
if (isFrontendPlanLedgerNode(input.task)) {
|
|
4536
|
+
try {
|
|
4537
|
+
await planLedgerTools?.flush?.();
|
|
4538
|
+
}
|
|
4539
|
+
catch {
|
|
4540
|
+
// best-effort flush; missing ledger still fails at the node validator
|
|
4541
|
+
}
|
|
4542
|
+
// NOTE: the legacy plan read-burst guard lived here. It is dead code
|
|
4543
|
+
// since the plan node went tool-only (resolveDagPiToolNames grants no
|
|
4544
|
+
// read/grep/ls/find), so a read burst is structurally impossible; the
|
|
4545
|
+
// read-budget path (scout etc.) keeps its own guard.
|
|
4546
|
+
}
|
|
740
4547
|
if (!isWriteTask) {
|
|
4548
|
+
if (!mapped.ok &&
|
|
4549
|
+
!(mapped.assistantText ?? "").trim() &&
|
|
4550
|
+
!mapped.stderr.trim()) {
|
|
4551
|
+
// Terminal-fact acceptance: the run finished with every fact committed
|
|
4552
|
+
// (including the terminal) but no final narrative text. A timeout or
|
|
4553
|
+
// provider error always leaves supervision/provider stderr, so a
|
|
4554
|
+
// blank stderr here means the only "failure" is the empty text.
|
|
4555
|
+
const terminalAccepted = await acceptCommittedTypedTerminalFact(meta.runDir, input.task.id);
|
|
4556
|
+
if (terminalAccepted) {
|
|
4557
|
+
return {
|
|
4558
|
+
...mapped,
|
|
4559
|
+
ok: true,
|
|
4560
|
+
failureCategory: undefined,
|
|
4561
|
+
};
|
|
4562
|
+
}
|
|
4563
|
+
}
|
|
741
4564
|
return mapped;
|
|
742
4565
|
}
|
|
743
4566
|
let writeGuardOk = true;
|
|
@@ -870,8 +4693,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
870
4693
|
changeManifestChangedFiles = changedFiles;
|
|
871
4694
|
// Phase 5: record the attempt changed paths/hashes into the rollback
|
|
872
4695
|
// intent so a transient partial write can be CAS-restored.
|
|
873
|
-
if (
|
|
874
|
-
input.task.id === "frontend-repair-pi") &&
|
|
4696
|
+
if (input.task.id === "frontend-implement-pi" &&
|
|
875
4697
|
(input.task.writeSet?.length ?? 0) > 0) {
|
|
876
4698
|
try {
|
|
877
4699
|
const { recordFrontendWriterAttempt } = await import("../workflows/dag/frontend-writer-recovery.js");
|
|
@@ -921,15 +4743,96 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
921
4743
|
}
|
|
922
4744
|
}
|
|
923
4745
|
let writerOutcomeViolation;
|
|
4746
|
+
let factDerivedStatus;
|
|
924
4747
|
if (mapped.ok && input.task.writerOutcomePolicy) {
|
|
925
|
-
if (
|
|
4748
|
+
if (isFrontendFactsWriter(input.task)) {
|
|
4749
|
+
// AC-001/AC-003: facts-derived status. The first line is never read;
|
|
4750
|
+
// the legacy validator still runs for the shadow comparison only.
|
|
4751
|
+
try {
|
|
4752
|
+
const [writerStatus, contractModule, traceModule] = await Promise.all([
|
|
4753
|
+
import("../workflows/dag/frontend-writer-status.js"),
|
|
4754
|
+
import("../workflows/dag/frontend-implementation-contract.js"),
|
|
4755
|
+
import("../workflows/dag/frontend-verification-trace.js"),
|
|
4756
|
+
]);
|
|
4757
|
+
const { collectFrontendWriterFacts, deriveFrontendWriterStatus, computeFailureFingerprint, compareFactStatusToLegacyOutcome, } = writerStatus;
|
|
4758
|
+
const contractPath = path.join(meta.runDir, "contracts", "frontend-implementation-contract.json");
|
|
4759
|
+
const contractRaw = JSON.parse(await readFile(contractPath, "utf8"));
|
|
4760
|
+
const parsedContract = contractModule.frontendImplementationContractSchema.safeParse(contractRaw);
|
|
4761
|
+
if (!parsedContract.success) {
|
|
4762
|
+
throw new Error(`invalid run-owned canonical contract: ${parsedContract.error.issues
|
|
4763
|
+
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
|
4764
|
+
.join("; ")}`);
|
|
4765
|
+
}
|
|
4766
|
+
const tracePreflight = await traceModule.evaluateFrontendWriterTracePreflight({
|
|
4767
|
+
contract: parsedContract.data,
|
|
4768
|
+
workspaceRoot: input.cwd,
|
|
4769
|
+
});
|
|
4770
|
+
await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "frontend-trace-preflight.json", {
|
|
4771
|
+
schemaVersion: 1,
|
|
4772
|
+
nodeId: input.task.id,
|
|
4773
|
+
ok: tracePreflight.ok,
|
|
4774
|
+
targets: tracePreflight.targets,
|
|
4775
|
+
failures: tracePreflight.failures,
|
|
4776
|
+
});
|
|
4777
|
+
const facts = await collectFrontendWriterFacts({
|
|
4778
|
+
sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
|
|
4779
|
+
changedFiles: changeManifestChangedFiles ?? [],
|
|
4780
|
+
writeGuard: { ok: writeGuardOk, violations: writeGuardViolations },
|
|
4781
|
+
requirementTargets: [],
|
|
4782
|
+
coveredRequirementIds: [],
|
|
4783
|
+
focusedCheckFailures: tracePreflight.failures,
|
|
4784
|
+
tokensUsed: result.tokensUsed,
|
|
4785
|
+
wallTimeMs: Date.now() - started,
|
|
4786
|
+
rounds: 1,
|
|
4787
|
+
writeAttempts: attempt,
|
|
4788
|
+
firstLineText: mapped.assistantText,
|
|
4789
|
+
});
|
|
4790
|
+
const derived = deriveFrontendWriterStatus(facts);
|
|
4791
|
+
factDerivedStatus = derived.status;
|
|
4792
|
+
const legacyValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles ?? [], {
|
|
4793
|
+
requireChangedFiles: false,
|
|
4794
|
+
allowMissingChangedOutcomeWhenDiffPresent: false,
|
|
4795
|
+
});
|
|
4796
|
+
const legacyOutcome = legacyValidation.ok
|
|
4797
|
+
? legacyValidation.outcome
|
|
4798
|
+
: legacyValidation.reason.includes("blocked")
|
|
4799
|
+
? "blocked"
|
|
4800
|
+
: "missing";
|
|
4801
|
+
await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "fact-implementation-status.json", {
|
|
4802
|
+
schemaVersion: 1,
|
|
4803
|
+
nodeId: input.task.id,
|
|
4804
|
+
status: derived.status,
|
|
4805
|
+
reason: derived.reason,
|
|
4806
|
+
changedFiles: facts.changedFiles,
|
|
4807
|
+
writeToolEvents: facts.writeToolEvents,
|
|
4808
|
+
writeGuardOk: facts.writeGuardOk,
|
|
4809
|
+
writeGuardViolations: facts.writeGuardViolations,
|
|
4810
|
+
focusedCheckFailures: facts.focusedCheckFailures,
|
|
4811
|
+
alreadySatisfiedEvidence: facts.alreadySatisfiedEvidence,
|
|
4812
|
+
tokensUsed: facts.tokensUsed,
|
|
4813
|
+
wallTimeMs: facts.wallTimeMs,
|
|
4814
|
+
rounds: facts.rounds,
|
|
4815
|
+
writeAttempts: facts.writeAttempts,
|
|
4816
|
+
failureFingerprint: computeFailureFingerprint(facts),
|
|
4817
|
+
shadow: compareFactStatusToLegacyOutcome(derived.status, legacyOutcome),
|
|
4818
|
+
});
|
|
4819
|
+
if (derived.status !== "changed" &&
|
|
4820
|
+
derived.status !== "already-satisfied") {
|
|
4821
|
+
writerOutcomeViolation = `fact-derived status ${derived.status}: ${derived.reason}`;
|
|
4822
|
+
}
|
|
4823
|
+
}
|
|
4824
|
+
catch (error) {
|
|
4825
|
+
writerOutcomeViolation = `frontend facts derivation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
4826
|
+
}
|
|
4827
|
+
}
|
|
4828
|
+
else if (changeManifestChangedFiles === undefined) {
|
|
926
4829
|
writerOutcomeViolation =
|
|
927
4830
|
"writer outcome validation failed: actual diff is unavailable";
|
|
928
4831
|
}
|
|
929
4832
|
else {
|
|
930
4833
|
const outcomeValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles, {
|
|
931
4834
|
requireChangedFiles: input.task.writerOutcomePolicy.requireChangedFiles === true,
|
|
932
|
-
allowMissingChangedOutcomeWhenDiffPresent:
|
|
4835
|
+
allowMissingChangedOutcomeWhenDiffPresent: allowsMissingChangedWriterOutcomeRecovery(input.task),
|
|
933
4836
|
});
|
|
934
4837
|
if (!outcomeValidation.ok) {
|
|
935
4838
|
writerOutcomeViolation = outcomeValidation.reason;
|
|
@@ -969,6 +4872,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
969
4872
|
? "pytest-generate"
|
|
970
4873
|
: "md-generate",
|
|
971
4874
|
writeSet: input.task.writeSet ?? [],
|
|
4875
|
+
runDir: meta.runDir,
|
|
972
4876
|
})
|
|
973
4877
|
: isBackendTestMdPlanTask(input.task)
|
|
974
4878
|
? await assessBackendTestMdPlanCompleteness(input.cwd, backendLayout)
|
|
@@ -1045,6 +4949,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
1045
4949
|
changeManifestChangedFiles !== undefined &&
|
|
1046
4950
|
changeManifestChangedFiles.length === 0 &&
|
|
1047
4951
|
writeToolCallCount === 0;
|
|
4952
|
+
// Budget-exhausted: the provider session consumed an extreme amount of
|
|
4953
|
+
// tokens (e.g. an unresolved read-edit-test loop) and still failed. Only
|
|
4954
|
+
// applies on a non-ok writer result; a successful write is never re-labeled.
|
|
4955
|
+
const writerBudgetExhausted = !mapped.ok &&
|
|
4956
|
+
typeof mapped.tokensUsed === "number" &&
|
|
4957
|
+
mapped.tokensUsed > WRITER_TOKEN_BUDGET;
|
|
1048
4958
|
const targetCleanTransport = !mapped.ok &&
|
|
1049
4959
|
isTargetTemplateTransientRetryNode(input.task) &&
|
|
1050
4960
|
rawFailureCategory !== undefined &&
|
|
@@ -1065,11 +4975,13 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
1065
4975
|
? writeGuardOk
|
|
1066
4976
|
? completenessFailure
|
|
1067
4977
|
? completenessFailure.failureCategory
|
|
1068
|
-
:
|
|
1069
|
-
isWriterEmptyDiffRetryCandidate(input.task) &&
|
|
1070
|
-
isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
|
|
4978
|
+
: factDerivedStatus === "empty-diff"
|
|
1071
4979
|
? WRITER_EMPTY_DIFF_RETRY_CATEGORY
|
|
1072
|
-
:
|
|
4980
|
+
: changeManifestChangedFiles?.length === 0 &&
|
|
4981
|
+
isWriterEmptyDiffRetryCandidate(input.task) &&
|
|
4982
|
+
isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
|
|
4983
|
+
? WRITER_EMPTY_DIFF_RETRY_CATEGORY
|
|
4984
|
+
: "invalid-output"
|
|
1073
4985
|
: "write-guard"
|
|
1074
4986
|
: // When the attempt already failed with a writer-style category
|
|
1075
4987
|
// (empty-output / invalid-output / writer-empty-diff) but the workspace
|
|
@@ -1086,17 +4998,35 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
1086
4998
|
mapped.failureCategory === "invalid-output" ||
|
|
1087
4999
|
mapped.failureCategory === WRITER_EMPTY_DIFF_RETRY_CATEGORY)
|
|
1088
5000
|
? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
|
|
1089
|
-
: //
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
// the
|
|
1093
|
-
//
|
|
1094
|
-
//
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
5001
|
+
: // partial-success-with-context-overflow: the writer hit a context
|
|
5002
|
+
// overflow on its final provider call but had already persisted
|
|
5003
|
+
// real file changes (change-manifest non-empty). Classify it
|
|
5004
|
+
// distinctly so the report shows "code largely done, verification
|
|
5005
|
+
// incomplete" instead of a misleading empty-output / spec issue,
|
|
5006
|
+
// and recovery can rerun from verify/implement with a compacted
|
|
5007
|
+
// session rather than asking the operator for spec clarification.
|
|
5008
|
+
rawFailureCategory === "context-overflow" &&
|
|
5009
|
+
(changeManifestChangedFiles?.length ?? 0) > 0
|
|
5010
|
+
? "partial-success-with-context-overflow"
|
|
5011
|
+
: // writer-thinking-exhausted: a length-stopped thinking-only attempt with
|
|
5012
|
+
// zero write tool calls and zero attributed diff is terminal and
|
|
5013
|
+
// non-retryable; recommend a model switch + fresh run. Only applies on
|
|
5014
|
+
// the empty-output base category so provider/transport failures keep
|
|
5015
|
+
// their original category, and only when the completeness gate did not
|
|
5016
|
+
// upgrade to incomplete-write-set above.
|
|
5017
|
+
writerThinkingExhausted
|
|
5018
|
+
? WRITER_THINKING_EXHAUSTED_CATEGORY
|
|
5019
|
+
: writerCleanTimeout
|
|
5020
|
+
? WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY
|
|
5021
|
+
: // writer-budget-exhausted: the provider session consumed an
|
|
5022
|
+
// excessive amount of tokens (a read-edit-test loop that never
|
|
5023
|
+
// converged) and still failed. Classify distinctly so the
|
|
5024
|
+
// report says the run burned its budget instead of a generic
|
|
5025
|
+
// empty-output, and recovery recommends a fresh compacted
|
|
5026
|
+
// run rather than spec-clarification.
|
|
5027
|
+
writerBudgetExhausted
|
|
5028
|
+
? WRITER_BUDGET_EXHAUSTED_CATEGORY
|
|
5029
|
+
: mapped.failureCategory,
|
|
1100
5030
|
durationMs: mapped.durationMs || Date.now() - started,
|
|
1101
5031
|
};
|
|
1102
5032
|
}
|
|
@@ -1241,6 +5171,8 @@ export function mapPiResultToDagNodeResult(result, firstProtocolLine) {
|
|
|
1241
5171
|
tokensUsed: result.tokensUsed,
|
|
1242
5172
|
parsedEvents: result.parsedEvents,
|
|
1243
5173
|
stopReason: readWriterThinkingExhaustionEvidence(result).stopReason,
|
|
5174
|
+
thinkingObserved: readWriterThinkingExhaustionEvidence(result).thinkingObserved,
|
|
5175
|
+
writeToolCallCount: readWriterThinkingExhaustionEvidence(result).writeToolCallCount,
|
|
1244
5176
|
};
|
|
1245
5177
|
}
|
|
1246
5178
|
function canonicalizeProtocolFirstLine(assistantText, firstProtocolLine) {
|