@tea-agent/loop-agent 0.42.0-next.14 → 0.42.0-next.15

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.
Files changed (92) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/build-stamp.json +3 -3
  3. package/dist/executors/dag-pi-executor.js +617 -94
  4. package/dist/executors/shell-executor.js +4 -0
  5. package/dist/worker/console/chat/pi-runtime.js +45 -2
  6. package/dist/worker/console/chat/resource-loader.js +4 -1
  7. package/dist/worker/console/chat/routes.js +8 -0
  8. package/dist/worker/console/chat/subagents/agent-tool.js +62 -0
  9. package/dist/worker/console/chat/subagents/explore-agent.js +169 -0
  10. package/dist/worker/console/chat/subagents/index.js +5 -0
  11. package/dist/worker/console/chat/subagents/orchestrator.js +273 -0
  12. package/dist/worker/console/chat/subagents/tool-policy.js +53 -0
  13. package/dist/worker/console/chat/subagents/types.js +13 -0
  14. package/dist/worker/console/chat/tool-preview.js +10 -0
  15. package/dist/worker/console/chat/tools.js +11 -1
  16. package/dist/worker/console/interview/tools.js +1 -0
  17. package/dist/worker/console/static/assets/{abnfDiagram-N423BO3Z-BGMbAdAG.js → abnfDiagram-N423BO3Z-CgXb0EVO.js} +1 -1
  18. package/dist/worker/console/static/assets/{arc-CJu1LND8.js → arc-DN59MZqN.js} +1 -1
  19. package/dist/worker/console/static/assets/{architectureDiagram-T3A2C74G-BmJh9gMQ.js → architectureDiagram-T3A2C74G-BUk3sWpn.js} +1 -1
  20. package/dist/worker/console/static/assets/{blockDiagram-VBNYF7ZC-BWjaX3ZF.js → blockDiagram-VBNYF7ZC-IH-cPBFE.js} +1 -1
  21. package/dist/worker/console/static/assets/{c4Diagram-5PPSVZJV-CLEOAxLB.js → c4Diagram-5PPSVZJV-BJWf1nGo.js} +1 -1
  22. package/dist/worker/console/static/assets/channel-i1DjpDIw.js +1 -0
  23. package/dist/worker/console/static/assets/{chunk-2GRJ4B5K-CydnyQUX.js → chunk-2GRJ4B5K-BCPb5H0y.js} +1 -1
  24. package/dist/worker/console/static/assets/{chunk-2Q5K7J3B-tjJ1IoZ1.js → chunk-2Q5K7J3B-Cfs3jeW2.js} +1 -1
  25. package/dist/worker/console/static/assets/{chunk-5RXB4S5H-DR358Phd.js → chunk-5RXB4S5H-BhQI1B_T.js} +1 -1
  26. package/dist/worker/console/static/assets/{chunk-5VM5RSS4-B-LuWFZD.js → chunk-5VM5RSS4-Cc_m4gyV.js} +1 -1
  27. package/dist/worker/console/static/assets/{chunk-6Q2QTUOP-FG53fA6v.js → chunk-6Q2QTUOP-JPqDS3IV.js} +1 -1
  28. package/dist/worker/console/static/assets/{chunk-GF5L2VYU-Bkt-w8SZ.js → chunk-GF5L2VYU-AeGX6EAg.js} +1 -1
  29. package/dist/worker/console/static/assets/{chunk-JWPE2WC7-WxHo0z2I.js → chunk-JWPE2WC7-pEyTjekV.js} +1 -1
  30. package/dist/worker/console/static/assets/{chunk-KBJHAD2P-CqHrBJhp.js → chunk-KBJHAD2P-Bi5UopKd.js} +1 -1
  31. package/dist/worker/console/static/assets/{chunk-RYQCIY6F-D4MVae_f.js → chunk-RYQCIY6F-FkGFJxgQ.js} +1 -1
  32. package/dist/worker/console/static/assets/{chunk-XXDRQBXY-CamBh0Ll.js → chunk-XXDRQBXY-D-7LGrV2.js} +1 -1
  33. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-DgDRdE1V.js +1 -0
  34. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-DgDRdE1V.js +1 -0
  35. package/dist/worker/console/static/assets/{cose-bilkent-JH36ORCC-C3qheCuW.js → cose-bilkent-JH36ORCC-CCEDNDaX.js} +1 -1
  36. package/dist/worker/console/static/assets/{cynefin-VYW2F7L2-DQlWfkS7.js → cynefin-VYW2F7L2-DDd-t7fm.js} +1 -1
  37. package/dist/worker/console/static/assets/{cynefinDiagram-MW4NZA55-BAxa9rFb.js → cynefinDiagram-MW4NZA55-LWe4Ikzr.js} +1 -1
  38. package/dist/worker/console/static/assets/{dagre-VZM6K2ZE-YM09nLJw.js → dagre-VZM6K2ZE-BBCZScc-.js} +1 -1
  39. package/dist/worker/console/static/assets/{diagram-7IWD3JNH-I4W9EJj2.js → diagram-7IWD3JNH-B6liRVig.js} +1 -1
  40. package/dist/worker/console/static/assets/{diagram-B4RE2ZJO-DH8eJ8Ff.js → diagram-B4RE2ZJO-DO_hIrpW.js} +1 -1
  41. package/dist/worker/console/static/assets/{diagram-LBJQPF4R-D0D0ntDW.js → diagram-LBJQPF4R-dmCy93uR.js} +1 -1
  42. package/dist/worker/console/static/assets/{diagram-Q27KOJAE-BIISKq1s.js → diagram-Q27KOJAE-D6mFTIxP.js} +1 -1
  43. package/dist/worker/console/static/assets/{diagram-UB23O5K3--xyAWj0w.js → diagram-UB23O5K3-DCteVUfY.js} +1 -1
  44. package/dist/worker/console/static/assets/{ebnfDiagram-BXEA7PRR-B3sYXLJ4.js → ebnfDiagram-BXEA7PRR-DTv530VK.js} +1 -1
  45. package/dist/worker/console/static/assets/{erDiagram-JOGREHBK-zRSYWhJP.js → erDiagram-JOGREHBK-DCXDMxCr.js} +1 -1
  46. package/dist/worker/console/static/assets/{flowDiagram-UKHOOZJN-BUYlie03.js → flowDiagram-UKHOOZJN-BwEBLOJh.js} +1 -1
  47. package/dist/worker/console/static/assets/{ganttDiagram-PKOTCBZU-DP4kzzrM.js → ganttDiagram-PKOTCBZU-Ck-Vymjm.js} +1 -1
  48. package/dist/worker/console/static/assets/{gitGraphDiagram-DS77QQ5N-CAPoUxTF.js → gitGraphDiagram-DS77QQ5N-Hqs6X_3L.js} +1 -1
  49. package/dist/worker/console/static/assets/{index-CWhTyxvc.js → index-BmMi-Bve.js} +71 -71
  50. package/dist/worker/console/static/assets/{infoDiagram-6WML65LV-BXPfjekV.js → infoDiagram-6WML65LV-Cjc6M9eg.js} +1 -1
  51. package/dist/worker/console/static/assets/{ishikawaDiagram-WSZJBQD7-aOI3pV6t.js → ishikawaDiagram-WSZJBQD7-CPchYZMl.js} +1 -1
  52. package/dist/worker/console/static/assets/{journeyDiagram-NVQOT4AX-DAPZuvi4.js → journeyDiagram-NVQOT4AX-BABdJNNC.js} +1 -1
  53. package/dist/worker/console/static/assets/{kanban-definition-27J2QSJJ-atwDzL3C.js → kanban-definition-27J2QSJJ-1oXhbM4j.js} +1 -1
  54. package/dist/worker/console/static/assets/{linear-D5E09qji.js → linear-PTmQ9LkV.js} +1 -1
  55. package/dist/worker/console/static/assets/{mermaid.core-CE4idY-s.js → mermaid.core-B6Lxil0V.js} +5 -5
  56. package/dist/worker/console/static/assets/{mindmap-definition-FAOFIHXS-CbZxtPca.js → mindmap-definition-FAOFIHXS-BUqjNGEw.js} +1 -1
  57. package/dist/worker/console/static/assets/{pegDiagram-VL7TDLO6-De-FYEte.js → pegDiagram-VL7TDLO6-8D1N-orJ.js} +1 -1
  58. package/dist/worker/console/static/assets/{pieDiagram-7S7Q4E2Y-Bs0USobz.js → pieDiagram-7S7Q4E2Y-BlPMN9d9.js} +1 -1
  59. package/dist/worker/console/static/assets/{quadrantDiagram-CIZ2JOQS-Bq3ZrGCJ.js → quadrantDiagram-CIZ2JOQS-BIL5YDes.js} +1 -1
  60. package/dist/worker/console/static/assets/{railroadDiagram-AXF67PYL-Bk8ojyIj.js → railroadDiagram-AXF67PYL-Dqmil3ie.js} +1 -1
  61. package/dist/worker/console/static/assets/{requirementDiagram-LRYGKXZP-CGhoyws1.js → requirementDiagram-LRYGKXZP-C_-Dp4mH.js} +1 -1
  62. package/dist/worker/console/static/assets/{sankeyDiagram-W5VNT64P-DmVSQs3b.js → sankeyDiagram-W5VNT64P-C0s5-bQS.js} +1 -1
  63. package/dist/worker/console/static/assets/{sequenceDiagram-SI44F4Z6-C80h1u0Q.js → sequenceDiagram-SI44F4Z6-DiVCHPfi.js} +1 -1
  64. package/dist/worker/console/static/assets/{sizeCapture-X5ZJPWSS-CcbksQVZ.js → sizeCapture-X5ZJPWSS-CxQ9WVvx.js} +1 -1
  65. package/dist/worker/console/static/assets/{stateDiagram-OKZ733FA-CFTTTQKj.js → stateDiagram-OKZ733FA-D9mFHA-Y.js} +1 -1
  66. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-BqXQxfFC.js +1 -0
  67. package/dist/worker/console/static/assets/{swimlanes-SLNWSIFB-Cw7Jr7Tg.js → swimlanes-SLNWSIFB-DDnC5l-M.js} +2 -2
  68. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-CXwWNRJl.js +8 -0
  69. package/dist/worker/console/static/assets/{timeline-definition-Z64GVDOM-CVcdTVa5.js → timeline-definition-Z64GVDOM-C8QySPC-.js} +1 -1
  70. package/dist/worker/console/static/assets/{vennDiagram-T6HMQDX7-BpLbmWp9.js → vennDiagram-T6HMQDX7-BeYdDiz6.js} +1 -1
  71. package/dist/worker/console/static/assets/{wardleyDiagram-T6FBY63Y-6PXfbwj-.js → wardleyDiagram-T6FBY63Y-D8hjFWLH.js} +1 -1
  72. package/dist/worker/console/static/assets/{xychartDiagram-ELKLHX3M-Bjkcz4ih.js → xychartDiagram-ELKLHX3M-ClqG_JGK.js} +1 -1
  73. package/dist/worker/console/static/index.html +1 -1
  74. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +21 -2
  75. package/dist/workflows/dag/frontend-contract-facts.js +130 -0
  76. package/dist/workflows/dag/frontend-design-policy.js +99 -14
  77. package/dist/workflows/dag/frontend-implementation-contract.js +56 -8
  78. package/dist/workflows/dag/frontend-plan-render.js +13 -2
  79. package/dist/workflows/dag/frontend-shadow-dual-write.js +1 -0
  80. package/dist/workflows/dag/frontend-typed-event-store.js +3 -0
  81. package/dist/workflows/dag/init-hybrid.js +7 -3
  82. package/dist/workflows/dag/node-execution.js +54 -16
  83. package/docs/operations/README.md +1 -0
  84. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  85. package/package.json +2 -3
  86. package/skills/frontend-plan/SKILL.md +8 -0
  87. package/skills/frontend-plan/references/decision-contract.md +26 -3
  88. package/dist/worker/console/static/assets/channel-DOsaUk1k.js +0 -1
  89. package/dist/worker/console/static/assets/classDiagram-JCYQIIEL-D4-D49vY.js +0 -1
  90. package/dist/worker/console/static/assets/classDiagram-v2-OCEON4UE-D4-D49vY.js +0 -1
  91. package/dist/worker/console/static/assets/stateDiagram-v2-UEYNNEHI-C4YCesmi.js +0 -1
  92. package/dist/worker/console/static/assets/swimlanesDiagram-ULZ7WXOC-lN2U_DLl.js +0 -8
@@ -12,10 +12,13 @@ import { createPiReadBudgetCustomTools, } from "./pi-read-budget-policy.js";
12
12
  import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
13
13
  import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
14
14
  import { parseLedgerJson } from "../task/source-prepare/ledger.js";
15
+ import { splitDocumentIntoFragments, sha256Text, } from "../task/source-prepare/fragment-inventory.js";
15
16
  import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
16
17
  import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
17
18
  import { captureWorkspaceWriteSnapshot, diffWorkspaceWriteSnapshots, } from "./workspace-write-snapshot.js";
18
19
  import { pathMatchesPattern } from "../shared/git-progress.js";
20
+ import { readTypedEventStoreFromJsonl } from "../workflows/dag/frontend-typed-event-store.js";
21
+ import { collectCanonicalStateFlowNames, frontendEvidenceExpectationSchema, resolveFrontendContractRequirements, validateFrontendRequiredDeliverables, } from "../workflows/dag/frontend-contract-facts.js";
19
22
  import { isTargetTemplateTransientRetryNode, isWriterEmptyDiffRetryCandidate, isWriterTransportRetryCandidate, INCOMPLETE_WRITE_SET_RETRY_CATEGORY, STRUCTURED_OUTPUT_RETRY_CATEGORY, WRITER_CLEAN_TIMEOUT_RETRY_CATEGORY, WRITER_EMPTY_DIFF_RETRY_CATEGORY, } from "../workflows/dag/retry-policy.js";
20
23
  import { assessBackendTestMdPlanCompleteness, assessBackendTestMdWriterCompleteness, assessBackendTestPytestPlanCompleteness, assessBackendTestPytestWriterCompleteness, assessBackendTestShardChildCompleteness, backendTestWriterProgressRoleForTask, classifyBackendTestWriterCompletenessFailure, isBackendTestCompletenessRetryCandidate, isBackendTestMdPlanTask, isBackendTestPytestCollectionRepairOutcomeRecoveryCandidate, isBackendTestPytestPlanTask, isBackendTestShardChildTask, writeBackendTestWriterProgressArtifacts, } from "../workflows/dag/backend-test-writer-completeness.js";
21
24
  import { resolveBackendTestLayout } from "../workflows/dag/backend-test-layout.js";
@@ -251,6 +254,8 @@ export const FRONTEND_CONTRACT_RECORD_TOOL_NAMES = [
251
254
  "record_handoff_intent",
252
255
  "record_open_question",
253
256
  "record_split_proposal",
257
+ "record_ui_state",
258
+ "record_required_deliverables",
254
259
  ];
255
260
  export const FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES = [
256
261
  "finalize_contract",
@@ -264,6 +269,7 @@ export const FRONTEND_SCOUT_EVIDENCE_TOOL_NAMES = [
264
269
  export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
265
270
  "record_route_selection",
266
271
  "record_component_choice",
272
+ "record_state_registry",
267
273
  "record_state_flow",
268
274
  "record_data_flow",
269
275
  "record_mock_api",
@@ -907,8 +913,81 @@ async function resolveFrontendPlanNewComponentSourceReferences(input) {
907
913
  }
908
914
  }
909
915
  /**
910
- * A+B: `frontend-plan-pi` now records its decision ledger through seven
911
- * incremental `record_*` tools (origin=plan) and closes with exactly one
916
+ * Frozen canonical requirements keyed by id, resolved from the source-fidelity
917
+ * ledger before the contract node starts. record_requirement commits these
918
+ * runtime-owned values so the model can never rewrite authoritative requirement
919
+ * text or stringify the fragment bindings.
920
+ */
921
+ async function resolveFrontendCanonicalRequirements(input) {
922
+ const binding = input.sourceBinding;
923
+ if (!binding || binding.schemaVersion !== 2 || !binding.ledgerPath) {
924
+ return new Map();
925
+ }
926
+ const absolutePath = path.resolve(input.cwd, binding.ledgerPath);
927
+ const workspaceRoot = path.resolve(input.cwd);
928
+ if (absolutePath !== workspaceRoot &&
929
+ !absolutePath.startsWith(`${workspaceRoot}${path.sep}`)) {
930
+ throw new Error("canonical requirement ledger escapes workspace");
931
+ }
932
+ try {
933
+ const raw = await readFile(absolutePath, "utf8");
934
+ if (sha256Text(raw) !== binding.ledgerSha256) {
935
+ throw new Error("source ledger is stale");
936
+ }
937
+ const ledger = parseLedgerJson(raw);
938
+ const sourceFragments = new Map();
939
+ const boundFragments = new Map(ledger.fragments.map((fragment) => [fragment.id, fragment]));
940
+ for (const sourcePath of new Set(ledger.fragments.map((fragment) => fragment.path))) {
941
+ const sourceAbsolute = path.resolve(workspaceRoot, sourcePath);
942
+ if (!sourceAbsolute.startsWith(`${workspaceRoot}${path.sep}`)) {
943
+ throw new Error("source fragment escapes workspace");
944
+ }
945
+ const content = await readFile(sourceAbsolute, "utf8");
946
+ for (const fragment of splitDocumentIntoFragments({ path: sourcePath, content })) {
947
+ const frozen = boundFragments.get(fragment.id);
948
+ if (frozen && frozen.sha256 === sha256Text(fragment.text)) {
949
+ sourceFragments.set(fragment.id, fragment.text);
950
+ }
951
+ }
952
+ }
953
+ return new Map(ledger.canonicalRequirements.map((requirement) => [
954
+ requirement.id,
955
+ {
956
+ text: requirement.text,
957
+ sourceFragmentIds: [...(requirement.sourceFragmentIds ?? [])],
958
+ sourceFragments,
959
+ },
960
+ ]));
961
+ }
962
+ catch (error) {
963
+ throw new Error(`canonical requirement sources unavailable: ${error instanceof Error ? error.message : String(error)}`);
964
+ }
965
+ }
966
+ /**
967
+ * Authoritative UI state ids declared by the contract node
968
+ * (ui-state-declaration facts). Empty when the source declares no UI-state
969
+ * table, in which case the plan's state vocabulary is registry-only.
970
+ */
971
+ async function resolveFrontendDeclaredUiStateIds(input) {
972
+ try {
973
+ const records = await readTypedEventStoreFromJsonl(path.join(input.runDir, "frontend-contract-pi", "contract-typed-facts.jsonl"));
974
+ return records.flatMap((record) => {
975
+ const fact = record.fact;
976
+ return record.phase === "committed" &&
977
+ fact.kind === "ui-state-declaration" &&
978
+ typeof fact.id === "string"
979
+ ? [fact.id]
980
+ : [];
981
+ });
982
+ }
983
+ catch {
984
+ // No contract facts (or no declared states): registry-only vocabulary.
985
+ return [];
986
+ }
987
+ }
988
+ /**
989
+ * A+B: `frontend-plan-pi` records its decision ledger through incremental
990
+ * `record_*` tools (origin=plan) and closes with exactly one
912
991
  * `finalize_plan` terminal. A later attempt may explicitly adopt a quarantined
913
992
  * fact via `adopt_staged_fact`. Flush writes `plan-typed-facts.jsonl` for the
914
993
  * node validator / compile authority.
@@ -1042,6 +1121,12 @@ export async function createFrontendPlanLedgerTools(input) {
1042
1121
  line: Type.Optional(Type.Number({})),
1043
1122
  }, { additionalProperties: false })),
1044
1123
  rationale: Type.String({}),
1124
+ covers: Type.Optional(Type.Array(Type.String({}), {
1125
+ description: "UI state and/or interaction names this single component choice covers (one choice may cover many ids).",
1126
+ })),
1127
+ evidencePath: Type.Optional(Type.String({
1128
+ description: "REQUIRED for decision=reuse-existing: repo-relative path whose existing file is the reuse evidence. Greenfield paths must use decision=new.",
1129
+ })),
1045
1130
  }, { additionalProperties: false });
1046
1131
  const stringList = (value) => Array.isArray(value)
1047
1132
  ? value.filter((item) => typeof item === "string")
@@ -1120,7 +1205,7 @@ export async function createFrontendPlanLedgerTools(input) {
1120
1205
  const recordComponentChoiceTool = defineTool({
1121
1206
  name: "record_component_choice",
1122
1207
  label: "record_component_choice",
1123
- description: "Record ONE component choice (origin=plan component-choice fact). Declare every UI purpose's component selection. For decision=new, pass sourceRequirementIds containing the frozen requirement ID(s) that mandate the component and sourceFragmentId selecting one frozen citation listed in the plan checklist; the runtime validates the relation and derives the exact PRD specReference. Do not read the PRD or invent a path/line. decision=reuse-existing is only for components that already exist in the repo (e.g. reusing ActiveRunBadge's styling convention). Omit rationale for reuse-existing; it is optional. Call up to 5 component choices per assistant message (batching reduces API round trips and rate-limit risk); never more than 5 per message. Optionally include stylingStrategy (set it once, on the first call). Example: {\"choice\": {\"purpose\": \"<interaction or UI state name>\", \"component\": \"<component name>\", \"decision\": \"new\"}, \"sourceRequirementIds\": [\"<AC-XXX mandating this component>\"], \"sourceFragmentId\": \"<REQ-SRC-...>\"}",
1208
+ description: "Record ONE component choice (origin=plan component-choice fact). One choice may cover MULTIPLE UI states/interactions via covers: [\"<state-or-interaction name>\", ...]; do not emit one row per interaction. For decision=new, pass sourceRequirementIds containing the frozen requirement ID(s) that mandate the component and sourceFragmentId selecting one frozen citation listed in the plan checklist; the runtime validates the relation and derives the exact PRD specReference. Do not read the PRD or invent a path/line. decision=reuse-existing is only for components that already exist in the repo and REQUIRES evidencePath: a repo-relative path to the existing file that proves the reuse — the runtime verifies the file exists (fresh evidence); a path with no existing file is greenfield and must use decision=new instead. Call up to 5 component choices per assistant message; never more than 5 per message. Optionally include stylingStrategy (set it once, on the first call). Example: {\"choice\": {\"purpose\": \"<interaction or UI state name>\", \"component\": \"<component name>\", \"decision\": \"new\", \"covers\": [\"<other interaction names this component also serves>\"]}, \"sourceRequirementIds\": [\"<AC-XXX mandating this component>\"], \"sourceFragmentId\": \"<REQ-SRC-...>\"} or {\"choice\": {\"purpose\": \"FocusQueuePanel\", \"component\": \"FocusQueuePanel\", \"decision\": \"reuse-existing\", \"evidencePath\": \"src/journal.ts\"}}",
1124
1209
  promptSnippet: "Record 1-5 component choices (up to 5 per message).",
1125
1210
  parameters: Type.Object({
1126
1211
  choice: uiComponentChoiceSchema,
@@ -1171,6 +1256,46 @@ export async function createFrontendPlanLedgerTools(input) {
1171
1256
  ...(citation.line !== undefined ? { line: citation.line } : {}),
1172
1257
  };
1173
1258
  }
1259
+ if (choice.decision === "reuse-existing") {
1260
+ // reuse-existing must cite a file that actually exists NOW (fresh
1261
+ // existence evidence, same semantics as Scout pathEvidence.fresh).
1262
+ // A path with no file on disk is greenfield: decision=new is the
1263
+ // only honest choice (dogfood run dag-1788504923861-0f7b17a9
1264
+ // claimed 18 reuse-existing conventions in a not-yet-written
1265
+ // src/planner.ts and every gate let it through).
1266
+ const evidencePath = typeof choice.evidencePath === "string"
1267
+ ? choice.evidencePath.trim()
1268
+ : "";
1269
+ if (!evidencePath) {
1270
+ return planToolReceipt({
1271
+ ok: false,
1272
+ kind: "component-choice",
1273
+ error: "decision=reuse-existing requires evidencePath naming the existing repo file that proves the reuse; if the file does not exist yet, use decision=new",
1274
+ });
1275
+ }
1276
+ if (input.workspaceRoot) {
1277
+ const absolute = path.resolve(input.workspaceRoot, evidencePath);
1278
+ const workspaceRoot = path.resolve(input.workspaceRoot);
1279
+ const contained = absolute === workspaceRoot ||
1280
+ absolute.startsWith(`${workspaceRoot}${path.sep}`);
1281
+ let exists = false;
1282
+ if (contained) {
1283
+ try {
1284
+ exists = (await stat(absolute)).isFile();
1285
+ }
1286
+ catch {
1287
+ exists = false;
1288
+ }
1289
+ }
1290
+ if (!exists) {
1291
+ return planToolReceipt({
1292
+ ok: false,
1293
+ kind: "component-choice",
1294
+ error: `decision=reuse-existing evidencePath "${evidencePath}" has no fresh existence evidence (file not found in the workspace); reuse requires an existing file — use decision=new for greenfield paths`,
1295
+ });
1296
+ }
1297
+ }
1298
+ }
1174
1299
  const components = typeof choice.component === "string" ? [choice.component] : [];
1175
1300
  const result = await adoptPlanFact("component-choice", `${attemptId}:record_component_choice:${randomUUID()}`, {
1176
1301
  kind: "component-choice",
@@ -1194,10 +1319,86 @@ export async function createFrontendPlanLedgerTools(input) {
1194
1319
  return planToolReceipt(echo);
1195
1320
  },
1196
1321
  });
1322
+ // Global UX vocabulary: one compact registry committed BEFORE any state
1323
+ // flow details. Coverage may slice by AC; UX must not — the registry is
1324
+ // the anti-duplication anchor that keeps every later slice on the same
1325
+ // named concepts instead of re-inventing them per AC chunk.
1326
+ const recordStateRegistryTool = defineTool({
1327
+ name: "record_state_registry",
1328
+ label: "record_state_registry",
1329
+ description: "Commit the GLOBAL UX vocabulary (origin=plan state-registry fact) BEFORE any record_state_flow call: uiStateNames (use the contract's declared authoritative state ids when provided) and interactionNames (stable behavior-domain kebab-case names, e.g. planner-task-edit / focus-queue-move — one name per behavior domain, never one per AC). Empty arrays explicitly mean the request has no UI state or interaction vocabulary. Re-record the full vocabulary to correct it; the latest commit wins, but it may not remove names still referenced by committed state-flow facts. Example: {\"uiStateNames\": [\"planner-empty\"], \"interactionNames\": [\"planner-task-create\", \"focus-queue-move\"]}",
1330
+ promptSnippet: "Record the global UI-state/interaction vocabulary once, before any state flow.",
1331
+ parameters: Type.Object({
1332
+ uiStateNames: stringArray,
1333
+ interactionNames: stringArray,
1334
+ }, { additionalProperties: false }),
1335
+ async execute(_toolCallId, params) {
1336
+ const uiStateNames = [...new Set(stringList(params?.uiStateNames).map((name) => name.trim()))];
1337
+ const interactionNames = [
1338
+ ...new Set(stringList(params?.interactionNames).map((name) => name.trim())),
1339
+ ];
1340
+ if (uiStateNames.some((name) => name.length === 0) || interactionNames.some((name) => name.length === 0)) {
1341
+ return planToolReceipt({
1342
+ ok: false,
1343
+ kind: "state-registry",
1344
+ error: "record_state_registry names must be non-empty strings",
1345
+ });
1346
+ }
1347
+ const invalidInteractionNames = interactionNames.filter((name) => !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name));
1348
+ if (invalidInteractionNames.length > 0) {
1349
+ return planToolReceipt({
1350
+ ok: false,
1351
+ kind: "state-registry",
1352
+ error: `record_state_registry interactionNames must be stable kebab-case behavior-domain names: ${invalidInteractionNames.join(", ")}`,
1353
+ });
1354
+ }
1355
+ if (input.declaredUiStateIds &&
1356
+ input.declaredUiStateIds.length > 0) {
1357
+ const declared = new Set(input.declaredUiStateIds);
1358
+ const undeclared = uiStateNames.filter((name) => !declared.has(name));
1359
+ if (undeclared.length > 0) {
1360
+ return planToolReceipt({
1361
+ ok: false,
1362
+ kind: "state-registry",
1363
+ error: `record_state_registry uiStateNames are not declared by the contract's authoritative UI-state table: ${undeclared.join(", ")}; use the declared ids (${input.declaredUiStateIds.join(", ")})`,
1364
+ });
1365
+ }
1366
+ const missing = input.declaredUiStateIds.filter((name) => !uiStateNames.includes(name));
1367
+ if (missing.length > 0) {
1368
+ return planToolReceipt({
1369
+ ok: false,
1370
+ kind: "state-registry",
1371
+ error: `record_state_registry must include every state from the contract's authoritative UI-state table; missing: ${missing.join(", ")}`,
1372
+ });
1373
+ }
1374
+ }
1375
+ // A correction is deterministic only when the new last-wins registry
1376
+ // still contains every live name already committed by state-flow facts.
1377
+ // This permits adding a missed concept, while preventing a registry edit
1378
+ // from retroactively orphaning earlier slices.
1379
+ const liveNames = collectCanonicalStateFlowNames(readCommittedEvents(store, attemptId));
1380
+ const orphanedUiStates = [...liveNames.uiStateNames].filter((name) => !uiStateNames.includes(name));
1381
+ const orphanedInteractions = [...liveNames.interactionNames].filter((name) => !interactionNames.includes(name));
1382
+ if (orphanedUiStates.length > 0 || orphanedInteractions.length > 0) {
1383
+ return planToolReceipt({
1384
+ ok: false,
1385
+ kind: "state-registry",
1386
+ error: `record_state_registry cannot remove names still referenced by committed state-flow facts (uiStates: ${orphanedUiStates.join(", ") || "none"}; interactions: ${orphanedInteractions.join(", ") || "none"}); first correct/remove those state-flow entries, then re-record the full registry`,
1387
+ });
1388
+ }
1389
+ const result = await adoptPlanFact("state-registry", `${attemptId}:record_state_registry:${randomUUID()}`, {
1390
+ kind: "state-registry",
1391
+ origin: "plan",
1392
+ uiStateNames,
1393
+ interactionNames,
1394
+ });
1395
+ return planToolReceipt(result);
1396
+ },
1397
+ });
1197
1398
  const recordStateFlowTool = defineTool({
1198
1399
  name: "record_state_flow",
1199
1400
  label: "record_state_flow",
1200
- description: "Record UI states and interactions as an origin=plan state-flow fact. To correct stale named entries from an earlier UX batch, include removeUiStateNames and/or removeInteractionNames. Example: {\"uiStates\": [{\"name\": \"<state>\", \"applicable\": true, \"expectedBehavior\": \"<behavior>\", \"implementationTargets\": [\"<file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}], \"interactions\": [{\"name\": \"<interaction>\", \"trigger\": \"<user event>\", \"expectedBehavior\": \"<behavior>\", \"implementationTargets\": [\"<file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}]}",
1401
+ description: "Record UI states and interactions as an origin=plan state-flow fact. REQUIRES a committed record_state_registry vocabulary first, and every name here must be in that registry; uiState names must also be contract-declared authoritative ids when the contract declares them. To correct stale named entries from an earlier UX batch, include removeUiStateNames and/or removeInteractionNames. Example: {\"uiStates\": [{\"name\": \"<state>\", \"applicable\": true, \"expectedBehavior\": \"<behavior>\", \"implementationTargets\": [\"<file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}], \"interactions\": [{\"name\": \"<interaction>\", \"trigger\": \"<user event>\", \"expectedBehavior\": \"<behavior>\", \"implementationTargets\": [\"<file>\"], \"verificationTargetIds\": [\"<VT-XXX>\"]}]}",
1201
1402
  promptSnippet: "Record the plan state-flow fact.",
1202
1403
  parameters: Type.Object({
1203
1404
  uiStates: Type.Array(uiStateSchema),
@@ -1301,9 +1502,63 @@ export async function createFrontendPlanLedgerTools(input) {
1301
1502
  }
1302
1503
  interactions.push({ ...interaction, name: resolvedName });
1303
1504
  }
1505
+ // UX vocabulary gate: every recorded state/interaction name must be
1506
+ // declared in the committed global registry first. This keeps UX out
1507
+ // of AC-number slicing — the model commits one compact vocabulary
1508
+ // (record_state_registry), then fills behavior-domain details, and a
1509
+ // later slice cannot silently rename an earlier concept (dogfood
1510
+ // dag-1788504923861-0f7b17a9: 5 renames of "prioritize" + 7 of
1511
+ // "focus queue" across AC chunks).
1304
1512
  const states = uiStates
1305
1513
  .map((state) => (typeof state?.name === "string" ? state.name : ""))
1306
1514
  .filter(Boolean);
1515
+ const committedRegistry = readCommittedEvents(store, attemptId)
1516
+ .map((event) => event.fact)
1517
+ .filter((fact) => isRecordObject(fact) && fact.kind === "state-registry")
1518
+ .at(-1);
1519
+ if (!committedRegistry) {
1520
+ return planToolReceipt({
1521
+ ok: false,
1522
+ kind: "state-flow",
1523
+ error: "record_state_flow requires a committed UX registry first: call record_state_registry with the full uiStateNames/interactionNames vocabulary, then record state flows against it",
1524
+ });
1525
+ }
1526
+ const registryUiStateNames = new Set(stringList(committedRegistry.uiStateNames));
1527
+ const registryInteractionNames = new Set(stringList(committedRegistry.interactionNames));
1528
+ const unknownStates = states.filter((name) => !registryUiStateNames.has(name));
1529
+ if (unknownStates.length > 0) {
1530
+ return planToolReceipt({
1531
+ ok: false,
1532
+ kind: "state-flow",
1533
+ error: `record_state_flow uiState names not in the committed registry: ${unknownStates.join(", ")}; re-record record_state_registry with the complete vocabulary first (registered: ${[...registryUiStateNames].join(", ") || "(none)"})`,
1534
+ });
1535
+ }
1536
+ const unknownInteractions = interactions
1537
+ .map((interaction) => typeof interaction?.name === "string" ? interaction.name : "")
1538
+ .filter((name) => name && !registryInteractionNames.has(name));
1539
+ if (unknownInteractions.length > 0) {
1540
+ return planToolReceipt({
1541
+ ok: false,
1542
+ kind: "state-flow",
1543
+ error: `record_state_flow interaction names not in the committed registry: ${unknownInteractions.join(", ")}; re-record record_state_registry with the complete vocabulary first (registered: ${[...registryInteractionNames].join(", ") || "(none)"})`,
1544
+ });
1545
+ }
1546
+ // Authoritative UI states: when the contract node declared the
1547
+ // source's UI-state table, planner states must bind those ids — no
1548
+ // invented variants (planner-empty/create-invalid/no-results/
1549
+ // editing/focus-full/storage-unavailable, not "planner-list").
1550
+ if (input.declaredUiStateIds &&
1551
+ input.declaredUiStateIds.length > 0) {
1552
+ const declared = new Set(input.declaredUiStateIds);
1553
+ const undeclaredStates = states.filter((name) => !declared.has(name));
1554
+ if (undeclaredStates.length > 0) {
1555
+ return planToolReceipt({
1556
+ ok: false,
1557
+ kind: "state-flow",
1558
+ error: `record_state_flow uiState names are not declared by the contract's authoritative UI-state table: ${undeclaredStates.join(", ")}; use the declared ids (${input.declaredUiStateIds.join(", ")}), or record a design deviation if the source table is genuinely incomplete`,
1559
+ });
1560
+ }
1561
+ }
1307
1562
  const result = await adoptPlanFact("state-flow", `${attemptId}:record_state_flow:${randomUUID()}`, {
1308
1563
  kind: "state-flow",
1309
1564
  origin: "plan",
@@ -1672,6 +1927,29 @@ export async function createFrontendPlanLedgerTools(input) {
1672
1927
  ? { realIntegrationGap: params.realIntegrationGap }
1673
1928
  : {}),
1674
1929
  };
1930
+ const latestRegistry = [...committed]
1931
+ .reverse()
1932
+ .map((record) => record.fact)
1933
+ .find((fact) => isRecordObject(fact) && fact.kind === "state-registry");
1934
+ if (latestRegistry) {
1935
+ const registryUiStates = new Set(stringList(latestRegistry.uiStateNames));
1936
+ const registryInteractions = new Set(stringList(latestRegistry.interactionNames));
1937
+ const live = collectCanonicalStateFlowNames(committed);
1938
+ const missingUiStates = [...registryUiStates].filter((name) => !live.uiStateNames.has(name));
1939
+ const missingInteractions = [...registryInteractions].filter((name) => !live.interactionNames.has(name));
1940
+ const undeclaredUiStates = [...live.uiStateNames].filter((name) => !registryUiStates.has(name));
1941
+ const undeclaredInteractions = [...live.interactionNames].filter((name) => !registryInteractions.has(name));
1942
+ if (missingUiStates.length > 0 ||
1943
+ missingInteractions.length > 0 ||
1944
+ undeclaredUiStates.length > 0 ||
1945
+ undeclaredInteractions.length > 0) {
1946
+ return planToolReceipt({
1947
+ ok: false,
1948
+ kind: "finalize_plan",
1949
+ error: `finalize_plan UX registry mismatch: every registered name must have one live state-flow entry and every live entry must be registered (missing uiStates: ${missingUiStates.join(", ") || "none"}; missing interactions: ${missingInteractions.join(", ") || "none"}; undeclared uiStates: ${undeclaredUiStates.join(", ") || "none"}; undeclared interactions: ${undeclaredInteractions.join(", ") || "none"})`,
1950
+ });
1951
+ }
1952
+ }
1675
1953
  // Front-load the node's compile + policy gates into the finalize
1676
1954
  // receipt (same pipeline the design-policy shell and the node
1677
1955
  // self-check run: merge the patch onto the runtime skeleton,
@@ -1698,9 +1976,10 @@ export async function createFrontendPlanLedgerTools(input) {
1698
1976
  catch (error) {
1699
1977
  if (error instanceof PlanPolicyPrecheckFailure) {
1700
1978
  // Template the fix: every uncovered interaction / state
1701
- // maps to a ready-to-submit record_component_choice
1702
- // call. One reuse-existing choice covers all
1703
- // behavioural interactions.
1979
+ // maps to a record_component_choice skeleton. Reuse is
1980
+ // never implied: the operator/model must fill an existing
1981
+ // evidencePath or switch the choice to decision=new with
1982
+ // the required frozen source citation.
1704
1983
  const suggestions = error.findings
1705
1984
  .filter((finding) => finding.code === "ui-design-coverage-missing" &&
1706
1985
  finding.path)
@@ -1711,6 +1990,7 @@ export async function createFrontendPlanLedgerTools(input) {
1711
1990
  purpose: finding.path,
1712
1991
  component: "<name the existing or new component>",
1713
1992
  decision: "reuse-existing",
1993
+ evidencePath: "<existing repo file that proves this reuse>",
1714
1994
  },
1715
1995
  },
1716
1996
  }));
@@ -1796,6 +2076,7 @@ export async function createFrontendPlanLedgerTools(input) {
1796
2076
  customTools: [
1797
2077
  recordRouteSelectionTool,
1798
2078
  recordComponentChoiceTool,
2079
+ recordStateRegistryTool,
1799
2080
  recordStateFlowTool,
1800
2081
  recordDataFlowTool,
1801
2082
  recordMockApiTool,
@@ -1845,6 +2126,10 @@ function contractFactSourceSpan(fact) {
1845
2126
  if (Array.isArray(fact.sourceRefs) && fact.sourceRefs.length > 0) {
1846
2127
  return fact.sourceRefs;
1847
2128
  }
2129
+ if (Array.isArray(fact.sourceFragmentIds) &&
2130
+ fact.sourceFragmentIds.some((value) => typeof value === "string" && value.trim().length > 0)) {
2131
+ return fact.sourceFragmentIds;
2132
+ }
1848
2133
  if (typeof fact.source === "string" && fact.source.trim().length > 0) {
1849
2134
  return fact.source;
1850
2135
  }
@@ -1894,6 +2179,7 @@ export function buildFrontendTaskContractVNext(records) {
1894
2179
  requirements,
1895
2180
  constraints: byKind("constraint"),
1896
2181
  evidenceExpectations: byKind("evidence-expectation"),
2182
+ deliverableDeclarations: byKind("required-deliverables").at(-1)?.items ?? [],
1897
2183
  handoffIntents: byKind("handoff-intent"),
1898
2184
  openQuestions: byKind("open-question"),
1899
2185
  splitProposals: byKind("split-proposal"),
@@ -1901,7 +2187,7 @@ export function buildFrontendTaskContractVNext(records) {
1901
2187
  };
1902
2188
  }
1903
2189
  /**
1904
- * A+B: `frontend-contract-pi` incremental contract tools. Six `record_*` tools
2190
+ * A+B: `frontend-contract-pi` incremental contract tools. The `record_*` tools
1905
2191
  * commit origin=contract facts and `finalize_contract` commits the terminal
1906
2192
  * disposition (ready | ready-with-assumptions | blocked + blockingOwner).
1907
2193
  */
@@ -1953,9 +2239,7 @@ export async function createFrontendContractTools(input) {
1953
2239
  }
1954
2240
  }
1955
2241
  const recordKinds = {
1956
- record_requirement: "requirement",
1957
2242
  record_constraint: "constraint",
1958
- record_evidence_expectation: "evidence-expectation",
1959
2243
  record_handoff_intent: "handoff-intent",
1960
2244
  record_open_question: "open-question",
1961
2245
  record_split_proposal: "split-proposal",
@@ -1975,6 +2259,141 @@ export async function createFrontendContractTools(input) {
1975
2259
  return receipt(result);
1976
2260
  },
1977
2261
  }));
2262
+ // record_requirement is runtime-owned by design: the requirement TEXT and
2263
+ // sourceFragmentIds come from the frozen source-fidelity ledger, never from
2264
+ // model-authored prose. The model only names the canonical id it confirms.
2265
+ // This closes the free-shape hole (additionalProperties:true accepted
2266
+ // `statement` rewrites and JSON-stringified `sourceFragmentIds` arrays,
2267
+ // which then reached the planner as empty text + dead fragment bindings).
2268
+ const recordRequirementTool = defineTool({
2269
+ name: "record_requirement",
2270
+ label: "record_requirement",
2271
+ description: 'Confirm one canonical ledger requirement (origin=contract requirement fact). Pass ONLY the canonical id listed in the <frontend_contract_input> inventory, e.g. {"id": "AC-001"} — the runtime commits the authoritative text and sourceFragmentIds from the frozen ledger. Never pass text/statement/sourceFragmentIds yourself: free-form rewrites and JSON-stringified fragment arrays are rejected. IMPORTANT: batch up to 5 record_* calls per message, starting from the FIRST message.',
2272
+ promptSnippet: "Confirm 1-5 canonical requirements by id (up to 5 per message).",
2273
+ parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }) }, { additionalProperties: false }),
2274
+ async execute(_toolCallId, params) {
2275
+ const id = typeof params?.id === "string" ? params.id.trim() : "";
2276
+ if (!id) {
2277
+ return receipt({
2278
+ ok: false,
2279
+ kind: "requirement",
2280
+ error: "record_requirement requires the canonical requirement id",
2281
+ });
2282
+ }
2283
+ const canonical = input.canonicalRequirements?.get(id);
2284
+ if (!canonical) {
2285
+ const known = [...(input.canonicalRequirements?.keys() ?? [])];
2286
+ return receipt({
2287
+ ok: false,
2288
+ kind: "requirement",
2289
+ error: `record_requirement id "${id}" is not a canonical ledger requirement; canonical ids are: ${known.join(", ") || "(none)"}`,
2290
+ });
2291
+ }
2292
+ const result = await adoptContractFact("requirement", {
2293
+ kind: "requirement",
2294
+ origin: "contract",
2295
+ disposition: "explicit",
2296
+ id,
2297
+ text: canonical.text,
2298
+ sourceFragmentIds: canonical.sourceFragmentIds,
2299
+ });
2300
+ return receipt(result);
2301
+ },
2302
+ });
2303
+ // Authoritative UI state declarations: the contract node extracts the
2304
+ // PRD/reference UI-state table into structured facts so the planner binds
2305
+ // uiStates to declared ids instead of inventing names (dogfood
2306
+ // dag-1788504923861-0f7b17a9: 10 invented list-visibility variants, 0 of
2307
+ // the 7 PRD states).
2308
+ const recordUiStateTool = defineTool({
2309
+ name: "record_ui_state",
2310
+ label: "record_ui_state",
2311
+ description: 'Declare ONE authoritative UI state extracted from the task source\'s UI-state table (origin=contract ui-state-declaration fact). Call once per declared state, exactly as the source names it: {"id": "<state id from the source table>", "trigger": "<when this state applies>", "observableOutcome": "<what the user can observe>"}. The planner must bind these ids later; do not rename or invent states.',
2312
+ promptSnippet: "Declare 1-5 authoritative UI states (up to 5 per message).",
2313
+ parameters: Type.Object({
2314
+ id: Type.String({ description: "UI state id exactly as the source table declares it" }),
2315
+ trigger: Type.String({ description: "When this state applies" }),
2316
+ observableOutcome: Type.String({ description: "Observable result for the user" }),
2317
+ }, { additionalProperties: false }),
2318
+ async execute(_toolCallId, params) {
2319
+ const id = typeof params?.id === "string" ? params.id.trim() : "";
2320
+ const trigger = typeof params?.trigger === "string" ? params.trigger.trim() : "";
2321
+ const observableOutcome = typeof params?.observableOutcome === "string"
2322
+ ? params.observableOutcome.trim()
2323
+ : "";
2324
+ if (!id || !trigger || !observableOutcome) {
2325
+ return receipt({
2326
+ ok: false,
2327
+ kind: "ui-state-declaration",
2328
+ error: "record_ui_state requires non-empty id, trigger, and observableOutcome",
2329
+ });
2330
+ }
2331
+ const result = await adoptContractFact("ui-state-declaration", {
2332
+ kind: "ui-state-declaration",
2333
+ origin: "contract",
2334
+ id,
2335
+ trigger,
2336
+ observableOutcome,
2337
+ });
2338
+ return receipt(result);
2339
+ },
2340
+ });
2341
+ const evidenceStatus = Type.Enum({
2342
+ required: "required", optional: "optional", "not-applicable": "not-applicable",
2343
+ });
2344
+ const recordEvidenceExpectationTool = defineTool({
2345
+ name: "record_evidence_expectation",
2346
+ label: "record_evidence_expectation",
2347
+ description: 'Record evidence requirements as {requirementId:"<canonical id>",evidence:{static:"required|optional|not-applicable",behavior:"required|optional|not-applicable",mock:"required|optional|not-applicable","real-integration":"required|optional|not-applicable"}}. Declare at least one lane; later calls replace only the lanes they name.',
2348
+ parameters: Type.Object({
2349
+ requirementId: Type.String(),
2350
+ evidence: Type.Object({
2351
+ static: Type.Optional(evidenceStatus),
2352
+ behavior: Type.Optional(evidenceStatus),
2353
+ mock: Type.Optional(evidenceStatus),
2354
+ "real-integration": Type.Optional(evidenceStatus),
2355
+ }, { additionalProperties: false }),
2356
+ }, { additionalProperties: false }),
2357
+ async execute(_toolCallId, params) {
2358
+ try {
2359
+ const fact = frontendEvidenceExpectationSchema.parse(params);
2360
+ if (!input.canonicalRequirements?.has(fact.requirementId)) {
2361
+ throw new Error(`unknown canonical requirement ${fact.requirementId}`);
2362
+ }
2363
+ return receipt(await adoptContractFact("evidence-expectation", {
2364
+ kind: "evidence-expectation", origin: "contract", ...fact,
2365
+ }));
2366
+ }
2367
+ catch (error) {
2368
+ return receipt({
2369
+ ok: false, kind: "evidence-expectation",
2370
+ error: error instanceof Error ? error.message : String(error),
2371
+ });
2372
+ }
2373
+ },
2374
+ });
2375
+ const recordRequiredDeliverablesTool = defineTool({
2376
+ name: "record_required_deliverables",
2377
+ label: "record_required_deliverables",
2378
+ description: 'Declare the complete source-required file deliverables as {items:[{path,requirementId,sourceFragmentId}]}. Interpret obligations from the original source, including lists/tables: permissions (allowedPaths/only allowed to modify), prohibitions, examples and read-only references are NOT delivery obligations. Each path must appear exactly in its frozen requirement-bound source fragment. Submit {items:[]} explicitly if no files are mandatory. A correction replaces the whole inventory. Required before finalize_contract ready.',
2379
+ parameters: Type.Object({ items: Type.Array(Type.Object({
2380
+ path: Type.String(), requirementId: Type.String(), sourceFragmentId: Type.String(),
2381
+ }, { additionalProperties: false })) }, { additionalProperties: false }),
2382
+ async execute(_toolCallId, params) {
2383
+ try {
2384
+ const declaration = validateFrontendRequiredDeliverables(params, input.canonicalRequirements ?? new Map());
2385
+ return receipt(await adoptContractFact("required-deliverables", {
2386
+ kind: "required-deliverables", origin: "contract", ...declaration,
2387
+ }));
2388
+ }
2389
+ catch (error) {
2390
+ return receipt({
2391
+ ok: false, kind: "required-deliverables",
2392
+ error: error instanceof Error ? error.message : String(error),
2393
+ });
2394
+ }
2395
+ },
2396
+ });
1978
2397
  // OpenSpec selection committed as individual typed facts (one path per
1979
2398
  // call) so a large candidate set never exceeds a single model output
1980
2399
  // budget: each tool call carries exactly one {path, disposition,
@@ -2045,6 +2464,13 @@ export async function createFrontendContractTools(input) {
2045
2464
  error: "blocked disposition requires blockingOwner (blocked-human | blocked-external)",
2046
2465
  });
2047
2466
  }
2467
+ if (disposition !== "blocked" && input.canonicalRequirements?.size &&
2468
+ !readCommittedEvents(store, attemptId).some((record) => record.fact.kind === "required-deliverables")) {
2469
+ return receipt({
2470
+ ok: false, kind: "finalize_contract",
2471
+ error: "call record_required_deliverables with the complete source-bound inventory (or items:[] when none) before finalizing",
2472
+ });
2473
+ }
2048
2474
  const blockedOwner = mapContractBlockedOwner({
2049
2475
  disposition: disposition ?? "",
2050
2476
  blockingOwner,
@@ -2068,6 +2494,10 @@ export async function createFrontendContractTools(input) {
2068
2494
  return {
2069
2495
  customTools: [
2070
2496
  ...recordTools,
2497
+ recordRequirementTool,
2498
+ recordEvidenceExpectationTool,
2499
+ recordUiStateTool,
2500
+ recordRequiredDeliverablesTool,
2071
2501
  recordOpenspecSelectionTool,
2072
2502
  finalizeContractTool,
2073
2503
  ],
@@ -2682,6 +3112,15 @@ const FRONTEND_PLAN_SEGMENTS = [
2682
3112
  "If a requirement genuinely cannot have a verification target, record a non-empty record_plan_evidence_gap. Do not call finalize_plan; it is not available in this phase.",
2683
3113
  ].join(" "),
2684
3114
  },
3115
+ {
3116
+ id: "ux-registry",
3117
+ toolNames: new Set(["record_state_registry", "adopt_staged_fact"]),
3118
+ instruction: [
3119
+ "PLAN PHASE — global UX vocabulary.",
3120
+ "Review ALL frozen requirements together and call record_state_registry exactly once with the complete UI-state and interaction vocabulary. UI states use declaredUiStates ids when present. Interaction names are stable kebab-case behavior domains; merge requirements that describe the same behavior instead of renaming it per AC slice. Empty arrays explicitly declare that no UX vocabulary applies. Do not record component choices or state-flow details in this phase.",
3121
+ "Do not call finalize_plan; it is not available in this phase.",
3122
+ ].join(" "),
3123
+ },
2685
3124
  {
2686
3125
  id: "ux-local",
2687
3126
  toolNames: new Set([
@@ -2690,8 +3129,8 @@ const FRONTEND_PLAN_SEGMENTS = [
2690
3129
  "adopt_staged_fact",
2691
3130
  ]),
2692
3131
  instruction: [
2693
- "PLAN PHASE — requirement-local UX decisions.",
2694
- "Requirements and verification targets are already committed in the ledger (do NOT re-record them; duplicates are rejected). For ONLY this requirement slice, record component choices and UI state flow. Keep these facts for this slice together. Cross-cutting data flow belongs to the global Mock/data phase. Do not record routes, Mock/API policy, dependencies, or design deviations here.",
3132
+ "PLAN PHASE — global UX decisions.",
3133
+ "Requirements and verification targets are already committed in the ledger (do NOT re-record them; duplicates are rejected). Review the complete requirement set and the committed global UX registry together, then record each component choice, UI state and interaction exactly once. Multiple requirements that describe one behavior share one registry name and one state-flow entry; never repeat or rename it per AC. Cross-cutting data flow belongs to the global Mock/data phase. Do not record routes, Mock/API policy, dependencies, or design deviations here.",
2695
3134
  "Do not call finalize_plan; it is not available in this phase.",
2696
3135
  ].join(" "),
2697
3136
  },
@@ -2749,40 +3188,6 @@ function planFactStringList(value) {
2749
3188
  function planFactScopeIntersects(fact, requirementIds) {
2750
3189
  return planFactStringList(fact.scopeRequirementIds).some((id) => requirementIds.has(id));
2751
3190
  }
2752
- /** Apply state-flow replacement/removal semantics exactly as the plan compiler does. */
2753
- function collectCanonicalStateFlowNames(committedFacts) {
2754
- const uiStateNames = new Set();
2755
- const interactionNames = new Set();
2756
- for (const value of committedFacts) {
2757
- const fact = committedFactFromPlanRecord(value);
2758
- if (!fact || fact.origin !== "plan" || fact.kind !== "state-flow")
2759
- continue;
2760
- for (const name of planFactStringList(fact.removeUiStateNames)) {
2761
- uiStateNames.delete(name);
2762
- }
2763
- for (const name of planFactStringList(fact.removeInteractionNames)) {
2764
- interactionNames.delete(name);
2765
- }
2766
- for (const state of Array.isArray(fact.uiStates) ? fact.uiStates : []) {
2767
- if (!state || typeof state !== "object" || Array.isArray(state))
2768
- continue;
2769
- const name = state.name;
2770
- if (typeof name === "string" && name.trim())
2771
- uiStateNames.add(name);
2772
- }
2773
- for (const interaction of Array.isArray(fact.interactions)
2774
- ? fact.interactions
2775
- : []) {
2776
- if (!interaction || typeof interaction !== "object" || Array.isArray(interaction)) {
2777
- continue;
2778
- }
2779
- const name = interaction.name;
2780
- if (typeof name === "string" && name.trim())
2781
- interactionNames.add(name);
2782
- }
2783
- }
2784
- return { uiStateNames, interactionNames };
2785
- }
2786
3191
  /** Compute the authoritative coverage queue from the committed plan ledger. */
2787
3192
  export function collectFrontendPlanMissingFacts(input) {
2788
3193
  const requirements = new Map();
@@ -2864,6 +3269,17 @@ export function collectFrontendPlanPhaseMissingFacts(input) {
2864
3269
  const facts = input.committedFacts
2865
3270
  .map(committedFactFromPlanRecord)
2866
3271
  .filter((fact) => Boolean(fact && fact.origin === "plan"));
3272
+ if (input.phase === "ux-registry") {
3273
+ return facts.some((fact) => fact.kind === "state-registry")
3274
+ ? []
3275
+ : [
3276
+ {
3277
+ kind: "state-registry",
3278
+ requirementIds: [...input.requirementIds],
3279
+ reason: "global UX vocabulary phase has no committed state-registry fact",
3280
+ },
3281
+ ];
3282
+ }
2867
3283
  if (input.phase === "ux-local") {
2868
3284
  const needsUx = input.requirementIds.some((id) => input.behaviorRequiredRequirementIds?.includes(id));
2869
3285
  if (!needsUx)
@@ -2958,10 +3374,9 @@ export function batchFrontendPlanRequirements(input) {
2958
3374
  return batches;
2959
3375
  }
2960
3376
  const FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS = 10;
2961
- const FRONTEND_PLAN_UX_LOCAL_MAX_RECORD_CALLS = 6;
2962
- // A large requirement set creates both coverage and UX-local shards. Keep a
2963
- // safety bound, but do not let the old 32-session ceiling skip finalize for a
2964
- // legitimate large plan.
3377
+ // A large requirement set creates coverage shards; UX remains one global
3378
+ // decision session so behavior names and component/state facts are not
3379
+ // reinvented per AC batch. Keep a safety bound for adaptive coverage retries.
2965
3380
  const FRONTEND_PLAN_BATCH_MAX_SESSIONS = 128;
2966
3381
  const FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS = 8;
2967
3382
  const FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS = 24;
@@ -3147,8 +3562,33 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3147
3562
  }];
3148
3563
  })
3149
3564
  : [];
3565
+ const compactDeclaredUiStates = Array.isArray(payload.declaredUiStates)
3566
+ ? payload.declaredUiStates.flatMap((value) => {
3567
+ if (!value || typeof value !== "object" || Array.isArray(value))
3568
+ return [];
3569
+ const state = value;
3570
+ const id = compactPromptString(state.id, 80);
3571
+ if (!id)
3572
+ return [];
3573
+ return [{
3574
+ id,
3575
+ ...(compactPromptString(state.trigger, 180)
3576
+ ? { trigger: compactPromptString(state.trigger, 180) }
3577
+ : {}),
3578
+ ...(compactPromptString(state.observableOutcome, 240)
3579
+ ? { observableOutcome: compactPromptString(state.observableOutcome, 240) }
3580
+ : {}),
3581
+ }];
3582
+ })
3583
+ : [];
3584
+ const committedUx = payload.committedUx &&
3585
+ typeof payload.committedUx === "object" &&
3586
+ !Array.isArray(payload.committedUx)
3587
+ ? payload.committedUx
3588
+ : undefined;
3150
3589
  const compactPayload = {
3151
3590
  requirements: compactRequirements,
3591
+ requiredDeliverables: Array.isArray(payload.requiredDeliverables) ? payload.requiredDeliverables : [],
3152
3592
  ...(compactTargetSurface.length > 0
3153
3593
  ? { targetSurface: compactTargetSurface }
3154
3594
  : {}),
@@ -3158,6 +3598,17 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3158
3598
  ...(compactDesignEvidence.length > 0
3159
3599
  ? { designEvidence: compactDesignEvidence }
3160
3600
  : {}),
3601
+ ...(compactDeclaredUiStates.length > 0
3602
+ ? { declaredUiStates: compactDeclaredUiStates }
3603
+ : {}),
3604
+ ...(committedUx
3605
+ ? {
3606
+ committedUx: {
3607
+ uiStateNames: compactPromptStringArray(committedUx.uiStateNames, 40, 100),
3608
+ interactionNames: compactPromptStringArray(committedUx.interactionNames, 60, 120),
3609
+ },
3610
+ }
3611
+ : {}),
3161
3612
  };
3162
3613
  const compactBlock = [
3163
3614
  "<frontend_plan_input>",
@@ -3254,6 +3705,8 @@ function compactFrontendPlanLedgerContext(input) {
3254
3705
  purpose: compactPromptString(item.purpose, 120),
3255
3706
  component: compactPromptString(item.component, 120),
3256
3707
  decision: compactPromptString(item.decision, 40),
3708
+ covers: compactPromptStringArray(item.covers, 40, 120),
3709
+ evidencePath: compactPromptString(item.evidencePath, 180),
3257
3710
  }];
3258
3711
  }).slice(0, 24)
3259
3712
  : [];
@@ -3261,6 +3714,14 @@ function compactFrontendPlanLedgerContext(input) {
3261
3714
  compactFacts.push({ kind: fact.kind, uiComponentChoices: choices });
3262
3715
  continue;
3263
3716
  }
3717
+ if (fact.kind === "state-registry") {
3718
+ compactFacts.push({
3719
+ kind: fact.kind,
3720
+ uiStateNames: compactPromptStringArray(fact.uiStateNames, 40, 100),
3721
+ interactionNames: compactPromptStringArray(fact.interactionNames, 60, 120),
3722
+ });
3723
+ continue;
3724
+ }
3264
3725
  if (fact.kind === "state-flow") {
3265
3726
  compactFacts.push({
3266
3727
  kind: fact.kind,
@@ -3323,6 +3784,7 @@ function compactFrontendPlanLedgerContext(input) {
3323
3784
  export async function runFrontendPlanSegmentedSessions(input) {
3324
3785
  const queue = [];
3325
3786
  const coverageSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "coverage");
3787
+ const uxRegistrySegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-registry");
3326
3788
  const uxSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "ux-local");
3327
3789
  const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
3328
3790
  const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
@@ -3342,7 +3804,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
3342
3804
  ? ["plan-requirement", "plan-verification-target", "state-flow", "data-flow", "mock-api"]
3343
3805
  : segment.id === "global-dependency-deviation"
3344
3806
  ? ["dependency", "design-deviation"]
3345
- : ["plan-requirement", "plan-verification-target", "component-choice", "state-flow", "data-flow", "mock-api", "design-deviation", "dependency", "target-surface"];
3807
+ : ["plan-requirement", "plan-verification-target", "state-registry", "component-choice", "state-flow", "data-flow", "mock-api", "design-deviation", "dependency", "target-surface"];
3346
3808
  const ledger = input.committedFacts
3347
3809
  ? compactFrontendPlanLedgerContext({
3348
3810
  committedFacts: input.committedFacts(),
@@ -3378,14 +3840,20 @@ export async function runFrontendPlanSegmentedSessions(input) {
3378
3840
  ? compactFrontendPlanLedgerContext({
3379
3841
  committedFacts: input.committedFacts(),
3380
3842
  requirementIds: slice,
3381
- kinds: ["plan-requirement", "plan-verification-target", "component-choice", "state-flow"],
3843
+ kinds: [
3844
+ "plan-requirement",
3845
+ "plan-verification-target",
3846
+ "state-registry",
3847
+ "component-choice",
3848
+ "state-flow",
3849
+ ],
3382
3850
  scopedKinds: ["component-choice", "state-flow"],
3383
3851
  })
3384
3852
  : "";
3385
3853
  return [
3386
3854
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, slice),
3387
3855
  uxSegment.instruction,
3388
- `UX LOCAL BATCH: process ONLY these requirements: ${slice.join(", ")}.`,
3856
+ `GLOBAL UX SCOPE: process the complete requirement set together: ${slice.join(", ")}. Do not split or rename one behavior by requirement id.`,
3389
3857
  ledger,
3390
3858
  ...(missing.length > 0
3391
3859
  ? [
@@ -3395,6 +3863,37 @@ export async function runFrontendPlanSegmentedSessions(input) {
3395
3863
  : []),
3396
3864
  ].filter(Boolean).join("\n\n");
3397
3865
  };
3866
+ const buildUxRegistryPrompt = (missing = []) => {
3867
+ const ledger = input.committedFacts
3868
+ ? compactFrontendPlanLedgerContext({
3869
+ committedFacts: input.committedFacts(),
3870
+ requirementIds: allRequirementIds,
3871
+ kinds: [
3872
+ "plan-requirement",
3873
+ "plan-verification-target",
3874
+ "state-registry",
3875
+ ],
3876
+ })
3877
+ : "";
3878
+ const compact = allRequirementIds.length > 0
3879
+ ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
3880
+ includeVerificationTargets: false,
3881
+ includeDesignEvidence: false,
3882
+ includeChecklist: false,
3883
+ })
3884
+ : input.basePrompt;
3885
+ return [
3886
+ compact,
3887
+ uxRegistrySegment.instruction,
3888
+ ledger,
3889
+ ...(missing.length > 0
3890
+ ? [
3891
+ "MISSING-FACT QUEUE: commit the global UX registry, then re-check this phase:",
3892
+ ...missing.map((item) => `- ${item.reason}`),
3893
+ ]
3894
+ : []),
3895
+ ].filter(Boolean).join("\n\n");
3896
+ };
3398
3897
  const buildCompactLocalPrompt = (missing = []) => {
3399
3898
  const ledger = input.committedFacts
3400
3899
  ? compactFrontendPlanLedgerContext({
@@ -3403,6 +3902,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
3403
3902
  kinds: [
3404
3903
  "plan-requirement",
3405
3904
  "plan-verification-target",
3905
+ "state-registry",
3406
3906
  "component-choice",
3407
3907
  "state-flow",
3408
3908
  ],
@@ -3411,7 +3911,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
3411
3911
  return [
3412
3912
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds),
3413
3913
  "PLAN PHASE — compact local planning for a small frontend request.",
3414
- "For every listed requirement, record plan-requirement and verification-target facts, then record the component-choice and state-flow facts needed by the observable UX. Do not read the repository or task source; use only the committed input above. Do not call finalize_plan in this session.",
3914
+ "Review all listed requirements together and record_state_registry first with one global UX vocabulary. Then record every plan-requirement and verification-target fact, followed by the component-choice and state-flow facts needed by the observable UX. Do not read the repository or task source; use only the committed input above. Do not call finalize_plan in this session.",
3415
3915
  "TOOL-FIRST: your first assistant actions must be record_* tool calls, at most 2-3 facts per message. Do not draft the whole analysis before recording; if a fact is uncertain, record it with an evidence gap instead of reasoning longer.",
3416
3916
  ledger,
3417
3917
  ...(missing.length > 0
@@ -3462,6 +3962,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
3462
3962
  "record_plan_requirement",
3463
3963
  "record_plan_verification_target",
3464
3964
  "record_plan_evidence_gap",
3965
+ "record_state_registry",
3465
3966
  "record_component_choice",
3466
3967
  "record_state_flow",
3467
3968
  "adopt_staged_fact",
@@ -3504,28 +4005,29 @@ export async function runFrontendPlanSegmentedSessions(input) {
3504
4005
  // Compact mode already queued both sessions above.
3505
4006
  }
3506
4007
  else {
3507
- const uxCosts = new Map((input.requirementIds ?? []).map((id) => [
3508
- id,
3509
- Math.max(2, Math.min(3, input.requirementCosts?.get(id) ?? 2)),
3510
- ]));
3511
- const uxSlices = requirementIdsProvided
3512
- ? batchFrontendPlanRequirements({
3513
- requirementIds: input.requirementIds,
3514
- maxEstimatedRecordCalls: FRONTEND_PLAN_UX_LOCAL_MAX_RECORD_CALLS,
3515
- maxRequirements: 3,
3516
- requirementCosts: uxCosts,
3517
- })
3518
- : [[]];
3519
- uxSlices.forEach((slice, batchIndex) => queue.push({
3520
- id: `ux-local-${batchIndex + 1}`,
3521
- toolNames: uxSegment.toolNames,
3522
- ...(slice.length > 0 ? { requirementSlice: slice } : {}),
3523
- prompt: slice.length > 0
3524
- ? buildUxPrompt(slice)
4008
+ if (requirementIdsProvided) {
4009
+ queue.push({
4010
+ id: uxRegistrySegment.id,
4011
+ toolNames: uxRegistrySegment.toolNames,
4012
+ prompt: buildUxRegistryPrompt(),
4013
+ });
4014
+ }
4015
+ const uxSlice = requirementIdsProvided ? [...allRequirementIds] : [];
4016
+ queue.push({
4017
+ id: "ux-local-1",
4018
+ // Compatibility path for an unreadable requirement inventory: there is
4019
+ // no safe all-requirements registry phase, so the unscoped UX session
4020
+ // must establish its registry before recording state flow.
4021
+ toolNames: requirementIdsProvided
4022
+ ? uxSegment.toolNames
4023
+ : new Set([...(uxSegment.toolNames ?? []), "record_state_registry"]),
4024
+ ...(uxSlice.length > 0 ? { requirementSlice: uxSlice } : {}),
4025
+ prompt: uxSlice.length > 0
4026
+ ? buildUxPrompt(uxSlice)
3525
4027
  : buildPhasePrompt(uxSegment),
3526
- }));
4028
+ });
3527
4029
  for (const segment of FRONTEND_PLAN_SEGMENTS) {
3528
- if (["coverage", "ux-local", "finalize"].includes(segment.id))
4030
+ if (["coverage", "ux-registry", "ux-local", "finalize"].includes(segment.id))
3529
4031
  continue;
3530
4032
  queue.push({
3531
4033
  id: segment.id,
@@ -3567,6 +4069,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
3567
4069
  else if (session.id === "compact-local") {
3568
4070
  prompt = buildCompactLocalPrompt(session.missingFacts);
3569
4071
  }
4072
+ else if (session.id === "ux-registry") {
4073
+ prompt = buildUxRegistryPrompt(session.missingFacts);
4074
+ }
3570
4075
  else if (session.id.startsWith("ux-local-")) {
3571
4076
  // Build this at execution time: coverage facts are committed by the
3572
4077
  // preceding sessions and must be visible to the UX-local model.
@@ -3626,6 +4131,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
3626
4131
  })
3627
4132
  : [];
3628
4133
  const isCompactLocalSession = session.id === "compact-local";
4134
+ const isUxRegistrySession = session.id === "ux-registry";
3629
4135
  const isUxLocalSession = session.id.startsWith("ux-local-");
3630
4136
  const missingPhase = isCompactLocalSession && input.committedFacts
3631
4137
  ? [
@@ -3633,6 +4139,11 @@ export async function runFrontendPlanSegmentedSessions(input) {
3633
4139
  requirementIds: allRequirementIds,
3634
4140
  committedFacts: input.committedFacts(),
3635
4141
  }),
4142
+ ...collectFrontendPlanPhaseMissingFacts({
4143
+ phase: "ux-registry",
4144
+ requirementIds: allRequirementIds,
4145
+ committedFacts: input.committedFacts(),
4146
+ }),
3636
4147
  ...collectFrontendPlanPhaseMissingFacts({
3637
4148
  phase: "ux-local",
3638
4149
  requirementIds: allRequirementIds,
@@ -3640,20 +4151,26 @@ export async function runFrontendPlanSegmentedSessions(input) {
3640
4151
  behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
3641
4152
  }),
3642
4153
  ]
3643
- : isUxLocalSession && session.requirementSlice && input.committedFacts
4154
+ : isUxRegistrySession && input.committedFacts
3644
4155
  ? collectFrontendPlanPhaseMissingFacts({
3645
- phase: "ux-local",
3646
- requirementIds: session.requirementSlice,
4156
+ phase: "ux-registry",
4157
+ requirementIds: allRequirementIds,
3647
4158
  committedFacts: input.committedFacts(),
3648
- behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
3649
4159
  })
3650
- : session.id === "global-mock-data" && allRequirementIds.length > 0 && input.committedFacts
4160
+ : isUxLocalSession && session.requirementSlice && input.committedFacts
3651
4161
  ? collectFrontendPlanPhaseMissingFacts({
3652
- phase: "global-mock-data",
3653
- requirementIds: allRequirementIds,
4162
+ phase: "ux-local",
4163
+ requirementIds: session.requirementSlice,
3654
4164
  committedFacts: input.committedFacts(),
4165
+ behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
3655
4166
  })
3656
- : [];
4167
+ : session.id === "global-mock-data" && allRequirementIds.length > 0 && input.committedFacts
4168
+ ? collectFrontendPlanPhaseMissingFacts({
4169
+ phase: "global-mock-data",
4170
+ requirementIds: allRequirementIds,
4171
+ committedFacts: input.committedFacts(),
4172
+ })
4173
+ : [];
3657
4174
  const missingPhaseFacts = [...missingCoverage, ...missingPhase];
3658
4175
  // Frozen verification commands must operate on files the planner has
3659
4176
  // committed verification targets for; otherwise the admission writeSet
@@ -3681,11 +4198,13 @@ export async function runFrontendPlanSegmentedSessions(input) {
3681
4198
  ? buildCoveragePrompt(session.coverageSlice ?? [], missing)
3682
4199
  : isCompactLocalSession
3683
4200
  ? buildCompactLocalPrompt(missing)
3684
- : session.id === "finalize" && useCompactSmallPlan
3685
- ? buildCompactFinalizePrompt(missing)
3686
- : isUxLocalSession
3687
- ? buildUxPrompt(session.requirementSlice ?? [], missing)
3688
- : buildPhasePrompt(globalMockDataSegment, missing);
4201
+ : isUxRegistrySession
4202
+ ? buildUxRegistryPrompt(missing)
4203
+ : session.id === "finalize" && useCompactSmallPlan
4204
+ ? buildCompactFinalizePrompt(missing)
4205
+ : isUxLocalSession
4206
+ ? buildUxPrompt(session.requirementSlice ?? [], missing)
4207
+ : buildPhasePrompt(globalMockDataSegment, missing);
3689
4208
  if (result.ok) {
3690
4209
  if (missingPhaseFacts.length === 0) {
3691
4210
  index += 1;
@@ -3800,7 +4319,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
3800
4319
  });
3801
4320
  continue;
3802
4321
  }
3803
- if ((session.coverageOnly || isUxLocalSession || session.id === "global-mock-data") &&
4322
+ if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id === "global-mock-data") &&
3804
4323
  missingPhaseFacts.length > 0 &&
3805
4324
  !result.stderr.trim() &&
3806
4325
  !result.timedOut &&
@@ -4083,6 +4602,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
4083
4602
  store,
4084
4603
  runDir: meta.runDir,
4085
4604
  nodeId: input.task.id,
4605
+ canonicalRequirements: await resolveFrontendCanonicalRequirements({
4606
+ cwd: input.cwd,
4607
+ sourceBinding: meta.spec.sourceBinding,
4608
+ }),
4086
4609
  });
4087
4610
  writerToolPolicy = {
4088
4611
  requireSdk: true,
@@ -4145,6 +4668,10 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
4145
4668
  cwd: input.cwd,
4146
4669
  sourceBinding: meta.spec.sourceBinding,
4147
4670
  }),
4671
+ declaredUiStateIds: await resolveFrontendDeclaredUiStateIds({
4672
+ runDir: meta.runDir,
4673
+ }),
4674
+ workspaceRoot: input.cwd,
4148
4675
  });
4149
4676
  writerToolPolicy = {
4150
4677
  requireSdk: true,
@@ -4306,12 +4833,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
4306
4833
  ?.kind === "requirement")
4307
4834
  .map((record) => record.fact?.id)
4308
4835
  .filter((id) => typeof id === "string");
4309
- for (const record of contractFacts) {
4310
- const fact = record.fact;
4311
- if (fact?.kind !== "requirement" || typeof fact.id !== "string")
4312
- continue;
4313
- const evidence = fact.evidence;
4314
- if (evidence?.behavior === "required")
4836
+ for (const fact of resolveFrontendContractRequirements(contractFacts.map((record) => record.fact))) {
4837
+ if (fact.evidence.behavior === "required")
4315
4838
  behaviorRequiredRequirementIds.push(fact.id);
4316
4839
  planRequirementCosts.set(fact.id, estimateFrontendPlanRequirementRecordCalls(fact));
4317
4840
  }