@tea-agent/loop-agent 0.43.0-next.1 → 0.43.0-next.2

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 (78) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/application/evaluation/budget.js +19 -1
  3. package/dist/build-stamp.json +3 -3
  4. package/dist/executors/dag-pi-executor.js +762 -252
  5. package/dist/executors/pi-executor.js +20 -1
  6. package/dist/executors/pi-sdk-executor.js +64 -1
  7. package/dist/executors/shell-executor.js +5 -2
  8. package/dist/shared/frontend-execution-policy.js +22 -0
  9. package/dist/task/config-types.js +4 -0
  10. package/dist/task/source-prepare/ledger-reconciliation.js +2 -2
  11. package/dist/task/source-prepare/ledger-review.js +6 -9
  12. package/dist/task/source-prepare/semantic-intake.js +16 -26
  13. package/dist/task/source-prepare/source-fidelity-pi.js +26 -7
  14. package/dist/worker/observe/node-transparency.js +81 -72
  15. package/dist/worker/observe/routes.js +20 -1
  16. package/dist/worker/observe/static/dag-inspector-humanize.js +3 -0
  17. package/dist/worker/observe/static/dom.js +20 -1
  18. package/dist/worker/observe/static/format-pool.d.ts +2 -0
  19. package/dist/worker/observe/static/format-pool.js +6 -0
  20. package/dist/worker/observe/static/format.js +7 -0
  21. package/dist/worker/observe/static/inspect-workspace.js +34 -7
  22. package/dist/worker/observe/static/inspector-submission.js +32 -0
  23. package/dist/worker/observe/static/kpi.js +1 -0
  24. package/dist/worker/observe/static/relations.js +2 -0
  25. package/dist/worker/observe/static/router.js +13 -0
  26. package/dist/worker/observe/static/run-processing.js +2 -0
  27. package/dist/worker/observe/static/shell-chrome.js +36 -3
  28. package/dist/worker/observe/static/state.js +35 -2
  29. package/dist/worker/observe/static/styles.css +260 -39
  30. package/dist/worker/observe/static/task-failure-labels.d.ts +4 -0
  31. package/dist/worker/observe/static/task-failure-labels.js +67 -0
  32. package/dist/worker/observe/static/task-history.js +12 -0
  33. package/dist/worker/observe/static/views/batch.js +6 -13
  34. package/dist/worker/observe/static/views/dag-graph.js +50 -3
  35. package/dist/worker/observe/static/views/dag-inspector.js +746 -265
  36. package/dist/worker/observe/static/views/dag-trajectory.js +3 -0
  37. package/dist/worker/observe/static/views/dag.d.ts +6 -0
  38. package/dist/worker/observe/static/views/dag.js +48 -10
  39. package/dist/worker/observe/static/views/dags.js +2 -0
  40. package/dist/worker/observe/static/views/dashboard.js +21 -12
  41. package/dist/worker/observe/static/views/failures.js +21 -11
  42. package/dist/worker/observe/static/views/feature.js +11 -29
  43. package/dist/worker/observe/static/views/pool.js +37 -28
  44. package/dist/worker/observe/static/views/run.js +48 -5
  45. package/dist/worker/observe/static/views/session-timeline.js +189 -240
  46. package/dist/worker/observe/static/views/task.js +81 -62
  47. package/dist/workflows/dag/budget-enforcement.js +53 -3
  48. package/dist/workflows/dag/frontend-durable-tools.js +193 -0
  49. package/dist/workflows/dag/frontend-execution-groups.js +24 -0
  50. package/dist/workflows/dag/frontend-implementation-contract.js +9 -0
  51. package/dist/workflows/dag/frontend-input-projection.js +76 -0
  52. package/dist/workflows/dag/frontend-plan-render.js +10 -3
  53. package/dist/workflows/dag/frontend-recovery-controller.js +7 -7
  54. package/dist/workflows/dag/frontend-recovery-lineage.js +13 -0
  55. package/dist/workflows/dag/frontend-recovery-run.js +4 -0
  56. package/dist/workflows/dag/frontend-review-scopes.js +117 -0
  57. package/dist/workflows/dag/frontend-session-budget.js +249 -0
  58. package/dist/workflows/dag/frontend-shadow-dual-write.js +20 -2
  59. package/dist/workflows/dag/frontend-test-execution-evidence.js +3 -2
  60. package/dist/workflows/dag/frontend-typed-event-store.js +11 -0
  61. package/dist/workflows/dag/init-hybrid.js +16 -10
  62. package/dist/workflows/dag/node-execution.js +32 -155
  63. package/dist/workflows/dag/prompt.js +4 -0
  64. package/dist/workflows/dag/rerun-plan.js +7 -1
  65. package/dist/workflows/dag/runner.js +26 -1
  66. package/dist/workflows/dag/types.js +6 -0
  67. package/docs/operations/README.md +1 -0
  68. package/docs/templates/frontend-design-contract.md +4 -4
  69. package/docs/templates/frontend-implementation-contract.schema.json +34 -2
  70. package/docs/templates/frontend-implementation-dag.json +5 -5
  71. package/package.json +1 -1
  72. package/skills/frontend-contract/SKILL.md +2 -1
  73. package/skills/frontend-contract/references/contract-protocol.md +19 -3
  74. package/skills/frontend-design-review/SKILL.md +12 -11
  75. package/skills/frontend-plan/SKILL.md +2 -2
  76. package/skills/frontend-plan/references/decision-contract.md +18 -5
  77. package/skills/frontend-review/SKILL.md +10 -11
  78. package/skills/frontend-scout/references/scout-evidence.md +4 -0
@@ -1,3 +1,8 @@
1
+ import { collectFrontendExecutionGroups, frontendExecutionSchema } from "../workflows/dag/frontend-execution-groups.js";
2
+ import { FRONTEND_SCOPE_TARGET_BYTES, packFrontendInputUnits, parseFrontendInputBlock, projectFrontendContractPrompt, projectFrontendInputScope } from "../workflows/dag/frontend-input-projection.js";
3
+ import { createDurableFrontendTools } from "../workflows/dag/frontend-durable-tools.js";
4
+ import { createFrontendReviewScopeProtocol, loadFrontendReviewScopes } from "../workflows/dag/frontend-review-scopes.js";
5
+ import { observeFrontendSession } from "../workflows/dag/frontend-session-budget.js";
1
6
  import path from "node:path";
2
7
  import { createHash, randomUUID } from "node:crypto";
3
8
  import { readFile, stat } from "node:fs/promises";
@@ -454,6 +459,7 @@ export const FRONTEND_CONTRACT_RECORD_TOOL_NAMES = [
454
459
  "record_split_proposal",
455
460
  "record_ui_state",
456
461
  "record_required_deliverables",
462
+ "complete_contract_scope",
457
463
  ];
458
464
  export const FRONTEND_CONTRACT_TERMINAL_TOOL_NAMES = [
459
465
  "finalize_contract",
@@ -471,9 +477,11 @@ export const FRONTEND_PLAN_RECORD_TOOL_NAMES = [
471
477
  "record_state_flow",
472
478
  "record_data_flow",
473
479
  "record_mock_api",
480
+ "record_mock_endpoint",
474
481
  "record_design_deviation",
475
482
  "record_dependency",
476
483
  "record_plan_requirement",
484
+ "record_plan_group_coverage",
477
485
  "record_plan_verification_target",
478
486
  "record_plan_evidence_gap",
479
487
  ];
@@ -548,6 +556,7 @@ export function resolveDagPiToolNames(task) {
548
556
  if (isFrontendReviewTypedTerminalNode(task)) {
549
557
  return [
550
558
  ...DAG_PI_READONLY_TOOLS,
559
+ "record_review_finding",
551
560
  "approve_review",
552
561
  "request_review_changes",
553
562
  ];
@@ -555,6 +564,7 @@ export function resolveDagPiToolNames(task) {
555
564
  if (isFrontendDesignTypedTerminalNode(task)) {
556
565
  return [
557
566
  ...DAG_PI_READONLY_TOOLS,
567
+ "record_design_finding",
558
568
  "approve_design",
559
569
  "request_design_changes",
560
570
  ];
@@ -759,21 +769,35 @@ export async function createFrontendReviewTerminalTools(input) {
759
769
  ]);
760
770
  const { approveReviewFactSchema, readCommittedEvents, requestReviewChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
761
771
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
762
- const store = input.store;
772
+ let store = input.store;
763
773
  const attemptId = input.attemptId;
764
774
  const findingSchema = Type.Object({
765
- severity: Type.String({
766
- description: "Critical | Important | Minor | Info",
767
- }),
768
- file: Type.Optional(Type.String({})),
769
- line: Type.Optional(Type.Number({})),
770
- issue: Type.String({}),
771
- requiredChange: Type.Optional(Type.String({})),
775
+ severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
776
+ file: Type.Optional(Type.String({ minLength: 1 })),
777
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
778
+ issue: Type.String({ minLength: 1 }),
779
+ requiredChange: Type.Optional(Type.String({ minLength: 1 })),
772
780
  }, { additionalProperties: false });
781
+ const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "review", inventory: input.inventory, getStore: () => store, attemptId });
782
+ const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "review-finding").map(r => [r.fact.id, r.fact.finding])).values()];
783
+ const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
784
+ const recordFindingTool = defineTool({
785
+ name: "record_review_finding", label: "record_review_finding",
786
+ description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
787
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
788
+ async execute(_callId, params) {
789
+ const fact = { kind: "review-finding", id: params.id, finding: params.finding };
790
+ const requestId = `${attemptId}:finding:${randomUUID()}`;
791
+ const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
792
+ const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
793
+ const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
794
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
795
+ },
796
+ });
773
797
  const approveParameters = Type.Object({
774
- findings: Type.Array(findingSchema, {
798
+ findings: Type.Optional(Type.Array(findingSchema, {
775
799
  description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
776
- }),
800
+ })),
777
801
  }, { additionalProperties: false });
778
802
  const requestParameters = Type.Object({
779
803
  issueCategory: Type.Enum({
@@ -783,16 +807,17 @@ export async function createFrontendReviewTerminalTools(input) {
783
807
  "contract-requirement-gap": "contract-requirement-gap",
784
808
  "unknown": "unknown",
785
809
  }, { description: "Typed issue category (five-value enum)" }),
786
- evidenceRefs: Type.Array(Type.String({}), {
787
- description: "Evidence refs (paths or artifact ids); at least one",
788
- }),
789
- findings: Type.Array(findingSchema, {
790
- description: "At least one finding",
810
+ evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
811
+ description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
791
812
  }),
813
+ findings: Type.Optional(Type.Array(findingSchema, {
814
+ description: "At least one finding", minItems: 1,
815
+ })),
792
816
  }, { additionalProperties: false });
793
817
  async function adoptReviewFact(kind, fact) {
794
818
  const requestId = randomUUID();
795
819
  try {
820
+ scopeProtocol.assertComplete();
796
821
  const parsed = kind === "approve_review"
797
822
  ? approveReviewFactSchema.parse(fact)
798
823
  : requestReviewChangesFactSchema.parse(fact);
@@ -854,7 +879,7 @@ export async function createFrontendReviewTerminalTools(input) {
854
879
  return adoptReviewFact("approve_review", {
855
880
  kind: "approve_review",
856
881
  verdict: "approve_review",
857
- findings: params?.findings ?? [],
882
+ findings: allFindings(params?.findings),
858
883
  });
859
884
  },
860
885
  });
@@ -870,17 +895,15 @@ export async function createFrontendReviewTerminalTools(input) {
870
895
  verdict: "request_review_changes",
871
896
  issueCategory: params?.issueCategory,
872
897
  evidenceRefs: params?.evidenceRefs,
873
- findings: params?.findings,
898
+ findings: allFindings(params?.findings),
874
899
  });
875
900
  },
876
901
  });
877
- return {
878
- customTools: [approveReviewTool, requestReviewChangesTool],
879
- flush: async () => {
880
- const committed = readCommittedEvents(store, attemptId);
881
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), committed);
882
- },
883
- };
902
+ const durable = await createDurableFrontendTools({
903
+ file: path.join(input.runDir, input.nodeId, "review-typed-facts.jsonl"), attemptId, store: input.store,
904
+ binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveReviewTool, requestReviewChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
905
+ });
906
+ return { ...durable, scopeProtocol };
884
907
  }
885
908
  /**
886
909
  * M8: build the two committed typed design terminal tools (approve_design /
@@ -897,21 +920,35 @@ export async function createFrontendDesignTerminalTools(input) {
897
920
  ]);
898
921
  const { approveDesignFactSchema, readCommittedEvents, requestDesignChangesFactSchema, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
899
922
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
900
- const store = input.store;
923
+ let store = input.store;
901
924
  const attemptId = input.attemptId;
902
925
  const findingSchema = Type.Object({
903
- severity: Type.String({
904
- description: "Critical | Important | Minor | Info",
905
- }),
906
- file: Type.Optional(Type.String({})),
907
- line: Type.Optional(Type.Number({})),
908
- issue: Type.String({}),
909
- requiredChange: Type.Optional(Type.String({})),
926
+ severity: Type.Enum({ Critical: "Critical", Important: "Important", Minor: "Minor", Info: "Info" }),
927
+ file: Type.Optional(Type.String({ minLength: 1 })),
928
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
929
+ issue: Type.String({ minLength: 1 }),
930
+ requiredChange: Type.Optional(Type.String({ minLength: 1 })),
910
931
  }, { additionalProperties: false });
932
+ const scopeProtocol = createFrontendReviewScopeProtocol({ phase: "design", inventory: input.inventory, getStore: () => store, attemptId });
933
+ const savedFindings = () => [...new Map(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "design-finding").map(r => [r.fact.id, r.fact.finding])).values()];
934
+ const allFindings = (direct) => [...new Map([...savedFindings(), ...(Array.isArray(direct) ? direct : [])].map(finding => [JSON.stringify(finding), finding])).values()];
935
+ const recordFindingTool = defineTool({
936
+ name: "record_design_finding", label: "record_design_finding",
937
+ description: "Save one finding with a stable id. Submit findings incrementally, then finalize without repeating the findings array. Saved blocking findings cannot be omitted from approval; correct a finding explicitly with replace:true.",
938
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }), finding: findingSchema }, { additionalProperties: false }),
939
+ async execute(_callId, params) {
940
+ const fact = { kind: "design-finding", id: params.id, finding: params.finding };
941
+ const requestId = `${attemptId}:finding:${randomUUID()}`;
942
+ const staged = stageTypedEventFact({ store, requestId, attemptId, fact });
943
+ const adopted = await adoptTypedEventFact({ store, requestId, attemptId, fact, eventId: staged.eventId, expectedRevision: store.revision });
944
+ const details = { ok: true, eventId: adopted.eventId, revision: adopted.revision };
945
+ return { content: [{ type: "text", text: JSON.stringify(details) }], details };
946
+ },
947
+ });
911
948
  const approveParameters = Type.Object({
912
- findings: Type.Array(findingSchema, {
949
+ findings: Type.Optional(Type.Array(findingSchema, {
913
950
  description: "Optional informational findings (Minor/Info only; no Critical/Important on approval)",
914
- }),
951
+ })),
915
952
  }, { additionalProperties: false });
916
953
  const requestParameters = Type.Object({
917
954
  issueCategory: Type.Enum({
@@ -921,16 +958,17 @@ export async function createFrontendDesignTerminalTools(input) {
921
958
  "contract-requirement-gap": "contract-requirement-gap",
922
959
  "unknown": "unknown",
923
960
  }, { description: "Typed issue category (five-value enum)" }),
924
- evidenceRefs: Type.Array(Type.String({}), {
925
- description: "Evidence refs (paths or artifact ids); at least one",
926
- }),
927
- findings: Type.Array(findingSchema, {
928
- description: "At least one finding",
961
+ evidenceRefs: Type.Array(Type.String({ minLength: 1 }), {
962
+ description: "Evidence refs (paths or artifact ids); at least one", minItems: 1,
929
963
  }),
964
+ findings: Type.Optional(Type.Array(findingSchema, {
965
+ description: "At least one finding", minItems: 1,
966
+ })),
930
967
  }, { additionalProperties: false });
931
968
  async function adoptDesignFact(kind, fact) {
932
969
  const requestId = randomUUID();
933
970
  try {
971
+ scopeProtocol.assertComplete();
934
972
  const parsed = kind === "approve_design"
935
973
  ? approveDesignFactSchema.parse(fact)
936
974
  : requestDesignChangesFactSchema.parse(fact);
@@ -992,7 +1030,7 @@ export async function createFrontendDesignTerminalTools(input) {
992
1030
  return adoptDesignFact("approve_design", {
993
1031
  kind: "approve_design",
994
1032
  verdict: "approve_design",
995
- findings: params?.findings ?? [],
1033
+ findings: allFindings(params?.findings),
996
1034
  });
997
1035
  },
998
1036
  });
@@ -1008,17 +1046,15 @@ export async function createFrontendDesignTerminalTools(input) {
1008
1046
  verdict: "request_design_changes",
1009
1047
  issueCategory: params?.issueCategory,
1010
1048
  evidenceRefs: params?.evidenceRefs,
1011
- findings: params?.findings,
1049
+ findings: allFindings(params?.findings),
1012
1050
  });
1013
1051
  },
1014
1052
  });
1015
- return {
1016
- customTools: [approveDesignTool, requestDesignChangesTool],
1017
- flush: async () => {
1018
- const committed = readCommittedEvents(store, attemptId);
1019
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), committed);
1020
- },
1021
- };
1053
+ const durable = await createDurableFrontendTools({
1054
+ file: path.join(input.runDir, input.nodeId, "design-typed-facts.jsonl"), attemptId, store: input.store,
1055
+ binding: { inputDigest: input.inputDigest, scopeDigest: input.inventory?.digest }, setWorkingStore: next => { store = next; }, tools: [...scopeProtocol.customTools, recordFindingTool, approveDesignTool, requestDesignChangesTool], validateRestored: async () => { await input.inventory?.validate(); },
1056
+ });
1057
+ return { ...durable, scopeProtocol };
1022
1058
  }
1023
1059
  /**
1024
1060
  * Source fidelity ledger (AC-005/AC-006): load the contract node's committed
@@ -1063,6 +1099,8 @@ async function loadContractRequirementInheritance(runDir) {
1063
1099
  : undefined;
1064
1100
  if (sourceFragmentIds || sourceRefs) {
1065
1101
  byId.set(id, {
1102
+ ...(typeof recordFact.text === "string" ? { text: recordFact.text } : {}),
1103
+ ...(recordFact.execution !== undefined ? { execution: frontendExecutionSchema.parse(recordFact.execution) } : {}),
1066
1104
  ...(sourceFragmentIds ? { sourceFragmentIds } : {}),
1067
1105
  ...(sourceRefs ? { sourceRefs } : {}),
1068
1106
  });
@@ -1228,26 +1266,12 @@ export async function createFrontendPlanLedgerTools(input) {
1228
1266
  const { loadTypedEventStore, readCommittedEvents, writeTypedEventStoreJsonl, } = await import("../workflows/dag/frontend-typed-event-store.js");
1229
1267
  const { adoptStagedFact, adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
1230
1268
  const { assemblePlanPatchFromCommittedFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");
1231
- const store = input.store;
1269
+ let store = input.store;
1232
1270
  const attemptId = input.attemptId;
1233
1271
  let activeRequirementScope = [];
1234
1272
  const scopedRequirementIds = () => [...activeRequirementScope];
1235
- // A retry creates a fresh executor-local store, but the plan ledger is the
1236
- // cross-attempt authority. Restore the committed prefix before registering
1237
- // tools; otherwise the first flush of a retry can overwrite facts that the
1238
- // previous attempt had already committed. The on-disk file contains only
1239
- // committed records, so loading it is also fail-closed with respect to
1240
- // staged/quarantined facts.
1241
- const persisted = await loadTypedEventStore(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"));
1242
- if (persisted.records.length > 0) {
1243
- const existingEventIds = new Set(store.records.map((record) => record.eventId));
1244
- for (const record of persisted.records) {
1245
- if (!existingEventIds.has(record.eventId)) {
1246
- store.records.push(record);
1247
- }
1248
- }
1249
- store.revision = Math.max(store.revision, persisted.revision);
1250
- }
1273
+ const contractInheritance = await loadContractRequirementInheritance(input.runDir);
1274
+ const executionGroups = collectFrontendExecutionGroups([...contractInheritance].map(([id, r]) => ({ id, ...r })));
1251
1275
  const stringArray = Type.Array(Type.String({}));
1252
1276
  const optionalString = Type.Optional(Type.String({}));
1253
1277
  const optionalStringArray = Type.Optional(stringArray);
@@ -1282,9 +1306,7 @@ export async function createFrontendPlanLedgerTools(input) {
1282
1306
  verificationTargetIds: stringArray,
1283
1307
  }, { additionalProperties: false });
1284
1308
  const mockEndpointSchema = Type.Object({
1285
- method: Type.String({
1286
- description: "GET | POST | PUT | PATCH | DELETE | HEAD | OPTIONS",
1287
- }),
1309
+ method: Type.Enum({ GET: "GET", POST: "POST", PUT: "PUT", PATCH: "PATCH", DELETE: "DELETE", HEAD: "HEAD", OPTIONS: "OPTIONS" }),
1288
1310
  path: Type.String({}),
1289
1311
  fixture: optionalString,
1290
1312
  consumer: optionalString,
@@ -1346,9 +1368,9 @@ export async function createFrontendPlanLedgerTools(input) {
1346
1368
  specReference: Type.Optional(Type.Object({
1347
1369
  path: Type.String({}),
1348
1370
  section: Type.String({}),
1349
- line: Type.Optional(Type.Number({})),
1371
+ line: Type.Optional(Type.Integer({ minimum: 1 })),
1350
1372
  }, { additionalProperties: false })),
1351
- rationale: Type.String({}),
1373
+ rationale: Type.Optional(Type.String({ minLength: 1 })),
1352
1374
  covers: Type.Optional(Type.Array(Type.String({}), {
1353
1375
  description: "UI state and/or interaction names this single component choice covers (one choice may cover many ids).",
1354
1376
  })),
@@ -1805,13 +1827,14 @@ export async function createFrontendPlanLedgerTools(input) {
1805
1827
  label: "record_data_flow",
1806
1828
  description: "Record interaction/endpoint data flow as an origin=plan data-flow fact. Example: {\"interactions\": [\"<interaction name>\"], \"endpoints\": [\"GET <path>\"]}",
1807
1829
  promptSnippet: "Record the plan data-flow fact.",
1808
- parameters: Type.Object({ interactions: stringArray, endpoints: stringArray }, { additionalProperties: false }),
1830
+ parameters: Type.Object({ interactions: stringArray, endpoints: stringArray, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
1809
1831
  async execute(_toolCallId, params) {
1832
+ const previous = params.replace ? undefined : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "data-flow").at(-1)?.fact;
1810
1833
  const result = await adoptPlanFact("data-flow", `${attemptId}:record_data_flow:${randomUUID()}`, {
1811
1834
  kind: "data-flow",
1812
1835
  origin: "plan",
1813
- interactions: stringList(params?.interactions),
1814
- endpoints: stringList(params?.endpoints),
1836
+ interactions: [...new Set([...stringList(previous?.interactions), ...stringList(params?.interactions)])],
1837
+ endpoints: [...new Set([...stringList(previous?.endpoints), ...stringList(params?.endpoints)])],
1815
1838
  });
1816
1839
  return planToolReceipt(result);
1817
1840
  },
@@ -1839,6 +1862,16 @@ export async function createFrontendPlanLedgerTools(input) {
1839
1862
  return planToolReceipt(result);
1840
1863
  },
1841
1864
  });
1865
+ const recordMockEndpointTool = defineTool({
1866
+ name: "record_mock_endpoint", label: "record_mock_endpoint",
1867
+ description: "Record one Mock/API endpoint. First record_mock_api with the policy and endpoints: []; then submit each endpoint separately. Never regenerate the whole endpoint collection. Use replace:true to revise an existing method/path, or replace:true plus remove:true to withdraw it.",
1868
+ parameters: Type.Object({ endpoint: mockEndpointSchema, remove: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
1869
+ async execute(_callId, params) {
1870
+ if (!readCommittedEvents(store, attemptId).some(r => r.fact.kind === "mock-api"))
1871
+ return planToolReceipt({ ok: false, kind: "mock-endpoint", code: "MOCK_POLICY_MISSING", error: "Record the mock policy before its endpoints" });
1872
+ return planToolReceipt(await adoptPlanFact("mock-endpoint", `${attemptId}:endpoint:${randomUUID()}`, { kind: "mock-endpoint", origin: "plan", endpoint: params.endpoint, ...(params.remove ? { removed: true } : {}) }));
1873
+ },
1874
+ });
1842
1875
  const recordDesignDeviationTool = defineTool({
1843
1876
  name: "record_design_deviation",
1844
1877
  label: "record_design_deviation",
@@ -1914,6 +1947,8 @@ export async function createFrontendPlanLedgerTools(input) {
1914
1947
  // canonical-coverage gate rejects with no in-node cure. Reject here
1915
1948
  // and name the allowed ids.
1916
1949
  const id = typeof entry.id === "string" ? entry.id : "";
1950
+ if (activeRequirementScope.length && !activeRequirementScope.includes(id))
1951
+ return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `Requirement ${id} is outside this session` });
1917
1952
  if (id &&
1918
1953
  input.requirementIds &&
1919
1954
  input.requirementIds.length > 0 &&
@@ -1963,6 +1998,29 @@ export async function createFrontendPlanLedgerTools(input) {
1963
1998
  return planToolReceipt(result);
1964
1999
  },
1965
2000
  });
2001
+ const recordPlanGroupCoverageTool = defineTool({
2002
+ name: "record_plan_group_coverage", label: "record_plan_group_coverage",
2003
+ description: "Submit shared implementation/verification references for one declared execution group. Runtime expands to every canonical member and retains its full outcome and source bindings. A shared VT must actually verify each independent condition. Use per-requirement records for differences; never create UI for constraints or exclusions. replace:true explicitly revises the group.",
2004
+ parameters: Type.Object({ id: Type.String({ minLength: 1 }), implementationTargets: stringArray, verificationTargetIds: stringArray, replace: Type.Optional(Type.Boolean()) }, { additionalProperties: false }),
2005
+ async execute(callId, params, signal, onUpdate, ctx) {
2006
+ const group = executionGroups.find(g => g.id === params.id && g.kind !== "unclassified");
2007
+ if (!group || (activeRequirementScope.length && group.requirementIds.some(id => !activeRequirementScope.includes(id))))
2008
+ return planToolReceipt({ ok: false, kind: "plan-requirement", code: "EXECUTION_GROUP_SCOPE_INVALID", error: "A known complete group must be present in this session; submit individual member records when the group spans scopes" });
2009
+ let last;
2010
+ for (const id of group.requirementIds) {
2011
+ const existing = readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "plan-requirement" && r.fact.entry?.id === id).at(-1)?.fact.entry;
2012
+ if (existing && !params.replace) {
2013
+ if (JSON.stringify(existing.implementationTargets) !== JSON.stringify(params.implementationTargets) || JSON.stringify(existing.verificationTargetIds) !== JSON.stringify(params.verificationTargetIds))
2014
+ return planToolReceipt({ ok: false, kind: "plan-requirement", code: "FACT_IDENTITY_CONFLICT", error: `${id}: existing coverage differs; use replace:true to revise explicitly` });
2015
+ continue;
2016
+ }
2017
+ last = await recordPlanRequirementTool.execute(`${callId}:${id}`, { entry: { id, implementationTargets: params.implementationTargets, verificationTargetIds: params.verificationTargetIds }, ...(params.replace ? { replace: true } : {}) }, signal, onUpdate, ctx);
2018
+ if (!last.details?.ok)
2019
+ return last;
2020
+ }
2021
+ return planToolReceipt(await adoptPlanFact("plan-group-coverage", `${attemptId}:group:${randomUUID()}`, { kind: "plan-group-coverage", origin: "plan", id: group.id, requirementIds: group.requirementIds }));
2022
+ },
2023
+ });
1966
2024
  const recordPlanVerificationTargetTool = defineTool({
1967
2025
  name: "record_plan_verification_target",
1968
2026
  label: "record_plan_verification_target",
@@ -2182,7 +2240,7 @@ export async function createFrontendPlanLedgerTools(input) {
2182
2240
  const finalizePlanTool = defineTool({
2183
2241
  name: "finalize_plan",
2184
2242
  label: "finalize_plan",
2185
- 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.",
2243
+ description: "Commit the finalize_plan terminal. Requirements, verification targets, and evidence gaps (optional) were already committed incrementally through record_plan_requirement / record_plan_verification_target / record_plan_evidence_gap; finalize_plan assembles them from the ledger together with these optional remaining fields, publishes the canonical editable patch on a target-surface fact, and commits the terminal. A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2186
2244
  promptSnippet: "Commit the finalize_plan terminal (ledger fields + optional residualRisks / realIntegrationGap).",
2187
2245
  parameters: Type.Object({
2188
2246
  residualRisks: optionalStringArray,
@@ -2197,6 +2255,9 @@ export async function createFrontendPlanLedgerTools(input) {
2197
2255
  // when the plan did not re-declare them. The contract node is the
2198
2256
  // sole synthesis point; the plan inherits by requirement id.
2199
2257
  const contractInheritance = await loadContractRequirementInheritance(input.runDir);
2258
+ const missingData = collectFrontendPlanPhaseMissingFacts({ phase: "global-mock-data", requirementIds: input.requirementIds ?? [...contractInheritance.keys()], committedFacts: committed }).filter(f => f.kind === "data-flow");
2259
+ if (missingData.length)
2260
+ return planToolReceipt({ ok: false, kind: "finalize_plan", code: "PLAN_DATA_FLOW_INCOMPLETE", error: missingData.map(f => f.reason).join("; ") });
2200
2261
  const fragment = assemblePlanPatchFromCommittedFacts(committed, contractInheritance) ?? {};
2201
2262
  const patch = {
2202
2263
  ...fragment,
@@ -2352,23 +2413,31 @@ export async function createFrontendPlanLedgerTools(input) {
2352
2413
  }
2353
2414
  },
2354
2415
  });
2355
- return {
2356
- customTools: [
2416
+ const durable = await createDurableFrontendTools({
2417
+ file: path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), attemptId, store: input.store,
2418
+ setWorkingStore: next => { store = next; },
2419
+ binding: { sourceBinding: input.sourceBinding, skeleton: input.skeleton, requirementIds: input.requirementIds, writeSet: input.writeSetPatterns, declaredUiStateIds: input.declaredUiStateIds, citations: input.componentNewSourceReferences, contractInheritance },
2420
+ tools: [
2357
2421
  recordRouteSelectionTool,
2358
2422
  recordComponentChoiceTool,
2359
2423
  recordStateRegistryTool,
2360
2424
  recordStateFlowTool,
2361
2425
  recordDataFlowTool,
2362
2426
  recordMockApiTool,
2427
+ recordMockEndpointTool,
2363
2428
  recordDesignDeviationTool,
2364
2429
  recordDependencyTool,
2365
2430
  recordPlanRequirementTool,
2431
+ recordPlanGroupCoverageTool,
2366
2432
  recordPlanVerificationTargetTool,
2367
2433
  recordPlanEvidenceGapTool,
2368
2434
  adoptStagedFactTool,
2369
2435
  finalizePlanTool,
2370
2436
  ],
2371
- adoptCommittedFacts: async (records) => {
2437
+ });
2438
+ return {
2439
+ customTools: durable.customTools,
2440
+ adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
2372
2441
  for (const record of records) {
2373
2442
  if (record.phase !== "committed")
2374
2443
  continue;
@@ -2427,16 +2496,13 @@ export async function createFrontendPlanLedgerTools(input) {
2427
2496
  throw new Error(`frontend plan shard fact merge failed: ${result.error}`);
2428
2497
  }
2429
2498
  }
2430
- },
2499
+ }),
2431
2500
  setActiveRequirementScope: (requirementIds) => {
2432
2501
  activeRequirementScope = [
2433
2502
  ...new Set(requirementIds.filter((id) => id.trim().length > 0)),
2434
2503
  ];
2435
2504
  },
2436
- flush: async () => {
2437
- const committed = readCommittedEvents(store, attemptId);
2438
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "plan-typed-facts.jsonl"), committed);
2439
- },
2505
+ flush: durable.flush,
2440
2506
  committedFactCount: () => readCommittedEvents(store, attemptId).length,
2441
2507
  committedRequirementIds: () => {
2442
2508
  const ids = new Set();
@@ -2538,8 +2604,11 @@ export async function createFrontendContractTools(input) {
2538
2604
  ]);
2539
2605
  const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2540
2606
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2541
- const store = input.store;
2607
+ let store = input.store;
2542
2608
  const attemptId = input.attemptId;
2609
+ let activeScope = null;
2610
+ const committedRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement").map(r => String(r.fact.id)));
2611
+ const completedScopeRequirementIds = () => new Set(readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "contract-scope-completed").flatMap(r => Array.isArray(r.fact.requirementIds) ? r.fact.requirementIds.filter((id) => typeof id === "string") : []));
2543
2612
  const receipt = (details) => ({
2544
2613
  content: [{ type: "text", text: JSON.stringify(details) }],
2545
2614
  details,
@@ -2589,12 +2658,24 @@ export async function createFrontendContractTools(input) {
2589
2658
  label: name,
2590
2659
  description: `Commit an origin=contract ${kind} fact. IMPORTANT: submit incrementally — batch up to 5 record_* calls per message, starting from the FIRST message; never attempt to emit the whole contract in one response (a single large dump will be truncated and rejected). Every message must make progress by committing at least one record_* fact.`,
2591
2660
  promptSnippet: `Commit 1-5 origin=contract ${kind} facts (up to 5 per message).`,
2592
- parameters: Type.Object({}, { additionalProperties: true }),
2661
+ parameters: Type.Object({
2662
+ text: Type.String({ minLength: 1 }),
2663
+ requirementIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
2664
+ sourceFragmentIds: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
2665
+ ...(kind === "constraint" ? { category: Type.Optional(Type.Enum({ constraint: "constraint", "non-goal": "non-goal", risk: "risk" })) } : {}),
2666
+ ...(kind === "handoff-intent" ? { taskKind: Type.Literal("frontend-test"), blocking: Type.Optional(Type.Boolean()) } : {}),
2667
+ }, { additionalProperties: false }),
2593
2668
  async execute(_toolCallId, params) {
2669
+ const data = params;
2670
+ if (!data.text?.trim())
2671
+ return receipt({ ok: false, code: "TOOL_SCHEMA_INVALID", error: `${name}: text must be non-empty` });
2672
+ const knownFragments = new Set([...(input.canonicalRequirements?.values() ?? [])].flatMap(r => r.sourceFragmentIds));
2673
+ if (data.requirementIds?.some(id => !input.canonicalRequirements?.has(id)) || data.sourceFragmentIds?.some(id => !knownFragments.has(id)))
2674
+ return receipt({ ok: false, code: "CONTRACT_REFERENCE_UNKNOWN", error: `${name}: reference is outside the frozen source inventory` });
2594
2675
  const result = await adoptContractFact(kind, {
2676
+ ...(params ?? {}),
2595
2677
  kind,
2596
2678
  origin: "contract",
2597
- ...(params ?? {}),
2598
2679
  });
2599
2680
  return receipt(result);
2600
2681
  },
@@ -2608,9 +2689,9 @@ export async function createFrontendContractTools(input) {
2608
2689
  const recordRequirementTool = defineTool({
2609
2690
  name: "record_requirement",
2610
2691
  label: "record_requirement",
2611
- 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.',
2692
+ description: 'Confirm one canonical ledger requirement (origin=contract requirement fact). Pass the canonical id and optional execution:{groupId,kind,summary} only. Reuse a group only when its behavior and all permission/threshold/error conditions agree; retain separate groups for differences. Constraints/exclusions do not require invented UI. The canonical id is listed in the <frontend_contract_input> inventory, e.g. {"id": "AC-001"} — the runtime commits the authoritative text and sourceFragmentIds from the frozen ledger. Never pass text/statement/sourceFragmentIds yourself: free-form rewrites and JSON-stringified fragment arrays are rejected. IMPORTANT: batch up to 5 record_* calls per message, starting from the FIRST message.',
2612
2693
  promptSnippet: "Confirm 1-5 canonical requirements by id (up to 5 per message).",
2613
- parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }) }, { additionalProperties: false }),
2694
+ parameters: Type.Object({ id: Type.String({ description: "Canonical ledger requirement id (e.g. AC-001)" }), execution: Type.Optional(Type.Object({ groupId: Type.String({ minLength: 1 }), kind: Type.Union([Type.Literal("behavior"), Type.Literal("constraint"), Type.Literal("exclusion")]), summary: Type.String({ minLength: 1 }) }, { additionalProperties: false })) }, { additionalProperties: false }),
2614
2695
  async execute(_toolCallId, params) {
2615
2696
  const id = typeof params?.id === "string" ? params.id.trim() : "";
2616
2697
  if (!id) {
@@ -2620,6 +2701,8 @@ export async function createFrontendContractTools(input) {
2620
2701
  error: "record_requirement requires the canonical requirement id",
2621
2702
  });
2622
2703
  }
2704
+ if (activeScope && !activeScope.has(id))
2705
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: `requirement ${id} is outside the complete input scope of this session` });
2623
2706
  const canonical = input.canonicalRequirements?.get(id);
2624
2707
  if (!canonical) {
2625
2708
  const known = [...(input.canonicalRequirements?.keys() ?? [])];
@@ -2629,6 +2712,14 @@ export async function createFrontendContractTools(input) {
2629
2712
  error: `record_requirement id "${id}" is not a canonical ledger requirement; canonical ids are: ${known.join(", ") || "(none)"}`,
2630
2713
  });
2631
2714
  }
2715
+ if (params.execution) {
2716
+ try {
2717
+ collectFrontendExecutionGroups([...readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "requirement" && r.fact.id !== id).map(r => ({ id: String(r.fact.id), execution: r.fact.execution })), { id, execution: params.execution }]);
2718
+ }
2719
+ catch (error) {
2720
+ return receipt({ ok: false, code: "EXECUTION_GROUP_CONFLICT", error: String(error) });
2721
+ }
2722
+ }
2632
2723
  const result = await adoptContractFact("requirement", {
2633
2724
  kind: "requirement",
2634
2725
  origin: "contract",
@@ -2636,6 +2727,7 @@ export async function createFrontendContractTools(input) {
2636
2727
  id,
2637
2728
  text: canonical.text,
2638
2729
  sourceFragmentIds: canonical.sourceFragmentIds,
2730
+ ...(params.execution ? { execution: params.execution } : {}),
2639
2731
  });
2640
2732
  return receipt(result);
2641
2733
  },
@@ -2715,15 +2807,17 @@ export async function createFrontendContractTools(input) {
2715
2807
  const recordRequiredDeliverablesTool = defineTool({
2716
2808
  name: "record_required_deliverables",
2717
2809
  label: "record_required_deliverables",
2718
- 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.',
2719
- parameters: Type.Object({ items: Type.Array(Type.Object({
2810
+ description: 'Declare the complete source-required file deliverables as {items:[{path,requirementId,sourceFragmentId}]}. Interpret obligations from the original source, including lists/tables: permissions (allowedPaths/only allowed to modify), prohibitions, examples and read-only references are NOT delivery obligations. Each path must appear exactly in its frozen requirement-bound source fragment. Submit {items:[]} explicitly if no files are mandatory. Each call appends complete source-bound items; replace:true explicitly replaces the inventory. Required before finalize_contract ready.',
2811
+ parameters: Type.Object({ replace: Type.Optional(Type.Boolean()), items: Type.Array(Type.Object({
2720
2812
  path: Type.String(), requirementId: Type.String(), sourceFragmentId: Type.String(),
2721
2813
  }, { additionalProperties: false })) }, { additionalProperties: false }),
2722
2814
  async execute(_toolCallId, params) {
2723
2815
  try {
2724
- const declaration = validateFrontendRequiredDeliverables(params, input.canonicalRequirements ?? new Map());
2816
+ const declaration = validateFrontendRequiredDeliverables({ items: params.items }, input.canonicalRequirements ?? new Map());
2817
+ const previous = params.replace ? [] : readCommittedEvents(store, attemptId).filter(r => r.fact.kind === "required-deliverables").at(-1)?.fact.items;
2818
+ const items = [...new Map([...(Array.isArray(previous) ? previous : []), ...declaration.items].map(item => [JSON.stringify(item), item])).values()];
2725
2819
  return receipt(await adoptContractFact("required-deliverables", {
2726
- kind: "required-deliverables", origin: "contract", ...declaration,
2820
+ kind: "required-deliverables", origin: "contract", items,
2727
2821
  }));
2728
2822
  }
2729
2823
  catch (error) {
@@ -2779,7 +2873,7 @@ export async function createFrontendContractTools(input) {
2779
2873
  const finalizeContractTool = defineTool({
2780
2874
  name: "finalize_contract",
2781
2875
  label: "finalize_contract",
2782
- description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). Call exactly once.",
2876
+ description: "Commit the contract-finalized terminal fact with a disposition of ready | ready-with-assumptions | blocked (blocked requires blockingOwner). A successful terminal commit occurs exactly once. If validation fails, correct only the reported facts and retry finalize.",
2783
2877
  promptSnippet: "Commit the contract-finalized terminal (disposition + optional blockingOwner).",
2784
2878
  parameters: Type.Object({
2785
2879
  disposition: Type.Enum({
@@ -2811,6 +2905,9 @@ export async function createFrontendContractTools(input) {
2811
2905
  error: "call record_required_deliverables with the complete source-bound inventory (or items:[] when none) before finalizing",
2812
2906
  });
2813
2907
  }
2908
+ const missing = [...(input.canonicalRequirements?.keys() ?? [])].filter(id => !committedRequirementIds().has(id) || (activeScope !== null && !completedScopeRequirementIds().has(id)));
2909
+ if (disposition !== "blocked" && missing.length)
2910
+ return receipt({ ok: false, code: "CONTRACT_REQUIREMENT_COVERAGE_MISSING", error: `Confirm all complete source obligations before finalizing: ${missing.join(", ")}` });
2814
2911
  const blockedOwner = mapContractBlockedOwner({
2815
2912
  disposition: disposition ?? "",
2816
2913
  blockingOwner,
@@ -2831,19 +2928,41 @@ export async function createFrontendContractTools(input) {
2831
2928
  return receipt(result);
2832
2929
  },
2833
2930
  });
2834
- return {
2835
- customTools: [
2931
+ const completeScopeTool = defineTool({
2932
+ name: "complete_contract_scope", label: "complete_contract_scope",
2933
+ description: "After recording all requirements AND their evidence, constraints, questions and deliverables for this session, mark the scope complete. Confirming an ID alone does not complete its analysis. Do this before finalize_contract.",
2934
+ parameters: Type.Object({ requirementIds: Type.Array(Type.String({ minLength: 1 }), { uniqueItems: true }) }, { additionalProperties: false }),
2935
+ async execute(_id, params) {
2936
+ const ids = params.requirementIds;
2937
+ if (activeScope === null || ids.length !== activeScope.size || ids.some(id => !activeScope?.has(id) || !committedRequirementIds().has(id)))
2938
+ return receipt({ ok: false, code: "CONTRACT_SCOPE_INCOMPLETE", error: "Complete exactly the active scope after confirming all its obligations" });
2939
+ return receipt(await adoptContractFact("contract-scope-completed", { kind: "contract-scope-completed", origin: "contract", requirementIds: ids }));
2940
+ },
2941
+ });
2942
+ const durable = await createDurableFrontendTools({
2943
+ file: path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), attemptId, store: input.store,
2944
+ setWorkingStore: next => { store = next; }, binding: { canonicalRequirements: input.canonicalRequirements, sourceDigest: input.sourceDigest },
2945
+ tools: [
2836
2946
  ...recordTools,
2837
2947
  recordRequirementTool,
2838
2948
  recordEvidenceExpectationTool,
2839
2949
  recordUiStateTool,
2840
2950
  recordRequiredDeliverablesTool,
2841
2951
  recordOpenspecSelectionTool,
2952
+ completeScopeTool,
2842
2953
  finalizeContractTool,
2843
2954
  ],
2955
+ });
2956
+ return {
2957
+ customTools: durable.customTools,
2958
+ inputRequirements: () => [...(input.canonicalRequirements ?? [])].map(([id, value]) => ({ id, text: value.text, sourceFragmentIds: [...value.sourceFragmentIds] })),
2959
+ completedScopeRequirementIds,
2960
+ setActiveRequirementScope: ids => { activeScope = ids === null ? null : new Set(ids); },
2961
+ committedRequirementIds,
2962
+ committedFacts: () => readCommittedEvents(input.store, attemptId),
2844
2963
  flush: async () => {
2845
- const committed = readCommittedEvents(store, attemptId);
2846
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "contract-typed-facts.jsonl"), committed);
2964
+ await durable.flush();
2965
+ const committed = readCommittedEvents(input.store, attemptId);
2847
2966
  await writeJsonAtomic(path.join(input.runDir, input.nodeId, "frontend-task-contract-vNext.json"), buildFrontendTaskContractVNext(committed));
2848
2967
  },
2849
2968
  };
@@ -2895,7 +3014,7 @@ export async function createFrontendScoutEvidenceTools(input) {
2895
3014
  ]);
2896
3015
  const { readCommittedEvents, writeTypedEventStoreJsonl } = await import("../workflows/dag/frontend-typed-event-store.js");
2897
3016
  const { adoptTypedEventFact, stageTypedEventFact } = await import("../workflows/dag/frontend-typed-event-transaction.js");
2898
- const store = input.store;
3017
+ let store = input.store;
2899
3018
  const attemptId = input.attemptId;
2900
3019
  const stringArray = Type.Array(Type.String({}));
2901
3020
  const optionalString = Type.Optional(Type.String({}));
@@ -2909,6 +3028,18 @@ export async function createFrontendScoutEvidenceTools(input) {
2909
3028
  });
2910
3029
  const sourceDeclaredPaths = (input.sourceDeclaredPaths ?? []).map((value) => value.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, ""));
2911
3030
  const hasSourceDeclarations = input.sourceDeclaredPaths !== undefined;
3031
+ let activeScope;
3032
+ const scopeIdentity = (ids) => createHash("sha256").update(JSON.stringify([...ids].sort())).digest("hex");
3033
+ const latestScopes = () => {
3034
+ const byRequirement = new Map();
3035
+ for (const record of readCommittedEvents(store, attemptId))
3036
+ if (record.fact.kind === "scout-scope" && Array.isArray(record.fact.requirementIds)) {
3037
+ for (const id of record.fact.requirementIds)
3038
+ if (typeof id === "string")
3039
+ byRequirement.set(id, record.fact);
3040
+ }
3041
+ return byRequirement;
3042
+ };
2912
3043
  const isSourceDeclared = (candidate) => {
2913
3044
  const normalized = candidate.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
2914
3045
  return sourceDeclaredPaths.some((declared) => declared === normalized || declared.startsWith(`${normalized}/`));
@@ -3001,6 +3132,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3001
3132
  description: "Commit an origin=scout target-surface fact with complete/blocked discovery status. A complete surface needs a proven target path and no unresolved paths; blocked surfaces name the unresolved paths instead of guessing. Example: {\"completeness\": \"complete\", \"entrypoint\": \"<file>\", \"implementationPaths\": [\"<dir or file>\"], \"testPaths\": [\"<file>\"], \"allowedPathConflicts\": [], \"unresolvedPaths\": []}",
3002
3133
  promptSnippet: "Commit an origin=scout target-surface fact.",
3003
3134
  parameters: Type.Object({
3135
+ scopeId: Type.Optional(Type.String({ minLength: 1 })),
3004
3136
  completeness: scoutCompleteness,
3005
3137
  entrypoint: optionalString,
3006
3138
  routeOrMount: optionalString,
@@ -3011,6 +3143,8 @@ export async function createFrontendScoutEvidenceTools(input) {
3011
3143
  unresolvedPaths: stringArray,
3012
3144
  }, { additionalProperties: false }),
3013
3145
  async execute(_toolCallId, params) {
3146
+ if (input.requirementIds && (!activeScope?.length || params.scopeId !== scopeIdentity(activeScope)))
3147
+ return receipt({ ok: false, code: "FRONTEND_INPUT_SCOPE_VIOLATION", error: "Use exactly the runtime Scout scopeId; discovery may complete only the supplied obligations" });
3014
3148
  const implementationPaths = params?.implementationPaths ?? [];
3015
3149
  const testPaths = params?.testPaths ?? [];
3016
3150
  const pathEvidence = await enrichScoutPathEvidence([
@@ -3018,7 +3152,7 @@ export async function createFrontendScoutEvidenceTools(input) {
3018
3152
  ...implementationPaths,
3019
3153
  ...testPaths,
3020
3154
  ]);
3021
- const result = await adoptScoutFact("target-surface", {
3155
+ const surface = {
3022
3156
  kind: "target-surface",
3023
3157
  origin: "scout",
3024
3158
  completeness: params?.completeness ?? "blocked",
@@ -3031,7 +3165,27 @@ export async function createFrontendScoutEvidenceTools(input) {
3031
3165
  unresolvedPaths: params?.unresolvedPaths ?? [],
3032
3166
  ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}),
3033
3167
  ...(pathEvidence.length > 0 ? { pathEvidence } : {}),
3034
- });
3168
+ };
3169
+ if (activeScope) {
3170
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
3171
+ if (surface.completeness === "complete") {
3172
+ const check = readCompleteScoutTargetSurface([{ phase: "committed", fact: surface }]);
3173
+ if (!check.ok)
3174
+ return receipt({ ok: false, code: "SCOUT_SCOPE_INCOMPLETE", error: check.reason });
3175
+ }
3176
+ const saved = await adoptScoutFact("scout-scope", { kind: "scout-scope", origin: "scout", id: params.scopeId, requirementIds: activeScope, surface });
3177
+ if (!saved.ok)
3178
+ return receipt(saved);
3179
+ const current = latestScopes();
3180
+ if (input.requirementIds?.every(id => current.get(id)?.surface?.completeness === "complete")) {
3181
+ const surfaces = [...new Set(input.requirementIds.map(id => current.get(id)))].map(f => f.surface);
3182
+ const union = (key) => [...new Set(surfaces.flatMap(s => Array.isArray(s[key]) ? s[key] : []))];
3183
+ const entries = [...new Set(surfaces.map(s => String(s.entrypoint ?? "")).filter(Boolean))];
3184
+ return receipt(await adoptScoutFact("target-surface", { kind: "target-surface", origin: "scout", completeness: "complete", entrypoint: entries[0] ?? "", implementationPaths: [...new Set([...entries, ...union("implementationPaths")])], testPaths: union("testPaths"), allowedPathConflicts: union("allowedPathConflicts"), unresolvedPaths: union("unresolvedPaths"), routeOrMount: [...new Set(surfaces.map(s => s.routeOrMount).filter(Boolean))].join("\n"), dataSource: [...new Set(surfaces.map(s => s.dataSource).filter(Boolean))].join("\n"), pathEvidence: surfaces.flatMap(s => s.pathEvidence ?? []), ...(hasSourceDeclarations ? { sourceDeclaredPaths } : {}) }));
3185
+ }
3186
+ return receipt(saved);
3187
+ }
3188
+ const result = await adoptScoutFact("target-surface", surface);
3035
3189
  return receipt(result);
3036
3190
  },
3037
3191
  });
@@ -3055,10 +3209,30 @@ export async function createFrontendScoutEvidenceTools(input) {
3055
3209
  return receipt(result);
3056
3210
  },
3057
3211
  });
3212
+ const durable = await createDurableFrontendTools({
3213
+ file: path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), attemptId, store: input.store,
3214
+ setWorkingStore: next => { store = next; }, binding: { requirementIds: input.requirementIds, sourceDeclaredPaths: input.sourceDeclaredPaths, sourceDigest: input.sourceDigest, workspaceRoot: input.workspaceRoot },
3215
+ tools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
3216
+ validateRestored: async (records) => {
3217
+ if (!input.workspaceRoot)
3218
+ return;
3219
+ for (const record of records) {
3220
+ const evidence = record.fact.kind === "scout-scope" ? record.fact.surface?.pathEvidence : record.fact.pathEvidence;
3221
+ if (!Array.isArray(evidence))
3222
+ continue;
3223
+ for (const previous of evidence) {
3224
+ if (!isRecordObject(previous) || typeof previous.path !== "string")
3225
+ throw Error("scout path evidence is malformed");
3226
+ const current = (await enrichScoutPathEvidence([previous.path]))[0];
3227
+ if (!current || current.sha256 !== previous.sha256 || current.fresh !== previous.fresh)
3228
+ throw Error(`scout evidence drift: ${previous.path}; refresh Scout before reusing facts`);
3229
+ }
3230
+ }
3231
+ },
3232
+ });
3058
3233
  return {
3059
- customTools: [recordTargetSurfaceTool, recordDesignEvidenceTool],
3060
- committedFacts: () => readCommittedEvents(store, attemptId),
3061
- adoptCommittedFacts: async (records) => {
3234
+ ...durable,
3235
+ adoptCommittedFacts: async (records) => durable.commitExternal(async () => {
3062
3236
  for (const record of records) {
3063
3237
  if (record.phase !== "committed")
3064
3238
  continue;
@@ -3072,11 +3246,16 @@ export async function createFrontendScoutEvidenceTools(input) {
3072
3246
  throw new Error(`frontend scout shard fact merge failed: ${result.error}`);
3073
3247
  }
3074
3248
  }
3249
+ }),
3250
+ setActiveScope: ids => {
3251
+ if (!ids.length || ids.some(id => !input.requirementIds?.includes(id)))
3252
+ throw Error("FRONTEND_INPUT_SCOPE_VIOLATION");
3253
+ activeScope = [...new Set(ids)];
3254
+ return scopeIdentity(activeScope);
3075
3255
  },
3076
- flush: async () => {
3077
- const committed = readCommittedEvents(store, attemptId);
3078
- await writeTypedEventStoreJsonl(path.join(input.runDir, input.nodeId, "scout-typed-facts.jsonl"), committed);
3079
- },
3256
+ completedRequirementIds: () => new Set([...latestScopes()].filter(([, fact]) => fact.surface?.completeness === "complete").map(([id]) => id)),
3257
+ completedScopeFacts: () => [...new Set(latestScopes().values())].filter(f => f.surface?.completeness === "complete"),
3258
+ committedFacts: () => readCommittedEvents(store, attemptId),
3080
3259
  };
3081
3260
  }
3082
3261
  export function buildDagPiUserMessage(task, persona, step) {
@@ -3327,15 +3506,14 @@ async function runFrontendReviewTerminalShadow(input) {
3327
3506
  return input.mapped;
3328
3507
  const { compareTypedReviewToLegacyJsonVerdict } = await import("../workflows/dag/frontend-review-context.js");
3329
3508
  const { parseJsonReviewVerdict } = await import("../workflows/dag/output-protocol.js");
3330
- const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
3331
3509
  let typedKinds = [];
3332
3510
  try {
3333
- const content = await readFile(sessionEventsPath, "utf8");
3334
- typedKinds = scanReviewTerminalKindsFromSessionEvents(content);
3511
+ await input.tools?.flush();
3512
+ input.tools?.scopeProtocol?.assertComplete();
3513
+ typedKinds = input.tools?.scopeProtocol?.committedFacts().filter(r => ["approve_review", "request_review_changes"].includes(String(r.fact.kind))).map(r => String(r.fact.kind)) ?? [];
3335
3514
  }
3336
- catch {
3337
- // Missing/unreadable session log fail-closed at zero terminal facts.
3338
- typedKinds = [];
3515
+ catch (error) {
3516
+ return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3339
3517
  }
3340
3518
  let legacyVerdict;
3341
3519
  try {
@@ -3361,13 +3539,7 @@ async function runFrontendReviewTerminalShadow(input) {
3361
3539
  reason: `typed review equivalence comparison crashed: ${error instanceof Error ? error.message : String(error)}`,
3362
3540
  };
3363
3541
  }
3364
- // Audit-only flush + artifact. Neither blocks the node.
3365
- try {
3366
- await input.tools?.flush?.();
3367
- }
3368
- catch {
3369
- // best-effort
3370
- }
3542
+ // The durable ledger was validated above; this artifact is audit-only.
3371
3543
  try {
3372
3544
  await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-review-status.json", {
3373
3545
  schemaVersion: 1,
@@ -3409,23 +3581,16 @@ async function runFrontendDesignTerminalShadow(input) {
3409
3581
  // diagnosed as an omitted terminal tool call.
3410
3582
  if (!input.mapped.ok)
3411
3583
  return input.mapped;
3412
- const sessionEventsPath = path.join(input.meta.runDir, input.task.id, "session-events.jsonl");
3413
3584
  let typedKinds = [];
3414
3585
  try {
3415
- const content = await readFile(sessionEventsPath, "utf8");
3416
- typedKinds = scanDesignTerminalKindsFromSessionEvents(content);
3586
+ await input.tools?.flush();
3587
+ input.tools?.scopeProtocol?.assertComplete();
3588
+ typedKinds = input.tools?.scopeProtocol?.committedFacts().filter(r => ["approve_design", "request_design_changes"].includes(String(r.fact.kind))).map(r => String(r.fact.kind)) ?? [];
3417
3589
  }
3418
- catch {
3419
- // Missing/unreadable session log fail-closed at zero terminal facts.
3420
- typedKinds = [];
3421
- }
3422
- // Audit-only flush + artifact. Neither blocks the node.
3423
- try {
3424
- await input.tools?.flush?.();
3425
- }
3426
- catch {
3427
- // best-effort
3590
+ catch (error) {
3591
+ return { ...input.mapped, ok: false, failureCategory: "frontend-ledger-invalid", stderr: `FRONTEND_REVIEW_SCOPE_INCOMPLETE: ${error instanceof Error ? error.message : String(error)}` };
3428
3592
  }
3593
+ // The durable ledger was validated above; this artifact is audit-only.
3429
3594
  try {
3430
3595
  await writeDagNodeJsonArtifact(input.meta.runDir, input.task.id, "fact-design-status.json", {
3431
3596
  schemaVersion: 1,
@@ -3458,6 +3623,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3458
3623
  id: "coverage",
3459
3624
  toolNames: new Set([
3460
3625
  "record_plan_requirement",
3626
+ "record_plan_group_coverage",
3461
3627
  "record_plan_verification_target",
3462
3628
  "record_plan_evidence_gap",
3463
3629
  "adopt_staged_fact",
@@ -3474,13 +3640,14 @@ const FRONTEND_PLAN_SEGMENTS = [
3474
3640
  toolNames: new Set(["record_state_registry", "adopt_staged_fact"]),
3475
3641
  instruction: [
3476
3642
  "PLAN PHASE — global UX vocabulary.",
3477
- "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.",
3643
+ "Bootstrap the global UX vocabulary from the execution-group index and authoritative declared states. This is navigation, not permission to decide unseen behavior. Detailed complete scopes may extend the registry with replace:true while preserving live names. 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.",
3478
3644
  "Do not call finalize_plan; it is not available in this phase.",
3479
3645
  ].join(" "),
3480
3646
  },
3481
3647
  {
3482
3648
  id: "ux-local",
3483
3649
  toolNames: new Set([
3650
+ "record_state_registry",
3484
3651
  "record_component_choice",
3485
3652
  "record_state_flow",
3486
3653
  "record_plan_verification_target",
@@ -3488,7 +3655,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3488
3655
  ]),
3489
3656
  instruction: [
3490
3657
  "PLAN PHASE — global UX decisions.",
3491
- "Requirements and verification targets are already committed in the ledger. Review the complete requirement set and the committed global UX registry together, then record each component choice, UI state and interaction. Bind each applicable state to its verificationTargetIds; the runtime derives the reverse VT.uiStates relation. If a VT requires correction, record_plan_verification_target with replace:true is available after declaring its states; preserve its requirement coverage. Multiple requirements describing one behavior share one registry name and state-flow entry. Cross-cutting data flow belongs to the global Mock/data phase. Do not record routes, Mock/API policy, dependencies, or design deviations here.",
3658
+ "Requirements and verification targets are already committed in the ledger; do not re-record unchanged facts. Review the current complete execution-group scope and the committed global UX registry together, then record each component choice, UI state and interaction exactly once. Bind each applicable state to its verificationTargetIds; the runtime derives the reverse VT.uiStates relation. If a VT requires correction, record_plan_verification_target with replace:true is available after declaring its states; preserve its requirement coverage. Multiple requirements describing one behavior share one registry name and 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.",
3492
3659
  "Do not call finalize_plan; it is not available in this phase.",
3493
3660
  ].join(" "),
3494
3661
  },
@@ -3502,7 +3669,7 @@ const FRONTEND_PLAN_SEGMENTS = [
3502
3669
  },
3503
3670
  {
3504
3671
  id: "global-mock-data",
3505
- toolNames: new Set(["record_data_flow", "record_mock_api", "adopt_staged_fact"]),
3672
+ toolNames: new Set(["record_data_flow", "record_mock_api", "record_mock_endpoint", "adopt_staged_fact"]),
3506
3673
  instruction: [
3507
3674
  "PLAN PHASE — global Mock/API and data policy.",
3508
3675
  "Record the cross-cutting interaction-to-endpoint data flow and Mock/API strategy only. Keep this decision set separate from route, component, state, dependency, and deviation facts. Do not call finalize_plan.",
@@ -3668,7 +3835,8 @@ export function collectFrontendPlanPhaseMissingFacts(input) {
3668
3835
  return missing;
3669
3836
  }
3670
3837
  const hasMockApi = facts.some((fact) => fact.kind === "mock-api");
3671
- const liveInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
3838
+ const allInteractions = collectCanonicalStateFlowNames(input.committedFacts).interactionNames;
3839
+ const liveInteractions = new Set([...collectCanonicalStateFlowNames(facts.filter(f => !planFactStringList(f.scopeRequirementIds).length || planFactScopeIntersects(f, new Set(input.requirementIds)))).interactionNames].filter(name => allInteractions.has(name)));
3672
3840
  const coveredInteractions = new Set(facts
3673
3841
  .filter((fact) => fact.kind === "data-flow")
3674
3842
  .flatMap((fact) => planFactStringList(fact.interactions)));
@@ -3754,21 +3922,15 @@ async function mapWithConcurrency(items, limit, worker) {
3754
3922
  }));
3755
3923
  return results;
3756
3924
  }
3757
- function compactPromptString(value, maxChars) {
3758
- if (typeof value !== "string" || value.trim().length === 0)
3759
- return undefined;
3760
- const normalized = value.trim();
3761
- return normalized.length <= maxChars
3762
- ? normalized
3763
- : `${normalized.slice(0, maxChars - 1)}…`;
3925
+ function compactPromptString(value, _maxChars) {
3926
+ return typeof value === "string" && value.trim().length ? value.trim() : undefined;
3764
3927
  }
3765
- function compactPromptStringArray(value, maxEntries = 12, maxChars = 180) {
3928
+ function compactPromptStringArray(value, _maxEntries = 12, maxChars = 180) {
3766
3929
  if (!Array.isArray(value))
3767
3930
  return [];
3768
3931
  return value
3769
3932
  .map((item) => compactPromptString(item, maxChars))
3770
- .filter((item) => item !== undefined)
3771
- .slice(0, maxEntries);
3933
+ .filter((item) => item !== undefined);
3772
3934
  }
3773
3935
  function countFrontendPlanTargetSurfaces(basePrompt) {
3774
3936
  const match = /<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/.exec(basePrompt);
@@ -3841,7 +4003,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3841
4003
  }
3842
4004
  }
3843
4005
  if (!payload || !Array.isArray(payload.requirements))
3844
- return basePrompt;
4006
+ throw Error("FRONTEND_INPUT_INVALID: plan inventory is not parseable");
3845
4007
  const requirementsById = new Map();
3846
4008
  for (const value of payload.requirements) {
3847
4009
  if (!value || typeof value !== "object" || Array.isArray(value))
@@ -3853,7 +4015,7 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3853
4015
  }
3854
4016
  const requirements = slice.map((id) => requirementsById.get(id));
3855
4017
  if (requirements.some((requirement) => requirement === undefined)) {
3856
- return basePrompt;
4018
+ throw Error(`FRONTEND_INPUT_SCOPE_MISSING: ${slice.filter(id => !requirementsById.has(id)).join(", ")}`);
3857
4019
  }
3858
4020
  const compactRequirements = requirements.map((requirement) => ({
3859
4021
  id: compactPromptString(requirement.id, 80),
@@ -3961,6 +4123,9 @@ export function compactFrontendPlanPromptForRequirementSlice(basePrompt, require
3961
4123
  ? payload.committedUx
3962
4124
  : undefined;
3963
4125
  const compactPayload = {
4126
+ inputManifest: { ...projectFrontendInputScope({ ...payload, requirements: [...requirementsById.values()] }, slice).inputManifest, semantics: options.includeRequirementText === false ? "navigation-only" : "full" },
4127
+ constraints: payload.constraints,
4128
+ executionGroups: Array.isArray(payload.executionGroups) ? payload.executionGroups.filter(g => isRecordObject(g) && Array.isArray(g.requirementIds) && g.requirementIds.some(id => slice.includes(String(id)))) : [],
3964
4129
  requirements: compactRequirements,
3965
4130
  requiredDeliverables: Array.isArray(payload.requiredDeliverables) ? payload.requiredDeliverables : [],
3966
4131
  ...(compactTargetSurface.length > 0
@@ -4082,7 +4247,7 @@ function compactFrontendPlanLedgerContext(input) {
4082
4247
  covers: compactPromptStringArray(item.covers, 40, 120),
4083
4248
  evidencePath: compactPromptString(item.evidencePath, 180),
4084
4249
  }];
4085
- }).slice(0, 24)
4250
+ })
4086
4251
  : [];
4087
4252
  if (choices.length > 0)
4088
4253
  compactFacts.push({ kind: fact.kind, uiComponentChoices: choices });
@@ -4099,8 +4264,8 @@ function compactFrontendPlanLedgerContext(input) {
4099
4264
  if (fact.kind === "state-flow") {
4100
4265
  compactFacts.push({
4101
4266
  kind: fact.kind,
4102
- uiStates: Array.isArray(fact.uiStates) ? fact.uiStates.slice(0, 24) : [],
4103
- interactions: Array.isArray(fact.interactions) ? fact.interactions.slice(0, 24) : [],
4267
+ uiStates: Array.isArray(fact.uiStates) ? fact.uiStates : [],
4268
+ interactions: Array.isArray(fact.interactions) ? fact.interactions : [],
4104
4269
  removeUiStateNames: compactPromptStringArray(fact.removeUiStateNames, 24, 100),
4105
4270
  removeInteractionNames: compactPromptStringArray(fact.removeInteractionNames, 24, 100),
4106
4271
  });
@@ -4123,7 +4288,7 @@ function compactFrontendPlanLedgerContext(input) {
4123
4288
  mockApi: {
4124
4289
  strategy: compactPromptString(mockApi.strategy, 40),
4125
4290
  activation: compactPromptString(mockApi.activation, 180),
4126
- endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints.slice(0, 24) : [],
4291
+ endpoints: Array.isArray(mockApi.endpoints) ? mockApi.endpoints : [],
4127
4292
  },
4128
4293
  });
4129
4294
  continue;
@@ -4145,9 +4310,9 @@ function compactFrontendPlanLedgerContext(input) {
4145
4310
  const priorityFacts = compactFacts.filter((fact) => fact.kind === "plan-requirement" || fact.kind === "plan-verification-target");
4146
4311
  const otherFacts = compactFacts.filter((fact) => fact.kind !== "plan-requirement" && fact.kind !== "plan-verification-target");
4147
4312
  const boundedFacts = [
4148
- ...priorityFacts.slice(0, 64),
4149
- ...otherFacts.slice(-32),
4150
- ].slice(0, 96);
4313
+ ...priorityFacts,
4314
+ ...otherFacts,
4315
+ ];
4151
4316
  return [
4152
4317
  "<frontend_plan_ledger>",
4153
4318
  "Committed plan facts from earlier sessions. Treat these as authoritative; correct them only with the allowed replacement/removal fields.",
@@ -4155,6 +4320,208 @@ function compactFrontendPlanLedgerContext(input) {
4155
4320
  "</frontend_plan_ledger>",
4156
4321
  ].join("\n");
4157
4322
  }
4323
+ export async function runFrontendReviewSegmentedSessions(input) {
4324
+ const protocol = input.tools.scopeProtocol;
4325
+ const terminalKinds = input.phase === "review" ? ["approve_review", "request_review_changes"] : ["approve_design", "request_design_changes"];
4326
+ const terminal = () => protocol.committedFacts().some(r => terminalKinds.includes(String(r.fact.kind)));
4327
+ const targetBytes = input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4328
+ const queue = packFrontendInputUnits(input.inventory.scopes.filter(s => !protocol.completedScopeIds().has(s.id)), { targetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits }).map(scopes => ({ scopes, repairs: 0 }));
4329
+ if (!queue.length)
4330
+ queue.push({ scopes: [], repairs: 0 });
4331
+ let calls = 0;
4332
+ let last = { ok: true, assistantText: "", command: [], durationMs: 0, exitCode: 0, failureCategory: "success", modelDisplay: "unknown", parsedEvents: 0, stderr: "", stdout: "", timedOut: false, attemptedModels: [], fallbackUsed: false, tokensUsed: 0 };
4333
+ for (let index = 0; index < queue.length; index++) {
4334
+ await input.tools.flush();
4335
+ await input.inventory.validate();
4336
+ if (terminal()) {
4337
+ protocol.assertComplete();
4338
+ return last;
4339
+ }
4340
+ const item = queue[index];
4341
+ const scopes = item.scopes.filter(s => !protocol.completedScopeIds().has(s.id));
4342
+ protocol.setActiveScope(scopes.map(s => s.id));
4343
+ const finalScope = input.inventory.scopes.every(s => protocol.completedScopeIds().has(s.id) || scopes.some(current => current.id === s.id));
4344
+ const customTools = finalScope ? input.customTools : input.customTools.filter(t => !terminalKinds.includes(String(t.name)));
4345
+ const prompt = `${input.basePrompt}\n<frontend_review_scope>\n${JSON.stringify({ semantics: "full", inventoryDigest: input.inventory.digest, scopes, previouslyCompleted: [...protocol.completedScopeIds()], savedFindings: protocol.committedFacts().filter(r => String(r.fact.kind).endsWith("-finding")).map(r => ({ id: r.fact.id, finding: r.fact.finding })) })}\n</frontend_review_scope>\nReview the complete supplied scopes, saving each finding immediately. Call complete_review_scope for each exact id only after all its independent permissions, thresholds, errors and evidence have been checked. ${finalScope ? "After every scope is complete, make one independent overall approve/request_changes decision; persisted findings cannot be omitted." : "More scopes remain. Do not finalize or reread already completed scopes unless resolving a cross-scope issue."}`;
4346
+ if (Buffer.byteLength(prompt) > targetBytes && scopes.length > 1) {
4347
+ const at = Math.ceil(scopes.length / 2);
4348
+ queue.splice(index, 1, { scopes: scopes.slice(0, at), repairs: 0 }, { scopes: scopes.slice(at), repairs: 0 });
4349
+ index--;
4350
+ continue;
4351
+ }
4352
+ if (++calls > 128)
4353
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_RECOVERY_EXHAUSTED: session quota reached" };
4354
+ last = await observeFrontendSession({ ...input.observation, phase: `${input.phase}/scope`, scopeIds: scopes.map(s => s.id), prompt, userMessage: input.sessionOptions.userMessage, customTools,
4355
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4356
+ committedCount: () => protocol.committedFacts().length, durableCommittedCount: () => protocol.committedFacts().length,
4357
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4358
+ await input.tools.flush();
4359
+ await input.inventory.validate();
4360
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4361
+ return { ...last, ok: false };
4362
+ const capacity = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4363
+ const missing = scopes.filter(s => !protocol.completedScopeIds().has(s.id));
4364
+ if (capacity && (missing.length || !terminal() && finalScope)) {
4365
+ if (missing.length === 1 && scopes.length === 1 || !missing.length && !scopes.length)
4366
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_INPUT_UNIT_TOO_LARGE: complete review unit or terminal exhausted" };
4367
+ const at = Math.ceil(missing.length / 2);
4368
+ const smaller = missing.length ? [missing.slice(0, at), missing.slice(at)].filter(s => s.length) : [[]];
4369
+ queue.splice(index, 1, ...smaller.map(scopes => ({ scopes, repairs: 0 })));
4370
+ index--;
4371
+ continue;
4372
+ }
4373
+ if (!last.ok && !capacity)
4374
+ return last;
4375
+ if (missing.length || finalScope && !terminal()) {
4376
+ if (item.repairs >= 1)
4377
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: required checkpoint or verdict missing" };
4378
+ queue.splice(index, 1, { scopes: missing, repairs: item.repairs + 1 });
4379
+ index--;
4380
+ }
4381
+ }
4382
+ protocol.assertComplete();
4383
+ return terminal() ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "FRONTEND_REVIEW_SCOPE_INCOMPLETE: missing independent verdict" };
4384
+ }
4385
+ export async function runFrontendScoutSegmentedSessions(input) {
4386
+ const inventory = parseFrontendInputBlock(input.basePrompt, "scout");
4387
+ if (!inventory)
4388
+ throw Error("FRONTEND_INPUT_MISSING: Scout compiled inventory unavailable");
4389
+ const units = collectFrontendExecutionGroups(inventory.payload.requirements).map(group => ({ ...group, id: `${group.kind === "unclassified" ? "requirement" : "group"}:${group.id}`, requirements: inventory.payload.requirements.filter(r => group.requirementIds.includes(r.id)) }));
4390
+ const targetBytes = input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES;
4391
+ const queue = packFrontendInputUnits(units, { targetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits }).map(batch => ({ groups: batch, repairs: 0 }));
4392
+ let last = { ok: true, assistantText: "", command: [], durationMs: 0, exitCode: 0, failureCategory: "success", modelDisplay: input.sessionOptions.modelConfig?.model ?? "unknown", parsedEvents: 0, stderr: "", stdout: "", timedOut: false, attemptedModels: [], fallbackUsed: false, tokensUsed: 0 };
4393
+ let calls = 0;
4394
+ for (let index = 0; index < queue.length; index++) {
4395
+ await input.tools.flush();
4396
+ const item = queue[index];
4397
+ const completed = input.tools.completedRequirementIds();
4398
+ const groups = item.groups.filter(g => g.requirementIds.some(id => !completed.has(id)));
4399
+ if (!groups.length)
4400
+ continue;
4401
+ const ids = groups.flatMap(g => g.requirementIds);
4402
+ const scopeId = input.tools.setActiveScope(ids);
4403
+ const prompt = input.basePrompt.replace(inventory.block, `<frontend_scout_input>\n${JSON.stringify(projectFrontendInputScope(inventory.payload, ids))}\n</frontend_scout_input>`) +
4404
+ `\nSCOUT SCOPE ${scopeId}: discover the related surfaces for exactly these complete obligations: ${ids.join(", ")}. Reuse proven paths from completed scope navigation; do not reread their content unless relevant new evidence is needed. Submit record_target_surface with scopeId="${scopeId}" after all discovery/design evidence for this scope. completeness=complete closes only this scope; runtime merges all scopes.\n` +
4405
+ JSON.stringify({ completedScopePaths: input.tools.completedScopeFacts().map(f => ({ id: f.id, requirementIds: f.requirementIds, paths: f.surface?.implementationPaths })) });
4406
+ const envelopeBytes = Buffer.byteLength(prompt + input.sessionOptions.userMessage + JSON.stringify(input.customTools.map(t => { const tool = t; return { name: tool.name, description: tool.description, parameters: tool.parameters }; })));
4407
+ if (envelopeBytes > targetBytes && groups.length > 1) {
4408
+ const at = Math.ceil(groups.length / 2);
4409
+ queue.splice(index, 1, { groups: groups.slice(0, at), repairs: 0 }, { groups: groups.slice(at), repairs: 0 });
4410
+ index--;
4411
+ continue;
4412
+ }
4413
+ if (++calls > 128)
4414
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "SCOUT_RECOVERY_EXHAUSTED: shared session quota reached" };
4415
+ last = await observeFrontendSession({ ...input.observation, phase: "scout/scope", scopeIds: ids, prompt, userMessage: input.sessionOptions.userMessage, customTools: input.customTools,
4416
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${calls}.json` : undefined,
4417
+ committedCount: () => input.tools.committedFacts().length, durableCommittedCount: () => input.tools.committedFacts().length,
4418
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools: input.customTools } }));
4419
+ await input.tools.flush();
4420
+ const missing = groups.filter(g => g.requirementIds.some(id => !input.tools.completedRequirementIds().has(id)));
4421
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4422
+ return { ...last, ok: false };
4423
+ const capacity = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4424
+ if (capacity && missing.length) {
4425
+ if (missing.length === 1 && groups.length === 1)
4426
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: Scout ${missing[0].id}; refine the complete source unit; unchanged retries disabled` };
4427
+ const at = Math.ceil(missing.length / 2);
4428
+ queue.splice(index, 1, ...[missing.slice(0, at), missing.slice(at)].filter(batch => batch.length).map(batch => ({ groups: batch, repairs: 0 })));
4429
+ index--;
4430
+ continue;
4431
+ }
4432
+ if (!last.ok && !capacity)
4433
+ return last;
4434
+ if (missing.length) {
4435
+ if (item.repairs >= 1)
4436
+ return { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: "SCOUT_SCOPE_INCOMPLETE: unresolved discovery remains after local correction" };
4437
+ queue.splice(index, 1, { groups: missing, repairs: item.repairs + 1 });
4438
+ index--;
4439
+ continue;
4440
+ }
4441
+ }
4442
+ await input.tools.flush();
4443
+ const { readCompleteScoutTargetSurface } = await import("../workflows/dag/frontend-shadow-dual-write.js");
4444
+ const closure = readCompleteScoutTargetSurface(input.tools.committedFacts());
4445
+ return closure.ok ? { ...last, ok: true, failureCategory: "success" } : { ...last, ok: false, failureCategory: "frontend-input-exhausted", stderr: closure.reason };
4446
+ }
4447
+ export async function runFrontendContractSegmentedSessions(input) {
4448
+ // Build from the frozen runtime inventory if the caller has not rendered it yet.
4449
+ const basePrompt = parseFrontendInputBlock(input.basePrompt, "contract") ? input.basePrompt : input.basePrompt +
4450
+ `\n<frontend_contract_input>\nFrozen complete source obligations.\n${JSON.stringify({ requirements: input.tools.inputRequirements() })}\n</frontend_contract_input>`;
4451
+ const inventory = parseFrontendInputBlock(basePrompt, "contract");
4452
+ const pending = inventory.payload.requirements.filter(r => !input.tools.completedScopeRequirementIds().has(r.id));
4453
+ const batches = packFrontendInputUnits(pending, { targetBytes: input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes, maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits });
4454
+ if (!batches.length)
4455
+ batches.push([]);
4456
+ let last;
4457
+ let invocation = 0;
4458
+ const maxSessions = batches.length * 3 + 2;
4459
+ const terminal = () => input.tools.committedFacts().some(r => r.fact.kind === "contract-finalized");
4460
+ try {
4461
+ for (let index = 0; index < batches.length; index += 1) {
4462
+ let scopeIds = batches[index].map(r => r.id);
4463
+ const finalScope = index === batches.length - 1;
4464
+ for (let repair = 0; repair < 2; repair += 1) {
4465
+ // Confirmed IDs remain visible until the model commits a scope checkpoint.
4466
+ const completed = input.tools.completedScopeRequirementIds();
4467
+ scopeIds = scopeIds.filter(id => !completed.has(id));
4468
+ input.tools.setActiveRequirementScope(scopeIds);
4469
+ const groupIndex = collectFrontendExecutionGroups(input.tools.committedFacts().filter(r => r.fact.kind === "requirement").map(r => ({ id: String(r.fact.id), execution: r.fact.execution })));
4470
+ const shared = input.tools.committedFacts().filter(r => !["requirement", "contract-finalized", "contract-scope-completed"].includes(String(r.fact.kind))).map(r => r.fact);
4471
+ const prompt = projectFrontendContractPrompt(basePrompt, scopeIds) +
4472
+ `\nCONTRACT SCOPE: analyze only ${scopeIds.join(", ") || "(all scopes complete; verify global facts and terminal)"}. Each obligation is complete. Submit small records immediately, then call complete_contract_scope after ALL decisions for this scope. ` +
4473
+ (finalScope ? "After complete scope coverage and source-bound deliverables, call finalize_contract. Correct rejected calls and retry." : "Do not finalize; subsequent complete scopes remain.") +
4474
+ `\n<committed_contract_facts>\n${JSON.stringify({ facts: shared, executionGroups: groupIndex })}\n</committed_contract_facts>`;
4475
+ const customTools = input.tools.customTools.filter(t => finalScope || t.name !== "finalize_contract");
4476
+ invocation += 1;
4477
+ if (invocation > maxSessions)
4478
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: "CONTRACT_RECOVERY_EXHAUSTED: session quota exceeded" };
4479
+ last = await observeFrontendSession({
4480
+ ...input.observation, phase: "contract/scope", scopeIds, prompt, userMessage: input.sessionOptions.userMessage, customTools,
4481
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocation}.json` : undefined,
4482
+ committedCount: () => input.tools.committedFacts().length, durableCommittedCount: () => input.tools.committedFacts().length,
4483
+ }, observer => input.piStepFn({ ...input.sessionOptions, prompt, onAttemptObservation: observer, writerToolPolicy: { requireSdk: true, customTools } }));
4484
+ await input.tools.flush();
4485
+ if (last.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(last.failureCategory))
4486
+ return { ...last, ok: false };
4487
+ const capacityExhausted = readWriterThinkingExhaustionEvidence(last).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(last.failureCategory);
4488
+ if (capacityExhausted && !last.timedOut) {
4489
+ const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4490
+ if (!missing.length) {
4491
+ if (finalScope && !terminal()) {
4492
+ if (!scopeIds.length)
4493
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: contract terminal exhausted; unchanged retry disabled` };
4494
+ batches.push([]);
4495
+ }
4496
+ break;
4497
+ }
4498
+ if (missing.length === 1 && scopeIds.length > 1) {
4499
+ batches.splice(index, 1, inventory.payload.requirements.filter(r => missing.includes(r.id)));
4500
+ index -= 1;
4501
+ break;
4502
+ }
4503
+ if (missing.length <= 1)
4504
+ return { ...last, ok: false, stderr: `${last.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${missing[0] ?? "contract terminal"}; atom could not complete; refine the source without dropping conditions` };
4505
+ const smaller = packFrontendInputUnits(inventory.payload.requirements.filter(r => missing.includes(r.id)), { maxUnits: Math.ceil(missing.length / 2) });
4506
+ batches.splice(index, 1, ...smaller);
4507
+ index -= 1;
4508
+ break;
4509
+ }
4510
+ if (!last.ok)
4511
+ return last;
4512
+ const missing = scopeIds.filter(id => !input.tools.completedScopeRequirementIds().has(id));
4513
+ if (!missing.length && (!finalScope || terminal()))
4514
+ break;
4515
+ if (repair === 1)
4516
+ return { ...last, ok: false, failureCategory: "invalid-output", stderr: `CONTRACT_SCOPE_INCOMPLETE: ${missing.join(", ") || "missing finalize_contract"}` };
4517
+ }
4518
+ }
4519
+ return last;
4520
+ }
4521
+ finally {
4522
+ input.tools.setActiveRequirementScope(null);
4523
+ }
4524
+ }
4158
4525
  export async function runFrontendPlanSegmentedSessions(input) {
4159
4526
  const queue = [];
4160
4527
  const coverageSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "coverage");
@@ -4163,10 +4530,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4163
4530
  const globalMockDataSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "global-mock-data");
4164
4531
  const finalizeSegment = FRONTEND_PLAN_SEGMENTS.find((segment) => segment.id === "finalize");
4165
4532
  const allRequirementIds = input.requirementIds ?? [];
4166
- const buildPhasePrompt = (segment, missing = []) => {
4533
+ const buildPhasePrompt = (segment, missing = [], scopeIds = allRequirementIds) => {
4167
4534
  const compact = allRequirementIds.length > 0
4168
- ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
4169
- includeRequirementText: segment.id === "global-mock-data" || segment.id === "global-dependency-deviation",
4535
+ ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, scopeIds, {
4536
+ includeRequirementText: segment.id === "global-mock-data",
4170
4537
  includeVerificationTargets: false,
4171
4538
  includeDesignEvidence: segment.id === "global-dependency-deviation" || segment.id === "finalize",
4172
4539
  includeChecklist: false,
@@ -4182,8 +4549,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
4182
4549
  const ledger = input.committedFacts
4183
4550
  ? compactFrontendPlanLedgerContext({
4184
4551
  committedFacts: input.committedFacts(),
4185
- requirementIds: allRequirementIds,
4552
+ requirementIds: scopeIds,
4186
4553
  kinds,
4554
+ ...(segment.id === "global-mock-data" ? { scopedKinds: ["state-flow"] } : {}),
4187
4555
  })
4188
4556
  : "";
4189
4557
  return [
@@ -4221,13 +4589,13 @@ export async function runFrontendPlanSegmentedSessions(input) {
4221
4589
  "component-choice",
4222
4590
  "state-flow",
4223
4591
  ],
4224
- scopedKinds: ["component-choice", "state-flow"],
4592
+ scopedKinds: ["state-flow"],
4225
4593
  })
4226
4594
  : "";
4227
4595
  return [
4228
4596
  compactFrontendPlanPromptForRequirementSlice(input.basePrompt, slice),
4229
4597
  uxSegment.instruction,
4230
- `GLOBAL UX SCOPE: process the complete requirement set together: ${slice.join(", ")}. Do not split or rename one behavior by requirement id.`,
4598
+ `UX SCOPE: process these complete behavior groups together: ${slice.join(", ")}. Reuse shared registry/component ownership; do not rename a behavior by requirement id. Constraint/exclusion groups do not create UI; preserve genuine verification gaps.`,
4231
4599
  ledger,
4232
4600
  ...(missing.length > 0
4233
4601
  ? [
@@ -4251,6 +4619,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4251
4619
  : "";
4252
4620
  const compact = allRequirementIds.length > 0
4253
4621
  ? compactFrontendPlanPromptForRequirementSlice(input.basePrompt, allRequirementIds, {
4622
+ includeRequirementText: false,
4254
4623
  includeVerificationTargets: false,
4255
4624
  includeDesignEvidence: false,
4256
4625
  includeChecklist: false,
@@ -4296,7 +4665,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4296
4665
  : []),
4297
4666
  ].filter(Boolean).join("\n\n");
4298
4667
  };
4299
- const compactFinalizeInstruction = "This is a small-request compact pass. Reconcile the committed local facts with route, data-flow, Mock/API, dependency and deviation policy, then call finalize_plan exactly once.";
4668
+ const compactFinalizeInstruction = "This is a small-request compact pass. Reconcile the committed local facts with route, data-flow, Mock/API, dependency and deviation policy, then call finalize_plan; correct rejected facts and retry until exactly one successful terminal commit.";
4300
4669
  const buildCompactFinalizePrompt = (missing = []) => [buildPhasePrompt(finalizeSegment, missing), compactFinalizeInstruction].join("\n\n");
4301
4670
  const mapPlannerExhaustion = (r, committedAnyFacts) => isPlannerThinkingExhausted(r, committedAnyFacts)
4302
4671
  ? {
@@ -4340,7 +4709,24 @@ export async function runFrontendPlanSegmentedSessions(input) {
4340
4709
  tokensUsed: 0,
4341
4710
  };
4342
4711
  }
4343
- const estimatedCalls = (input.requirementIds ?? []).reduce((total, id) => total + Math.max(1, input.requirementCosts?.get(id) ?? 2), 0);
4712
+ const compiledInput = parseFrontendInputBlock(input.basePrompt, "plan")?.payload;
4713
+ const fullUnits = new Map(compiledInput?.requirements.map(r => [r.id, r]) ?? []);
4714
+ const declaredGroups = Array.isArray(compiledInput?.executionGroups) ? compiledInput.executionGroups.filter(isRecordObject) : [];
4715
+ const workGroups = declaredGroups.length ? declaredGroups.map(g => ({ id: String(g.id), kind: String(g.kind), requirementIds: Array.isArray(g.requirementIds) ? g.requirementIds.filter((id) => typeof id === "string" && allRequirementIds.includes(id)) : [] })).filter(g => g.requirementIds.length) : allRequirementIds.map(id => ({ id, kind: "unclassified", requirementIds: [id] }));
4716
+ const groupedIds = new Set(workGroups.flatMap(g => g.requirementIds));
4717
+ for (const id of allRequirementIds)
4718
+ if (!groupedIds.has(id))
4719
+ workGroups.push({ id, kind: "unclassified", requirementIds: [id] });
4720
+ const workCost = (ids) => 1 + ids.reduce((total, id) => total + Math.max(1, (input.requirementCosts?.get(id) ?? 2) - 1), 0);
4721
+ const buildWorkBatches = (ids) => {
4722
+ const work = workGroups.flatMap((g, index) => {
4723
+ const members = g.requirementIds.filter(id => ids.includes(id));
4724
+ return members.length ? [{ id: `${index}:${g.id}`, requirementIds: members, requirements: members.map(id => fullUnits.get(id) ?? { id }), estimatedCalls: workCost(members) }] : [];
4725
+ });
4726
+ const scaffoldBytes = Buffer.byteLength(input.basePrompt.replace(/<frontend_plan_input>[\s\S]*?<\/frontend_plan_input>/, JSON.stringify({ ...compiledInput, requirements: [] }))) + Buffer.byteLength(JSON.stringify(input.segmentCustomTools(coverageSegment.toolNames)));
4727
+ return packFrontendInputUnits(work, { targetBytes: Math.max(1, (input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES) - scaffoldBytes), maxUnits: input.sessionOptions.frontendExecutionPolicy?.maxScopeUnits ?? 4, maxCost: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS, cost: g => g.estimatedCalls }).map(batch => batch.flatMap(g => g.requirementIds));
4728
+ };
4729
+ const estimatedCalls = workGroups.reduce((total, group) => total + workCost(group.requirementIds), 0);
4344
4730
  const targetSurfaceCount = countFrontendPlanTargetSurfaces(input.basePrompt);
4345
4731
  // Small, single-surface requests do not benefit from six isolated Pi
4346
4732
  // sessions. Keep the typed ledger as the authority, but let one local
@@ -4350,13 +4736,14 @@ export async function runFrontendPlanSegmentedSessions(input) {
4350
4736
  const useCompactSmallPlan = requirementIdsProvided &&
4351
4737
  input.compactSmallPlan === true &&
4352
4738
  input.requirementCosts !== undefined &&
4353
- estimatedCalls > 12 &&
4354
- (input.requirementIds?.length ?? 0) <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
4739
+ workGroups.length <= FRONTEND_PLAN_SMALL_MAX_REQUIREMENTS &&
4740
+ Buffer.byteLength(input.basePrompt) + Buffer.byteLength(JSON.stringify(input.segmentCustomTools(null))) <= (input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES) * 2 &&
4355
4741
  estimatedCalls <= FRONTEND_PLAN_SMALL_MAX_ESTIMATED_CALLS &&
4356
4742
  targetSurfaceCount === 1;
4357
4743
  if (useCompactSmallPlan) {
4358
4744
  const compactLocalTools = new Set([
4359
4745
  "record_plan_requirement",
4746
+ "record_plan_group_coverage",
4360
4747
  "record_plan_verification_target",
4361
4748
  "record_plan_evidence_gap",
4362
4749
  "record_state_registry",
@@ -4384,13 +4771,8 @@ export async function runFrontendPlanSegmentedSessions(input) {
4384
4771
  });
4385
4772
  }
4386
4773
  else if (coverageWorkIds.length > 0) {
4387
- const coverageBatches = batchFrontendPlanRequirements({
4388
- requirementIds: coverageWorkIds,
4389
- maxEstimatedRecordCalls: FRONTEND_PLAN_COVERAGE_MAX_RECORD_CALLS,
4390
- maxRequirements: 4,
4391
- requirementCosts: input.requirementCosts,
4392
- });
4393
- coverageBatches.forEach((slice, batchIndex) => queue.push({
4774
+ const completeBatches = buildWorkBatches(coverageWorkIds);
4775
+ completeBatches.forEach((slice, batchIndex) => queue.push({
4394
4776
  id: `coverage-batch-${batchIndex + 1}`,
4395
4777
  toolNames: coverageSegment.toolNames,
4396
4778
  coverageSlice: slice,
@@ -4409,28 +4791,27 @@ export async function runFrontendPlanSegmentedSessions(input) {
4409
4791
  prompt: buildUxRegistryPrompt(),
4410
4792
  });
4411
4793
  }
4412
- const uxSlice = requirementIdsProvided ? [...allRequirementIds] : [];
4413
- queue.push({
4414
- id: "ux-local-1",
4415
- // Compatibility path for an unreadable requirement inventory: there is
4416
- // no safe all-requirements registry phase, so the unscoped UX session
4417
- // must establish its registry before recording state flow.
4418
- toolNames: requirementIdsProvided
4419
- ? uxSegment.toolNames
4420
- : new Set([...(uxSegment.toolNames ?? []), "record_state_registry"]),
4421
- ...(uxSlice.length > 0 ? { requirementSlice: uxSlice } : {}),
4422
- prompt: uxSlice.length > 0
4423
- ? buildUxPrompt(uxSlice)
4424
- : buildPhasePrompt(uxSegment),
4425
- });
4794
+ const uxWorkIds = workGroups.filter(g => !["constraint", "exclusion"].includes(g.kind) || g.requirementIds.some(id => input.behaviorRequiredRequirementIds?.includes(id))).flatMap(g => g.requirementIds);
4795
+ const uxBatches = requirementIdsProvided ? buildWorkBatches(uxWorkIds) : [[]];
4796
+ uxBatches.forEach((slice, batchIndex) => queue.push({
4797
+ id: `ux-local-${batchIndex + 1}`,
4798
+ toolNames: requirementIdsProvided ? uxSegment.toolNames : new Set([...(uxSegment.toolNames ?? []), "record_state_registry"]),
4799
+ ...(slice.length ? { requirementSlice: slice } : {}),
4800
+ prompt: slice.length ? buildUxPrompt(slice) : buildPhasePrompt(uxSegment),
4801
+ }));
4426
4802
  for (const segment of FRONTEND_PLAN_SEGMENTS) {
4427
4803
  if (["coverage", "ux-registry", "ux-local", "finalize"].includes(segment.id))
4428
4804
  continue;
4429
- queue.push({
4430
- id: segment.id,
4431
- toolNames: segment.toolNames,
4432
- prompt: buildPhasePrompt(segment),
4433
- });
4805
+ if (segment.id === "global-mock-data" && requirementIdsProvided) {
4806
+ buildWorkBatches(allRequirementIds).forEach((slice, i) => queue.push({ id: `global-mock-data-${i + 1}`, toolNames: segment.toolNames, requirementSlice: slice, prompt: buildPhasePrompt(segment, [], slice) }));
4807
+ }
4808
+ else {
4809
+ queue.push({
4810
+ id: segment.id,
4811
+ toolNames: segment.toolNames,
4812
+ prompt: buildPhasePrompt(segment),
4813
+ });
4814
+ }
4434
4815
  }
4435
4816
  queue.push({
4436
4817
  id: finalizeSegment.id,
@@ -4441,6 +4822,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4441
4822
  let accumulated;
4442
4823
  let index = 0;
4443
4824
  let invocationCount = 0;
4825
+ let lastDurableCount = input.committedFactCount();
4444
4826
  while (index < queue.length) {
4445
4827
  const session = queue[index];
4446
4828
  const remaining = (session.coverageOnly ? session.coverageSlice ?? [] : []).filter((id) => !input.committedRequirementIds?.().has(id));
@@ -4460,7 +4842,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4460
4842
  }
4461
4843
  let prompt = session.prompt;
4462
4844
  if (session.coverageOnly && session.coverageSlice) {
4463
- const promptSlice = remaining.length > 0 ? remaining : session.coverageSlice;
4845
+ const promptSlice = [...new Set([...remaining, ...preexistingMissing.flatMap(item => item.requirementIds)])];
4464
4846
  prompt = buildCoveragePrompt(promptSlice, session.missingFacts ?? preexistingMissing);
4465
4847
  }
4466
4848
  else if (session.id === "compact-local") {
@@ -4480,45 +4862,61 @@ export async function runFrontendPlanSegmentedSessions(input) {
4480
4862
  // Global phases and finalize also consume the latest committed ledger;
4481
4863
  // constructing their prompt only when the session starts prevents a
4482
4864
  // stale queue entry from dropping facts written by earlier phases.
4483
- const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id);
4865
+ const segment = FRONTEND_PLAN_SEGMENTS.find((candidate) => candidate.id === session.id || (candidate.id === "global-mock-data" && session.id.startsWith("global-mock-data-")));
4484
4866
  if (segment) {
4485
4867
  prompt =
4486
4868
  session.id === "finalize" && useCompactSmallPlan
4487
4869
  ? buildCompactFinalizePrompt(session.missingFacts)
4488
- : buildPhasePrompt(segment, session.missingFacts);
4870
+ : buildPhasePrompt(segment, session.missingFacts, session.requirementSlice ?? allRequirementIds);
4489
4871
  }
4490
4872
  }
4491
4873
  const committedBefore = input.committedFactCount();
4492
- input.setActiveRequirementScope?.(session.coverageOnly ||
4493
- session.id === "compact-local" ||
4494
- session.id.startsWith("ux-local-")
4495
- ? session.coverageSlice ?? session.requirementSlice ?? []
4496
- : []);
4874
+ input.setActiveRequirementScope?.(session.coverageOnly ? session.coverageSlice ?? [] : session.requirementSlice ?? []);
4497
4875
  if (invocationCount >= FRONTEND_PLAN_BATCH_MAX_SESSIONS)
4498
4876
  break;
4499
4877
  invocationCount += 1;
4500
4878
  const customTools = input.segmentCustomTools(session.toolNames);
4501
- const result = await input.piStepFn({
4502
- ...input.sessionOptions,
4503
- prompt,
4504
- ...(customTools.length > 0
4505
- ? {
4506
- writerToolPolicy: {
4507
- requireSdk: true,
4508
- customTools,
4509
- },
4510
- }
4511
- : {}),
4879
+ const scopeForPacking = session.coverageSlice ?? session.requirementSlice;
4880
+ const envelopeBytes = Buffer.byteLength(prompt) + Buffer.byteLength(input.sessionOptions.userMessage ?? "") + Buffer.byteLength(JSON.stringify(customTools));
4881
+ if (envelopeBytes > (input.sessionOptions.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES) && scopeForPacking && session.id !== "compact-local") {
4882
+ const groups = workGroups.map(g => g.requirementIds.filter(id => scopeForPacking.includes(id))).filter(g => g.length);
4883
+ if (groups.length > 1) {
4884
+ const half = Math.ceil(groups.length / 2);
4885
+ queue.splice(index, 1, ...[groups.slice(0, half).flat(), groups.slice(half).flat()].map(slice => ({ ...session, ...(session.coverageOnly ? { coverageSlice: slice } : { requirementSlice: slice }) })));
4886
+ invocationCount -= 1;
4887
+ continue;
4888
+ }
4889
+ }
4890
+ const result = await observeFrontendSession({
4891
+ ...input.observation, phase: `plan/${session.id}`, scopeIds: session.requirementSlice ?? session.coverageSlice ?? allRequirementIds,
4892
+ prompt, userMessage: input.sessionOptions.userMessage, customTools, committedCount: input.committedFactCount, durableCommittedCount: () => lastDurableCount,
4893
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${invocationCount}.json` : undefined,
4894
+ }, async (observer) => {
4895
+ const result = await input.piStepFn({
4896
+ ...input.sessionOptions,
4897
+ onAttemptObservation: observer,
4898
+ prompt,
4899
+ ...(customTools.length > 0
4900
+ ? {
4901
+ writerToolPolicy: {
4902
+ requireSdk: true,
4903
+ customTools,
4904
+ },
4905
+ }
4906
+ : {}),
4907
+ });
4908
+ try {
4909
+ await input.flushLedger();
4910
+ lastDurableCount = input.committedFactCount();
4911
+ }
4912
+ catch {
4913
+ // best-effort: the node-level flush runs again after the attempt
4914
+ }
4915
+ return result;
4512
4916
  });
4513
4917
  accumulated = accumulated
4514
4918
  ? combineSequentialPiResults(accumulated, result)
4515
4919
  : result;
4516
- try {
4517
- await input.flushLedger();
4518
- }
4519
- catch {
4520
- // best-effort: the node-level flush runs again after the attempt
4521
- }
4522
4920
  const committedAfter = input.committedFactCount();
4523
4921
  const committedFactsOnlySuccess = session.id !== "finalize" &&
4524
4922
  !(result.assistantText ?? "").trim() &&
@@ -4565,10 +4963,10 @@ export async function runFrontendPlanSegmentedSessions(input) {
4565
4963
  committedFacts: input.committedFacts(),
4566
4964
  behaviorRequiredRequirementIds: input.behaviorRequiredRequirementIds,
4567
4965
  })
4568
- : session.id === "global-mock-data" && allRequirementIds.length > 0 && input.committedFacts
4966
+ : session.id.startsWith("global-mock-data") && allRequirementIds.length > 0 && input.committedFacts
4569
4967
  ? collectFrontendPlanPhaseMissingFacts({
4570
4968
  phase: "global-mock-data",
4571
- requirementIds: allRequirementIds,
4969
+ requirementIds: session.requirementSlice ?? allRequirementIds,
4572
4970
  committedFacts: input.committedFacts(),
4573
4971
  })
4574
4972
  : [];
@@ -4605,7 +5003,58 @@ export async function runFrontendPlanSegmentedSessions(input) {
4605
5003
  ? buildCompactFinalizePrompt(missing)
4606
5004
  : isUxLocalSession
4607
5005
  ? buildUxPrompt(session.requirementSlice ?? [], missing)
4608
- : buildPhasePrompt(globalMockDataSegment, missing);
5006
+ : buildPhasePrompt(globalMockDataSegment, missing, session.requirementSlice ?? allRequirementIds);
5007
+ if (result.timedOut || /interrupt|termination-unconfirmed|budget_breach/.test(result.failureCategory))
5008
+ return { ...result, ok: false };
5009
+ const capacityExhausted = readWriterThinkingExhaustionEvidence(result).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(result.failureCategory);
5010
+ if (capacityExhausted && !result.timedOut) {
5011
+ const failure = () => mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${session.id}; no smaller complete scope can finish; unchanged retries are disabled` }, committedAfter > committedBefore);
5012
+ const missingCoverageIds = input.committedFacts ? [...new Set(collectFrontendPlanMissingFacts({ requirementIds: session.coverageSlice ?? session.requirementSlice ?? allRequirementIds, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds))] : [...(session.coverageSlice ?? session.requirementSlice ?? allRequirementIds)];
5013
+ const splitScope = (ids, allowMemberSplit) => {
5014
+ const groups = workGroups.map(g => g.requirementIds.filter(id => ids.includes(id))).filter(g => g.length);
5015
+ if (groups.length > 1) {
5016
+ const half = Math.ceil(groups.length / 2);
5017
+ return [groups.slice(0, half).flat(), groups.slice(half).flat()];
5018
+ }
5019
+ if (allowMemberSplit && ids.length > 1) {
5020
+ const half = Math.ceil(ids.length / 2);
5021
+ return [ids.slice(0, half), ids.slice(half)];
5022
+ }
5023
+ return [];
5024
+ };
5025
+ if (isCompactLocalSession) {
5026
+ let scopes = splitScope(missingCoverageIds, true);
5027
+ if (!scopes.length && missingCoverageIds.length) {
5028
+ if (missingCoverageIds.length === allRequirementIds.length && committedAfter === committedBefore)
5029
+ return failure();
5030
+ scopes = [missingCoverageIds];
5031
+ }
5032
+ // UX ownership is independent of coverage completion. Preserve all groups.
5033
+ const recovery = scopes.map((slice, i) => ({ id: `coverage-capacity-${invocationCount}-${i}`, coverageOnly: true, coverageSlice: slice, toolNames: coverageSegment.toolNames, prompt: buildCoveragePrompt(slice) }));
5034
+ recovery.push({ id: "ux-registry", toolNames: uxRegistrySegment.toolNames, prompt: buildUxRegistryPrompt() });
5035
+ recovery.push(...buildWorkBatches([...allRequirementIds]).map((slice, i) => ({ id: `ux-local-capacity-${invocationCount}-${i}`, requirementSlice: slice, toolNames: uxSegment.toolNames, prompt: buildUxPrompt(slice) })));
5036
+ queue.splice(index, 1, ...recovery);
5037
+ continue;
5038
+ }
5039
+ if (session.coverageOnly || isUxLocalSession) {
5040
+ const missingIds = session.coverageOnly ? missingCoverageIds : [...new Set(missingPhase.flatMap(f => f.requirementIds))];
5041
+ if (!missingIds.length) {
5042
+ index += 1;
5043
+ continue;
5044
+ }
5045
+ const scopes = splitScope(missingIds, Boolean(session.coverageOnly));
5046
+ if (scopes.length) {
5047
+ queue.splice(index, 1, ...scopes.map(slice => ({ ...session, ...(session.coverageOnly ? { coverageSlice: slice } : { requirementSlice: slice }), missingFacts: missingPhaseFacts.filter(f => f.requirementIds.some(id => slice.includes(id))), retryCount: 0 })));
5048
+ continue;
5049
+ }
5050
+ const originalIds = session.coverageSlice ?? session.requirementSlice ?? [];
5051
+ if ((missingIds.length < originalIds.length || committedAfter > committedBefore) && (session.retryCount ?? 0) < 2) {
5052
+ queue[index] = { ...session, ...(session.coverageOnly ? { coverageSlice: missingIds } : { requirementSlice: missingIds }), missingFacts: missingPhaseFacts, retryCount: (session.retryCount ?? 0) + 1 };
5053
+ continue;
5054
+ }
5055
+ }
5056
+ return failure();
5057
+ }
4609
5058
  if (result.ok) {
4610
5059
  if (missingPhaseFacts.length === 0) {
4611
5060
  index += 1;
@@ -4649,24 +5098,6 @@ export async function runFrontendPlanSegmentedSessions(input) {
4649
5098
  index += 1;
4650
5099
  continue;
4651
5100
  }
4652
- // A length-stopped, fact-less compact session is the planner variant of
4653
- // writer-thinking-exhausted. Give the same scope exactly one tool-first
4654
- // retry before the split below re-batches the requirements, because one
4655
- // reinforced full-scope pass is cheaper than re-planning split halves.
4656
- const plannerThinkingBurn = isCompactLocalSession &&
4657
- committedAfter === committedBefore &&
4658
- !(result.assistantText ?? "").trim() &&
4659
- !result.stderr.trim() &&
4660
- !result.timedOut &&
4661
- readWriterThinkingExhaustionEvidence(result).stopReason === "length";
4662
- if (plannerThinkingBurn && (session.retryCount ?? 0) < 1) {
4663
- queue[index] = {
4664
- ...session,
4665
- retryCount: (session.retryCount ?? 0) + 1,
4666
- prompt: buildCompactLocalPrompt(),
4667
- };
4668
- continue;
4669
- }
4670
5101
  // Option 5: a multi-requirement coverage batch that failed with ZERO
4671
5102
  // new facts and no provider stderr is the upfront-reasoning burn —
4672
5103
  // halve the slice and retry instead of failing the attempt. The compact
@@ -4681,20 +5112,24 @@ export async function runFrontendPlanSegmentedSessions(input) {
4681
5112
  : undefined;
4682
5113
  const zeroProgressBurn = coverageSlice !== undefined &&
4683
5114
  coverageSlice.length > 1 &&
4684
- committedAfter === committedBefore &&
4685
- !(result.assistantText ?? "").trim() &&
4686
- !result.stderr.trim() &&
5115
+ (readWriterThinkingExhaustionEvidence(result).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(result.failureCategory) || (committedAfter === committedBefore && !(result.assistantText ?? "").trim() && !result.stderr.trim())) &&
4687
5116
  !result.timedOut;
4688
5117
  if (zeroProgressBurn && coverageSlice) {
4689
- const half = Math.ceil(coverageSlice.length / 2);
4690
- const firstSlice = coverageSlice.slice(0, half);
4691
- const secondSlice = coverageSlice.slice(half);
5118
+ const missingIds = input.committedFacts ? new Set(collectFrontendPlanMissingFacts({ requirementIds: coverageSlice, committedFacts: input.committedFacts() }).flatMap(f => f.requirementIds)) : undefined;
5119
+ const unfinished = coverageSlice.filter(id => !missingIds || missingIds.has(id));
5120
+ if (unfinished.length <= 1)
5121
+ return mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${unfinished[0] ?? session.id}; no smaller complete scope can finish` }, committedAfter > committedBefore);
5122
+ const half = Math.ceil(unfinished.length / 2);
5123
+ const firstSlice = unfinished.slice(0, half);
5124
+ const secondSlice = unfinished.slice(half);
4692
5125
  if (isCompactLocalSession) {
5126
+ queue.splice(index + 1, 0, { id: "ux-registry", toolNames: uxRegistrySegment.toolNames, prompt: buildUxRegistryPrompt() }, ...buildWorkBatches([...firstSlice, ...secondSlice]).map((slice, i) => ({ id: `ux-local-recovery-${i}`, toolNames: uxSegment.toolNames, requirementSlice: slice, prompt: buildUxPrompt(slice) })));
4693
5127
  // The split halves leave compact mode: continue them as ordinary
4694
5128
  // coverage sessions so every downstream ladder branch applies.
4695
5129
  queue.splice(index, 1, {
4696
5130
  ...session,
4697
5131
  id: `coverage-compact-split-1`,
5132
+ toolNames: coverageSegment.toolNames, retryCount: 0,
4698
5133
  coverageOnly: true,
4699
5134
  coverageSlice: firstSlice,
4700
5135
  requirementSlice: firstSlice,
@@ -4702,6 +5137,7 @@ export async function runFrontendPlanSegmentedSessions(input) {
4702
5137
  }, {
4703
5138
  ...session,
4704
5139
  id: `coverage-compact-split-2`,
5140
+ toolNames: coverageSegment.toolNames, retryCount: 0,
4705
5141
  coverageOnly: true,
4706
5142
  coverageSlice: secondSlice,
4707
5143
  requirementSlice: secondSlice,
@@ -4720,7 +5156,9 @@ export async function runFrontendPlanSegmentedSessions(input) {
4720
5156
  });
4721
5157
  continue;
4722
5158
  }
4723
- if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id === "global-mock-data") &&
5159
+ if (readWriterThinkingExhaustionEvidence(result).stopReason === "length" || ["context-overflow", "context-budget-exhausted"].includes(result.failureCategory))
5160
+ return mapPlannerExhaustion({ ...result, ok: false, stderr: `${result.stderr}\nFRONTEND_INPUT_UNIT_TOO_LARGE: ${session.id}; refine the remaining complete scope; unchanged retries are disabled` }, committedAfter > committedBefore);
5161
+ if ((session.coverageOnly || isUxRegistrySession || isUxLocalSession || session.id.startsWith("global-mock-data")) &&
4724
5162
  missingPhaseFacts.length > 0 &&
4725
5163
  !result.stderr.trim() &&
4726
5164
  !result.timedOut &&
@@ -4852,14 +5290,27 @@ async function runFrontendScoutParallelSessions(input) {
4852
5290
  ...input.sessionOptions,
4853
5291
  sessionEventsPath: path.join(input.runDir, shardNodeId, "session-events.jsonl"),
4854
5292
  };
4855
- shardResult = await input.piStepFn({
5293
+ const prompt = `${input.basePrompt}\n\n${shard.instruction}`;
5294
+ const tools = [...customTools, ...(input.readBudgetTools ?? [])];
5295
+ shardResult = await observeFrontendSession({
5296
+ ...input.observation,
5297
+ phase: `scout/parallel/${shard.id}`,
5298
+ scopeIds: [shard.id],
5299
+ prompt,
5300
+ userMessage: sessionOptions.userMessage,
5301
+ customTools: tools,
5302
+ artifactPath: input.observation?.artifactPath ? `${input.observation.artifactPath}-${shard.id}.json` : undefined,
5303
+ committedCount: () => shardTools?.committedFacts().length ?? 0,
5304
+ durableCommittedCount: () => shardTools?.committedFacts().length ?? 0,
5305
+ }, observer => input.piStepFn({
4856
5306
  ...sessionOptions,
4857
- prompt: `${input.basePrompt}\n\n${shard.instruction}`,
5307
+ onAttemptObservation: observer,
5308
+ prompt,
4858
5309
  writerToolPolicy: {
4859
5310
  requireSdk: true,
4860
- customTools: [...customTools, ...(input.readBudgetTools ?? [])],
5311
+ customTools: tools,
4861
5312
  },
4862
- });
5313
+ }));
4863
5314
  await shardTools.flush();
4864
5315
  return { shard, result: shardResult, tools: shardTools };
4865
5316
  }
@@ -5035,6 +5486,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5035
5486
  let writerToolPolicy;
5036
5487
  let reviewTerminalTools;
5037
5488
  let designTerminalTools;
5489
+ let reviewInventory;
5490
+ let designInventory;
5038
5491
  let planLedgerTools;
5039
5492
  let contractTools;
5040
5493
  let scoutEvidenceTools;
@@ -5128,6 +5581,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5128
5581
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5129
5582
  const store = createTypedEventStore();
5130
5583
  reviewTerminalTools = await createFrontendReviewTerminalTools({
5584
+ inventory: reviewInventory = await loadFrontendReviewScopes(meta.runDir, "review", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
5585
+ inputDigest: reviewInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
5131
5586
  attemptId: `${meta.runId}:${input.task.id}`,
5132
5587
  store,
5133
5588
  runDir: meta.runDir,
@@ -5153,6 +5608,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5153
5608
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5154
5609
  const store = createTypedEventStore();
5155
5610
  designTerminalTools = await createFrontendDesignTerminalTools({
5611
+ inventory: designInventory = await loadFrontendReviewScopes(meta.runDir, "design", meta.spec?.frontendExecutionPolicy?.scopeTargetBytes ?? FRONTEND_SCOPE_TARGET_BYTES),
5612
+ inputDigest: designInventory?.digest ?? createHash("sha256").update(input.prompt).digest("hex"),
5156
5613
  attemptId: `${meta.runId}:${input.task.id}`,
5157
5614
  store,
5158
5615
  runDir: meta.runDir,
@@ -5178,6 +5635,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5178
5635
  const { createTypedEventStore } = await import("../workflows/dag/frontend-typed-event-store.js");
5179
5636
  const store = createTypedEventStore();
5180
5637
  contractTools = await createFrontendContractTools({
5638
+ sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined),
5181
5639
  attemptId: `${meta.runId}:${input.task.id}`,
5182
5640
  store,
5183
5641
  runDir: meta.runDir,
@@ -5211,6 +5669,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5211
5669
  spec: meta.spec,
5212
5670
  });
5213
5671
  scoutEvidenceTools = await createFrontendScoutEvidenceTools({
5672
+ requirementIds: parseFrontendInputBlock(input.prompt, "scout")?.payload.requirements.map(r => r.id),
5673
+ sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined),
5214
5674
  attemptId: `${meta.runId}:${input.task.id}`,
5215
5675
  store,
5216
5676
  runDir: meta.runDir,
@@ -5368,6 +5828,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5368
5828
  activeTools: toolNames,
5369
5829
  });
5370
5830
  const piSessionOptions = {
5831
+ reserveProviderRequest: input.reserveProviderRequest,
5832
+ frontendExecutionPolicy: meta.spec?.frontendExecutionPolicy,
5371
5833
  attachedFiles: [],
5372
5834
  modelConfig,
5373
5835
  repoRoot: input.cwd,
@@ -5409,6 +5871,15 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5409
5871
  workspaceRoot: input.cwd,
5410
5872
  sourceDeclaredPaths: scoutSourceDeclaredPaths,
5411
5873
  readBudgetTools: readBudgetTools?.customTools,
5874
+ observation: {
5875
+ artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`),
5876
+ runId: meta.runId,
5877
+ nodeId: input.task.id,
5878
+ taskId: meta.spec?.sourceBinding?.taskId,
5879
+ attempt: input.attempt ?? 1,
5880
+ model: input.model,
5881
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
5882
+ },
5412
5883
  });
5413
5884
  }
5414
5885
  else if (isFrontendPlanLedgerNode(input.task) && planLedgerTools) {
@@ -5448,6 +5919,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5448
5919
  // finalize reducer. This avoids revision races while shortening the
5449
5920
  // longest coverage phase for large plans.
5450
5921
  const runPlanSessions = (options) => runFrontendPlanSegmentedSessions({
5922
+ observation: options.observation ?? {
5923
+ artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`),
5924
+ runId: meta.runId,
5925
+ nodeId: input.task.id,
5926
+ taskId: meta.spec?.sourceBinding?.taskId,
5927
+ attempt: input.attempt ?? 1,
5928
+ model: input.model,
5929
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
5930
+ contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
5931
+ },
5451
5932
  piStepFn,
5452
5933
  sessionOptions: options.sessionOptions,
5453
5934
  basePrompt: options.basePrompt ?? input.prompt,
@@ -5527,6 +6008,16 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5527
6008
  requirementIds: slice,
5528
6009
  basePrompt: `${input.prompt}\n\nPARALLEL COVERAGE SHARD ${index + 1}: use the frozen canonical behavior verification-target ids listed in the record_plan_verification_target tool description — do not prefix ids with a shard namespace or invent variant ids; identical cross-shard targets are deduped, divergent ones fail the merge.`,
5529
6010
  parallelCoverageOnly: true,
6011
+ observation: {
6012
+ artifactPath: path.join(meta.runDir, input.task.id, "parallel", `coverage-${index + 1}`, "session-budget", `attempt-${input.attempt ?? 1}`),
6013
+ runId: meta.runId,
6014
+ nodeId: `${input.task.id}/parallel/coverage-${index + 1}`,
6015
+ taskId: meta.spec?.sourceBinding?.taskId,
6016
+ attempt: input.attempt ?? 1,
6017
+ model: input.model,
6018
+ sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined,
6019
+ contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6020
+ },
5530
6021
  });
5531
6022
  await shardTools.flush();
5532
6023
  return { index, result: shardResult, tools: shardTools };
@@ -5626,12 +6117,31 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5626
6117
  });
5627
6118
  }
5628
6119
  }
6120
+ else if (contractTools) {
6121
+ result = await runFrontendContractSegmentedSessions({
6122
+ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, tools: contractTools,
6123
+ observation: { artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`), runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined },
6124
+ });
6125
+ }
6126
+ else if ((reviewInventory && reviewTerminalTools) || (designInventory && designTerminalTools)) {
6127
+ result = await runFrontendReviewSegmentedSessions({ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, inventory: (reviewInventory ?? designInventory), tools: (reviewTerminalTools ?? designTerminalTools), customTools: writerToolPolicy.customTools, phase: reviewInventory ? "review" : "design",
6128
+ observation: { artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`), runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, contractDigest: (reviewInventory ?? designInventory).digest } });
6129
+ }
6130
+ else if (scoutEvidenceTools && parseFrontendInputBlock(input.prompt, "scout")) {
6131
+ result = await runFrontendScoutSegmentedSessions({ piStepFn, sessionOptions: piSessionOptions, basePrompt: input.prompt, tools: scoutEvidenceTools, customTools: writerToolPolicy.customTools,
6132
+ observation: { artifactPath: path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}`), runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, sourceDigest: meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined } });
6133
+ }
5629
6134
  else {
5630
- result = await piStepFn({
6135
+ result = await observeFrontendSession({
6136
+ artifactPath: input.task.id.startsWith("frontend-") ? path.join(meta.runDir, input.task.id, "session-budget", `attempt-${input.attempt ?? 1}.json`) : undefined,
6137
+ phase: input.task.id, prompt: input.prompt, userMessage: piSessionOptions.userMessage, customTools: writerToolPolicy?.customTools,
6138
+ runId: meta.runId, nodeId: input.task.id, taskId: meta.spec?.sourceBinding?.taskId, attempt: input.attempt ?? 1, model: input.model, sourceDigest: (meta.spec?.sourceBinding?.schemaVersion === 2 ? meta.spec.sourceBinding.inputDigest : undefined), contractDigest: meta.spec?.taskContractBinding?.canonicalHash,
6139
+ }, observer => piStepFn({
5631
6140
  ...piSessionOptions,
6141
+ onAttemptObservation: observer,
5632
6142
  prompt: input.prompt,
5633
6143
  ...(writerToolPolicy ? { writerToolPolicy } : {}),
5634
- });
6144
+ }));
5635
6145
  }
5636
6146
  try {
5637
6147
  const verificationCommandFiles = collectFrontendVerificationCommandFiles(input.prompt);
@@ -5796,8 +6306,8 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
5796
6306
  try {
5797
6307
  await scoutEvidenceTools?.flush?.();
5798
6308
  }
5799
- catch {
5800
- // best-effort flush
6309
+ catch (error) {
6310
+ return { ...mapped, ok: false, failureCategory: mapped.ok ? "frontend-ledger-invalid" : mapped.failureCategory, stderr: `${mapped.stderr}\nFRONTEND_LEDGER_INTEGRITY_INVALID: ${error instanceof Error ? error.message : String(error)}` };
5801
6311
  }
5802
6312
  if (mapped.ok) {
5803
6313
  const { checkCommittedOriginFacts, readCommittedOriginFacts } = await import("../workflows/dag/frontend-shadow-dual-write.js");