@tea-agent/loop-agent 0.39.0-beta.1 → 0.39.0-beta.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 +4 -1
- package/CHANGELOG.md +349 -97
- package/README.md +9 -5
- package/bin/loop-agent.js +7 -3
- package/dist/application/task-lifecycle/advance.js +12 -0
- package/dist/application/task-lifecycle/recommendations.js +13 -2
- package/dist/build-stamp.json +3 -3
- package/dist/cli/command-definitions.js +23 -5
- package/dist/cli/program.js +21 -2
- package/dist/cli/update/notifier.js +2 -2
- package/dist/cli/update/runtime-activity.js +1 -29
- package/dist/cli.js +2 -1
- package/dist/commands/client-recovery.js +3 -0
- package/dist/commands/dag-follow-up.js +138 -0
- package/dist/commands/dag-rerun.js +55 -1
- package/dist/commands/init-model-catalog.js +464 -0
- package/dist/commands/init-upgrade.js +265 -97
- package/dist/commands/init.js +455 -28
- package/dist/commands/inspect-next.js +8 -0
- package/dist/commands/task-advance.js +19 -0
- package/dist/executors/dag-pi-executor.js +2370 -64
- package/dist/executors/pi-executor.js +11 -2
- package/dist/executors/pi-extension-resolver.js +233 -0
- package/dist/executors/pi-playwright-cli-tool.js +74 -28
- package/dist/executors/pi-read-budget-policy.js +239 -0
- package/dist/executors/pi-sdk-executor.js +225 -33
- package/dist/executors/pi-writer-tool-policy.js +57 -1
- package/dist/executors/shell-executor.js +1258 -224
- package/dist/executors/shell-write-guard.js +14 -0
- package/dist/executors/workspace-write-snapshot.js +68 -0
- package/dist/infrastructure/harness/atomic-write.js +23 -0
- package/dist/shared/dag-failure-category.js +150 -0
- package/dist/shared/dag-prompt-override.js +27 -0
- package/dist/shared/openspec-spec.js +70 -4
- package/dist/shared/operator/capabilities.js +39 -0
- package/dist/shared/playwright-cli-command-policy.js +15 -0
- package/dist/shared/update/console-notifier.js +100 -0
- package/dist/{cli → shared}/update/npm-client.js +70 -15
- package/dist/{cli → shared}/update/state.js +41 -13
- package/dist/task/config-types.js +50 -5
- package/dist/task/contract/apply.js +11 -1
- package/dist/task/contract/constants.js +2 -0
- package/dist/task/contract/hash.js +3 -0
- package/dist/task/contract/observe.js +25 -4
- package/dist/task/contract/paths.js +2 -1
- package/dist/task/contract/project.js +18 -1
- package/dist/task/contract/schema.js +4 -1
- package/dist/task/contract/transaction.js +12 -0
- package/dist/task/frontend-project-capability.js +199 -18
- package/dist/task/source-prepare/completeness.js +188 -0
- package/dist/task/source-prepare/fragment-inventory.js +477 -0
- package/dist/task/source-prepare/index.js +3 -0
- package/dist/task/source-prepare/ledger-reconciliation.js +127 -0
- package/dist/task/source-prepare/ledger-review.js +214 -0
- package/dist/task/source-prepare/ledger.js +545 -0
- package/dist/task/source-prepare/parse-intent.js +24 -4
- package/dist/task/source-prepare/prepare.js +298 -1
- package/dist/task/source-prepare/semantic-intake.js +25 -5
- package/dist/task/source-prepare/source-fidelity-pi.js +384 -0
- package/dist/task/source-prepare/types.js +22 -0
- package/dist/task/source-references.js +22 -1
- package/dist/worker/cli.js +18 -2
- package/dist/worker/console/chat/chat-event-store.js +75 -5
- package/dist/worker/console/chat/context-panel.js +7 -0
- package/dist/worker/console/chat/deferred-turn.js +12 -0
- package/dist/worker/console/chat/operation-card.js +8 -1
- package/dist/worker/console/chat/pi-console-config.js +81 -10
- package/dist/worker/console/chat/pi-runtime.js +331 -18
- package/dist/worker/console/chat/provider-error.js +98 -0
- package/dist/worker/console/chat/routes.js +395 -59
- package/dist/worker/console/chat/session-stats.js +179 -0
- package/dist/worker/console/chat/session-store.js +93 -63
- package/dist/worker/console/chat/todos.js +115 -0
- package/dist/worker/console/chat/tool-preview.js +42 -0
- package/dist/worker/console/chat/turn-process.js +20 -55
- package/dist/worker/console/chat/user-questions.js +266 -0
- package/dist/worker/console/chat/workspace-landing.js +79 -0
- package/dist/worker/console/console-handoff.js +82 -0
- package/dist/worker/console/console-update-and-init.js +115 -0
- package/dist/worker/console/console-update-runtime.js +84 -0
- package/dist/worker/console/dag-execution-receipt.js +33 -0
- package/dist/worker/console/draft-store.js +49 -0
- package/dist/worker/console/frontend-human-decision-adapter.js +19 -0
- package/dist/worker/console/frontend-split-operation-adapter.js +20 -0
- package/dist/worker/console/index.js +3 -0
- package/dist/worker/console/inspect-split.js +18 -0
- package/dist/worker/console/native-directory-picker.js +13 -0
- package/dist/worker/console/operation-run-facts.js +19 -3
- package/dist/worker/console/operation-store.js +227 -7
- package/dist/worker/console/operator-actions.js +388 -0
- package/dist/worker/console/operator-surface-health.js +1 -0
- package/dist/worker/console/operator-user-error.js +2 -2
- package/dist/worker/console/prd-intake-bridge.js +13 -0
- package/dist/worker/console/routes.js +355 -3
- package/dist/worker/console/security.js +67 -13
- package/dist/worker/console/server.js +609 -164
- package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-C4kVYweA.js → abnfDiagram-N423BO3Z-CXj_GnSb.js} +1 -1
- package/dist/worker/console/static/assets/{arc-Bj2M8iO5.js → arc-BZp6JAp7.js} +1 -1
- package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-CLlXe4-6.js → architectureDiagram-T3A2C74G-DfpcEuYU.js} +1 -1
- package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-psEp5xH0.js → blockDiagram-VBNYF7ZC-_Gf0xadb.js} +1 -1
- package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-DeKcOCGJ.js → c4Diagram-5PPSVZJV-Ct2QPCmv.js} +1 -1
- package/dist/worker/console/static/assets/channel-3TxJgYaH.js +1 -0
- package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-K0C744p1.js → chunk-2GRJ4B5K-BfklkzKl.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-xbNw4J9-.js → chunk-2Q5K7J3B-Dy25vZJV.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DjLaSDUO.js → chunk-5RXB4S5H-BFlCRZep.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-5VM5RSS4-CtiPlKel.js → chunk-5VM5RSS4-op3oVxIE.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-DZtolir7.js → chunk-6Q2QTUOP-C0R7rzF2.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-GF5L2VYU-CPcdNeew.js → chunk-GF5L2VYU-Bt44TCGy.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-JWPE2WC7-NgHc6V37.js → chunk-JWPE2WC7-_uEE_XFx.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-KBJHAD2P-DQ_T-hg8.js → chunk-KBJHAD2P-C3TOYGZ9.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-RYQCIY6F-DgLsxcVP.js → chunk-RYQCIY6F-Cz60oBPV.js} +1 -1
- package/dist/worker/console/static/assets/{chunk-XXDRQBXY-UrVoM9z1.js → chunk-XXDRQBXY-8Bik0qis.js} +1 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-BHIkXpp3.js +1 -0
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-BHIkXpp3.js +1 -0
- package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-CLkYvTb3.js → cose-bilkent-JH36ORCC-C2QIOA4a.js} +1 -1
- package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-BwT_xKrE.js → cynefin-VYW2F7L2-CSH_yUUd.js} +1 -1
- package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-Bi9tuzif.js → cynefinDiagram-MW4NZA55-BDLw2XFM.js} +1 -1
- package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-BCIqkWBV.js → dagre-VZM6K2ZE-CcD9ZtF4.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-7IWD3JNH-Dip6l9_Z.js → diagram-7IWD3JNH-DqwtkBsI.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-B-xkh_wK.js → diagram-B4RE2ZJO-BWVEcqsC.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-LBJQPF4R-DZO0kTt_.js → diagram-LBJQPF4R-M-WAbtQi.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-Q27KOJAE-D6ZplNbZ.js → diagram-Q27KOJAE-DKW7SixP.js} +1 -1
- package/dist/worker/console/static/assets/{diagram-UB23O5K3-BPekbhoS.js → diagram-UB23O5K3-PHHPPtrj.js} +1 -1
- package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-BtmaJpK5.js → ebnfDiagram-BXEA7PRR-BQD-B4RX.js} +1 -1
- package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-6DS0Xc44.js → erDiagram-JOGREHBK-BFvULb51.js} +1 -1
- package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-CihnROSm.js → flowDiagram-UKHOOZJN-Bw-aXTCb.js} +1 -1
- package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-CZC4zOE2.js → ganttDiagram-PKOTCBZU-BGKw04Qx.js} +1 -1
- package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-Dq1fhzGN.js → gitGraphDiagram-DS77QQ5N-RqKaPR-I.js} +1 -1
- package/dist/worker/console/static/assets/index-B_D8rbWc.js +407 -0
- package/dist/worker/console/static/assets/index-BdNx6fj0.css +1 -0
- package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-DlSl4BGx.js → infoDiagram-6WML65LV-YO5dnzrJ.js} +1 -1
- package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-snnzFl_U.js → ishikawaDiagram-WSZJBQD7-Bi1VZHMq.js} +1 -1
- package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-BDE7qpMx.js → journeyDiagram-NVQOT4AX-Bszls1DD.js} +1 -1
- package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-CD2Ci04G.js → kanban-definition-27J2QSJJ-PhzeaZ09.js} +1 -1
- package/dist/worker/console/static/assets/{linear-DdnapKIH.js → linear-BsjbDoXi.js} +1 -1
- package/dist/worker/console/static/assets/{mermaid.core-BVjAT9b8.js → mermaid.core-0B7NnWKk.js} +5 -5
- package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-D0yJk-zA.js → mindmap-definition-FAOFIHXS-BJr4Fj-q.js} +1 -1
- package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-BVo9tFP-.js → pegDiagram-VL7TDLO6-moC4fpGB.js} +1 -1
- package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-DnOV-mZ_.js → pieDiagram-7S7Q4E2Y-BEw37-2c.js} +1 -1
- package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-CzUJo58i.js → quadrantDiagram-CIZ2JOQS-Cq6LyasU.js} +1 -1
- package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bvikrolh.js → railroadDiagram-AXF67PYL-DUCMcK0D.js} +1 -1
- package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-DtaSVCap.js → requirementDiagram-LRYGKXZP-C3upTZm7.js} +1 -1
- package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-C2c9A0wW.js → sankeyDiagram-W5VNT64P-BI_gMsCW.js} +1 -1
- package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-DmXJcU7r.js → sequenceDiagram-SI44F4Z6-YFOIRzfN.js} +1 -1
- package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-B4GUFW92.js → sizeCapture-X5ZJPWSS-dOnB7UDD.js} +1 -1
- package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-Dsad2MXf.js → stateDiagram-OKZ733FA-BXUniaIh.js} +1 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-Cdi6UhLa.js +1 -0
- package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-DGq48Fbi.js → swimlanes-SLNWSIFB-2tA4wTNu.js} +2 -2
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-D-RJBbb0.js +8 -0
- package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CSSi6OFf.js → timeline-definition-Z64GVDOM-DO0HkJXC.js} +1 -1
- package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BqyzPrWv.js → vennDiagram-T6HMQDX7-DvIOzixv.js} +1 -1
- package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-DlznVb6S.js → wardleyDiagram-T6FBY63Y-DXDZ0cTj.js} +1 -1
- package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-CfBcgJ_K.js → xychartDiagram-ELKLHX3M-B32Ark0D.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/console-types.js +68 -4
- package/dist/worker/console/static-src/app/useConsoleShell.js +30 -12
- package/dist/worker/console/static-src/app/useOperatorActions.js +41 -6
- package/dist/worker/console/static-src/app/useRecoveryConsole.js +45 -10
- package/dist/worker/console/static-src/app/useRunProgress.js +47 -0
- package/dist/worker/console/static-src/app/useTaskWizard.js +74 -2
- package/dist/worker/console/static-src/night/useNightBoard.js +8 -5
- package/dist/worker/console/static-src/operator-chat/cards/failure-category-advice.js +25 -0
- package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +141 -8
- package/dist/worker/console/static-src/operator-chat/compaction-message.js +79 -0
- package/dist/worker/console/static-src/operator-chat/details-dag-actions.js +122 -0
- package/dist/worker/console/static-src/operator-chat/input-history.js +76 -0
- package/dist/worker/console/static-src/operator-chat/mutation-gate.js +1 -1
- package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
- package/dist/worker/console/static-src/operator-chat/runtime-selection-labels.js +27 -0
- package/dist/worker/console/static-src/operator-chat/spatial-overlay.js +1 -2
- package/dist/worker/console/static-src/operator-chat/turn-stream-controller.js +25 -0
- package/dist/worker/console/static-src/operator-chat/turn-submission.js +16 -3
- package/dist/worker/console/static-src/operator-chat/useChatSessions.js +181 -87
- package/dist/worker/console/static-src/operator-chat/useChatStream.js +183 -48
- package/dist/worker/console/static-src/operator-chat/useChatThread.js +177 -8
- package/dist/worker/console/static-src/operator-chat/useComposer.js +32 -0
- package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +63 -9
- package/dist/worker/console/static-src/operator-chat/useWorkspaceBrowserGate.js +4 -0
- package/dist/worker/console/static-src/operator-chat/workspace-layout-mode.js +3 -3
- package/dist/worker/console/static-src/pages/tasks/run-id-resolution.js +79 -0
- package/dist/worker/console/static-src/pages/tasks/run-ownership-verify.js +23 -0
- package/dist/worker/console/static-src/pages/tasks/run-panel-progress.js +110 -0
- package/dist/worker/console/static-src/shell/useWorkspaces.js +299 -0
- package/dist/worker/console/static-src/shell/workspace-route.js +339 -0
- package/dist/worker/console/workspace-context.js +344 -0
- package/dist/worker/console/workspace-registry.js +214 -0
- package/dist/worker/loop-agent/loop-agent-client.js +7 -3
- package/dist/worker/materialize/frontend-split-task-materializer.js +72 -0
- package/dist/worker/materialize/harness-task-lineage.js +5 -2
- package/dist/worker/observability/init-runtime-activity.js +26 -0
- package/dist/worker/observability/read-model.js +22 -0
- package/dist/worker/observe/node-input.js +160 -3
- package/dist/worker/observe/node-process.js +377 -0
- package/dist/worker/observe/routes.js +224 -3
- package/dist/worker/observe/server.js +4 -0
- package/dist/worker/observe/shell-handler-keys.js +34 -0
- package/dist/worker/observe/static/api.js +90 -4
- package/dist/worker/observe/static/app.js +18 -70
- package/dist/worker/observe/static/constants.js +8 -1
- package/dist/worker/observe/static/dag-history-labels.js +4 -0
- package/dist/worker/observe/static/dag-node-purpose.d.ts +6 -0
- package/dist/worker/observe/static/dag-node-purpose.js +208 -0
- package/dist/worker/observe/static/format.js +60 -0
- package/dist/worker/observe/static/index.html +6 -1
- package/dist/worker/observe/static/inspect-workspace.js +307 -0
- package/dist/worker/observe/static/markdown-render.js +20 -1
- package/dist/worker/observe/static/operator-chrome.css +145 -20
- package/dist/worker/observe/static/operator-chrome.d.ts +20 -3
- package/dist/worker/observe/static/operator-chrome.js +330 -96
- package/dist/worker/observe/static/prompt-restart-candidates.d.ts +18 -0
- package/dist/worker/observe/static/prompt-restart-candidates.js +36 -0
- package/dist/worker/observe/static/router.d.ts +46 -0
- package/dist/worker/observe/static/router.js +25 -1
- package/dist/worker/observe/static/state.d.ts +50 -0
- package/dist/worker/observe/static/state.js +184 -1
- package/dist/worker/observe/static/styles.css +395 -0
- package/dist/worker/observe/static/views/dag-graph.js +4 -2
- package/dist/worker/observe/static/views/dag-inspector.js +1067 -9
- package/dist/worker/observe/static/views/dag.d.ts +13 -0
- package/dist/worker/observe/static/views/dag.js +14 -5
- package/dist/worker/observe/static/views/dashboard.js +6 -4
- package/dist/worker/observe/static/views/failures.js +5 -2
- package/dist/worker/observe/static/views/night.js +2 -2
- package/dist/worker/observe/static/views/pool.js +1 -0
- package/dist/worker/observe/static/views/run.js +3 -1
- package/dist/worker/observe/static/views/session-timeline.js +78 -9
- package/dist/worker/observe/static/views/task.js +56 -1
- package/dist/workflows/dag/backend-test-case-coverage-analysis.js +49 -3
- package/dist/workflows/dag/backend-test-markdown-workflow.js +176 -1
- package/dist/workflows/dag/backend-test-pytest-collection.js +98 -14
- package/dist/workflows/dag/backend-test-writer-completeness.js +17 -17
- package/dist/workflows/dag/contract-output-registry.js +15 -0
- package/dist/workflows/dag/contract-validator-registrations.js +1 -2
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +1 -1
- package/dist/workflows/dag/dynamic-runtime/map.js +2 -1
- package/dist/workflows/dag/failure-category.js +7 -116
- 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 +538 -166
- package/dist/workflows/dag/frontend-plan-render.js +24 -0
- package/dist/workflows/dag/frontend-prewrite-gate.js +388 -392
- 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 +202 -0
- package/dist/workflows/dag/frontend-recovery-lineage.js +178 -0
- package/dist/workflows/dag/frontend-recovery-plan.js +17 -10
- package/dist/workflows/dag/frontend-recovery-run.js +33 -20
- package/dist/workflows/dag/frontend-repair.js +7 -424
- package/dist/workflows/dag/frontend-review-context.js +288 -14
- package/dist/workflows/dag/frontend-review-findings.js +270 -0
- package/dist/workflows/dag/frontend-risk.js +15 -2
- package/dist/workflows/dag/frontend-shadow-dual-write.js +814 -0
- package/dist/workflows/dag/frontend-shape-capsule-store.js +191 -0
- package/dist/workflows/dag/frontend-shape-facts.js +409 -0
- package/dist/workflows/dag/frontend-shape.js +427 -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-test-case-checklist.js +30 -4
- package/dist/workflows/dag/frontend-test-case-manifest.js +11 -4
- package/dist/workflows/dag/frontend-test-case-quality.js +11 -16
- package/dist/workflows/dag/frontend-test-environment-probe.js +230 -0
- package/dist/workflows/dag/frontend-test-html-report.js +3 -1
- package/dist/workflows/dag/frontend-test-l5-report.js +3 -1
- package/dist/workflows/dag/frontend-test-layout.js +159 -0
- package/dist/workflows/dag/frontend-test-markdown.js +61 -0
- package/dist/workflows/dag/frontend-test-result-contract.js +188 -42
- package/dist/workflows/dag/frontend-test-standard-scenarios.js +70 -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 +52 -17
- package/dist/workflows/dag/frontend-worktree-diff.js +250 -17
- package/dist/workflows/dag/frontend-writer-admission.js +272 -0
- package/dist/workflows/dag/frontend-writer-status.js +244 -0
- package/dist/workflows/dag/init-hybrid.js +1072 -660
- package/dist/workflows/dag/interrupt-request.js +10 -3
- package/dist/workflows/dag/lifecycle.js +3 -3
- package/dist/workflows/dag/node-execution.js +552 -58
- package/dist/workflows/dag/prompt.js +44 -19
- package/dist/workflows/dag/recovery-lease.js +80 -0
- package/dist/workflows/dag/report.js +37 -1
- package/dist/workflows/dag/rerun-feedback.js +124 -1
- package/dist/workflows/dag/rerun-plan.js +144 -5
- package/dist/workflows/dag/rerun-run.js +81 -6
- package/dist/workflows/dag/rerun-task.js +29 -0
- package/dist/workflows/dag/retry-policy.js +318 -8
- package/dist/workflows/dag/runner.js +429 -122
- package/dist/workflows/dag/scheduler.js +114 -16
- package/dist/workflows/dag/structured-output-repair.js +712 -0
- package/dist/workflows/dag/types.js +338 -31
- package/dist/workflows/dag/upstream-artifacts.js +0 -4
- package/dist/workflows/dag/validate.js +92 -32
- package/docs/README.md +2 -0
- package/docs/architecture/evolution.md +45 -0
- package/docs/init-surface.manifest.json +9 -0
- package/docs/operations/README.md +1 -1
- package/docs/operations/local-development-environment.md +21 -0
- package/docs/templates/README.md +2 -1
- package/docs/templates/agent-dag-report.schema.json +8 -2
- package/docs/templates/agent-dag.schema.json +63 -1
- package/docs/templates/backend-test-dag.json +265 -237
- package/docs/templates/frontend-implementation-contract.schema.json +684 -24
- package/docs/templates/frontend-test-case-checklist.md +2 -2
- package/docs/templates/frontend-test-dag.json +8 -10
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +1 -1
- package/docs/templates/init-managed-agents.md +13 -3
- package/docs/templates/spec-registry.schema.json +45 -0
- package/harness.json +1 -1
- package/package.json +7 -1
- package/scripts/next-info.mjs +356 -0
- package/scripts/next-publish-gate.mjs +238 -0
- package/scripts/release-source-binding.mjs +251 -0
- package/skills/codebase-scout/SKILL.md +1 -1
- package/skills/fe-test-ui-scout/SKILL.md +65 -0
- package/skills/fe-test-ui-scout/references/ledger-schema.md +62 -0
- package/skills/fe-test-ui-scout/references/recon-protocol.md +54 -0
- package/skills/frontend-bounded-implement/SKILL.md +8 -14
- package/skills/frontend-design-review/SKILL.md +20 -41
- package/skills/frontend-implementation/SKILL.md +3 -4
- package/skills/frontend-implementation/references/design-spec.md +15 -10
- package/skills/frontend-implementation/references/node-contracts.md +23 -13
- package/skills/frontend-review/SKILL.md +22 -14
- package/skills/frontend-review/references/review-findings.md +6 -7
- package/skills/loop-agent/references/command-reference.md +12 -4
- package/skills/loop-agent/references/hybrid-dag.md +8 -1
- package/skills/playwright-cli/SKILL.md +23 -1
- package/skills/playwright-cli-case-generator/SKILL.md +29 -8
- package/dist/worker/console/static/assets/channel-CPF4N7pf.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-C2DwA_Y1.js +0 -1
- package/dist/worker/console/static/assets/index-DTZOKgAn.js +0 -404
- package/dist/worker/console/static/assets/index-Ya5FE7cD.css +0 -1
- package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-B8FB_cNk.js +0 -1
- package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CrZYaWfx.js +0 -8
- package/dist/worker/console/static-src/operator-chat/activity-rail-presentation.js +0 -73
- package/dist/worker/console/static-src/operator-chat/useActivityRailTransition.js +0 -59
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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";
|
|
8
|
+
import { executePiStep, resolvePiBackend, } from "./pi-executor.js";
|
|
9
|
+
import { resolveDagPiExtensions, } from "./pi-extension-resolver.js";
|
|
10
|
+
import { buildPiWriterToolPolicyContext, createPiReaderCustomTools, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
|
|
11
|
+
import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
|
|
7
12
|
import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
|
|
8
13
|
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
14
|
+
import { parseLedgerJson } from "../task/source-prepare/ledger.js";
|
|
9
15
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
10
16
|
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
11
|
-
import {
|
|
17
|
+
import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
|
|
18
|
+
import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
|
|
12
19
|
import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
|
|
13
20
|
import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
|
|
21
|
+
import { frontendTestLayoutFromSpec, } from "../workflows/dag/frontend-test-layout.js";
|
|
14
22
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
15
23
|
/**
|
|
16
24
|
* Writer classification for a length-stopped thinking-only attempt: the model
|
|
@@ -21,6 +29,15 @@ import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
|
21
29
|
* recoverable partial-write-set (incomplete-write-set) upgrade.
|
|
22
30
|
*/
|
|
23
31
|
export const WRITER_THINKING_EXHAUSTED_CATEGORY = "writer-thinking-exhausted";
|
|
32
|
+
/**
|
|
33
|
+
* The writer session burned an excessive token budget (a read-edit-test loop
|
|
34
|
+
* that never converged) and still failed. Distinct from empty-output so the
|
|
35
|
+
* report shows the real cause and recovery recommends a fresh compacted run.
|
|
36
|
+
* Not auto-retried by default; operators may rerun after a model switch.
|
|
37
|
+
*/
|
|
38
|
+
export const WRITER_BUDGET_EXHAUSTED_CATEGORY = "writer-budget-exhausted";
|
|
39
|
+
/** Token ceiling for a single writer node before it is judged budget-exhausted. */
|
|
40
|
+
export const WRITER_TOKEN_BUDGET = 2_000_000;
|
|
24
41
|
function readWriterThinkingExhaustionEvidence(result) {
|
|
25
42
|
const wider = result;
|
|
26
43
|
return {
|
|
@@ -163,6 +180,14 @@ function resolvePiExecutorStep(persona) {
|
|
|
163
180
|
function isDagPiWriteTask(task) {
|
|
164
181
|
return task.executor === "pi" && task.toolProfile === "write";
|
|
165
182
|
}
|
|
183
|
+
/**
|
|
184
|
+
* M4: `frontend-implement-pi` derives its status from mechanical facts instead
|
|
185
|
+
* of the IMPLEMENTATION_OUTCOME first line. Everything else (backend/README/
|
|
186
|
+
* module writers + frontend-repair-pi) keeps the legacy protocol face.
|
|
187
|
+
*/
|
|
188
|
+
export function isFrontendFactsWriter(task) {
|
|
189
|
+
return task.writerOutcomePolicy?.type === "frontend-facts-v1";
|
|
190
|
+
}
|
|
166
191
|
/** Map DAG `piStep` / `role` to a safe workflow step for read-only Pi or bounded write. */
|
|
167
192
|
export function resolveDagPiStepName(task) {
|
|
168
193
|
if (isDagPiWriteTask(task)) {
|
|
@@ -174,35 +199,1694 @@ export function resolveDagPiStepName(task) {
|
|
|
174
199
|
if (task.role) {
|
|
175
200
|
return PI_WRITE_ROLE_TO_STEP[task.role];
|
|
176
201
|
}
|
|
177
|
-
return "implement";
|
|
178
|
-
}
|
|
179
|
-
return resolvePiExecutorStep(resolveDagPiPersona(task));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
202
|
+
return "implement";
|
|
203
|
+
}
|
|
204
|
+
return resolvePiExecutorStep(resolveDagPiPersona(task));
|
|
205
|
+
}
|
|
206
|
+
/** A+B: `frontend-contract-pi` incremental record tools (origin=contract). */
|
|
207
|
+
export const FRONTEND_CONTRACT_RECORD_TOOL_NAMES = [
|
|
208
|
+
"record_requirement",
|
|
209
|
+
"record_constraint",
|
|
210
|
+
"record_evidence_expectation",
|
|
211
|
+
"record_handoff_intent",
|
|
212
|
+
"record_open_question",
|
|
213
|
+
"record_split_proposal",
|
|
214
|
+
];
|
|
215
|
+
export const FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES = [
|
|
216
|
+
"finalize_contract",
|
|
217
|
+
];
|
|
218
|
+
/** A+B: `frontend-scout-pi` incremental evidence tools (origin=scout). */
|
|
219
|
+
export const FRONTEND_SCOUT_EVIDENCE_TOOL_NAMES = [
|
|
220
|
+
"record_target_surface",
|
|
221
|
+
"record_design_evidence",
|
|
222
|
+
];
|
|
223
|
+
/** A+B: `frontend-plan-pi` incremental record tools (origin=plan). */
|
|
224
|
+
export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
|
|
225
|
+
"record_route_selection",
|
|
226
|
+
"record_component_choice",
|
|
227
|
+
"record_state_flow",
|
|
228
|
+
"record_data_flow",
|
|
229
|
+
"record_mock_api",
|
|
230
|
+
"record_design_deviation",
|
|
231
|
+
"record_dependency",
|
|
232
|
+
"record_plan_requirement",
|
|
233
|
+
"record_plan_verification_target",
|
|
234
|
+
"record_plan_evidence_gap",
|
|
235
|
+
];
|
|
236
|
+
export const FRONTEND_PLAN_TERMINAL_TOOL_NAMES = ["finalize_plan"];
|
|
237
|
+
export const FRONTEND_PLAN_ADOPT_TOOL_NAMES = ["adopt_staged_fact"];
|
|
238
|
+
/** M5: `frontend-review-pi` emits its authoritative terminal verdict through
|
|
239
|
+
* committed typed tools instead of the legacy JSON verdict parse. */
|
|
240
|
+
export function isFrontendReviewTypedTerminalNode(task) {
|
|
241
|
+
return task.id === "frontend-review-pi";
|
|
242
|
+
}
|
|
243
|
+
/** M8: `frontend-design-review-pi` emits its authoritative terminal verdict
|
|
244
|
+
* through committed typed tools (`approve_design` / `request_design_changes`)
|
|
245
|
+
* instead of the legacy first-line `VERDICT: pass|request-revision` text. */
|
|
246
|
+
export function isFrontendDesignTypedTerminalNode(task) {
|
|
247
|
+
return task.id === "frontend-design-review-pi";
|
|
248
|
+
}
|
|
249
|
+
/** A+B: `frontend-contract-pi` submits its contract through incremental typed
|
|
250
|
+
* tools + the `finalize_contract` terminal (origin=contract). */
|
|
251
|
+
export function isFrontendContractTypedNode(task) {
|
|
252
|
+
return task.id === "frontend-contract-pi";
|
|
253
|
+
}
|
|
254
|
+
/** A+B: `frontend-scout-pi` submits target-surface/design-evidence through
|
|
255
|
+
* incremental evidence tools (origin=scout). */
|
|
256
|
+
export function isFrontendScoutEvidenceNode(task) {
|
|
257
|
+
return task.id === "frontend-scout-pi";
|
|
258
|
+
}
|
|
259
|
+
/** A+B: `frontend-plan-pi` records its decision ledger through seven
|
|
260
|
+
* incremental `record_*` tools and closes with `finalize_plan`. */
|
|
261
|
+
export function isFrontendPlanLedgerNode(task) {
|
|
262
|
+
return task.id === "frontend-plan-pi";
|
|
263
|
+
}
|
|
264
|
+
export function resolveDagPiToolNames(task) {
|
|
265
|
+
if (isFrontendReviewTypedTerminalNode(task)) {
|
|
266
|
+
return [
|
|
267
|
+
...DAG_PI_READONLY_TOOLS,
|
|
268
|
+
"approve_review",
|
|
269
|
+
"request_review_changes",
|
|
270
|
+
];
|
|
271
|
+
}
|
|
272
|
+
if (isFrontendDesignTypedTerminalNode(task)) {
|
|
273
|
+
return [
|
|
274
|
+
...DAG_PI_READONLY_TOOLS,
|
|
275
|
+
"approve_design",
|
|
276
|
+
"request_design_changes",
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
if (isFrontendContractTypedNode(task)) {
|
|
280
|
+
return [
|
|
281
|
+
...DAG_PI_READONLY_TOOLS,
|
|
282
|
+
...FRONTEND_CONTRACT_RECORD_TOOL_NAMES,
|
|
283
|
+
...FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES,
|
|
284
|
+
];
|
|
285
|
+
}
|
|
286
|
+
if (isFrontendScoutEvidenceNode(task)) {
|
|
287
|
+
return [...DAG_PI_READONLY_TOOLS, ...FRONTEND_SCOUT_EVIDENCE_TOOL_NAMES];
|
|
288
|
+
}
|
|
289
|
+
if (isFrontendPlanLedgerNode(task)) {
|
|
290
|
+
return [
|
|
291
|
+
// Plan is a decision-only node. Contract/scout own source and repository
|
|
292
|
+
// discovery; omitting read tools prevents a planner from spending its
|
|
293
|
+
// output budget reconstructing already-frozen evidence.
|
|
294
|
+
...FRONTEND_PLAN_RECORD_TOOL_NAMES,
|
|
295
|
+
...FRONTEND_PLAN_TERMINAL_TOOL_NAMES,
|
|
296
|
+
...FRONTEND_PLAN_ADOPT_TOOL_NAMES,
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
if ((task.readSet?.length ?? 0) > 0) {
|
|
300
|
+
return isDagPiWriteTask(task) ? ["read", "edit", "write"] : ["read"];
|
|
301
|
+
}
|
|
302
|
+
if (!isDagPiWriteTask(task))
|
|
303
|
+
return [...DAG_PI_READONLY_TOOLS];
|
|
304
|
+
const tools = [...DAG_PI_WRITE_TOOLS];
|
|
305
|
+
// Pi SDK activates custom tools only when they are in this explicit list.
|
|
306
|
+
// Command capabilities (playwright_cli) stay capability-gated; read-only nodes never get command tools.
|
|
307
|
+
if (dagCommandPolicyAllows(task.commandPolicy, "playwright-cli")) {
|
|
308
|
+
tools.push("playwright_cli");
|
|
309
|
+
}
|
|
310
|
+
return tools;
|
|
311
|
+
}
|
|
312
|
+
export const FRONTEND_REVIEW_TERMINAL_TOOL_NAMES = new Set([
|
|
313
|
+
"approve_review",
|
|
314
|
+
"request_review_changes",
|
|
315
|
+
]);
|
|
316
|
+
/**
|
|
317
|
+
* Fail-closed scanner for the two committed review terminal tools. Mirrors
|
|
318
|
+
* M4's `countWriteToolEventsFromSessionEvents` pattern: an unparseable line
|
|
319
|
+
* never counts as a terminal fact, so a corrupt/empty log cannot inflate the
|
|
320
|
+
* typed verdict.
|
|
321
|
+
*/
|
|
322
|
+
export function scanReviewTerminalKindsFromSessionEvents(content) {
|
|
323
|
+
const kinds = [];
|
|
324
|
+
for (const line of content.split("\n")) {
|
|
325
|
+
const trimmed = line.trim();
|
|
326
|
+
if (!trimmed)
|
|
327
|
+
continue;
|
|
328
|
+
let event;
|
|
329
|
+
try {
|
|
330
|
+
event = JSON.parse(trimmed);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
if (event !== null &&
|
|
336
|
+
typeof event === "object" &&
|
|
337
|
+
event.type === "tool_execution_start" &&
|
|
338
|
+
typeof event.toolName === "string" &&
|
|
339
|
+
FRONTEND_REVIEW_TERMINAL_TOOL_NAMES.has(event.toolName)) {
|
|
340
|
+
kinds.push(event.toolName);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return kinds;
|
|
344
|
+
}
|
|
345
|
+
/** Read-only discovery tool budget for frontend-plan-pi. Exceeding it means
|
|
346
|
+
* the plan re-read upstream outputs/sources instead of trusting typed facts,
|
|
347
|
+
* which blows up the context window (400 request-too-large). */
|
|
348
|
+
const PLAN_READ_TOOL_BUDGET = 40;
|
|
349
|
+
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "ls", "find"]);
|
|
350
|
+
function readEventPath(event) {
|
|
351
|
+
const candidates = [event.path, event.readPath, event.input];
|
|
352
|
+
for (const value of candidates) {
|
|
353
|
+
if (typeof value === "string")
|
|
354
|
+
return value;
|
|
355
|
+
if (value && typeof value === "object") {
|
|
356
|
+
const nested = value;
|
|
357
|
+
for (const key of ["path", "readPath", "file"]) {
|
|
358
|
+
if (typeof nested[key] === "string")
|
|
359
|
+
return nested[key];
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return undefined;
|
|
364
|
+
}
|
|
365
|
+
function eventTimestamp(event) {
|
|
366
|
+
for (const key of ["timestamp", "timestampMs", "ts", "createdAt"]) {
|
|
367
|
+
const value = event[key];
|
|
368
|
+
if (typeof value === "number" && Number.isFinite(value))
|
|
369
|
+
return value < 10_000_000_000 ? value * 1000 : value;
|
|
370
|
+
if (typeof value === "string") {
|
|
371
|
+
const parsed = Date.parse(value);
|
|
372
|
+
if (Number.isFinite(parsed))
|
|
373
|
+
return parsed;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return undefined;
|
|
377
|
+
}
|
|
378
|
+
function eventBytes(event, line) {
|
|
379
|
+
const result = event.result ?? event.toolResult ?? event.output;
|
|
380
|
+
if (typeof result === "string")
|
|
381
|
+
return Buffer.byteLength(result);
|
|
382
|
+
if (result !== undefined)
|
|
383
|
+
return Buffer.byteLength(JSON.stringify(result));
|
|
384
|
+
return Buffer.byteLength(line);
|
|
385
|
+
}
|
|
386
|
+
export async function detectNodeReadBudget(input) {
|
|
387
|
+
if (!input.budget)
|
|
388
|
+
return [];
|
|
389
|
+
const sessionEventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
|
|
390
|
+
let content;
|
|
391
|
+
try {
|
|
392
|
+
content = await readFile(sessionEventsPath, "utf8");
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return [];
|
|
396
|
+
}
|
|
397
|
+
const stats = { files: new Set(), bytes: 0, tools: 0 };
|
|
398
|
+
let firstTs;
|
|
399
|
+
let lastTs;
|
|
400
|
+
for (const line of content.split("\n")) {
|
|
401
|
+
if (!line.trim())
|
|
402
|
+
continue;
|
|
403
|
+
try {
|
|
404
|
+
const event = JSON.parse(line);
|
|
405
|
+
if (event.type !== "tool_execution_start" || typeof event.toolName !== "string" || !READ_ONLY_TOOL_NAMES.has(event.toolName))
|
|
406
|
+
continue;
|
|
407
|
+
stats.tools++;
|
|
408
|
+
const readPath = readEventPath(event);
|
|
409
|
+
if (readPath)
|
|
410
|
+
stats.files.add(readPath);
|
|
411
|
+
stats.bytes += eventBytes(event, line);
|
|
412
|
+
const ts = eventTimestamp(event);
|
|
413
|
+
if (ts !== undefined) {
|
|
414
|
+
firstTs ??= ts;
|
|
415
|
+
lastTs = ts;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
catch { /* ignore malformed telemetry */ }
|
|
419
|
+
}
|
|
420
|
+
if (firstTs !== undefined && lastTs !== undefined)
|
|
421
|
+
stats.elapsedMs = Math.max(0, lastTs - firstTs);
|
|
422
|
+
const issues = [];
|
|
423
|
+
if (stats.files.size > input.budget.maxFiles)
|
|
424
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.files.size} files (budget ${input.budget.maxFiles})`);
|
|
425
|
+
if (stats.bytes > input.budget.maxBytes)
|
|
426
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.bytes} bytes (budget ${input.budget.maxBytes})`);
|
|
427
|
+
if (input.budget.maxMs !== undefined &&
|
|
428
|
+
stats.elapsedMs !== undefined &&
|
|
429
|
+
stats.elapsedMs > input.budget.maxMs)
|
|
430
|
+
issues.push(`frontend ${input.nodeId} read budget exceeded: ${stats.elapsedMs}ms (budget ${input.budget.maxMs}ms)`);
|
|
431
|
+
return issues;
|
|
432
|
+
}
|
|
433
|
+
/** Deterministic read-burst guard for frontend-plan-pi: count read-only
|
|
434
|
+
* discovery tool calls (read/grep/ls/find) from the session log. Over budget →
|
|
435
|
+
* read-burst, retried with a reduced-reading instruction. Pure scan; a
|
|
436
|
+
* successful plan under budget is never blocked. */
|
|
437
|
+
export async function detectPlanReadBurst(input) {
|
|
438
|
+
const sessionEventsPath = path.join(input.runDir, input.nodeId, "session-events.jsonl");
|
|
439
|
+
let count = 0;
|
|
440
|
+
try {
|
|
441
|
+
const content = await readFile(sessionEventsPath, "utf8");
|
|
442
|
+
for (const line of content.split("\n")) {
|
|
443
|
+
if (!line.trim())
|
|
444
|
+
continue;
|
|
445
|
+
try {
|
|
446
|
+
const event = JSON.parse(line);
|
|
447
|
+
if (event.type === "tool_execution_start" &&
|
|
448
|
+
typeof event.toolName === "string" &&
|
|
449
|
+
(event.toolName === "read" ||
|
|
450
|
+
event.toolName === "grep" ||
|
|
451
|
+
event.toolName === "ls" ||
|
|
452
|
+
event.toolName === "find")) {
|
|
453
|
+
count += 1;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
// skip unparseable line
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
// Missing/unreadable session log → no burst detection
|
|
463
|
+
return [];
|
|
464
|
+
}
|
|
465
|
+
if (count > PLAN_READ_TOOL_BUDGET) {
|
|
466
|
+
return [
|
|
467
|
+
`frontend plan read-burst: ${count} read-only tool calls (budget ${PLAN_READ_TOOL_BUDGET}). Trust the upstream contract/scout typed facts; do not re-read contract/scout outputs or source files already captured. Minimize discovery reads, commit record_* facts directly, then finalize_plan.`,
|
|
468
|
+
];
|
|
469
|
+
}
|
|
470
|
+
return [];
|
|
471
|
+
}
|
|
472
|
+
export const FRONTEND_DESIGN_TERMINAL_TOOL_NAMES = new Set([
|
|
473
|
+
"approve_design",
|
|
474
|
+
"request_design_changes",
|
|
475
|
+
]);
|
|
476
|
+
/**
|
|
477
|
+
* Fail-closed scanner for the two committed design terminal tools. Mirrors the
|
|
478
|
+
* review scanner: an unparseable line never counts as a terminal fact, so a
|
|
479
|
+
* corrupt/empty log cannot inflate the typed verdict.
|
|
480
|
+
*/
|
|
481
|
+
export function scanDesignTerminalKindsFromSessionEvents(content) {
|
|
482
|
+
const kinds = [];
|
|
483
|
+
for (const line of content.split("\n")) {
|
|
484
|
+
const trimmed = line.trim();
|
|
485
|
+
if (!trimmed)
|
|
486
|
+
continue;
|
|
487
|
+
let event;
|
|
488
|
+
try {
|
|
489
|
+
event = JSON.parse(trimmed);
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (event !== null &&
|
|
495
|
+
typeof event === "object" &&
|
|
496
|
+
event.type === "tool_execution_start" &&
|
|
497
|
+
typeof event.toolName === "string" &&
|
|
498
|
+
FRONTEND_DESIGN_TERMINAL_TOOL_NAMES.has(event.toolName)) {
|
|
499
|
+
kinds.push(event.toolName);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
return kinds;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* M5: build the two committed typed review terminal tools (approve_review /
|
|
506
|
+
* request_review_changes). Each tool validates its parameters with the review
|
|
507
|
+
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
508
|
+
* store, and returns a structured receipt. Terminal conflicts (a second
|
|
509
|
+
* terminal commit) are caught inside execute and returned as an error receipt
|
|
510
|
+
* rather than crashing the node.
|
|
511
|
+
*/
|
|
512
|
+
export async function createFrontendReviewTerminalTools(input) {
|
|
513
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
514
|
+
import("typebox"),
|
|
515
|
+
import("@earendil-works/pi-coding-agent"),
|
|
516
|
+
]);
|
|
517
|
+
const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
518
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
519
|
+
const store = input.store;
|
|
520
|
+
const attemptId = input.attemptId;
|
|
521
|
+
const findingSchema = Type.Object({
|
|
522
|
+
severity: Type.String({
|
|
523
|
+
description: "Critical | Important | Minor | Info",
|
|
524
|
+
}),
|
|
525
|
+
file: Type.Optional(Type.String({})),
|
|
526
|
+
line: Type.Optional(Type.Number({})),
|
|
527
|
+
issue: Type.String({}),
|
|
528
|
+
requiredChange: Type.Optional(Type.String({})),
|
|
529
|
+
}, { additionalProperties: false });
|
|
530
|
+
const approveParameters = Type.Object({
|
|
531
|
+
findings: Type.Array(findingSchema, {
|
|
532
|
+
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
533
|
+
}),
|
|
534
|
+
}, { additionalProperties: false });
|
|
535
|
+
const requestParameters = Type.Object({
|
|
536
|
+
issueCategory: Type.Enum({
|
|
537
|
+
"implementation-mismatch": "implementation-mismatch",
|
|
538
|
+
"approved-design-defect": "approved-design-defect",
|
|
539
|
+
"target-surface-defect": "target-surface-defect",
|
|
540
|
+
"contract-requirement-gap": "contract-requirement-gap",
|
|
541
|
+
"unknown": "unknown",
|
|
542
|
+
}, { description: "Typed issue category (five-value enum)" }),
|
|
543
|
+
evidenceRefs: Type.Array(Type.String({}), {
|
|
544
|
+
description: "Evidence refs (paths or artifact ids); at least one",
|
|
545
|
+
}),
|
|
546
|
+
findings: Type.Array(findingSchema, {
|
|
547
|
+
description: "At least one finding",
|
|
548
|
+
}),
|
|
549
|
+
}, { additionalProperties: false });
|
|
550
|
+
async function adoptReviewFact(kind, fact) {
|
|
551
|
+
const requestId = randomUUID();
|
|
552
|
+
try {
|
|
553
|
+
const parsed = kind === "approve_review"
|
|
554
|
+
? approveReviewFactSchema.parse(fact)
|
|
555
|
+
: requestReviewChangesFactSchema.parse(fact);
|
|
556
|
+
const staged = stageTypedEventFact({
|
|
557
|
+
store,
|
|
558
|
+
requestId,
|
|
559
|
+
attemptId,
|
|
560
|
+
fact: parsed,
|
|
561
|
+
});
|
|
562
|
+
const adopted = await adoptTypedEventFact({
|
|
563
|
+
store,
|
|
564
|
+
requestId,
|
|
565
|
+
attemptId,
|
|
566
|
+
fact: parsed,
|
|
567
|
+
eventId: staged.eventId,
|
|
568
|
+
expectedRevision: store.revision,
|
|
569
|
+
});
|
|
570
|
+
return {
|
|
571
|
+
content: [
|
|
572
|
+
{
|
|
573
|
+
type: "text",
|
|
574
|
+
text: JSON.stringify({
|
|
575
|
+
ok: true,
|
|
576
|
+
kind,
|
|
577
|
+
eventId: adopted.eventId,
|
|
578
|
+
revision: adopted.revision,
|
|
579
|
+
}),
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
details: {
|
|
583
|
+
ok: true,
|
|
584
|
+
kind,
|
|
585
|
+
eventId: adopted.eventId,
|
|
586
|
+
revision: adopted.revision,
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
catch (error) {
|
|
591
|
+
const code = error?.code;
|
|
592
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
593
|
+
return {
|
|
594
|
+
content: [
|
|
595
|
+
{
|
|
596
|
+
type: "text",
|
|
597
|
+
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
598
|
+
},
|
|
599
|
+
],
|
|
600
|
+
details: { ok: false, kind, code, error: message },
|
|
601
|
+
};
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const approveReviewTool = defineTool({
|
|
605
|
+
name: "approve_review",
|
|
606
|
+
label: "approve_review",
|
|
607
|
+
description: "Commit the authoritative approve_review terminal fact. Use only when the implementation passes review with no Critical/Important findings.",
|
|
608
|
+
promptSnippet: "Commit the authoritative approve_review terminal verdict (no Critical/Important findings).",
|
|
609
|
+
parameters: approveParameters,
|
|
610
|
+
async execute(_toolCallId, params) {
|
|
611
|
+
return adoptReviewFact("approve_review", {
|
|
612
|
+
kind: "approve_review",
|
|
613
|
+
verdict: "approve_review",
|
|
614
|
+
findings: params?.findings ?? [],
|
|
615
|
+
});
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
const requestReviewChangesTool = defineTool({
|
|
619
|
+
name: "request_review_changes",
|
|
620
|
+
label: "request_review_changes",
|
|
621
|
+
description: "Commit the authoritative request_review_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
622
|
+
promptSnippet: "Commit the authoritative request_review_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
623
|
+
parameters: requestParameters,
|
|
624
|
+
async execute(_toolCallId, params) {
|
|
625
|
+
return adoptReviewFact("request_review_changes", {
|
|
626
|
+
kind: "request_review_changes",
|
|
627
|
+
verdict: "request_review_changes",
|
|
628
|
+
issueCategory: params?.issueCategory,
|
|
629
|
+
evidenceRefs: params?.evidenceRefs,
|
|
630
|
+
findings: params?.findings,
|
|
631
|
+
});
|
|
632
|
+
},
|
|
633
|
+
});
|
|
634
|
+
return {
|
|
635
|
+
customTools: [approveReviewTool, requestReviewChangesTool],
|
|
636
|
+
flush: async () => {
|
|
637
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
638
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), committed);
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* M8: build the two committed typed design terminal tools (approve_design /
|
|
644
|
+
* request_design_changes). Each tool validates its parameters with the design
|
|
645
|
+
* fact zod schemas, stages + adopts a terminal fact into the typed event
|
|
646
|
+
* store, and returns a structured receipt. Terminal conflicts are caught
|
|
647
|
+
* inside execute and returned as an error receipt rather than crashing the
|
|
648
|
+
* node.
|
|
649
|
+
*/
|
|
650
|
+
export async function createFrontendDesignTerminalTools(input) {
|
|
651
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
652
|
+
import("typebox"),
|
|
653
|
+
import("@earendil-works/pi-coding-agent"),
|
|
654
|
+
]);
|
|
655
|
+
const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
656
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
657
|
+
const store = input.store;
|
|
658
|
+
const attemptId = input.attemptId;
|
|
659
|
+
const findingSchema = Type.Object({
|
|
660
|
+
severity: Type.String({
|
|
661
|
+
description: "Critical | Important | Minor | Info",
|
|
662
|
+
}),
|
|
663
|
+
file: Type.Optional(Type.String({})),
|
|
664
|
+
line: Type.Optional(Type.Number({})),
|
|
665
|
+
issue: Type.String({}),
|
|
666
|
+
requiredChange: Type.Optional(Type.String({})),
|
|
667
|
+
}, { additionalProperties: false });
|
|
668
|
+
const approveParameters = Type.Object({
|
|
669
|
+
findings: Type.Array(findingSchema, {
|
|
670
|
+
description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
|
|
671
|
+
}),
|
|
672
|
+
}, { additionalProperties: false });
|
|
673
|
+
const requestParameters = Type.Object({
|
|
674
|
+
issueCategory: Type.Enum({
|
|
675
|
+
"implementation-mismatch": "implementation-mismatch",
|
|
676
|
+
"approved-design-defect": "approved-design-defect",
|
|
677
|
+
"target-surface-defect": "target-surface-defect",
|
|
678
|
+
"contract-requirement-gap": "contract-requirement-gap",
|
|
679
|
+
"unknown": "unknown",
|
|
680
|
+
}, { description: "Typed issue category (five-value enum)" }),
|
|
681
|
+
evidenceRefs: Type.Array(Type.String({}), {
|
|
682
|
+
description: "Evidence refs (paths or artifact ids); at least one",
|
|
683
|
+
}),
|
|
684
|
+
findings: Type.Array(findingSchema, {
|
|
685
|
+
description: "At least one finding",
|
|
686
|
+
}),
|
|
687
|
+
}, { additionalProperties: false });
|
|
688
|
+
async function adoptDesignFact(kind, fact) {
|
|
689
|
+
const requestId = randomUUID();
|
|
690
|
+
try {
|
|
691
|
+
const parsed = kind === "approve_design"
|
|
692
|
+
? approveDesignFactSchema.parse(fact)
|
|
693
|
+
: requestDesignChangesFactSchema.parse(fact);
|
|
694
|
+
const staged = stageTypedEventFact({
|
|
695
|
+
store,
|
|
696
|
+
requestId,
|
|
697
|
+
attemptId,
|
|
698
|
+
fact: parsed,
|
|
699
|
+
});
|
|
700
|
+
const adopted = await adoptTypedEventFact({
|
|
701
|
+
store,
|
|
702
|
+
requestId,
|
|
703
|
+
attemptId,
|
|
704
|
+
fact: parsed,
|
|
705
|
+
eventId: staged.eventId,
|
|
706
|
+
expectedRevision: store.revision,
|
|
707
|
+
});
|
|
708
|
+
return {
|
|
709
|
+
content: [
|
|
710
|
+
{
|
|
711
|
+
type: "text",
|
|
712
|
+
text: JSON.stringify({
|
|
713
|
+
ok: true,
|
|
714
|
+
kind,
|
|
715
|
+
eventId: adopted.eventId,
|
|
716
|
+
revision: adopted.revision,
|
|
717
|
+
}),
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
details: {
|
|
721
|
+
ok: true,
|
|
722
|
+
kind,
|
|
723
|
+
eventId: adopted.eventId,
|
|
724
|
+
revision: adopted.revision,
|
|
725
|
+
},
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
catch (error) {
|
|
729
|
+
const code = error?.code;
|
|
730
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
731
|
+
return {
|
|
732
|
+
content: [
|
|
733
|
+
{
|
|
734
|
+
type: "text",
|
|
735
|
+
text: JSON.stringify({ ok: false, kind, code, error: message }),
|
|
736
|
+
},
|
|
737
|
+
],
|
|
738
|
+
details: { ok: false, kind, code, error: message },
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
const approveDesignTool = defineTool({
|
|
743
|
+
name: "approve_design",
|
|
744
|
+
label: "approve_design",
|
|
745
|
+
description: "Commit the authoritative approve_design terminal fact. Use only when the plan passes design review with no Critical/Important findings.",
|
|
746
|
+
promptSnippet: "Commit the authoritative approve_design terminal verdict (no Critical/Important findings).",
|
|
747
|
+
parameters: approveParameters,
|
|
748
|
+
async execute(_toolCallId, params) {
|
|
749
|
+
return adoptDesignFact("approve_design", {
|
|
750
|
+
kind: "approve_design",
|
|
751
|
+
verdict: "approve_design",
|
|
752
|
+
findings: params?.findings ?? [],
|
|
753
|
+
});
|
|
754
|
+
},
|
|
755
|
+
});
|
|
756
|
+
const requestDesignChangesTool = defineTool({
|
|
757
|
+
name: "request_design_changes",
|
|
758
|
+
label: "request_design_changes",
|
|
759
|
+
description: "Commit the authoritative request_design_changes terminal fact. Requires a typed issueCategory, at least one evidenceRef, and non-empty findings.",
|
|
760
|
+
promptSnippet: "Commit the authoritative request_design_changes terminal verdict (issueCategory + evidenceRefs + findings required).",
|
|
761
|
+
parameters: requestParameters,
|
|
762
|
+
async execute(_toolCallId, params) {
|
|
763
|
+
return adoptDesignFact("request_design_changes", {
|
|
764
|
+
kind: "request_design_changes",
|
|
765
|
+
verdict: "request_design_changes",
|
|
766
|
+
issueCategory: params?.issueCategory,
|
|
767
|
+
evidenceRefs: params?.evidenceRefs,
|
|
768
|
+
findings: params?.findings,
|
|
769
|
+
});
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
return {
|
|
773
|
+
customTools: [approveDesignTool, requestDesignChangesTool],
|
|
774
|
+
flush: async () => {
|
|
775
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
776
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), committed);
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* Source fidelity ledger (AC-005/AC-006): load the contract node's committed
|
|
782
|
+
* requirement facts and build a requirement-id → provenance map. The contract
|
|
783
|
+
* node declared sourceFragmentIds/sourceRefs from the ledger's
|
|
784
|
+
* requirement→fragment mapping; the plan inherits them by id so the compiled
|
|
785
|
+
* canonical contract carries authoritative provenance without the plan
|
|
786
|
+
* re-deriving (or fabricating) it. Best-effort: missing/unreadable contract
|
|
787
|
+
* ledger yields an empty map and the plan compiles as before (the design
|
|
788
|
+
* policy shell will then fail closed on missing provenance).
|
|
789
|
+
*/
|
|
790
|
+
async function loadContractRequirementInheritance(runDir) {
|
|
791
|
+
const { readTypedEventStoreFromJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
792
|
+
let records;
|
|
793
|
+
try {
|
|
794
|
+
records = await readTypedEventStoreFromJsonl(path.join(runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return new Map();
|
|
798
|
+
}
|
|
799
|
+
const byId = new Map();
|
|
800
|
+
for (const record of records) {
|
|
801
|
+
const fact = record.fact;
|
|
802
|
+
if (!fact || typeof fact !== "object")
|
|
803
|
+
continue;
|
|
804
|
+
const recordFact = fact;
|
|
805
|
+
if (recordFact.origin !== "contract" ||
|
|
806
|
+
recordFact.kind !== "requirement") {
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
// Contract requirement facts carry id/sourceFragmentIds/sourceRefs on
|
|
810
|
+
// the fact itself (origin=contract, kind=requirement, id, text, ...),
|
|
811
|
+
// not inside an `entry` wrapper.
|
|
812
|
+
const id = typeof recordFact.id === "string" ? recordFact.id : "";
|
|
813
|
+
if (!id)
|
|
814
|
+
continue;
|
|
815
|
+
const sourceFragmentIds = Array.isArray(recordFact.sourceFragmentIds)
|
|
816
|
+
? recordFact.sourceFragmentIds.filter((value) => typeof value === "string")
|
|
817
|
+
: undefined;
|
|
818
|
+
const sourceRefs = Array.isArray(recordFact.sourceRefs)
|
|
819
|
+
? recordFact.sourceRefs.filter((value) => typeof value === "string")
|
|
820
|
+
: undefined;
|
|
821
|
+
if (sourceFragmentIds || sourceRefs) {
|
|
822
|
+
byId.set(id, {
|
|
823
|
+
...(sourceFragmentIds ? { sourceFragmentIds } : {}),
|
|
824
|
+
...(sourceRefs ? { sourceRefs } : {}),
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return byId;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Resolve task-source citations from the source-fidelity ledger before the
|
|
832
|
+
* planner starts. The planner names a frozen requirement id; it never needs
|
|
833
|
+
* to re-read a PRD merely to recover a path/section/line triple.
|
|
834
|
+
*/
|
|
835
|
+
async function resolveFrontendPlanNewComponentSourceReferences(input) {
|
|
836
|
+
const binding = input.sourceBinding;
|
|
837
|
+
if (!binding || binding.schemaVersion !== 2)
|
|
838
|
+
return new Map();
|
|
839
|
+
const ledgerPath = binding.ledgerPath;
|
|
840
|
+
const absolutePath = path.resolve(input.cwd, ledgerPath);
|
|
841
|
+
const workspaceRoot = path.resolve(input.cwd);
|
|
842
|
+
if (absolutePath !== workspaceRoot &&
|
|
843
|
+
!absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
844
|
+
return new Map();
|
|
845
|
+
}
|
|
846
|
+
try {
|
|
847
|
+
const ledger = parseLedgerJson(await readFile(absolutePath, "utf8"));
|
|
848
|
+
const fragmentsById = new Map(ledger.fragments.map((fragment) => [fragment.id, fragment]));
|
|
849
|
+
const references = new Map();
|
|
850
|
+
for (const requirement of ledger.canonicalRequirements) {
|
|
851
|
+
const fragment = requirement.sourceFragmentIds
|
|
852
|
+
.map((fragmentId) => fragmentsById.get(fragmentId))
|
|
853
|
+
.find((candidate) => candidate !== undefined);
|
|
854
|
+
if (!fragment)
|
|
855
|
+
continue;
|
|
856
|
+
references.set(requirement.id, {
|
|
857
|
+
path: fragment.path,
|
|
858
|
+
section: fragment.headingPath,
|
|
859
|
+
line: fragment.lineRange.start,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
return references;
|
|
863
|
+
}
|
|
864
|
+
catch {
|
|
865
|
+
return new Map();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* A+B: `frontend-plan-pi` now records its decision ledger through seven
|
|
870
|
+
* incremental `record_*` tools (origin=plan) and closes with exactly one
|
|
871
|
+
* `finalize_plan` terminal. A later attempt may explicitly adopt a quarantined
|
|
872
|
+
* fact via `adopt_staged_fact`. Flush writes `plan-typed-facts.jsonl` for the
|
|
873
|
+
* node validator / compile authority.
|
|
874
|
+
*/
|
|
875
|
+
export async function createFrontendPlanLedgerTools(input) {
|
|
876
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
877
|
+
import("typebox"),
|
|
878
|
+
import("@earendil-works/pi-coding-agent"),
|
|
879
|
+
]);
|
|
880
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
881
|
+
const { adoptStagedFact, adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
882
|
+
const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
|
|
883
|
+
const store = input.store;
|
|
884
|
+
const attemptId = input.attemptId;
|
|
885
|
+
const stringArray = Type.Array(Type.String({}));
|
|
886
|
+
const optionalString = Type.Optional(Type.String({}));
|
|
887
|
+
const optionalStringArray = Type.Optional(stringArray);
|
|
888
|
+
const requirementSchema = Type.Object({
|
|
889
|
+
id: Type.String({}),
|
|
890
|
+
expectedOutcome: Type.Optional(Type.String({
|
|
891
|
+
description: "Optional. When omitted, the runtime derives it from the contract requirement. Prefer omitting it to keep this tool call small.",
|
|
892
|
+
})),
|
|
893
|
+
implementationTargets: stringArray,
|
|
894
|
+
verificationTargetIds: stringArray,
|
|
895
|
+
evidenceGap: Type.Optional(Type.Object({
|
|
896
|
+
requirementId: optionalString,
|
|
897
|
+
description: Type.String({}),
|
|
898
|
+
blocking: Type.Boolean(),
|
|
899
|
+
}, { additionalProperties: false })),
|
|
900
|
+
}, { additionalProperties: false });
|
|
901
|
+
const uiStateSchema = Type.Object({
|
|
902
|
+
name: Type.String({}),
|
|
903
|
+
applicable: Type.Boolean(),
|
|
904
|
+
expectedBehavior: optionalString,
|
|
905
|
+
implementationTargets: optionalStringArray,
|
|
906
|
+
verificationTargetIds: optionalStringArray,
|
|
907
|
+
notApplicableReason: optionalString,
|
|
908
|
+
reason: Type.Optional(Type.String({})),
|
|
909
|
+
}, { additionalProperties: false });
|
|
910
|
+
const interactionSchema = Type.Object({
|
|
911
|
+
name: optionalString,
|
|
912
|
+
id: Type.Optional(Type.String({})),
|
|
913
|
+
trigger: Type.String({}),
|
|
914
|
+
expectedBehavior: Type.String({}),
|
|
915
|
+
implementationTargets: stringArray,
|
|
916
|
+
verificationTargetIds: stringArray,
|
|
917
|
+
}, { additionalProperties: false });
|
|
918
|
+
const mockEndpointSchema = Type.Object({
|
|
919
|
+
method: Type.String({
|
|
920
|
+
description: "GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS",
|
|
921
|
+
}),
|
|
922
|
+
path: Type.String({}),
|
|
923
|
+
fixture: optionalString,
|
|
924
|
+
consumer: optionalString,
|
|
925
|
+
}, { additionalProperties: false });
|
|
926
|
+
const mockApiSchema = Type.Object({
|
|
927
|
+
strategy: Type.String({
|
|
928
|
+
description: "native | browser-intercept | request-adapter | not-needed",
|
|
929
|
+
}),
|
|
930
|
+
activation: Type.String({}),
|
|
931
|
+
endpoints: Type.Array(mockEndpointSchema),
|
|
932
|
+
}, { additionalProperties: false });
|
|
933
|
+
const designEvidenceSchema = Type.Object({
|
|
934
|
+
source: Type.String({}),
|
|
935
|
+
paths: stringArray,
|
|
936
|
+
conflicts: stringArray,
|
|
937
|
+
}, { additionalProperties: false });
|
|
938
|
+
const verificationTargetSchema = Type.Object({
|
|
939
|
+
id: Type.String({}),
|
|
940
|
+
type: Type.String({
|
|
941
|
+
description: "static | unit | component | integration | mock",
|
|
942
|
+
}),
|
|
943
|
+
commandLabel: Type.String({}),
|
|
944
|
+
file: Type.String({}),
|
|
945
|
+
symbol: Type.Optional(Type.String({
|
|
946
|
+
description: "Optional. Real exported identifier or describe/it title in the target file. Omit it to keep the call small — the trace gate then only verifies the file exists and the command ran. Forbidden for type=static (runtime strips it).",
|
|
947
|
+
})),
|
|
948
|
+
requirementIds: stringArray,
|
|
949
|
+
uiStates: Type.Optional(Type.Array(Type.String({}), {
|
|
950
|
+
description: "Optional only at this tool boundary. An omitted value is deterministically recorded as []. Pass an explicit array for new calls.",
|
|
951
|
+
})),
|
|
952
|
+
}, { additionalProperties: false });
|
|
953
|
+
const evidenceGapSchema = Type.Object({
|
|
954
|
+
requirementId: optionalString,
|
|
955
|
+
description: Type.String({}),
|
|
956
|
+
blocking: Type.Boolean(),
|
|
957
|
+
}, { additionalProperties: false });
|
|
958
|
+
const uiComponentChoiceSchema = Type.Object({
|
|
959
|
+
purpose: Type.String({}),
|
|
960
|
+
component: Type.String({}),
|
|
961
|
+
decision: Type.String({
|
|
962
|
+
description: "specified | reuse-existing | new",
|
|
963
|
+
}),
|
|
964
|
+
specReference: Type.Optional(Type.Object({
|
|
965
|
+
path: Type.String({}),
|
|
966
|
+
section: Type.String({}),
|
|
967
|
+
line: Type.Optional(Type.Number({})),
|
|
968
|
+
}, { additionalProperties: false })),
|
|
969
|
+
rationale: Type.String({}),
|
|
970
|
+
}, { additionalProperties: false });
|
|
971
|
+
const stringList = (value) => Array.isArray(value)
|
|
972
|
+
? value.filter((item) => typeof item === "string")
|
|
973
|
+
: [];
|
|
974
|
+
const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0
|
|
975
|
+
? value.trim()
|
|
976
|
+
: undefined;
|
|
977
|
+
const planToolReceipt = (details) => ({
|
|
978
|
+
content: [
|
|
979
|
+
{
|
|
980
|
+
type: "text",
|
|
981
|
+
text: JSON.stringify(details),
|
|
982
|
+
},
|
|
983
|
+
],
|
|
984
|
+
details,
|
|
985
|
+
});
|
|
986
|
+
async function adoptPlanFact(kind, requestId, fact) {
|
|
987
|
+
// A+B (AC-005): a provider-capability fact kind reaching the plan ledger
|
|
988
|
+
// is out of route — it belongs to the shadow provider capability channel,
|
|
989
|
+
// not the plan decision ledger. Route it through the frozen seven-kind
|
|
990
|
+
// matrix and fail closed to `unsupported-provider-capability` instead of
|
|
991
|
+
// silently widening the plan catalog.
|
|
992
|
+
const capabilityRoute = routeFrontendProviderCapability({ factKind: kind });
|
|
993
|
+
if (capabilityRoute.ok) {
|
|
994
|
+
return {
|
|
995
|
+
ok: false,
|
|
996
|
+
kind,
|
|
997
|
+
code: "unsupported-provider-capability",
|
|
998
|
+
error: `plan ledger cannot adopt provider capability fact kind: ${kind}`,
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
try {
|
|
1002
|
+
const staged = stageTypedEventFact({
|
|
1003
|
+
store,
|
|
1004
|
+
requestId,
|
|
1005
|
+
attemptId,
|
|
1006
|
+
fact: fact,
|
|
1007
|
+
});
|
|
1008
|
+
const committed = await adoptTypedEventFact({
|
|
1009
|
+
store,
|
|
1010
|
+
requestId,
|
|
1011
|
+
attemptId,
|
|
1012
|
+
fact: fact,
|
|
1013
|
+
eventId: staged.eventId,
|
|
1014
|
+
expectedRevision: store.revision,
|
|
1015
|
+
});
|
|
1016
|
+
return {
|
|
1017
|
+
ok: true,
|
|
1018
|
+
kind,
|
|
1019
|
+
eventId: committed.eventId,
|
|
1020
|
+
revision: committed.revision,
|
|
1021
|
+
error: "",
|
|
1022
|
+
};
|
|
1023
|
+
}
|
|
1024
|
+
catch (error) {
|
|
1025
|
+
return {
|
|
1026
|
+
ok: false,
|
|
1027
|
+
kind,
|
|
1028
|
+
code: error?.code,
|
|
1029
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
const recordRouteSelectionTool = defineTool({
|
|
1034
|
+
name: "record_route_selection",
|
|
1035
|
+
label: "record_route_selection",
|
|
1036
|
+
description: "Record the route selection needed by this plan. Repository target surface and file ownership belong to Scout/runtime.",
|
|
1037
|
+
promptSnippet: "Record the selected routes.",
|
|
1038
|
+
parameters: Type.Object({ routes: stringArray }, { additionalProperties: false }),
|
|
1039
|
+
async execute(_toolCallId, params) {
|
|
1040
|
+
const routes = stringList(params?.routes);
|
|
1041
|
+
const result = await adoptPlanFact("target-surface", `${attemptId}:record_route_selection:${randomUUID()}`, { kind: "target-surface", origin: "plan", routes });
|
|
1042
|
+
return planToolReceipt(result);
|
|
1043
|
+
},
|
|
1044
|
+
});
|
|
1045
|
+
const recordComponentChoiceTool = defineTool({
|
|
1046
|
+
name: "record_component_choice",
|
|
1047
|
+
label: "record_component_choice",
|
|
1048
|
+
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; the runtime derives its exact PRD specReference from the source-fidelity ledger. 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 once per component — one tool call per message. Optionally include stylingStrategy (set it once, on the first call).",
|
|
1049
|
+
promptSnippet: "Record one component choice (one tool call per message).",
|
|
1050
|
+
parameters: Type.Object({
|
|
1051
|
+
choice: uiComponentChoiceSchema,
|
|
1052
|
+
sourceRequirementIds: Type.Optional(stringArray),
|
|
1053
|
+
stylingStrategy: optionalString,
|
|
1054
|
+
}, { additionalProperties: false }),
|
|
1055
|
+
async execute(_toolCallId, params) {
|
|
1056
|
+
const rawChoice = params?.choice;
|
|
1057
|
+
if (!isRecordObject(rawChoice)) {
|
|
1058
|
+
return planToolReceipt({
|
|
1059
|
+
ok: false,
|
|
1060
|
+
kind: "component-choice",
|
|
1061
|
+
error: "record_component_choice requires a non-empty choice object",
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
const sourceRequirementIds = stringList(params?.sourceRequirementIds);
|
|
1065
|
+
const choice = { ...rawChoice };
|
|
1066
|
+
if (choice.decision === "new") {
|
|
1067
|
+
if (sourceRequirementIds.length === 0) {
|
|
1068
|
+
return planToolReceipt({
|
|
1069
|
+
ok: false,
|
|
1070
|
+
kind: "component-choice",
|
|
1071
|
+
error: "decision=new requires sourceRequirementIds so runtime can materialize the task-source specReference",
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
const specReference = sourceRequirementIds
|
|
1075
|
+
.map((id) => input.componentNewSourceReferences?.get(id))
|
|
1076
|
+
.find((reference) => reference !== undefined);
|
|
1077
|
+
if (!specReference) {
|
|
1078
|
+
return planToolReceipt({
|
|
1079
|
+
ok: false,
|
|
1080
|
+
kind: "component-choice",
|
|
1081
|
+
error: `decision=new sourceRequirementIds have no frozen task-source citation: ${sourceRequirementIds.join(", ")}`,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
choice.specReference = specReference;
|
|
1085
|
+
}
|
|
1086
|
+
const components = typeof choice.component === "string" ? [choice.component] : [];
|
|
1087
|
+
const result = await adoptPlanFact("component-choice", `${attemptId}:record_component_choice:${randomUUID()}`, {
|
|
1088
|
+
kind: "component-choice",
|
|
1089
|
+
origin: "plan",
|
|
1090
|
+
components,
|
|
1091
|
+
uiComponentChoices: [choice],
|
|
1092
|
+
...(params?.stylingStrategy
|
|
1093
|
+
? { stylingStrategy: params.stylingStrategy }
|
|
1094
|
+
: {}),
|
|
1095
|
+
});
|
|
1096
|
+
return planToolReceipt(result);
|
|
1097
|
+
},
|
|
1098
|
+
});
|
|
1099
|
+
const recordStateFlowTool = defineTool({
|
|
1100
|
+
name: "record_state_flow",
|
|
1101
|
+
label: "record_state_flow",
|
|
1102
|
+
description: "Record UI states and interactions as an origin=plan state-flow fact.",
|
|
1103
|
+
promptSnippet: "Record the plan state-flow fact.",
|
|
1104
|
+
parameters: Type.Object({
|
|
1105
|
+
uiStates: Type.Array(uiStateSchema),
|
|
1106
|
+
interactions: Type.Array(interactionSchema),
|
|
1107
|
+
}, { additionalProperties: false }),
|
|
1108
|
+
async execute(_toolCallId, params) {
|
|
1109
|
+
const rawUiStates = Array.isArray(params?.uiStates)
|
|
1110
|
+
? params.uiStates
|
|
1111
|
+
: [];
|
|
1112
|
+
const rawInteractions = Array.isArray(params?.interactions)
|
|
1113
|
+
? params.interactions
|
|
1114
|
+
: [];
|
|
1115
|
+
const uiStates = [];
|
|
1116
|
+
for (const rawState of rawUiStates) {
|
|
1117
|
+
if (!isRecordObject(rawState)) {
|
|
1118
|
+
return planToolReceipt({
|
|
1119
|
+
ok: false,
|
|
1120
|
+
kind: "state-flow",
|
|
1121
|
+
error: "record_state_flow uiStates entries must be objects",
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
const { reason, notApplicableReason, ...state } = rawState;
|
|
1125
|
+
const canonicalReason = nonEmptyString(notApplicableReason);
|
|
1126
|
+
const aliasReason = nonEmptyString(reason);
|
|
1127
|
+
if (canonicalReason !== undefined &&
|
|
1128
|
+
aliasReason !== undefined &&
|
|
1129
|
+
canonicalReason !== aliasReason) {
|
|
1130
|
+
return planToolReceipt({
|
|
1131
|
+
ok: false,
|
|
1132
|
+
kind: "state-flow",
|
|
1133
|
+
error: "record_state_flow uiState has conflicting reason and notApplicableReason values",
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
const resolvedReason = canonicalReason ?? aliasReason;
|
|
1137
|
+
if (state.applicable === false && !resolvedReason) {
|
|
1138
|
+
return planToolReceipt({
|
|
1139
|
+
ok: false,
|
|
1140
|
+
kind: "state-flow",
|
|
1141
|
+
error: "record_state_flow requires non-empty notApplicableReason when uiState.applicable is false",
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
uiStates.push({
|
|
1145
|
+
...state,
|
|
1146
|
+
...(resolvedReason
|
|
1147
|
+
? { notApplicableReason: resolvedReason }
|
|
1148
|
+
: {}),
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
const interactions = [];
|
|
1152
|
+
for (const rawInteraction of rawInteractions) {
|
|
1153
|
+
if (!isRecordObject(rawInteraction)) {
|
|
1154
|
+
return planToolReceipt({
|
|
1155
|
+
ok: false,
|
|
1156
|
+
kind: "state-flow",
|
|
1157
|
+
error: "record_state_flow interactions entries must be objects",
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
const { id, name, ...interaction } = rawInteraction;
|
|
1161
|
+
const canonicalName = nonEmptyString(name);
|
|
1162
|
+
const aliasName = nonEmptyString(id);
|
|
1163
|
+
if (canonicalName !== undefined &&
|
|
1164
|
+
aliasName !== undefined &&
|
|
1165
|
+
canonicalName !== aliasName) {
|
|
1166
|
+
return planToolReceipt({
|
|
1167
|
+
ok: false,
|
|
1168
|
+
kind: "state-flow",
|
|
1169
|
+
error: "record_state_flow interaction has conflicting id and name values",
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
const resolvedName = canonicalName ?? aliasName;
|
|
1173
|
+
if (!resolvedName) {
|
|
1174
|
+
return planToolReceipt({
|
|
1175
|
+
ok: false,
|
|
1176
|
+
kind: "state-flow",
|
|
1177
|
+
error: "record_state_flow requires non-empty interaction.name (id is accepted only as a legacy alias)",
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
interactions.push({ ...interaction, name: resolvedName });
|
|
1181
|
+
}
|
|
1182
|
+
const states = uiStates
|
|
1183
|
+
.map((state) => (typeof state?.name === "string" ? state.name : ""))
|
|
1184
|
+
.filter(Boolean);
|
|
1185
|
+
const result = await adoptPlanFact("state-flow", `${attemptId}:record_state_flow:${randomUUID()}`, {
|
|
1186
|
+
kind: "state-flow",
|
|
1187
|
+
origin: "plan",
|
|
1188
|
+
states,
|
|
1189
|
+
uiStates,
|
|
1190
|
+
interactions,
|
|
1191
|
+
});
|
|
1192
|
+
return planToolReceipt(result);
|
|
1193
|
+
},
|
|
1194
|
+
});
|
|
1195
|
+
const recordDataFlowTool = defineTool({
|
|
1196
|
+
name: "record_data_flow",
|
|
1197
|
+
label: "record_data_flow",
|
|
1198
|
+
description: "Record interaction/endpoint data flow as an origin=plan data-flow fact.",
|
|
1199
|
+
promptSnippet: "Record the plan data-flow fact.",
|
|
1200
|
+
parameters: Type.Object({ interactions: stringArray, endpoints: stringArray }, { additionalProperties: false }),
|
|
1201
|
+
async execute(_toolCallId, params) {
|
|
1202
|
+
const result = await adoptPlanFact("data-flow", `${attemptId}:record_data_flow:${randomUUID()}`, {
|
|
1203
|
+
kind: "data-flow",
|
|
1204
|
+
origin: "plan",
|
|
1205
|
+
interactions: stringList(params?.interactions),
|
|
1206
|
+
endpoints: stringList(params?.endpoints),
|
|
1207
|
+
});
|
|
1208
|
+
return planToolReceipt(result);
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
const recordMockApiTool = defineTool({
|
|
1212
|
+
name: "record_mock_api",
|
|
1213
|
+
label: "record_mock_api",
|
|
1214
|
+
description: "Record the Mock/API strategy as an origin=plan mock-api fact.",
|
|
1215
|
+
promptSnippet: "Record the plan mock-api fact.",
|
|
1216
|
+
parameters: Type.Object({ mockApi: mockApiSchema }, { additionalProperties: false }),
|
|
1217
|
+
async execute(_toolCallId, params) {
|
|
1218
|
+
const mockApi = params?.mockApi ?? {
|
|
1219
|
+
strategy: "not-needed",
|
|
1220
|
+
activation: "",
|
|
1221
|
+
endpoints: [],
|
|
1222
|
+
};
|
|
1223
|
+
const endpoints = stringList((Array.isArray(mockApi?.endpoints) ? mockApi.endpoints : []).map((endpoint) => `${typeof endpoint?.method === "string" ? endpoint.method : ""} ${typeof endpoint?.path === "string" ? endpoint.path : ""}`.trim()));
|
|
1224
|
+
const result = await adoptPlanFact("mock-api", `${attemptId}:record_mock_api:${randomUUID()}`, {
|
|
1225
|
+
kind: "mock-api",
|
|
1226
|
+
origin: "plan",
|
|
1227
|
+
strategy: typeof mockApi?.strategy === "string" ? mockApi.strategy : "",
|
|
1228
|
+
endpoints,
|
|
1229
|
+
mockApi,
|
|
1230
|
+
});
|
|
1231
|
+
return planToolReceipt(result);
|
|
1232
|
+
},
|
|
1233
|
+
});
|
|
1234
|
+
const recordDesignDeviationTool = defineTool({
|
|
1235
|
+
name: "record_design_deviation",
|
|
1236
|
+
label: "record_design_deviation",
|
|
1237
|
+
description: "Record design evidence conflicts as an origin=plan design-deviation fact.",
|
|
1238
|
+
promptSnippet: "Record the plan design-deviation fact.",
|
|
1239
|
+
parameters: Type.Object({ designEvidence: designEvidenceSchema }, { additionalProperties: false }),
|
|
1240
|
+
async execute(_toolCallId, params) {
|
|
1241
|
+
const conflicts = stringList(params?.designEvidence?.conflicts);
|
|
1242
|
+
const result = await adoptPlanFact("design-deviation", `${attemptId}:record_design_deviation:${randomUUID()}`, { kind: "design-deviation", origin: "plan", conflicts });
|
|
1243
|
+
return planToolReceipt(result);
|
|
1244
|
+
},
|
|
1245
|
+
});
|
|
1246
|
+
const recordDependencyTool = defineTool({
|
|
1247
|
+
name: "record_dependency",
|
|
1248
|
+
label: "record_dependency",
|
|
1249
|
+
description: "Record the dependency policy as an origin=plan dependency fact.",
|
|
1250
|
+
promptSnippet: "Record the plan dependency fact.",
|
|
1251
|
+
parameters: Type.Object({ policy: Type.String({}) }, { additionalProperties: false }),
|
|
1252
|
+
async execute(_toolCallId, params) {
|
|
1253
|
+
const result = await adoptPlanFact("dependency", `${attemptId}:record_dependency:${randomUUID()}`, {
|
|
1254
|
+
kind: "dependency",
|
|
1255
|
+
origin: "plan",
|
|
1256
|
+
policy: typeof params?.policy === "string" ? params.policy : "",
|
|
1257
|
+
});
|
|
1258
|
+
return planToolReceipt(result);
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1261
|
+
// Incremental plan payload records (one entry per call) so requirements,
|
|
1262
|
+
// verification targets, evidence gaps, and implementation steps never have
|
|
1263
|
+
// to be emitted as one large array inside a single finalize_plan call —
|
|
1264
|
+
// they aggregate from the ledger in commit order. Mirrors the contract
|
|
1265
|
+
// node's record_requirement pattern to stay within any model output budget.
|
|
1266
|
+
const recordPlanRequirementTool = defineTool({
|
|
1267
|
+
name: "record_plan_requirement",
|
|
1268
|
+
label: "record_plan_requirement",
|
|
1269
|
+
description: "Commit one plan requirement entry (origin=plan plan-requirement fact). Call once per requirement; entry carries id, implementationTargets, verificationTargetIds, and optional expectedOutcome (omit it — the runtime derives the outcome text from the contract requirement). Each requirement id must be recorded EXACTLY once — re-recording the same id is rejected as a duplicate and would compile a duplicated requirements[] entry. IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
|
|
1270
|
+
promptSnippet: "Commit one plan requirement entry (one tool call per message).",
|
|
1271
|
+
parameters: Type.Object({
|
|
1272
|
+
entry: requirementSchema,
|
|
1273
|
+
}, { additionalProperties: false }),
|
|
1274
|
+
async execute(_toolCallId, params) {
|
|
1275
|
+
const rawEntry = params?.entry;
|
|
1276
|
+
if (!isRecordObject(rawEntry)) {
|
|
1277
|
+
return planToolReceipt({
|
|
1278
|
+
ok: false,
|
|
1279
|
+
kind: "plan-requirement",
|
|
1280
|
+
error: "record_plan_requirement requires a non-empty entry object",
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
const entry = rawEntry;
|
|
1284
|
+
// A requirement id is a canonical identity: recording it twice would
|
|
1285
|
+
// compile a duplicate requirements[] entry and fail design review.
|
|
1286
|
+
// Reject duplicates at the tool boundary so the model can fix them
|
|
1287
|
+
// in-node instead of burning the attempt on a later validation error.
|
|
1288
|
+
const id = typeof entry.id === "string" ? entry.id : "";
|
|
1289
|
+
if (id) {
|
|
1290
|
+
const existing = readCommittedEvents(store, attemptId).find((event) => {
|
|
1291
|
+
const fact = event.fact;
|
|
1292
|
+
if (!fact || fact.kind !== "plan-requirement")
|
|
1293
|
+
return false;
|
|
1294
|
+
const entryFact = fact.entry;
|
|
1295
|
+
return (typeof entryFact?.id === "string" &&
|
|
1296
|
+
entryFact.id === id);
|
|
1297
|
+
});
|
|
1298
|
+
if (existing) {
|
|
1299
|
+
return planToolReceipt({
|
|
1300
|
+
ok: false,
|
|
1301
|
+
kind: "plan-requirement",
|
|
1302
|
+
error: `record_plan_requirement duplicate: requirement ${id} is already recorded; do not record the same requirement id twice`,
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
const result = await adoptPlanFact("plan-requirement", `${attemptId}:record_plan_requirement:${randomUUID()}`, { kind: "plan-requirement", origin: "plan", entry });
|
|
1307
|
+
return planToolReceipt(result);
|
|
1308
|
+
},
|
|
1309
|
+
});
|
|
1310
|
+
const recordPlanVerificationTargetTool = defineTool({
|
|
1311
|
+
name: "record_plan_verification_target",
|
|
1312
|
+
label: "record_plan_verification_target",
|
|
1313
|
+
description: "Commit one plan verification target entry (origin=plan plan-verification-target fact). Call once per target; entry carries id, type, commandLabel, file, requirementIds, uiStates, and optional symbol (omit it — the trace gate verifies the file and command, not a symbol). IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
|
|
1314
|
+
promptSnippet: "Commit one plan verification target entry (one tool call per message).",
|
|
1315
|
+
parameters: Type.Object({
|
|
1316
|
+
entry: verificationTargetSchema,
|
|
1317
|
+
}, { additionalProperties: false }),
|
|
1318
|
+
async execute(_toolCallId, params) {
|
|
1319
|
+
const rawEntry = params?.entry;
|
|
1320
|
+
if (!isRecordObject(rawEntry)) {
|
|
1321
|
+
return planToolReceipt({
|
|
1322
|
+
ok: false,
|
|
1323
|
+
kind: "plan-verification-target",
|
|
1324
|
+
error: "record_plan_verification_target requires a non-empty entry object",
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
// uiStates: [] means this verification target is intentionally not
|
|
1328
|
+
// bound to a named UI state. Keep that canonical representation even
|
|
1329
|
+
// when a model omits the optional tool-boundary field.
|
|
1330
|
+
const entry = {
|
|
1331
|
+
...rawEntry,
|
|
1332
|
+
uiStates: stringList(rawEntry.uiStates),
|
|
1333
|
+
};
|
|
1334
|
+
const result = await adoptPlanFact("plan-verification-target", `${attemptId}:record_plan_verification_target:${randomUUID()}`, { kind: "plan-verification-target", origin: "plan", entry });
|
|
1335
|
+
return planToolReceipt(result);
|
|
1336
|
+
},
|
|
1337
|
+
});
|
|
1338
|
+
const recordPlanEvidenceGapTool = defineTool({
|
|
1339
|
+
name: "record_plan_evidence_gap",
|
|
1340
|
+
label: "record_plan_evidence_gap",
|
|
1341
|
+
description: "Commit one plan evidence gap entry (origin=plan plan-evidence-gap fact). Call once per gap; entry carries requirementId, description, blocking. IMPORTANT: call this tool exactly ONE time per assistant message — never batch multiple record_* calls together in one message; emit one call, wait for its result, then call the next.",
|
|
1342
|
+
promptSnippet: "Commit one plan evidence gap entry (one tool call per message).",
|
|
1343
|
+
parameters: Type.Object({
|
|
1344
|
+
entry: evidenceGapSchema,
|
|
1345
|
+
}, { additionalProperties: false }),
|
|
1346
|
+
async execute(_toolCallId, params) {
|
|
1347
|
+
const rawEntry = params?.entry;
|
|
1348
|
+
if (!isRecordObject(rawEntry)) {
|
|
1349
|
+
return planToolReceipt({
|
|
1350
|
+
ok: false,
|
|
1351
|
+
kind: "plan-evidence-gap",
|
|
1352
|
+
error: "record_plan_evidence_gap requires a non-empty entry object",
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
const entry = rawEntry;
|
|
1356
|
+
const result = await adoptPlanFact("plan-evidence-gap", `${attemptId}:record_plan_evidence_gap:${randomUUID()}`, { kind: "plan-evidence-gap", origin: "plan", entry });
|
|
1357
|
+
return planToolReceipt(result);
|
|
1358
|
+
},
|
|
1359
|
+
});
|
|
1360
|
+
const finalizePlanTool = defineTool({
|
|
1361
|
+
name: "finalize_plan",
|
|
1362
|
+
label: "finalize_plan",
|
|
1363
|
+
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.",
|
|
1364
|
+
promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
|
|
1365
|
+
parameters: Type.Object({
|
|
1366
|
+
residualRisks: optionalStringArray,
|
|
1367
|
+
realIntegrationGap: optionalString,
|
|
1368
|
+
}, { additionalProperties: false }),
|
|
1369
|
+
async execute(_toolCallId, params) {
|
|
1370
|
+
try {
|
|
1371
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
1372
|
+
// Source fidelity ledger (AC-005/AC-006): inherit the contract
|
|
1373
|
+
// node's declared requirement→fragment provenance so the compiled
|
|
1374
|
+
// canonical contract carries sourceFragmentIds/sourceRefs even
|
|
1375
|
+
// when the plan did not re-declare them. The contract node is the
|
|
1376
|
+
// sole synthesis point; the plan inherits by requirement id.
|
|
1377
|
+
const contractInheritance = await loadContractRequirementInheritance(input.runDir);
|
|
1378
|
+
const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
|
|
1379
|
+
const patch = {
|
|
1380
|
+
...fragment,
|
|
1381
|
+
...(params?.residualRisks
|
|
1382
|
+
? { residualRisks: params.residualRisks }
|
|
1383
|
+
: {}),
|
|
1384
|
+
...(params?.realIntegrationGap
|
|
1385
|
+
? { realIntegrationGap: params.realIntegrationGap }
|
|
1386
|
+
: {}),
|
|
1387
|
+
};
|
|
1388
|
+
const patchResult = await adoptPlanFact("target-surface", `${attemptId}:finalize_plan:patch:${randomUUID()}`, { kind: "target-surface", origin: "plan", patch });
|
|
1389
|
+
if (!patchResult.ok) {
|
|
1390
|
+
return planToolReceipt({
|
|
1391
|
+
ok: false,
|
|
1392
|
+
kind: "finalize_plan",
|
|
1393
|
+
error: patchResult.error,
|
|
1394
|
+
code: patchResult.code,
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
const terminal = await adoptPlanFact("finalize_plan", `${attemptId}:finalize_plan:terminal:${randomUUID()}`, { kind: "finalize_plan", origin: "plan", patch });
|
|
1398
|
+
return planToolReceipt(terminal);
|
|
1399
|
+
}
|
|
1400
|
+
catch (error) {
|
|
1401
|
+
return planToolReceipt({
|
|
1402
|
+
ok: false,
|
|
1403
|
+
kind: "finalize_plan",
|
|
1404
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1405
|
+
});
|
|
1406
|
+
}
|
|
1407
|
+
},
|
|
1408
|
+
});
|
|
1409
|
+
const adoptStagedFactTool = defineTool({
|
|
1410
|
+
name: "adopt_staged_fact",
|
|
1411
|
+
label: "adopt_staged_fact",
|
|
1412
|
+
description: "Explicitly adopt a quarantined fact from a prior attempt into the committed ledger (idempotent by requestId).",
|
|
1413
|
+
promptSnippet: "Adopt a quarantined fact into the committed ledger.",
|
|
1414
|
+
parameters: Type.Object({
|
|
1415
|
+
requestId: Type.String({}),
|
|
1416
|
+
eventId: Type.String({}),
|
|
1417
|
+
expectedRevision: Type.Number({}),
|
|
1418
|
+
}, { additionalProperties: false }),
|
|
1419
|
+
async execute(_toolCallId, params) {
|
|
1420
|
+
try {
|
|
1421
|
+
const adopted = await adoptStagedFact({
|
|
1422
|
+
store,
|
|
1423
|
+
requestId: typeof params?.requestId === "string" ? params.requestId : "",
|
|
1424
|
+
attemptId,
|
|
1425
|
+
eventId: typeof params?.eventId === "string" ? params.eventId : "",
|
|
1426
|
+
expectedRevision: typeof params?.expectedRevision === "number"
|
|
1427
|
+
? params.expectedRevision
|
|
1428
|
+
: store.revision,
|
|
1429
|
+
});
|
|
1430
|
+
return planToolReceipt({
|
|
1431
|
+
ok: true,
|
|
1432
|
+
kind: "adopt_staged_fact",
|
|
1433
|
+
eventId: adopted.eventId,
|
|
1434
|
+
revision: adopted.revision,
|
|
1435
|
+
error: "",
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
catch (error) {
|
|
1439
|
+
return planToolReceipt({
|
|
1440
|
+
ok: false,
|
|
1441
|
+
kind: "adopt_staged_fact",
|
|
1442
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1443
|
+
code: error?.code,
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1446
|
+
},
|
|
1447
|
+
});
|
|
1448
|
+
return {
|
|
1449
|
+
customTools: [
|
|
1450
|
+
recordRouteSelectionTool,
|
|
1451
|
+
recordComponentChoiceTool,
|
|
1452
|
+
recordStateFlowTool,
|
|
1453
|
+
recordDataFlowTool,
|
|
1454
|
+
recordMockApiTool,
|
|
1455
|
+
recordDesignDeviationTool,
|
|
1456
|
+
recordDependencyTool,
|
|
1457
|
+
recordPlanRequirementTool,
|
|
1458
|
+
recordPlanVerificationTargetTool,
|
|
1459
|
+
recordPlanEvidenceGapTool,
|
|
1460
|
+
adoptStagedFactTool,
|
|
1461
|
+
finalizePlanTool,
|
|
1462
|
+
],
|
|
1463
|
+
flush: async () => {
|
|
1464
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
1465
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), committed);
|
|
1466
|
+
},
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function isRecordObject(value) {
|
|
1470
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1471
|
+
}
|
|
1472
|
+
/** A contract fact is source-mapped when it carries a non-empty `sourceSpan`
|
|
1473
|
+
* (object or string), a non-empty `sourceRefs` array, or a non-empty `source`
|
|
1474
|
+
* string. Anything else is an unmapped source segment (AC-001). */
|
|
1475
|
+
function contractFactSourceSpan(fact) {
|
|
1476
|
+
if (isRecordObject(fact.sourceSpan) || typeof fact.sourceSpan === "string") {
|
|
1477
|
+
return fact.sourceSpan;
|
|
1478
|
+
}
|
|
1479
|
+
if (Array.isArray(fact.sourceRefs) && fact.sourceRefs.length > 0) {
|
|
1480
|
+
return fact.sourceRefs;
|
|
1481
|
+
}
|
|
1482
|
+
if (typeof fact.source === "string" && fact.source.trim().length > 0) {
|
|
1483
|
+
return fact.source;
|
|
1484
|
+
}
|
|
1485
|
+
return undefined;
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* A+B (AC-001): deterministically assemble the read-only `frontend-task-contract-vNext`
|
|
1489
|
+
* audit artifact from committed contract facts. It never rewrites the original
|
|
1490
|
+
* task source: unmapped requirement/constraint segments are listed explicitly
|
|
1491
|
+
* so Plan/Implement/Review keep binding to the raw source, not the model's
|
|
1492
|
+
* summarized contract. The blocked disposition is projected through the frozen
|
|
1493
|
+
* `mapContractBlockedOwner` mapping (AC-002).
|
|
1494
|
+
*/
|
|
1495
|
+
export function buildFrontendTaskContractVNext(records) {
|
|
1496
|
+
const facts = records
|
|
1497
|
+
.filter((record) => record.phase === "committed")
|
|
1498
|
+
.map((record) => record.fact)
|
|
1499
|
+
.filter(isRecordObject);
|
|
1500
|
+
const finalized = facts.find((fact) => fact.kind === "contract-finalized");
|
|
1501
|
+
const disposition = typeof finalized?.disposition === "string" ? finalized.disposition : null;
|
|
1502
|
+
const blockingOwner = typeof finalized?.blockingOwner === "string"
|
|
1503
|
+
? finalized.blockingOwner
|
|
1504
|
+
: null;
|
|
1505
|
+
const projected = mapContractBlockedOwner({
|
|
1506
|
+
disposition: disposition ?? "",
|
|
1507
|
+
blockingOwner: blockingOwner ?? undefined,
|
|
1508
|
+
});
|
|
1509
|
+
const byKind = (kind) => facts.filter((fact) => fact.kind === kind);
|
|
1510
|
+
const requirements = byKind("requirement");
|
|
1511
|
+
const unmappedSourceSegments = requirements
|
|
1512
|
+
.filter((fact) => contractFactSourceSpan(fact) === undefined)
|
|
1513
|
+
.map((fact) => ({
|
|
1514
|
+
kind: "requirement",
|
|
1515
|
+
id: typeof fact.id === "string"
|
|
1516
|
+
? fact.id
|
|
1517
|
+
: typeof fact.text === "string"
|
|
1518
|
+
? fact.text
|
|
1519
|
+
: undefined,
|
|
1520
|
+
}))
|
|
1521
|
+
.filter((segment) => segment.id !== undefined);
|
|
1522
|
+
return {
|
|
1523
|
+
schemaVersion: 1,
|
|
1524
|
+
schemaId: "frontend-task-contract-vNext",
|
|
1525
|
+
disposition,
|
|
1526
|
+
blockingOwner,
|
|
1527
|
+
blockedOwner: projected === "not-blocked" ? null : projected,
|
|
1528
|
+
requirements,
|
|
1529
|
+
constraints: byKind("constraint"),
|
|
1530
|
+
evidenceExpectations: byKind("evidence-expectation"),
|
|
1531
|
+
handoffIntents: byKind("handoff-intent"),
|
|
1532
|
+
openQuestions: byKind("open-question"),
|
|
1533
|
+
splitProposals: byKind("split-proposal"),
|
|
1534
|
+
unmappedSourceSegments,
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* A+B: `frontend-contract-pi` incremental contract tools. Six `record_*` tools
|
|
1539
|
+
* commit origin=contract facts and `finalize_contract` commits the terminal
|
|
1540
|
+
* disposition (ready | ready-with-assumptions | blocked + blockingOwner).
|
|
1541
|
+
*/
|
|
1542
|
+
export async function createFrontendContractTools(input) {
|
|
1543
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
1544
|
+
import("typebox"),
|
|
1545
|
+
import("@earendil-works/pi-coding-agent"),
|
|
1546
|
+
]);
|
|
1547
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1548
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
1549
|
+
const store = input.store;
|
|
1550
|
+
const attemptId = input.attemptId;
|
|
1551
|
+
const receipt = (details) => ({
|
|
1552
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
1553
|
+
details,
|
|
1554
|
+
});
|
|
1555
|
+
async function adoptContractFact(kind, fact) {
|
|
1556
|
+
try {
|
|
1557
|
+
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
1558
|
+
const staged = stageTypedEventFact({
|
|
1559
|
+
store,
|
|
1560
|
+
requestId,
|
|
1561
|
+
attemptId,
|
|
1562
|
+
fact: fact,
|
|
1563
|
+
});
|
|
1564
|
+
const committed = await adoptTypedEventFact({
|
|
1565
|
+
store,
|
|
1566
|
+
requestId,
|
|
1567
|
+
attemptId,
|
|
1568
|
+
fact: fact,
|
|
1569
|
+
eventId: staged.eventId,
|
|
1570
|
+
expectedRevision: store.revision,
|
|
1571
|
+
});
|
|
1572
|
+
return {
|
|
1573
|
+
ok: true,
|
|
1574
|
+
kind,
|
|
1575
|
+
eventId: committed.eventId,
|
|
1576
|
+
revision: committed.revision,
|
|
1577
|
+
error: "",
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
catch (error) {
|
|
1581
|
+
return {
|
|
1582
|
+
ok: false,
|
|
1583
|
+
kind,
|
|
1584
|
+
code: error?.code,
|
|
1585
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
const recordKinds = {
|
|
1590
|
+
record_requirement: "requirement",
|
|
1591
|
+
record_constraint: "constraint",
|
|
1592
|
+
record_evidence_expectation: "evidence-expectation",
|
|
1593
|
+
record_handoff_intent: "handoff-intent",
|
|
1594
|
+
record_open_question: "open-question",
|
|
1595
|
+
record_split_proposal: "split-proposal",
|
|
1596
|
+
};
|
|
1597
|
+
const recordTools = Object.entries(recordKinds).map(([name, kind]) => defineTool({
|
|
1598
|
+
name,
|
|
1599
|
+
label: name,
|
|
1600
|
+
description: `Commit an origin=contract ${kind} fact.`,
|
|
1601
|
+
promptSnippet: `Commit an origin=contract ${kind} fact.`,
|
|
1602
|
+
parameters: Type.Object({}, { additionalProperties: true }),
|
|
1603
|
+
async execute(_toolCallId, params) {
|
|
1604
|
+
const result = await adoptContractFact(kind, {
|
|
1605
|
+
kind,
|
|
1606
|
+
origin: "contract",
|
|
1607
|
+
...(params ?? {}),
|
|
1608
|
+
});
|
|
1609
|
+
return receipt(result);
|
|
1610
|
+
},
|
|
1611
|
+
}));
|
|
1612
|
+
// OpenSpec selection committed as individual typed facts (one path per
|
|
1613
|
+
// call) so a large candidate set never exceeds a single model output
|
|
1614
|
+
// budget: each tool call carries exactly one {path, disposition,
|
|
1615
|
+
// rationale} row and the ledger accumulates them across calls. Only
|
|
1616
|
+
// positive classifications (required | relevant) are legal; unmentioned
|
|
1617
|
+
// candidates default to irrelevant at the prewrite gate.
|
|
1618
|
+
const recordOpenspecSelectionTool = defineTool({
|
|
1619
|
+
name: "record_openspec_selection",
|
|
1620
|
+
label: "record_openspec_selection",
|
|
1621
|
+
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.",
|
|
1622
|
+
promptSnippet: "Commit one OpenSpec candidate classification (required | relevant); one path per call; skip irrelevant candidates.",
|
|
1623
|
+
parameters: Type.Object({
|
|
1624
|
+
path: Type.String({
|
|
1625
|
+
description: "Repo-relative candidate spec path, e.g. openspec/project-specs/ui/ucp-components-md/AdvancedSearch.md",
|
|
1626
|
+
}),
|
|
1627
|
+
disposition: Type.Enum({
|
|
1628
|
+
required: "required",
|
|
1629
|
+
relevant: "relevant",
|
|
1630
|
+
}),
|
|
1631
|
+
rationale: Type.String({}),
|
|
1632
|
+
}, { additionalProperties: false }),
|
|
1633
|
+
async execute(_toolCallId, params) {
|
|
1634
|
+
const path = typeof params?.path === "string" ? params.path : "";
|
|
1635
|
+
const disposition = params?.disposition;
|
|
1636
|
+
const rationale = typeof params?.rationale === "string" ? params.rationale : "";
|
|
1637
|
+
if (!path || !disposition || !rationale.trim()) {
|
|
1638
|
+
return receipt({
|
|
1639
|
+
ok: false,
|
|
1640
|
+
kind: "openspec-selection",
|
|
1641
|
+
error: "record_openspec_selection requires non-empty path, disposition (required|relevant), and rationale",
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
const result = await adoptContractFact("openspec-selection", {
|
|
1645
|
+
kind: "openspec-selection",
|
|
1646
|
+
origin: "contract",
|
|
1647
|
+
path,
|
|
1648
|
+
disposition,
|
|
1649
|
+
rationale,
|
|
1650
|
+
});
|
|
1651
|
+
return receipt(result);
|
|
1652
|
+
},
|
|
1653
|
+
});
|
|
1654
|
+
const finalizeContractTool = defineTool({
|
|
1655
|
+
name: "finalize_contract",
|
|
1656
|
+
label: "finalize_contract",
|
|
1657
|
+
description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). Call exactly once.",
|
|
1658
|
+
promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
|
|
1659
|
+
parameters: Type.Object({
|
|
1660
|
+
disposition: Type.Enum({
|
|
1661
|
+
ready: "ready",
|
|
1662
|
+
"ready-with-assumptions": "ready-with-assumptions",
|
|
1663
|
+
blocked: "blocked",
|
|
1664
|
+
}),
|
|
1665
|
+
blockingOwner: Type.Optional(Type.Enum({
|
|
1666
|
+
"blocked-human": "blocked-human",
|
|
1667
|
+
"blocked-external": "blocked-external",
|
|
1668
|
+
})),
|
|
1669
|
+
assumptions: Type.Optional(Type.Array(Type.String({}))),
|
|
1670
|
+
summary: Type.Optional(Type.String({})),
|
|
1671
|
+
}, { additionalProperties: false }),
|
|
1672
|
+
async execute(_toolCallId, params) {
|
|
1673
|
+
const disposition = params?.disposition;
|
|
1674
|
+
const blockingOwner = params?.blockingOwner;
|
|
1675
|
+
if (disposition === "blocked" && !blockingOwner) {
|
|
1676
|
+
return receipt({
|
|
1677
|
+
ok: false,
|
|
1678
|
+
kind: "finalize_contract",
|
|
1679
|
+
error: "blocked disposition requires blockingOwner (blocked-human | blocked-external)",
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
const blockedOwner = mapContractBlockedOwner({
|
|
1683
|
+
disposition: disposition ?? "",
|
|
1684
|
+
blockingOwner,
|
|
1685
|
+
});
|
|
1686
|
+
const result = await adoptContractFact("contract-finalized", {
|
|
1687
|
+
kind: "contract-finalized",
|
|
1688
|
+
origin: "contract",
|
|
1689
|
+
disposition,
|
|
1690
|
+
...(blockingOwner ? { blockingOwner } : {}),
|
|
1691
|
+
...(blockedOwner !== "not-blocked" ? { blockedOwner } : {}),
|
|
1692
|
+
...(Array.isArray(params?.assumptions)
|
|
1693
|
+
? { assumptions: params.assumptions }
|
|
1694
|
+
: {}),
|
|
1695
|
+
...(typeof params?.summary === "string"
|
|
1696
|
+
? { summary: params.summary }
|
|
1697
|
+
: {}),
|
|
1698
|
+
});
|
|
1699
|
+
return receipt(result);
|
|
1700
|
+
},
|
|
1701
|
+
});
|
|
1702
|
+
return {
|
|
1703
|
+
customTools: [
|
|
1704
|
+
...recordTools,
|
|
1705
|
+
recordOpenspecSelectionTool,
|
|
1706
|
+
finalizeContractTool,
|
|
1707
|
+
],
|
|
1708
|
+
flush: async () => {
|
|
1709
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
1710
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), committed);
|
|
1711
|
+
await writeJsonAtomic(path.join(input.runDir, input.nodeId, "frontend-task-contract-vNext.json"), buildFrontendTaskContractVNext(committed));
|
|
1712
|
+
},
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* A+B: `frontend-scout-pi` incremental evidence tools (origin=scout). Runtime
|
|
1717
|
+
* enriches committed target-surface / design-evidence facts with hash/section/
|
|
1718
|
+
* freshness from real read events; the tool itself never trusts model self-report.
|
|
1719
|
+
*/
|
|
1720
|
+
export async function createFrontendScoutEvidenceTools(input) {
|
|
1721
|
+
const [{ Type }, { defineTool }] = await Promise.all([
|
|
1722
|
+
import("typebox"),
|
|
1723
|
+
import("@earendil-works/pi-coding-agent"),
|
|
1724
|
+
]);
|
|
1725
|
+
const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
1726
|
+
const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
|
|
1727
|
+
const store = input.store;
|
|
1728
|
+
const attemptId = input.attemptId;
|
|
1729
|
+
const stringArray = Type.Array(Type.String({}));
|
|
1730
|
+
const optionalString = Type.Optional(Type.String({}));
|
|
1731
|
+
const scoutCompleteness = Type.Union([
|
|
1732
|
+
Type.Literal("complete"),
|
|
1733
|
+
Type.Literal("blocked"),
|
|
1734
|
+
]);
|
|
1735
|
+
const receipt = (details) => ({
|
|
1736
|
+
content: [{ type: "text", text: JSON.stringify(details) }],
|
|
1737
|
+
details,
|
|
1738
|
+
});
|
|
1739
|
+
// A+B (AC-003): runtime enriches declared scout paths with hash/freshness
|
|
1740
|
+
// from the real filesystem. The model's self-reported path list is never
|
|
1741
|
+
// trusted for content identity; a missing file fails closed to fresh=false
|
|
1742
|
+
// with a zero hash instead of inventing content.
|
|
1743
|
+
const enrichScoutPathEvidence = async (paths) => {
|
|
1744
|
+
if (!input.workspaceRoot)
|
|
1745
|
+
return [];
|
|
1746
|
+
const workspaceRoot = path.resolve(input.workspaceRoot);
|
|
1747
|
+
const evidence = [];
|
|
1748
|
+
for (const relative of new Set(paths)) {
|
|
1749
|
+
const absolute = path.resolve(workspaceRoot, relative);
|
|
1750
|
+
if (absolute !== workspaceRoot &&
|
|
1751
|
+
!absolute.startsWith(`${workspaceRoot}${path.sep}`)) {
|
|
1752
|
+
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false });
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
try {
|
|
1756
|
+
const bytes = await readFile(absolute);
|
|
1757
|
+
evidence.push({
|
|
1758
|
+
path: relative,
|
|
1759
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
1760
|
+
fresh: true,
|
|
1761
|
+
});
|
|
1762
|
+
}
|
|
1763
|
+
catch {
|
|
1764
|
+
evidence.push({ path: relative, sha256: "0".repeat(64), fresh: false });
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
return evidence;
|
|
1768
|
+
};
|
|
1769
|
+
async function adoptScoutFact(kind, fact) {
|
|
1770
|
+
try {
|
|
1771
|
+
const requestId = `${attemptId}:${kind}:${randomUUID()}`;
|
|
1772
|
+
const staged = stageTypedEventFact({
|
|
1773
|
+
store,
|
|
1774
|
+
requestId,
|
|
1775
|
+
attemptId,
|
|
1776
|
+
fact: fact,
|
|
1777
|
+
});
|
|
1778
|
+
const committed = await adoptTypedEventFact({
|
|
1779
|
+
store,
|
|
1780
|
+
requestId,
|
|
1781
|
+
attemptId,
|
|
1782
|
+
fact: fact,
|
|
1783
|
+
eventId: staged.eventId,
|
|
1784
|
+
expectedRevision: store.revision,
|
|
1785
|
+
});
|
|
1786
|
+
return {
|
|
1787
|
+
ok: true,
|
|
1788
|
+
kind,
|
|
1789
|
+
eventId: committed.eventId,
|
|
1790
|
+
revision: committed.revision,
|
|
1791
|
+
error: "",
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
catch (error) {
|
|
1795
|
+
return {
|
|
1796
|
+
ok: false,
|
|
1797
|
+
kind,
|
|
1798
|
+
code: error?.code,
|
|
1799
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1800
|
+
};
|
|
1801
|
+
}
|
|
189
1802
|
}
|
|
190
|
-
|
|
1803
|
+
const recordTargetSurfaceTool = defineTool({
|
|
1804
|
+
name: "record_target_surface",
|
|
1805
|
+
label: "record_target_surface",
|
|
1806
|
+
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.",
|
|
1807
|
+
promptSnippet: "Commit an origin=scout target-surface fact.",
|
|
1808
|
+
parameters: Type.Object({
|
|
1809
|
+
completeness: scoutCompleteness,
|
|
1810
|
+
entrypoint: optionalString,
|
|
1811
|
+
routeOrMount: optionalString,
|
|
1812
|
+
implementationPaths: stringArray,
|
|
1813
|
+
testPaths: stringArray,
|
|
1814
|
+
dataSource: optionalString,
|
|
1815
|
+
allowedPathConflicts: stringArray,
|
|
1816
|
+
unresolvedPaths: stringArray,
|
|
1817
|
+
}, { additionalProperties: false }),
|
|
1818
|
+
async execute(_toolCallId, params) {
|
|
1819
|
+
const implementationPaths = params?.implementationPaths ?? [];
|
|
1820
|
+
const testPaths = params?.testPaths ?? [];
|
|
1821
|
+
const pathEvidence = await enrichScoutPathEvidence([
|
|
1822
|
+
...(params?.entrypoint ? [params.entrypoint] : []),
|
|
1823
|
+
...implementationPaths,
|
|
1824
|
+
...testPaths,
|
|
1825
|
+
]);
|
|
1826
|
+
const result = await adoptScoutFact("target-surface", {
|
|
1827
|
+
kind: "target-surface",
|
|
1828
|
+
origin: "scout",
|
|
1829
|
+
completeness: params?.completeness ?? "blocked",
|
|
1830
|
+
entrypoint: params?.entrypoint ?? "",
|
|
1831
|
+
routeOrMount: params?.routeOrMount ?? "",
|
|
1832
|
+
implementationPaths,
|
|
1833
|
+
testPaths,
|
|
1834
|
+
dataSource: params?.dataSource ?? "",
|
|
1835
|
+
allowedPathConflicts: params?.allowedPathConflicts ?? [],
|
|
1836
|
+
unresolvedPaths: params?.unresolvedPaths ?? [],
|
|
1837
|
+
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
1838
|
+
});
|
|
1839
|
+
return receipt(result);
|
|
1840
|
+
},
|
|
1841
|
+
});
|
|
1842
|
+
const recordDesignEvidenceTool = defineTool({
|
|
1843
|
+
name: "record_design_evidence",
|
|
1844
|
+
label: "record_design_evidence",
|
|
1845
|
+
description: "Commit an origin=scout design-evidence fact (source, paths, conflicts).",
|
|
1846
|
+
promptSnippet: "Commit an origin=scout design-evidence fact.",
|
|
1847
|
+
parameters: Type.Object({ source: Type.String({}), paths: stringArray, conflicts: stringArray }, { additionalProperties: false }),
|
|
1848
|
+
async execute(_toolCallId, params) {
|
|
1849
|
+
const paths = params?.paths ?? [];
|
|
1850
|
+
const pathEvidence = await enrichScoutPathEvidence(paths);
|
|
1851
|
+
const result = await adoptScoutFact("design-evidence", {
|
|
1852
|
+
kind: "design-evidence",
|
|
1853
|
+
origin: "scout",
|
|
1854
|
+
source: params?.source ?? "",
|
|
1855
|
+
paths,
|
|
1856
|
+
conflicts: params?.conflicts ?? [],
|
|
1857
|
+
...(pathEvidence.length > 0 ? { pathEvidence } : {}),
|
|
1858
|
+
});
|
|
1859
|
+
return receipt(result);
|
|
1860
|
+
},
|
|
1861
|
+
});
|
|
1862
|
+
return {
|
|
1863
|
+
customTools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
|
|
1864
|
+
flush: async () => {
|
|
1865
|
+
const committed = readCommittedEvents(store, attemptId);
|
|
1866
|
+
await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), committed);
|
|
1867
|
+
},
|
|
1868
|
+
};
|
|
191
1869
|
}
|
|
192
1870
|
export function buildDagPiUserMessage(task, persona, step) {
|
|
193
1871
|
const role = task.role ?? "unspecified";
|
|
194
1872
|
const writePolicy = task.writePolicy ?? "read-only (default)";
|
|
195
1873
|
if (isDagPiWriteTask(task)) {
|
|
196
|
-
const outcomeInstruction = task
|
|
1874
|
+
const outcomeInstruction = isFrontendFactsWriter(task)
|
|
197
1875
|
? [
|
|
198
|
-
"
|
|
199
|
-
task.writerOutcomePolicy.requireChangedFiles
|
|
200
|
-
? "This generation node requires a non-empty bounded diff; already-satisfied cannot complete it successfully."
|
|
201
|
-
: undefined,
|
|
1876
|
+
"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.",
|
|
202
1877
|
]
|
|
203
1878
|
.filter((value) => Boolean(value))
|
|
204
1879
|
.join(" ")
|
|
205
|
-
:
|
|
1880
|
+
: task.writerOutcomePolicy
|
|
1881
|
+
? [
|
|
1882
|
+
"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.",
|
|
1883
|
+
task.writerOutcomePolicy.requireChangedFiles
|
|
1884
|
+
? "This generation node requires a non-empty bounded diff; already-satisfied cannot complete it successfully."
|
|
1885
|
+
: undefined,
|
|
1886
|
+
]
|
|
1887
|
+
.filter((value) => Boolean(value))
|
|
1888
|
+
.join(" ")
|
|
1889
|
+
: undefined;
|
|
206
1890
|
return [
|
|
207
1891
|
`You are executing hybrid DAG node "${task.id}" (role=${role}, piStep=${step}, writePolicy=${writePolicy}).`,
|
|
208
1892
|
"The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
|
|
@@ -235,6 +1919,9 @@ export function buildDagPiUserMessage(task, persona, step) {
|
|
|
235
1919
|
`You are executing hybrid DAG node "${task.id}" (role=${role}, piPersona=${persona}, piStep=${step}, writePolicy=${writePolicy}).`,
|
|
236
1920
|
"The system prompt contains the full DAG envelope: objective, constraints, upstream context, and task.",
|
|
237
1921
|
"Use read-only tools only. Do not edit, write, or commit repository files.",
|
|
1922
|
+
(task.readSet?.length ?? 0) > 0
|
|
1923
|
+
? `Strict read set: ${task.readSet.join(", ")}. The read tool rejects every other repository path; do not search for substitutes.`
|
|
1924
|
+
: undefined,
|
|
238
1925
|
"Return your conclusion as plain Markdown text suitable for downstream DAG nodes.",
|
|
239
1926
|
"Do not wrap the output in code fences and do not add conversational preamble.",
|
|
240
1927
|
].join(" ");
|
|
@@ -327,6 +2014,36 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
|
|
|
327
2014
|
await writeTextArtifactFile(summaryPath, buildPiResultSummaryMarkdown(input));
|
|
328
2015
|
return { promptPath, summaryPath };
|
|
329
2016
|
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Run-owned per-node Pi extension facts (plan 2026-08-21): requested ids,
|
|
2019
|
+
* resolved packages (with absolute entry paths), and degraded/missing ids.
|
|
2020
|
+
* Written only for nodes that declare `piExtensions`; never into the repo-level
|
|
2021
|
+
* DAG template. Best-effort: artifact failures never fail the node.
|
|
2022
|
+
*/
|
|
2023
|
+
async function writePiExtensionsArtifact(nodeArtifactsDir, requested, resolved, missing) {
|
|
2024
|
+
try {
|
|
2025
|
+
const runDir = path.dirname(nodeArtifactsDir);
|
|
2026
|
+
const nodeId = path.basename(nodeArtifactsDir);
|
|
2027
|
+
await writeDagNodeJsonArtifact(runDir, nodeId, "pi-extensions.json", {
|
|
2028
|
+
schemaVersion: 1,
|
|
2029
|
+
requested: [...requested],
|
|
2030
|
+
resolved: resolved.map((entry) => ({
|
|
2031
|
+
id: entry.id,
|
|
2032
|
+
packageName: entry.packageName,
|
|
2033
|
+
version: entry.version,
|
|
2034
|
+
path: entry.entryPaths.join(", "),
|
|
2035
|
+
})),
|
|
2036
|
+
missing: missing.map((entry) => ({
|
|
2037
|
+
id: entry.id,
|
|
2038
|
+
reason: entry.reason,
|
|
2039
|
+
...(entry.detail ? { detail: entry.detail } : {}),
|
|
2040
|
+
})),
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
catch {
|
|
2044
|
+
// best-effort run fact; node execution continues
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
330
2047
|
async function resolveValidatedFrontendBaseUrlFromContext(workspaceRoot, runDir) {
|
|
331
2048
|
try {
|
|
332
2049
|
const capability = JSON.parse(await readFile(path.join(runDir, "preflight-frontend-browser-tool-shell", "frontend-browser-capability.json"), "utf8"));
|
|
@@ -383,6 +2100,145 @@ const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
|
|
|
383
2100
|
readGitStatusPorcelain,
|
|
384
2101
|
recoverRootNulArtifact,
|
|
385
2102
|
};
|
|
2103
|
+
/**
|
|
2104
|
+
* M5 shadow pass for `frontend-review-pi`: extract the committed typed terminal
|
|
2105
|
+
* facts from session events, parse the legacy JSON verdict from the response
|
|
2106
|
+
* text, compare them (audit-only), and persist the audit artifact. Missing
|
|
2107
|
+
* typed terminal facts fail the node closed (AC-001); a shadow mismatch never
|
|
2108
|
+
* blocks the node.
|
|
2109
|
+
*/
|
|
2110
|
+
async function runFrontendReviewTerminalShadow(input) {
|
|
2111
|
+
// A provider/executor failure (in particular context-overflow) is already
|
|
2112
|
+
// authoritative. Do not rewrite it to review-terminal-missing merely
|
|
2113
|
+
// because no terminal tool could be submitted after the failed call.
|
|
2114
|
+
if (!input.mapped.ok)
|
|
2115
|
+
return input.mapped;
|
|
2116
|
+
const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
|
|
2117
|
+
const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
|
|
2118
|
+
const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
|
|
2119
|
+
let typedKinds = [];
|
|
2120
|
+
try {
|
|
2121
|
+
const content = await readFile(sessionEventsPath, "utf8");
|
|
2122
|
+
typedKinds = scanReviewTerminalKindsFromSessionEvents(content);
|
|
2123
|
+
}
|
|
2124
|
+
catch {
|
|
2125
|
+
// Missing/unreadable session log → fail-closed at zero terminal facts.
|
|
2126
|
+
typedKinds = [];
|
|
2127
|
+
}
|
|
2128
|
+
let legacyVerdict;
|
|
2129
|
+
try {
|
|
2130
|
+
const parsed = parseJsonReviewVerdict(input.mapped.assistantText ?? input.mapped.stdout);
|
|
2131
|
+
if (parsed.ok)
|
|
2132
|
+
legacyVerdict = parsed.verdict;
|
|
2133
|
+
}
|
|
2134
|
+
catch {
|
|
2135
|
+
legacyVerdict = undefined;
|
|
2136
|
+
}
|
|
2137
|
+
let comparison;
|
|
2138
|
+
try {
|
|
2139
|
+
comparison = compareTypedReviewToLegacyJsonVerdict({
|
|
2140
|
+
typedKinds,
|
|
2141
|
+
legacyVerdict,
|
|
2142
|
+
});
|
|
2143
|
+
}
|
|
2144
|
+
catch (error) {
|
|
2145
|
+
comparison = {
|
|
2146
|
+
typedVerdict: undefined,
|
|
2147
|
+
legacyVerdict: undefined,
|
|
2148
|
+
match: false,
|
|
2149
|
+
reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
|
|
2150
|
+
};
|
|
2151
|
+
}
|
|
2152
|
+
// Audit-only flush + artifact. Neither blocks the node.
|
|
2153
|
+
try {
|
|
2154
|
+
await input.tools?.flush?.();
|
|
2155
|
+
}
|
|
2156
|
+
catch {
|
|
2157
|
+
// best-effort
|
|
2158
|
+
}
|
|
2159
|
+
try {
|
|
2160
|
+
await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
|
|
2161
|
+
schemaVersion: 1,
|
|
2162
|
+
nodeId: input.task.id,
|
|
2163
|
+
typedKinds,
|
|
2164
|
+
legacyVerdict,
|
|
2165
|
+
comparison,
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
catch {
|
|
2169
|
+
// best-effort audit artifact
|
|
2170
|
+
}
|
|
2171
|
+
if (typedKinds.length === 0) {
|
|
2172
|
+
return {
|
|
2173
|
+
...input.mapped,
|
|
2174
|
+
ok: false,
|
|
2175
|
+
failureCategory: "review-terminal-missing",
|
|
2176
|
+
stderr: [
|
|
2177
|
+
input.mapped.stderr,
|
|
2178
|
+
"frontend review typed terminal fact missing: no approve_review/request_review_changes tool call was committed",
|
|
2179
|
+
]
|
|
2180
|
+
.filter(Boolean)
|
|
2181
|
+
.join("\n\n"),
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
return input.mapped;
|
|
2185
|
+
}
|
|
2186
|
+
/**
|
|
2187
|
+
* M8 shadow pass for `frontend-design-review-pi`: extract the committed typed
|
|
2188
|
+
* design terminal facts from session events, flush them, and persist the audit
|
|
2189
|
+
* artifact. The design review's legacy output was a first-line
|
|
2190
|
+
* `VERDICT: pass|request-revision` text protocol (not a JSON verdict), so
|
|
2191
|
+
* there is no JSON equivalence comparison here. Missing typed terminal facts
|
|
2192
|
+
* fail the node closed; writer admission later reads the committed
|
|
2193
|
+
* `design-typed-facts.jsonl` as the only authoritative verdict.
|
|
2194
|
+
*/
|
|
2195
|
+
async function runFrontendDesignTerminalShadow(input) {
|
|
2196
|
+
// See the review counterpart above: a failed provider call cannot be
|
|
2197
|
+
// diagnosed as an omitted terminal tool call.
|
|
2198
|
+
if (!input.mapped.ok)
|
|
2199
|
+
return input.mapped;
|
|
2200
|
+
const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
|
|
2201
|
+
let typedKinds = [];
|
|
2202
|
+
try {
|
|
2203
|
+
const content = await readFile(sessionEventsPath, "utf8");
|
|
2204
|
+
typedKinds = scanDesignTerminalKindsFromSessionEvents(content);
|
|
2205
|
+
}
|
|
2206
|
+
catch {
|
|
2207
|
+
// Missing/unreadable session log → fail-closed at zero terminal facts.
|
|
2208
|
+
typedKinds = [];
|
|
2209
|
+
}
|
|
2210
|
+
// Audit-only flush + artifact. Neither blocks the node.
|
|
2211
|
+
try {
|
|
2212
|
+
await input.tools?.flush?.();
|
|
2213
|
+
}
|
|
2214
|
+
catch {
|
|
2215
|
+
// best-effort
|
|
2216
|
+
}
|
|
2217
|
+
try {
|
|
2218
|
+
await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
|
|
2219
|
+
schemaVersion: 1,
|
|
2220
|
+
nodeId: input.task.id,
|
|
2221
|
+
typedKinds,
|
|
2222
|
+
});
|
|
2223
|
+
}
|
|
2224
|
+
catch {
|
|
2225
|
+
// best-effort audit artifact
|
|
2226
|
+
}
|
|
2227
|
+
if (typedKinds.length === 0) {
|
|
2228
|
+
return {
|
|
2229
|
+
...input.mapped,
|
|
2230
|
+
ok: false,
|
|
2231
|
+
failureCategory: "design-terminal-missing",
|
|
2232
|
+
stderr: [
|
|
2233
|
+
input.mapped.stderr,
|
|
2234
|
+
"frontend design typed terminal fact missing: no approve_design/request_design_changes tool call was committed",
|
|
2235
|
+
]
|
|
2236
|
+
.filter(Boolean)
|
|
2237
|
+
.join("\n\n"),
|
|
2238
|
+
};
|
|
2239
|
+
}
|
|
2240
|
+
return input.mapped;
|
|
2241
|
+
}
|
|
386
2242
|
export async function executeDagPiNode(input, meta, piStepFn = executePiStep, writeGuardDependencies = DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES) {
|
|
387
2243
|
const started = Date.now();
|
|
388
2244
|
const persona = resolveDagPiPersona(input.task);
|
|
@@ -414,9 +2270,28 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
414
2270
|
};
|
|
415
2271
|
}
|
|
416
2272
|
let beforeStatus;
|
|
2273
|
+
let beforeWorkspaceSnapshot;
|
|
417
2274
|
let beforePathFingerprints;
|
|
418
|
-
/** tools-only
|
|
419
|
-
const
|
|
2275
|
+
/** tools-only and filesystem-only keep the tool sandbox but skip Git baseline. */
|
|
2276
|
+
const filesystemOnly = isWriteTask && meta.spec.backendTestWorkspaceControl === "filesystem-only";
|
|
2277
|
+
const skipGitWriteGuard = isWriteTask && (input.task.writeGuardPolicy === "tools-only" || filesystemOnly);
|
|
2278
|
+
if (isWriteTask && filesystemOnly) {
|
|
2279
|
+
try {
|
|
2280
|
+
beforeWorkspaceSnapshot = await captureWorkspaceWriteSnapshot({
|
|
2281
|
+
rootCwd: input.cwd,
|
|
2282
|
+
paths: input.task.writeSet ?? input.task.allowedPaths,
|
|
2283
|
+
});
|
|
2284
|
+
}
|
|
2285
|
+
catch (error) {
|
|
2286
|
+
return {
|
|
2287
|
+
ok: false,
|
|
2288
|
+
stdout: "",
|
|
2289
|
+
stderr: `workspace filesystem baseline unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2290
|
+
failureCategory: "write-guard",
|
|
2291
|
+
durationMs: Date.now() - started,
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
420
2295
|
if (isWriteTask && !skipGitWriteGuard) {
|
|
421
2296
|
try {
|
|
422
2297
|
beforeStatus = await writeGuardDependencies.readGitStatusPorcelain(input.cwd, { phase: "pi-writer-before" });
|
|
@@ -453,6 +2328,12 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
453
2328
|
}
|
|
454
2329
|
: undefined;
|
|
455
2330
|
let writerToolPolicy;
|
|
2331
|
+
let reviewTerminalTools;
|
|
2332
|
+
let designTerminalTools;
|
|
2333
|
+
let planLedgerTools;
|
|
2334
|
+
let contractTools;
|
|
2335
|
+
let scoutEvidenceTools;
|
|
2336
|
+
let readBudgetTools;
|
|
456
2337
|
const commandPolicy = resolveDagCommandPolicy(input.task.commandPolicy);
|
|
457
2338
|
const allowsPlaywrightCli = dagCommandPolicyAllows(input.task.commandPolicy, "playwright-cli");
|
|
458
2339
|
let playwrightToolContext;
|
|
@@ -461,9 +2342,17 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
461
2342
|
ok: false, stdout: "", stderr: "pi command capability requires an explicit bounded write tool profile", failureCategory: "tool-policy", durationMs: Date.now() - started,
|
|
462
2343
|
};
|
|
463
2344
|
}
|
|
464
|
-
|
|
2345
|
+
const strictReadSet = (input.task.readSet?.length ?? 0) > 0;
|
|
2346
|
+
if (isWriteTask || allowsPlaywrightCli || strictReadSet) {
|
|
465
2347
|
try {
|
|
466
2348
|
const customTools = [];
|
|
2349
|
+
if (strictReadSet) {
|
|
2350
|
+
const readContext = buildPiWriterToolPolicyContext({
|
|
2351
|
+
repoRoot: input.cwd,
|
|
2352
|
+
readSet: input.task.readSet,
|
|
2353
|
+
});
|
|
2354
|
+
customTools.push(...(await createPiReaderCustomTools(readContext)));
|
|
2355
|
+
}
|
|
467
2356
|
if (isWriteTask) {
|
|
468
2357
|
const policyContext = buildPiWriterToolPolicyContext({
|
|
469
2358
|
repoRoot: input.cwd,
|
|
@@ -471,6 +2360,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
471
2360
|
writeSet: input.task.writeSet,
|
|
472
2361
|
forbiddenPaths: input.task.forbiddenPaths,
|
|
473
2362
|
writePolicy: input.task.writePolicy,
|
|
2363
|
+
readSet: input.task.readSet,
|
|
474
2364
|
});
|
|
475
2365
|
customTools.push(...(await createPiWriterCustomTools(policyContext)));
|
|
476
2366
|
}
|
|
@@ -481,10 +2371,11 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
481
2371
|
throw new Error(`unknown command capability: ${capability}`);
|
|
482
2372
|
}
|
|
483
2373
|
if (capability === "playwright-cli") {
|
|
484
|
-
const
|
|
2374
|
+
const layout = frontendTestLayoutFromSpec(meta.spec);
|
|
2375
|
+
const caseId = resolveCaseIdFromWriteSet(input.task.writeSet, layout) ??
|
|
485
2376
|
input.task.id;
|
|
486
|
-
const evidenceDir = resolveEvidenceDirFromWriteSet(input.task.writeSet) ??
|
|
487
|
-
|
|
2377
|
+
const evidenceDir = resolveEvidenceDirFromWriteSet(input.task.writeSet, layout) ??
|
|
2378
|
+
`${layout.evidenceDir}/${caseId}`;
|
|
488
2379
|
playwrightToolContext = {
|
|
489
2380
|
repoRoot: input.cwd,
|
|
490
2381
|
runDir: meta.runDir,
|
|
@@ -492,7 +2383,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
492
2383
|
caseId,
|
|
493
2384
|
evidenceDir,
|
|
494
2385
|
baseUrl: await resolveValidatedFrontendBaseUrlFromContext(input.cwd, meta.runDir),
|
|
495
|
-
inputRoot:
|
|
2386
|
+
inputRoot: layout.fixturesDir,
|
|
2387
|
+
layout,
|
|
496
2388
|
};
|
|
497
2389
|
// Cleanup must be confirmed before a new case; otherwise default-session
|
|
498
2390
|
// isolation is unknown and no Pi invocation is permitted.
|
|
@@ -521,6 +2413,161 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
521
2413
|
};
|
|
522
2414
|
}
|
|
523
2415
|
}
|
|
2416
|
+
if (isFrontendReviewTypedTerminalNode(input.task)) {
|
|
2417
|
+
try {
|
|
2418
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2419
|
+
const store = createTypedEventStore();
|
|
2420
|
+
reviewTerminalTools = await createFrontendReviewTerminalTools({
|
|
2421
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
2422
|
+
store,
|
|
2423
|
+
runDir: meta.runDir,
|
|
2424
|
+
nodeId: input.task.id,
|
|
2425
|
+
});
|
|
2426
|
+
writerToolPolicy = {
|
|
2427
|
+
requireSdk: true,
|
|
2428
|
+
customTools: reviewTerminalTools.customTools,
|
|
2429
|
+
};
|
|
2430
|
+
}
|
|
2431
|
+
catch (error) {
|
|
2432
|
+
return {
|
|
2433
|
+
ok: false,
|
|
2434
|
+
stdout: "",
|
|
2435
|
+
stderr: `pi review terminal tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2436
|
+
failureCategory: "tool-policy",
|
|
2437
|
+
durationMs: Date.now() - started,
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
}
|
|
2441
|
+
if (isFrontendDesignTypedTerminalNode(input.task)) {
|
|
2442
|
+
try {
|
|
2443
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2444
|
+
const store = createTypedEventStore();
|
|
2445
|
+
designTerminalTools = await createFrontendDesignTerminalTools({
|
|
2446
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
2447
|
+
store,
|
|
2448
|
+
runDir: meta.runDir,
|
|
2449
|
+
nodeId: input.task.id,
|
|
2450
|
+
});
|
|
2451
|
+
writerToolPolicy = {
|
|
2452
|
+
requireSdk: true,
|
|
2453
|
+
customTools: designTerminalTools.customTools,
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
catch (error) {
|
|
2457
|
+
return {
|
|
2458
|
+
ok: false,
|
|
2459
|
+
stdout: "",
|
|
2460
|
+
stderr: `pi design terminal tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2461
|
+
failureCategory: "tool-policy",
|
|
2462
|
+
durationMs: Date.now() - started,
|
|
2463
|
+
};
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
if (isFrontendContractTypedNode(input.task)) {
|
|
2467
|
+
try {
|
|
2468
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2469
|
+
const store = createTypedEventStore();
|
|
2470
|
+
contractTools = await createFrontendContractTools({
|
|
2471
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
2472
|
+
store,
|
|
2473
|
+
runDir: meta.runDir,
|
|
2474
|
+
nodeId: input.task.id,
|
|
2475
|
+
});
|
|
2476
|
+
writerToolPolicy = {
|
|
2477
|
+
requireSdk: true,
|
|
2478
|
+
customTools: contractTools.customTools,
|
|
2479
|
+
};
|
|
2480
|
+
}
|
|
2481
|
+
catch (error) {
|
|
2482
|
+
return {
|
|
2483
|
+
ok: false,
|
|
2484
|
+
stdout: "",
|
|
2485
|
+
stderr: `pi contract tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2486
|
+
failureCategory: "tool-policy",
|
|
2487
|
+
durationMs: Date.now() - started,
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
if (isFrontendScoutEvidenceNode(input.task)) {
|
|
2492
|
+
try {
|
|
2493
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2494
|
+
const store = createTypedEventStore();
|
|
2495
|
+
scoutEvidenceTools = await createFrontendScoutEvidenceTools({
|
|
2496
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
2497
|
+
store,
|
|
2498
|
+
runDir: meta.runDir,
|
|
2499
|
+
nodeId: input.task.id,
|
|
2500
|
+
workspaceRoot: input.cwd,
|
|
2501
|
+
});
|
|
2502
|
+
writerToolPolicy = {
|
|
2503
|
+
requireSdk: true,
|
|
2504
|
+
customTools: scoutEvidenceTools.customTools,
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
catch (error) {
|
|
2508
|
+
return {
|
|
2509
|
+
ok: false,
|
|
2510
|
+
stdout: "",
|
|
2511
|
+
stderr: `pi scout evidence tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2512
|
+
failureCategory: "tool-policy",
|
|
2513
|
+
durationMs: Date.now() - started,
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
}
|
|
2517
|
+
if (isFrontendPlanLedgerNode(input.task)) {
|
|
2518
|
+
try {
|
|
2519
|
+
const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
|
|
2520
|
+
const store = createTypedEventStore();
|
|
2521
|
+
planLedgerTools = await createFrontendPlanLedgerTools({
|
|
2522
|
+
attemptId: `${meta.runId}:${input.task.id}`,
|
|
2523
|
+
store,
|
|
2524
|
+
runDir: meta.runDir,
|
|
2525
|
+
nodeId: input.task.id,
|
|
2526
|
+
skeleton: input.task.structuredContractOutput?.skeleton,
|
|
2527
|
+
componentNewSourceReferences: await resolveFrontendPlanNewComponentSourceReferences({
|
|
2528
|
+
cwd: input.cwd,
|
|
2529
|
+
sourceBinding: meta.spec.sourceBinding,
|
|
2530
|
+
}),
|
|
2531
|
+
});
|
|
2532
|
+
writerToolPolicy = {
|
|
2533
|
+
requireSdk: true,
|
|
2534
|
+
customTools: planLedgerTools.customTools,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
2537
|
+
catch (error) {
|
|
2538
|
+
return {
|
|
2539
|
+
ok: false,
|
|
2540
|
+
stdout: "",
|
|
2541
|
+
stderr: `pi plan ledger tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2542
|
+
failureCategory: "tool-policy",
|
|
2543
|
+
durationMs: Date.now() - started,
|
|
2544
|
+
};
|
|
2545
|
+
}
|
|
2546
|
+
}
|
|
2547
|
+
if (input.task.readBudget) {
|
|
2548
|
+
try {
|
|
2549
|
+
readBudgetTools = await createPiReadBudgetCustomTools({
|
|
2550
|
+
repoRoot: input.cwd,
|
|
2551
|
+
budget: input.task.readBudget,
|
|
2552
|
+
});
|
|
2553
|
+
writerToolPolicy = {
|
|
2554
|
+
requireSdk: true,
|
|
2555
|
+
customTools: [
|
|
2556
|
+
...(writerToolPolicy?.customTools ?? []),
|
|
2557
|
+
...readBudgetTools.customTools,
|
|
2558
|
+
],
|
|
2559
|
+
};
|
|
2560
|
+
}
|
|
2561
|
+
catch (error) {
|
|
2562
|
+
return {
|
|
2563
|
+
ok: false,
|
|
2564
|
+
stdout: "",
|
|
2565
|
+
stderr: `pi read budget policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
2566
|
+
failureCategory: "tool-policy",
|
|
2567
|
+
durationMs: Date.now() - started,
|
|
2568
|
+
};
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
524
2571
|
let result;
|
|
525
2572
|
try {
|
|
526
2573
|
// Phase 5 (P1-9): capture the writeSet baseline BEFORE the writer provider
|
|
@@ -529,8 +2576,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
529
2576
|
// hard fail (no baseline → no rollback → no recovery). Dynamic import avoids
|
|
530
2577
|
// an executor ↔ scheduler static cycle.
|
|
531
2578
|
if (isWriteTask &&
|
|
532
|
-
|
|
533
|
-
input.task.id === "frontend-repair-pi") &&
|
|
2579
|
+
input.task.id === "frontend-implement-pi" &&
|
|
534
2580
|
(input.task.writeSet?.length ?? 0) > 0) {
|
|
535
2581
|
try {
|
|
536
2582
|
const { captureFrontendWriterAttemptIntent } = await import("../workflows/dag/frontend-writer-recovery.js");
|
|
@@ -550,6 +2596,29 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
550
2596
|
};
|
|
551
2597
|
}
|
|
552
2598
|
}
|
|
2599
|
+
// Plan 2026-08-21 D4/D6: resolve the node's explicit Pi extension
|
|
2600
|
+
// allowlist against the settings inventory. Degrade on missing packages —
|
|
2601
|
+
// never fail the node — and persist requested/resolved/missing facts.
|
|
2602
|
+
const piExtensionsResolution = input.task.piExtensions
|
|
2603
|
+
? resolveDagPiExtensions({
|
|
2604
|
+
repoRoot: input.cwd,
|
|
2605
|
+
ids: input.task.piExtensions,
|
|
2606
|
+
})
|
|
2607
|
+
: undefined;
|
|
2608
|
+
if (piExtensionsResolution) {
|
|
2609
|
+
const piBackend = resolvePiBackend();
|
|
2610
|
+
const cliDegraded = piBackend === "cli-only";
|
|
2611
|
+
await writePiExtensionsArtifact(path.join(meta.runDir, input.task.id), input.task.piExtensions, cliDegraded ? [] : piExtensionsResolution.resolved, cliDegraded
|
|
2612
|
+
? input.task.piExtensions.map((id) => ({
|
|
2613
|
+
id,
|
|
2614
|
+
reason: "not-installed",
|
|
2615
|
+
detail: "cli backend does not load pi extensions (sdk-only v1)",
|
|
2616
|
+
}))
|
|
2617
|
+
: piExtensionsResolution.missing);
|
|
2618
|
+
}
|
|
2619
|
+
const piExtensionPaths = piExtensionsResolution && resolvePiBackend() !== "cli-only"
|
|
2620
|
+
? piExtensionsResolution.resolved.flatMap((entry) => entry.entryPaths)
|
|
2621
|
+
: undefined;
|
|
553
2622
|
result = await piStepFn({
|
|
554
2623
|
attachedFiles: [],
|
|
555
2624
|
modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
|
|
@@ -563,7 +2632,13 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
563
2632
|
timeoutMs: input.timeoutMs,
|
|
564
2633
|
stallTimeoutMs: input.stallTimeoutMs,
|
|
565
2634
|
abortGraceMs: input.abortGraceMs,
|
|
2635
|
+
...(input.task.contextBudget
|
|
2636
|
+
? { contextBudget: input.task.contextBudget }
|
|
2637
|
+
: {}),
|
|
566
2638
|
...(writerToolPolicy ? { writerToolPolicy } : {}),
|
|
2639
|
+
...(piExtensionPaths && piExtensionPaths.length > 0
|
|
2640
|
+
? { piExtensionPaths }
|
|
2641
|
+
: {}),
|
|
567
2642
|
onActivity: bridgeActivity
|
|
568
2643
|
? (activity) => {
|
|
569
2644
|
if (activity.kind === "lease" ||
|
|
@@ -608,9 +2683,128 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
608
2683
|
persona,
|
|
609
2684
|
step,
|
|
610
2685
|
});
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
2686
|
+
if (input.task.id === "generate-backend-md-plan-pi" && result.assistantText?.trim()) {
|
|
2687
|
+
await writeTextArtifactFile(path.join(meta.runDir, input.task.id, "plan.md"), result.assistantText.trim() + "\n");
|
|
2688
|
+
}
|
|
2689
|
+
const mapped = mapPiResultToDagNodeResult(result, isFrontendFactsWriter(input.task)
|
|
2690
|
+
? undefined
|
|
2691
|
+
: input.task.writerOutcomePolicy
|
|
2692
|
+
? WRITER_OUTCOME_PROTOCOL_LINE
|
|
2693
|
+
: input.task.firstProtocolLine);
|
|
2694
|
+
// A node-specific budget is enforced for every frontend reader that opts in,
|
|
2695
|
+
// including design/final review. Earlier code only checked contract, scout,
|
|
2696
|
+
// and plan nodes, leaving the two largest review sessions unbounded.
|
|
2697
|
+
// A soft-limit node has already stopped repository access in its SDK tool
|
|
2698
|
+
// guard. Session events still include the rejected tool attempt, so using
|
|
2699
|
+
// those telemetry events as a second budget gate would wrongly turn a
|
|
2700
|
+
// successful plan into read-burst.
|
|
2701
|
+
const readBudgetIssues = input.task.readBudget?.onExhaustion === "return-guidance"
|
|
2702
|
+
? []
|
|
2703
|
+
: [
|
|
2704
|
+
...(readBudgetTools?.issues() ?? []),
|
|
2705
|
+
...(await detectNodeReadBudget({
|
|
2706
|
+
runDir: meta.runDir,
|
|
2707
|
+
nodeId: input.task.id,
|
|
2708
|
+
budget: input.task.readBudget,
|
|
2709
|
+
})),
|
|
2710
|
+
];
|
|
2711
|
+
if (readBudgetIssues.length > 0) {
|
|
2712
|
+
// Read-budget telemetry is diagnostic only when the provider/executor has
|
|
2713
|
+
// already failed. In particular, keep context-overflow so node-execution
|
|
2714
|
+
// selects its compact retry envelope instead of treating the attempt as a
|
|
2715
|
+
// generic read-burst retry.
|
|
2716
|
+
if (!mapped.ok) {
|
|
2717
|
+
return {
|
|
2718
|
+
...mapped,
|
|
2719
|
+
stderr: [mapped.stderr, ...readBudgetIssues]
|
|
2720
|
+
.filter(Boolean)
|
|
2721
|
+
.join("\n\n"),
|
|
2722
|
+
};
|
|
2723
|
+
}
|
|
2724
|
+
return {
|
|
2725
|
+
...mapped,
|
|
2726
|
+
ok: false,
|
|
2727
|
+
failureCategory: "read-burst",
|
|
2728
|
+
stderr: [mapped.stderr, ...readBudgetIssues].filter(Boolean).join("\n\n"),
|
|
2729
|
+
};
|
|
2730
|
+
}
|
|
2731
|
+
if (isFrontendReviewTypedTerminalNode(input.task)) {
|
|
2732
|
+
return await runFrontendReviewTerminalShadow({
|
|
2733
|
+
task: input.task,
|
|
2734
|
+
meta,
|
|
2735
|
+
mapped,
|
|
2736
|
+
tools: reviewTerminalTools,
|
|
2737
|
+
});
|
|
2738
|
+
}
|
|
2739
|
+
if (isFrontendDesignTypedTerminalNode(input.task)) {
|
|
2740
|
+
return await runFrontendDesignTerminalShadow({
|
|
2741
|
+
task: input.task,
|
|
2742
|
+
meta,
|
|
2743
|
+
mapped,
|
|
2744
|
+
tools: designTerminalTools,
|
|
2745
|
+
});
|
|
2746
|
+
}
|
|
2747
|
+
if (isFrontendContractTypedNode(input.task)) {
|
|
2748
|
+
try {
|
|
2749
|
+
await contractTools?.flush?.();
|
|
2750
|
+
}
|
|
2751
|
+
catch {
|
|
2752
|
+
// best-effort flush
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
if (isFrontendScoutEvidenceNode(input.task)) {
|
|
2756
|
+
try {
|
|
2757
|
+
await scoutEvidenceTools?.flush?.();
|
|
2758
|
+
}
|
|
2759
|
+
catch {
|
|
2760
|
+
// best-effort flush
|
|
2761
|
+
}
|
|
2762
|
+
if (mapped.ok) {
|
|
2763
|
+
const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
|
|
2764
|
+
const scoutFacts = await readCommittedOriginFacts(meta.runDir, input.task.id, "scout-typed-facts.jsonl");
|
|
2765
|
+
const completeness = checkCommittedOriginFacts({
|
|
2766
|
+
records: scoutFacts,
|
|
2767
|
+
origin: "scout",
|
|
2768
|
+
});
|
|
2769
|
+
if (!completeness.ok) {
|
|
2770
|
+
return {
|
|
2771
|
+
...mapped,
|
|
2772
|
+
ok: false,
|
|
2773
|
+
failureCategory: "invalid-output",
|
|
2774
|
+
stderr: [mapped.stderr, completeness.reason]
|
|
2775
|
+
.filter(Boolean)
|
|
2776
|
+
.join("\n\n"),
|
|
2777
|
+
};
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
}
|
|
2781
|
+
if (isFrontendPlanLedgerNode(input.task)) {
|
|
2782
|
+
try {
|
|
2783
|
+
await planLedgerTools?.flush?.();
|
|
2784
|
+
}
|
|
2785
|
+
catch {
|
|
2786
|
+
// best-effort flush; missing ledger still fails at the node validator
|
|
2787
|
+
}
|
|
2788
|
+
// Read-burst guard: a plan that burned dozens of read/grep/ls/find
|
|
2789
|
+
// calls (re-reading upstream outputs and source files it should trust
|
|
2790
|
+
// from typed facts) blows up the context window and eventually fails
|
|
2791
|
+
// with 400 request-too-large. Detect it deterministically from the
|
|
2792
|
+
// session log and retry with a reduced-reading instruction.
|
|
2793
|
+
const readBurstIssues = input.task.readBudget?.onExhaustion === "return-guidance"
|
|
2794
|
+
? []
|
|
2795
|
+
: await detectPlanReadBurst({
|
|
2796
|
+
runDir: meta.runDir,
|
|
2797
|
+
nodeId: input.task.id,
|
|
2798
|
+
});
|
|
2799
|
+
if (readBurstIssues.length > 0) {
|
|
2800
|
+
return {
|
|
2801
|
+
...mapped,
|
|
2802
|
+
ok: false,
|
|
2803
|
+
failureCategory: "read-burst",
|
|
2804
|
+
stderr: [mapped.stderr, ...readBurstIssues].filter(Boolean).join("\n\n"),
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
}
|
|
614
2808
|
if (!isWriteTask) {
|
|
615
2809
|
return mapped;
|
|
616
2810
|
}
|
|
@@ -618,6 +2812,27 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
618
2812
|
let writeGuardViolations = [];
|
|
619
2813
|
let changeManifestAfterStatus;
|
|
620
2814
|
let changeManifestChangedFiles;
|
|
2815
|
+
let afterWorkspaceSnapshot;
|
|
2816
|
+
if (filesystemOnly && beforeWorkspaceSnapshot) {
|
|
2817
|
+
try {
|
|
2818
|
+
afterWorkspaceSnapshot = await captureWorkspaceWriteSnapshot({
|
|
2819
|
+
rootCwd: input.cwd,
|
|
2820
|
+
paths: input.task.writeSet ?? input.task.allowedPaths,
|
|
2821
|
+
});
|
|
2822
|
+
changeManifestChangedFiles = diffWorkspaceWriteSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot);
|
|
2823
|
+
const guard = validateShellWriteGuardFromDiff({
|
|
2824
|
+
changedFiles: changeManifestChangedFiles,
|
|
2825
|
+
task: input.task,
|
|
2826
|
+
concurrentSiblingWriteSets: meta.concurrentSiblingWriteSets,
|
|
2827
|
+
});
|
|
2828
|
+
writeGuardOk = guard.ok;
|
|
2829
|
+
writeGuardViolations = guard.violations;
|
|
2830
|
+
}
|
|
2831
|
+
catch (error) {
|
|
2832
|
+
writeGuardOk = false;
|
|
2833
|
+
writeGuardViolations = [`workspace snapshot unavailable: ${error instanceof Error ? error.message : String(error)}`];
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
621
2836
|
if (beforeStatus !== undefined) {
|
|
622
2837
|
let recoveryEvidence;
|
|
623
2838
|
let removalPendingRecheck;
|
|
@@ -723,8 +2938,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
723
2938
|
changeManifestChangedFiles = changedFiles;
|
|
724
2939
|
// Phase 5: record the attempt changed paths/hashes into the rollback
|
|
725
2940
|
// intent so a transient partial write can be CAS-restored.
|
|
726
|
-
if (
|
|
727
|
-
input.task.id === "frontend-repair-pi") &&
|
|
2941
|
+
if (input.task.id === "frontend-implement-pi" &&
|
|
728
2942
|
(input.task.writeSet?.length ?? 0) > 0) {
|
|
729
2943
|
try {
|
|
730
2944
|
const { recordFrontendWriterAttempt } = await import("../workflows/dag/frontend-writer-recovery.js");
|
|
@@ -774,8 +2988,65 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
774
2988
|
}
|
|
775
2989
|
}
|
|
776
2990
|
let writerOutcomeViolation;
|
|
2991
|
+
let factDerivedStatus;
|
|
777
2992
|
if (mapped.ok && input.task.writerOutcomePolicy) {
|
|
778
|
-
if (
|
|
2993
|
+
if (isFrontendFactsWriter(input.task)) {
|
|
2994
|
+
// AC-001/AC-003: facts-derived status. The first line is never read;
|
|
2995
|
+
// the legacy validator still runs for the shadow comparison only.
|
|
2996
|
+
try {
|
|
2997
|
+
const { collectFrontendWriterFacts, deriveFrontendWriterStatus, computeFailureFingerprint, compareFactStatusToLegacyOutcome, } = await import("../workflows/dag/frontend-writer-status.js");
|
|
2998
|
+
const facts = await collectFrontendWriterFacts({
|
|
2999
|
+
sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
|
|
3000
|
+
changedFiles: changeManifestChangedFiles ?? [],
|
|
3001
|
+
writeGuard: { ok: writeGuardOk, violations: writeGuardViolations },
|
|
3002
|
+
requirementTargets: [],
|
|
3003
|
+
coveredRequirementIds: [],
|
|
3004
|
+
focusedCheckFailures: [],
|
|
3005
|
+
tokensUsed: result.tokensUsed,
|
|
3006
|
+
wallTimeMs: Date.now() - started,
|
|
3007
|
+
rounds: 1,
|
|
3008
|
+
writeAttempts: attempt,
|
|
3009
|
+
firstLineText: mapped.assistantText,
|
|
3010
|
+
});
|
|
3011
|
+
const derived = deriveFrontendWriterStatus(facts);
|
|
3012
|
+
factDerivedStatus = derived.status;
|
|
3013
|
+
const legacyValidation = validateWriterImplementationOutcome(mapped.assistantText || mapped.stdout, changeManifestChangedFiles ?? [], {
|
|
3014
|
+
requireChangedFiles: false,
|
|
3015
|
+
allowMissingChangedOutcomeWhenDiffPresent: false,
|
|
3016
|
+
});
|
|
3017
|
+
const legacyOutcome = legacyValidation.ok
|
|
3018
|
+
? legacyValidation.outcome
|
|
3019
|
+
: legacyValidation.reason.includes("blocked")
|
|
3020
|
+
? "blocked"
|
|
3021
|
+
: "missing";
|
|
3022
|
+
await writeDagNodeJsonArtifact(meta.runDir, input.task.id, "fact-implementation-status.json", {
|
|
3023
|
+
schemaVersion: 1,
|
|
3024
|
+
nodeId: input.task.id,
|
|
3025
|
+
status: derived.status,
|
|
3026
|
+
reason: derived.reason,
|
|
3027
|
+
changedFiles: facts.changedFiles,
|
|
3028
|
+
writeToolEvents: facts.writeToolEvents,
|
|
3029
|
+
writeGuardOk: facts.writeGuardOk,
|
|
3030
|
+
writeGuardViolations: facts.writeGuardViolations,
|
|
3031
|
+
focusedCheckFailures: facts.focusedCheckFailures,
|
|
3032
|
+
alreadySatisfiedEvidence: facts.alreadySatisfiedEvidence,
|
|
3033
|
+
tokensUsed: facts.tokensUsed,
|
|
3034
|
+
wallTimeMs: facts.wallTimeMs,
|
|
3035
|
+
rounds: facts.rounds,
|
|
3036
|
+
writeAttempts: facts.writeAttempts,
|
|
3037
|
+
failureFingerprint: computeFailureFingerprint(facts),
|
|
3038
|
+
shadow: compareFactStatusToLegacyOutcome(derived.status, legacyOutcome),
|
|
3039
|
+
});
|
|
3040
|
+
if (derived.status !== "changed" &&
|
|
3041
|
+
derived.status !== "already-satisfied") {
|
|
3042
|
+
writerOutcomeViolation = `fact-derived status ${derived.status}: ${derived.reason}`;
|
|
3043
|
+
}
|
|
3044
|
+
}
|
|
3045
|
+
catch (error) {
|
|
3046
|
+
writerOutcomeViolation = `frontend facts derivation failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
else if (changeManifestChangedFiles === undefined) {
|
|
779
3050
|
writerOutcomeViolation =
|
|
780
3051
|
"writer outcome validation failed: actual diff is unavailable";
|
|
781
3052
|
}
|
|
@@ -790,7 +3061,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
790
3061
|
}
|
|
791
3062
|
}
|
|
792
3063
|
if (writeGuardOk &&
|
|
793
|
-
beforeStatus !== undefined &&
|
|
3064
|
+
(beforeStatus !== undefined || beforeWorkspaceSnapshot !== undefined) &&
|
|
794
3065
|
changeManifestChangedFiles !== undefined) {
|
|
795
3066
|
await persistWriterChangeManifest({
|
|
796
3067
|
runDir: meta.runDir,
|
|
@@ -800,8 +3071,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
800
3071
|
approvalSourceNodeId: input.task.resolvedFinalWriteSetApproval?.approvalSourceNodeId,
|
|
801
3072
|
approvalDigest: input.task.resolvedFinalWriteSetApproval?.approvalDigest,
|
|
802
3073
|
changedFiles: changeManifestChangedFiles,
|
|
803
|
-
beforeStatus,
|
|
804
|
-
afterStatus: changeManifestAfterStatus ??
|
|
3074
|
+
beforeStatus: beforeStatus ?? JSON.stringify(beforeWorkspaceSnapshot),
|
|
3075
|
+
afterStatus: changeManifestAfterStatus ?? JSON.stringify(afterWorkspaceSnapshot),
|
|
805
3076
|
});
|
|
806
3077
|
}
|
|
807
3078
|
let completenessFailure;
|
|
@@ -885,31 +3156,48 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
885
3156
|
if (meta.writeGuardAttribution === "best-effort") {
|
|
886
3157
|
stderrParts.push("write guard note: concurrent rank writers use best-effort per-node attribution; keep same-rank writeSet entries disjoint");
|
|
887
3158
|
}
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const writerCleanTimeout = !mapped.ok &&
|
|
892
|
-
mapped.failureCategory === "timeout" &&
|
|
893
|
-
writeGuardOk &&
|
|
3159
|
+
const rawFailureCategory = mapped.failureCategory;
|
|
3160
|
+
const writeToolCallCount = readWriterThinkingExhaustionEvidence(result).writeToolCallCount;
|
|
3161
|
+
const zeroSideEffects = writeGuardOk &&
|
|
894
3162
|
!completenessFailure &&
|
|
895
3163
|
!writerThinkingExhausted &&
|
|
896
|
-
|
|
3164
|
+
!writerOutcomeViolation &&
|
|
897
3165
|
changeManifestChangedFiles !== undefined &&
|
|
898
3166
|
changeManifestChangedFiles.length === 0 &&
|
|
899
|
-
|
|
3167
|
+
writeToolCallCount === 0;
|
|
3168
|
+
// Budget-exhausted: the provider session consumed an extreme amount of
|
|
3169
|
+
// tokens (e.g. an unresolved read-edit-test loop) and still failed. Only
|
|
3170
|
+
// applies on a non-ok writer result; a successful write is never re-labeled.
|
|
3171
|
+
const writerBudgetExhausted = !mapped.ok &&
|
|
3172
|
+
typeof mapped.tokensUsed === "number" &&
|
|
3173
|
+
mapped.tokensUsed > WRITER_TOKEN_BUDGET;
|
|
3174
|
+
const targetCleanTransport = !mapped.ok &&
|
|
3175
|
+
isTargetTemplateTransientRetryNode(input.task) &&
|
|
3176
|
+
rawFailureCategory !== undefined &&
|
|
3177
|
+
["timeout", "network", "rate-limit", "unavailable"].includes(rawFailureCategory) &&
|
|
3178
|
+
zeroSideEffects;
|
|
3179
|
+
const frontendCleanTimeout = !mapped.ok &&
|
|
3180
|
+
rawFailureCategory === "timeout" &&
|
|
3181
|
+
isWriterTransportRetryCandidate(input.task) &&
|
|
3182
|
+
!isTargetTemplateTransientRetryNode(input.task) &&
|
|
3183
|
+
zeroSideEffects;
|
|
3184
|
+
const writerCleanTimeout = targetCleanTransport || frontendCleanTimeout;
|
|
900
3185
|
return {
|
|
901
3186
|
...mapped,
|
|
902
3187
|
ok: false,
|
|
903
3188
|
stderr: stderrParts.filter(Boolean).join("\n\n"),
|
|
3189
|
+
...(rawFailureCategory ? { rawFailureCategory } : {}),
|
|
904
3190
|
failureCategory: mapped.ok
|
|
905
3191
|
? writeGuardOk
|
|
906
3192
|
? completenessFailure
|
|
907
3193
|
? completenessFailure.failureCategory
|
|
908
|
-
:
|
|
909
|
-
isWriterEmptyDiffRetryCandidate(input.task) &&
|
|
910
|
-
isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
|
|
3194
|
+
: factDerivedStatus === "empty-diff"
|
|
911
3195
|
? WRITER_EMPTY_DIFF_RETRY_CATEGORY
|
|
912
|
-
:
|
|
3196
|
+
: changeManifestChangedFiles?.length === 0 &&
|
|
3197
|
+
isWriterEmptyDiffRetryCandidate(input.task) &&
|
|
3198
|
+
isChangedWriterImplementationOutcome(mapped.assistantText || mapped.stdout)
|
|
3199
|
+
? WRITER_EMPTY_DIFF_RETRY_CATEGORY
|
|
3200
|
+
: "invalid-output"
|
|
913
3201
|
: "write-guard"
|
|
914
3202
|
: // When the attempt already failed with a writer-style category
|
|
915
3203
|
// (empty-output / invalid-output / writer-empty-diff) but the workspace
|
|
@@ -926,17 +3214,35 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
926
3214
|
mapped.failureCategory === "invalid-output" ||
|
|
927
3215
|
mapped.failureCategory === WRITER_EMPTY_DIFF_RETRY_CATEGORY)
|
|
928
3216
|
? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
|
|
929
|
-
: //
|
|
930
|
-
//
|
|
931
|
-
//
|
|
932
|
-
// the
|
|
933
|
-
//
|
|
934
|
-
//
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
3217
|
+
: // partial-success-with-context-overflow: the writer hit a context
|
|
3218
|
+
// overflow on its final provider call but had already persisted
|
|
3219
|
+
// real file changes (change-manifest non-empty). Classify it
|
|
3220
|
+
// distinctly so the report shows "code largely done, verification
|
|
3221
|
+
// incomplete" instead of a misleading empty-output / spec issue,
|
|
3222
|
+
// and recovery can rerun from verify/implement with a compacted
|
|
3223
|
+
// session rather than asking the operator for spec clarification.
|
|
3224
|
+
rawFailureCategory === "context-overflow" &&
|
|
3225
|
+
(changeManifestChangedFiles?.length ?? 0) > 0
|
|
3226
|
+
? "partial-success-with-context-overflow"
|
|
3227
|
+
: // writer-thinking-exhausted: a length-stopped thinking-only attempt with
|
|
3228
|
+
// zero write tool calls and zero attributed diff is terminal and
|
|
3229
|
+
// non-retryable; recommend a model switch + fresh run. Only applies on
|
|
3230
|
+
// the empty-output base category so provider/transport failures keep
|
|
3231
|
+
// their original category, and only when the completeness gate did not
|
|
3232
|
+
// upgrade to incomplete-write-set above.
|
|
3233
|
+
writerThinkingExhausted
|
|
3234
|
+
? WRITER_THINKING_EXHAUSTED_CATEGORY
|
|
3235
|
+
: writerCleanTimeout
|
|
3236
|
+
? WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY
|
|
3237
|
+
: // writer-budget-exhausted: the provider session consumed an
|
|
3238
|
+
// excessive amount of tokens (a read-edit-test loop that never
|
|
3239
|
+
// converged) and still failed. Classify distinctly so the
|
|
3240
|
+
// report says the run burned its budget instead of a generic
|
|
3241
|
+
// empty-output, and recovery recommends a fresh compacted
|
|
3242
|
+
// run rather than spec-clarification.
|
|
3243
|
+
writerBudgetExhausted
|
|
3244
|
+
? WRITER_BUDGET_EXHAUSTED_CATEGORY
|
|
3245
|
+
: mapped.failureCategory,
|
|
940
3246
|
durationMs: mapped.durationMs || Date.now() - started,
|
|
941
3247
|
};
|
|
942
3248
|
}
|