@moretea-labs/forge 1.6.0 → 1.7.1

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 (469) hide show
  1. package/AGENTS.md +4 -0
  2. package/CHANGELOG.md +39 -0
  3. package/CLAUDE.md +4 -0
  4. package/README.md +2 -2
  5. package/README.zh-CN.md +1 -1
  6. package/SKILL.md +2 -2
  7. package/THIRD_PARTY_NOTICES.md +1 -1
  8. package/agents/openai.yaml +1 -1
  9. package/assets/hooks/hook-input.sh +11 -8
  10. package/assets/hooks/lib/session-state.sh +10 -7
  11. package/assets/hooks/lib/workflow-state.sh +13 -177
  12. package/assets/hooks/post-tool-observer.sh +8 -2
  13. package/assets/hooks/prompt-guard.sh +5 -81
  14. package/assets/hooks/session-start-context.sh +56 -1
  15. package/assets/partials/05-workflow.partial.md +1 -1
  16. package/assets/partials-agents/02-operating-mode.partial.md +2 -0
  17. package/assets/partials-agents/03-orchestration.partial.md +1 -1
  18. package/assets/plugin-registry.v1.json +3 -3
  19. package/assets/reference-configs/document-generation.md +1 -1
  20. package/assets/reference-configs/harness-overview.md +1 -1
  21. package/assets/reference-configs/sprint-contracts.md +3 -3
  22. package/assets/skill-version.json +2 -2
  23. package/assets/templates/contract.template.md +2 -7
  24. package/assets/templates/helpers/capture-plan.sh +2 -2
  25. package/assets/templates/helpers/check-architecture-sync.sh +17 -0
  26. package/assets/templates/helpers/check-task-workflow.sh +4 -4
  27. package/assets/templates/helpers/contract-worktree.sh +1 -21
  28. package/assets/templates/helpers/ensure-task-workflow.sh +11 -36
  29. package/assets/templates/helpers/inspect-project-state.ts +8 -6
  30. package/assets/templates/helpers/migrate-project-template.sh +22 -45
  31. package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
  32. package/assets/templates/helpers/new-plan.sh +1 -1
  33. package/assets/templates/helpers/plan-to-todo.sh +12 -37
  34. package/assets/templates/helpers/ship-worktrees.sh +2 -29
  35. package/assets/templates/helpers/verify-contract.sh +1 -111
  36. package/assets/templates/helpers/verify-sprint.sh +3 -180
  37. package/assets/templates/helpers/workflow-contract.ts +5 -1
  38. package/assets/templates/plan.template.md +1 -1
  39. package/assets/templates/review.template.md +9 -34
  40. package/assets/workflow-contract.v1.json +10 -19
  41. package/docs/README.md +10 -10
  42. package/docs/architecture/CURRENT.md +178 -0
  43. package/docs/architecture/EVOLUTION.md +42 -0
  44. package/docs/architecture/current/README.md +4 -156
  45. package/docs/architecture/decisions/20260803-release-identity-binding-and-exit-policy.md +2 -2
  46. package/docs/architecture/decisions/20260823-immediate-controller-relay.md +53 -0
  47. package/docs/architecture/decisions/20260826-browser-runtime-v2.md +73 -0
  48. package/docs/architecture/history/README.md +7 -0
  49. package/docs/architecture/{chatgpt-handoff-facade.md → history/chatgpt-handoff-facade.md} +4 -0
  50. package/docs/architecture/{global-hook-runtime.md → history/global-hook-runtime.md} +7 -3
  51. package/docs/architecture/{ios-semantic-automation-provider-v2.md → history/ios-semantic-automation-provider-v2.md} +6 -2
  52. package/docs/architecture/history.md +3 -34
  53. package/docs/architecture/index.md +15 -98
  54. package/docs/architecture/modules/controller-runtime/transport-lifecycle.md +1 -1
  55. package/docs/architecture/reviews/2026-08-22-semantic-harness-convergence-acceptance.md +39 -0
  56. package/docs/architecture/versions/1.6.md +38 -0
  57. package/docs/architecture/versions/1.7.md +41 -0
  58. package/docs/forge-chatgpt-browser-engine.md +2 -2
  59. package/docs/forge-chatgpt-mcp-setup.md +4 -28
  60. package/docs/forge-chatgpt-supervised-automation.md +3 -3
  61. package/docs/forge-github-issue-launcher.md +1 -1
  62. package/docs/forge-plugin-management.md +1 -1
  63. package/docs/forge-runtime-self-healing-loop.md +1 -1
  64. package/docs/operations/controller-browser-plugin.md +26 -6
  65. package/docs/operations/controller-performance-and-502.md +143 -0
  66. package/docs/operations/controller-reliability-runbook.md +1 -1
  67. package/docs/operations/features.md +2 -2
  68. package/docs/operations/github-repository.md +1 -1
  69. package/docs/operations/homebrew.md +2 -2
  70. package/docs/operations/mcp-tool-exposure.md +5 -3
  71. package/docs/wiki/Architecture.md +6 -6
  72. package/docs/wiki/Implementation.md +2 -2
  73. package/docs/wiki/Integrations.md +2 -2
  74. package/docs/wiki/Work-Lifecycle.md +1 -1
  75. package/package.json +33 -11
  76. package/scripts/activate-source-baseline.ts +17 -13
  77. package/scripts/architecture-event.ts +2 -820
  78. package/scripts/architecture-queue.sh +2 -645
  79. package/scripts/archive-architecture-request.sh +2 -242
  80. package/scripts/archive-workflow.sh +1 -291
  81. package/scripts/benchmark-background-check-overlap.ts +228 -0
  82. package/scripts/benchmark-mcp-host.sh +117 -0
  83. package/scripts/benchmark-mcp-transport.ts +170 -0
  84. package/scripts/benchmark-quality-harness.ts +146 -0
  85. package/scripts/benchmark-route-session-concurrency.ts +207 -6
  86. package/scripts/benchmark-thin-harness-mcp-e2e.ts +7 -1
  87. package/scripts/benchmark-typescript-navigation.ts +186 -0
  88. package/scripts/benchmark-work-efficiency.ts +334 -0
  89. package/scripts/capability-config.ts +2 -392
  90. package/scripts/capability-resolver.ts +2 -503
  91. package/scripts/capture-plan.sh +1 -332
  92. package/scripts/check-agent-tooling.sh +2 -1558
  93. package/scripts/check-architecture-sync.sh +2 -371
  94. package/scripts/check-brain-manifest.sh +2 -253
  95. package/scripts/check-context-files.sh +2 -47
  96. package/scripts/check-deploy-sql-order.sh +2 -115
  97. package/scripts/check-mcp-compatibility.ts +14 -0
  98. package/scripts/check-runtime-architecture.mjs +349 -79
  99. package/scripts/check-skill-version.ts +3 -191
  100. package/scripts/check-source-duplication.mjs +61 -0
  101. package/scripts/check-stable-baseline.ts +92 -0
  102. package/scripts/check-task-sync.sh +2 -63
  103. package/scripts/check-task-workflow.sh +2 -1000
  104. package/scripts/codex-handoff-resume.sh +2 -267
  105. package/scripts/context-contract-sync.sh +2 -514
  106. package/scripts/contract-run.ts +2 -463
  107. package/scripts/contract-worktree.sh +1 -722
  108. package/scripts/ensure-task-workflow.sh +1 -1225
  109. package/scripts/host-temporary-cloud-mcp-e2e.sh +265 -0
  110. package/scripts/inspect-project-state.ts +4 -279
  111. package/scripts/install-standalone-recovery.ts +24 -0
  112. package/scripts/lib/project-init-lib.sh +22 -46
  113. package/scripts/lib/run-forge-helper.sh +19 -0
  114. package/scripts/lib/run-forge-helper.ts +26 -0
  115. package/scripts/migrate-project-template.sh +1 -1189
  116. package/scripts/migrate-workflow-docs.ts +4 -412
  117. package/scripts/new-plan.sh +1 -180
  118. package/scripts/new-spec.sh +1 -47
  119. package/scripts/new-sprint.sh +1 -46
  120. package/scripts/plan-to-todo.sh +1 -796
  121. package/scripts/prepare-codex-handoff.sh +2 -201
  122. package/scripts/prepare-handoff.sh +1 -78
  123. package/scripts/refresh-current-status.sh +2 -467
  124. package/scripts/run-governed-gate.ts +5 -1
  125. package/scripts/select-agent-context-blocks.sh +2 -64
  126. package/scripts/ship-worktrees.sh +1 -558
  127. package/scripts/smoke-ios-physical-device.ts +4 -2
  128. package/scripts/smoke-runtime-recovery.ts +1 -35
  129. package/scripts/smoke-schedule-engine.ts +1 -20
  130. package/scripts/sprint-backlog.sh +1 -834
  131. package/scripts/stage-runtime-release.ts +2 -1
  132. package/scripts/summarize-failures.sh +2 -123
  133. package/scripts/switch-plan.sh +1 -150
  134. package/scripts/sync-brain-docs.sh +2 -379
  135. package/scripts/sync-generated-authority.mjs +175 -0
  136. package/scripts/verify-contract.sh +2 -703
  137. package/scripts/verify-forge-runtime.sh +0 -1
  138. package/scripts/verify-sprint.sh +1 -677
  139. package/scripts/workflow-contract.ts +4 -205
  140. package/scripts/workstream-sync.sh +2 -261
  141. package/src/cli/agent-jobs/executor-health.ts +1 -1
  142. package/src/cli/commands/init-hook.ts +3 -3
  143. package/src/cli/commands/mcp.ts +9 -5
  144. package/src/cli/commands/plugin.ts +12 -3
  145. package/src/cli/commands/recovery.ts +25 -3
  146. package/src/cli/commands/runtime.ts +55 -3
  147. package/src/cli/commands/security.ts +12 -1
  148. package/src/cli/commands/setup-profile.ts +31 -21
  149. package/src/cli/controller/check-runner.ts +113 -5
  150. package/src/cli/controller/composite-result.ts +1 -1
  151. package/src/cli/controller/context/focus.ts +51 -0
  152. package/src/cli/controller/context/instruction-context.ts +114 -0
  153. package/src/cli/controller/context/known-paths.ts +76 -0
  154. package/src/cli/controller/context/query-planning.ts +110 -0
  155. package/src/cli/controller/context/rule-resolution.ts +532 -0
  156. package/src/cli/controller/context/source-materializer.ts +278 -0
  157. package/src/cli/controller/context/structural.ts +16 -0
  158. package/src/cli/controller/context/types.ts +286 -0
  159. package/src/cli/controller/context-pack.ts +828 -487
  160. package/src/cli/controller/launch-agents.ts +77 -2
  161. package/src/cli/controller/operational-plan.ts +2 -2
  162. package/src/cli/controller/work-mode.ts +6 -4
  163. package/src/cli/editing/edit-session.ts +60 -7
  164. package/src/cli/github/contracts.ts +16 -0
  165. package/src/cli/github/github.ts +135 -16
  166. package/src/cli/github/session.ts +2 -1
  167. package/src/cli/installer/managed-entries.ts +15 -1
  168. package/src/cli/local-bridge/console-automations.ts +54 -78
  169. package/src/cli/local-bridge/console-view-models.ts +1 -35
  170. package/src/cli/local-bridge/facade-api.ts +37 -264
  171. package/src/cli/local-bridge/frontend/src/App.tsx +3 -3
  172. package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +23 -13
  173. package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +37 -4
  174. package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +6 -4
  175. package/src/cli/local-bridge/frontend/src/lib/api.ts +1 -6
  176. package/src/cli/local-bridge/frontend/src/lib/routes.ts +1 -1
  177. package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -2
  178. package/src/cli/local-bridge/frontend/src/styles.css +1 -1
  179. package/src/cli/local-bridge/frontend/src/types.ts +5 -8
  180. package/src/cli/local-bridge/job-store.ts +1 -490
  181. package/src/cli/local-bridge/server.ts +2 -515
  182. package/src/cli/local-bridge/ui-dist/app.css +1 -1
  183. package/src/cli/local-bridge/ui-dist/app.js +3703 -3757
  184. package/src/cli/mcp/auth.ts +5 -5
  185. package/src/cli/mcp/instructions.ts +3 -2
  186. package/src/cli/mcp/legacy-tool-service.ts +10 -222
  187. package/src/cli/mcp/multi-repository.ts +16 -5
  188. package/src/cli/mcp/oauth.ts +175 -26
  189. package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
  190. package/src/cli/mcp/repository-tools.ts +282 -146
  191. package/src/cli/mcp/server.ts +586 -116
  192. package/src/cli/mcp/tool-capability-cache.ts +1 -1
  193. package/src/cli/mcp/tool-contract.ts +14 -0
  194. package/src/cli/mcp/toolset-selection.ts +1 -1
  195. package/src/cli/mcp/toolset.ts +44 -7
  196. package/src/cli/mcp/transports/http.ts +124 -14
  197. package/src/cli/mcp/transports/session-registry.ts +43 -5
  198. package/src/cli/mcp/types.ts +3 -3
  199. package/src/cli/repositories/command-classifier.ts +103 -21
  200. package/src/cli/repositories/command-executor.ts +78 -526
  201. package/src/cli/repositories/command-process.ts +108 -0
  202. package/src/cli/repositories/command-scope.ts +99 -41
  203. package/src/cli/repositories/controller-home.ts +7 -8
  204. package/src/cli/repositories/git-command-executor.ts +2 -0
  205. package/src/cli/repositories/locks.ts +53 -16
  206. package/src/cli/repositories/repository-snapshot.ts +124 -0
  207. package/src/cli/repositories/selected-path-actions.ts +17 -9
  208. package/src/cli/repositories/structured-git.ts +118 -6
  209. package/src/cli/repository/inspector.ts +563 -65
  210. package/src/cli/repository/session-cache.ts +58 -17
  211. package/src/cli/v81-entry.ts +1 -4
  212. package/src/effects/fs-transaction.ts +85 -17
  213. package/src/runtime/context/codegraph-read-provider.ts +233 -2
  214. package/src/runtime/context/codegraph-sidecar.cjs +74 -40
  215. package/src/runtime/context/generic-lsp-provider.ts +355 -0
  216. package/src/runtime/context/lsp-client.ts +283 -0
  217. package/src/runtime/context/semantic-navigation-contract.ts +61 -0
  218. package/src/runtime/context/semantic-navigation.ts +223 -0
  219. package/src/runtime/context/swift-navigation.ts +565 -0
  220. package/src/runtime/context/typescript-navigation.ts +226 -0
  221. package/src/runtime/control-plane/dispatch-priority.ts +28 -0
  222. package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +48 -16
  223. package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +86 -24
  224. package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
  225. package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
  226. package/src/runtime/control-plane/execution/session-store.ts +18 -0
  227. package/src/runtime/control-plane/execution/verification-evidence.ts +58 -3
  228. package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
  229. package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
  230. package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
  231. package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
  232. package/src/runtime/control-plane/execution/work-handle-authority.ts +96 -0
  233. package/src/runtime/control-plane/execution/work-handle-store.ts +27 -2
  234. package/src/runtime/control-plane/execution/work-lifecycle-audit.ts +258 -0
  235. package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
  236. package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
  237. package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
  238. package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
  239. package/src/runtime/control-plane/execution/work-remote-delivery.ts +215 -0
  240. package/src/runtime/control-plane/execution/work-task-receipt.ts +22 -18
  241. package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +156 -11
  242. package/src/runtime/control-plane/execution/work-validation-reconciler.ts +22 -14
  243. package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
  244. package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
  245. package/src/runtime/control-plane/execution/work-verification-snapshot.ts +90 -9
  246. package/src/runtime/control-plane/facade/capability-registry.ts +129 -25
  247. package/src/runtime/control-plane/facade/controller-round-relay.ts +1118 -0
  248. package/src/runtime/control-plane/facade/controller-session-store.ts +302 -12
  249. package/src/runtime/control-plane/facade/goal-workloop-access.ts +17 -1
  250. package/src/runtime/control-plane/facade/goal-workloop.ts +928 -190
  251. package/src/runtime/control-plane/facade/index.ts +3 -0
  252. package/src/runtime/control-plane/facade/plan-contract-store.ts +275 -42
  253. package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
  254. package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
  255. package/src/runtime/control-plane/facade/semantic-admission.ts +205 -0
  256. package/src/runtime/control-plane/facade/suggested-actions.ts +5 -1
  257. package/src/runtime/control-plane/facade/types.ts +90 -10
  258. package/src/runtime/control-plane/facade/work-contract-store.ts +157 -261
  259. package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
  260. package/src/runtime/control-plane/generated-cache-retention.ts +271 -0
  261. package/src/runtime/control-plane/global-scheduler/config.ts +44 -0
  262. package/src/runtime/control-plane/global-scheduler/dispatch-capacity.ts +80 -0
  263. package/src/runtime/control-plane/global-scheduler/durable-admission.ts +46 -0
  264. package/src/runtime/control-plane/global-scheduler/maintenance.ts +152 -0
  265. package/src/runtime/control-plane/global-scheduler/projection-refresh.ts +84 -0
  266. package/src/runtime/control-plane/global-scheduler/reconciliation.ts +3 -3
  267. package/src/runtime/control-plane/global-scheduler/scheduler.ts +278 -543
  268. package/src/runtime/control-plane/global-scheduler/source-scan.ts +59 -0
  269. package/src/runtime/control-plane/global-scheduler/state.ts +88 -0
  270. package/src/runtime/control-plane/global-scheduler/worker-attachment.ts +57 -0
  271. package/src/runtime/control-plane/global-scheduler/worker-exit-decision.ts +38 -0
  272. package/src/runtime/control-plane/global-scheduler/worker-exit-reconciler.ts +175 -0
  273. package/src/runtime/control-plane/global-scheduler/worker-launch.ts +111 -0
  274. package/src/runtime/control-plane/global-scheduler/worker-lifecycle-store.ts +64 -0
  275. package/src/runtime/control-plane/global-scheduler/worker-lifecycle.ts +150 -0
  276. package/src/runtime/control-plane/global-scheduler/worker-process.ts +119 -0
  277. package/src/runtime/control-plane/global-scheduler/worker-stderr.ts +60 -0
  278. package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +63 -0
  279. package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +365 -117
  280. package/src/runtime/control-plane/launcher/launch-reservation-store.ts +89 -0
  281. package/src/runtime/control-plane/launcher/thin-launcher.ts +244 -11
  282. package/src/runtime/control-plane/persistence/controller-home-migration.ts +487 -0
  283. package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
  284. package/src/runtime/control-plane/persistence/sqlite-store.ts +33 -2
  285. package/src/runtime/control-plane/release-retention.ts +62 -2
  286. package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
  287. package/src/runtime/control-plane/routing/route-policy.ts +24 -21
  288. package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
  289. package/src/runtime/control-plane/runtime-status-client.ts +0 -2
  290. package/src/runtime/diagnostics/mcp-timing.ts +13 -0
  291. package/src/runtime/diagnostics/mcp-transport-benchmark.ts +129 -0
  292. package/src/runtime/diagnostics/performance.ts +24 -1
  293. package/src/runtime/evidence/event-ledger.ts +1 -1
  294. package/src/runtime/evidence/process-check-execution.ts +18 -0
  295. package/src/runtime/execution/jobs/child-reference.ts +6 -2
  296. package/src/runtime/execution/jobs/store.ts +58 -4
  297. package/src/runtime/execution/jobs/types.ts +1 -1
  298. package/src/runtime/execution/managed-workspace.ts +203 -1
  299. package/src/runtime/execution/process-runtime/check-facade.ts +51 -4
  300. package/src/runtime/execution/process-runtime/check-result.ts +88 -0
  301. package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
  302. package/src/runtime/execution/process-runtime/check-scheduling.ts +114 -0
  303. package/src/runtime/execution/process-runtime/command-facade.ts +367 -40
  304. package/src/runtime/execution/process-runtime/gc.ts +38 -6
  305. package/src/runtime/execution/process-runtime/lightweight-managed.ts +1201 -0
  306. package/src/runtime/execution/process-runtime/persisted-check.ts +51 -5
  307. package/src/runtime/execution/process-runtime/resource-claims.ts +10 -6
  308. package/src/runtime/execution/process-runtime/runtime.ts +53 -3
  309. package/src/runtime/execution/process-runtime/store.ts +1 -0
  310. package/src/runtime/execution/process-runtime/types.ts +4 -12
  311. package/src/runtime/execution/thin-harness/async-process.ts +12 -2
  312. package/src/runtime/execution/thin-harness/search-worker.ts +54 -5
  313. package/src/runtime/execution/work-activity.ts +21 -0
  314. package/src/runtime/execution/workers/executor.ts +18 -61
  315. package/src/runtime/gateway/mcp/execution-tools.ts +35 -1852
  316. package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
  317. package/src/runtime/gateway/mcp/process-tools.ts +83 -37
  318. package/src/runtime/gateway/mcp/router.ts +186 -84
  319. package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +565 -0
  320. package/src/runtime/gateway/mcp/runtime-tools.ts +2378 -1947
  321. package/src/runtime/plugins/app-store-connect-adapter.ts +216 -47
  322. package/src/runtime/plugins/browser-adapter.ts +2662 -829
  323. package/src/runtime/plugins/browser-automation-service.ts +10 -0
  324. package/src/runtime/plugins/browser-handoff-host.ts +2 -25
  325. package/src/runtime/plugins/browser-handoff.ts +110 -5
  326. package/src/runtime/plugins/browser-macos-bridge.ts +701 -63
  327. package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
  328. package/src/runtime/plugins/browser-native-session-service.ts +222 -0
  329. package/src/runtime/plugins/browser-node-bridge.ts +6 -34
  330. package/src/runtime/plugins/browser-provider-registry.ts +109 -0
  331. package/src/runtime/plugins/browser-runtime-contract.ts +154 -0
  332. package/src/runtime/plugins/browser-runtime.ts +193 -0
  333. package/src/runtime/plugins/browser-session-authority.ts +332 -0
  334. package/src/runtime/plugins/browser-session-store.ts +121 -0
  335. package/src/runtime/plugins/browser-session-types.ts +98 -0
  336. package/src/runtime/plugins/capability-authorization-grants.ts +354 -0
  337. package/src/runtime/plugins/desktop-operator-registration.ts +10 -4
  338. package/src/runtime/plugins/external-adapter.ts +358 -3
  339. package/src/runtime/plugins/external-unix-socket.ts +7 -1
  340. package/src/runtime/plugins/first-party-registry.ts +2 -0
  341. package/src/runtime/plugins/github-adapter.ts +125 -0
  342. package/src/runtime/plugins/ios/agent-device-provider.ts +1 -1
  343. package/src/runtime/plugins/ios/agent-device-typed-provider.ts +52 -9
  344. package/src/runtime/plugins/ios/remote-xpc-hid.ts +1171 -0
  345. package/src/runtime/plugins/ios-adapter.ts +106 -16
  346. package/src/runtime/plugins/ios-agent-device.ts +234 -64
  347. package/src/runtime/plugins/ios-physical-device.ts +1143 -392
  348. package/src/runtime/plugins/lightweight-action.ts +153 -0
  349. package/src/runtime/plugins/local-system-adapter.ts +123 -6
  350. package/src/runtime/plugins/macos-capability-broker.ts +47 -9
  351. package/src/runtime/plugins/plugin-action-sidecar.ts +59 -0
  352. package/src/runtime/plugins/plugin-management-adapter.ts +49 -8
  353. package/src/runtime/plugins/resend-adapter.ts +1 -1
  354. package/src/runtime/plugins/store.ts +330 -33
  355. package/src/runtime/plugins/types.ts +38 -3
  356. package/src/runtime/plugins/xiaohongshu-publish.ts +515 -0
  357. package/src/runtime/projections/git-status-sampler.ts +16 -16
  358. package/src/runtime/projections/materialized-view.ts +28 -10
  359. package/src/runtime/recovery/maintenance-executor.ts +426 -25
  360. package/src/runtime/root/mcp-transport.ts +180 -10
  361. package/src/runtime/root/package-connector-service.ts +230 -10
  362. package/src/runtime/root/package-runtime-release.ts +143 -17
  363. package/src/runtime/root/package-runtime-service.ts +461 -22
  364. package/src/runtime/root/release-manifest.ts +144 -18
  365. package/src/runtime/root/release-materialize.ts +125 -32
  366. package/src/runtime/root/release-store.ts +48 -11
  367. package/src/runtime/root/runtime.ts +23 -5
  368. package/src/runtime/root/service.ts +100 -29
  369. package/src/runtime/root/types.ts +12 -0
  370. package/src/runtime/safe-tooling/ios-development.ts +321 -63
  371. package/src/runtime/shared/trusted-executable.ts +62 -0
  372. package/src/runtime/shared/trusted-node-executable.ts +48 -0
  373. package/src/runtime/standalone-recovery/core.ts +1079 -240
  374. package/src/runtime/standalone-recovery/entry.ts +100 -24
  375. package/src/runtime/standalone-recovery/installer.ts +20 -4
  376. package/src/runtime/standalone-recovery/service-labels.ts +2 -0
  377. package/src/runtime/standalone-recovery/watchdog-heartbeat.ts +160 -0
  378. package/src/runtime/workflow/schedules/browser-probe.ts +81 -3
  379. package/src/runtime/workflow/schedules/engine.ts +364 -202
  380. package/src/runtime/workflow/schedules/github-issue-watch.ts +158 -0
  381. package/src/runtime/workflow/schedules/settlement.ts +64 -17
  382. package/src/runtime/workflow/schedules/store.ts +209 -19
  383. package/src/runtime/workflow/schedules/types.ts +2 -4
  384. package/src/runtime/workflow/schedules/work-continuation.ts +26 -13
  385. package/src/testing/test-governance.ts +8 -40
  386. package/assets/reference-configs/heartbeat-triage.md +0 -57
  387. package/assets/templates/helpers/harness-trace-grade.sh +0 -150
  388. package/assets/templates/helpers/heartbeat-triage.sh +0 -361
  389. package/assets/templates/helpers/maintenance-triage.sh +0 -167
  390. package/docs/architecture/current/approved-target-architecture.zh-CN.md +0 -1501
  391. package/docs/architecture/current/architecture-invariants.md +0 -331
  392. package/docs/architecture/current/automation-and-schedule-engine.md +0 -459
  393. package/docs/architecture/current/codegraph-context-provider.md +0 -238
  394. package/docs/architecture/current/control-plane-authority-inventory.md +0 -94
  395. package/docs/architecture/current/controller-repository-decoupling.md +0 -148
  396. package/docs/architecture/current/dispatch-and-agent-strategy.md +0 -373
  397. package/docs/architecture/current/entity-model.md +0 -612
  398. package/docs/architecture/current/external-plugin-governance.md +0 -396
  399. package/docs/architecture/current/failure-recovery.md +0 -447
  400. package/docs/architecture/current/governance.md +0 -213
  401. package/docs/architecture/current/human-interaction-plane.md +0 -87
  402. package/docs/architecture/current/implementation-status.md +0 -128
  403. package/docs/architecture/current/job-and-run-lifecycle.md +0 -413
  404. package/docs/architecture/current/legacy-governance.md +0 -58
  405. package/docs/architecture/current/local-workspace-target-grants.md +0 -70
  406. package/docs/architecture/current/migration-roadmap.md +0 -105
  407. package/docs/architecture/current/multi-repository-execution.md +0 -371
  408. package/docs/architecture/current/operations-runbook.md +0 -45
  409. package/docs/architecture/current/peer-supercontroller-runtime.md +0 -32
  410. package/docs/architecture/current/personal-assistant-plugin-baseline.md +0 -206
  411. package/docs/architecture/current/repository-access-modes.md +0 -102
  412. package/docs/architecture/current/route-policy.md +0 -15
  413. package/docs/architecture/current/route-session-concurrency.md +0 -85
  414. package/docs/architecture/current/runtime-architecture-simplification.md +0 -214
  415. package/docs/architecture/current/runtime-directory-map.md +0 -49
  416. package/docs/architecture/current/runtime-health-and-resource-lifecycle.md +0 -85
  417. package/docs/architecture/current/scheduler-and-resource-claims.md +0 -422
  418. package/docs/architecture/current/session-aware-execution-and-authorization.md +0 -77
  419. package/docs/architecture/current/system-overview.md +0 -169
  420. package/docs/architecture/current/target-requirements-traceability.md +0 -202
  421. package/docs/architecture/current/thin-harness-governance-policy.md +0 -32
  422. package/docs/architecture/current/thin-harness-v1.md +0 -321
  423. package/docs/architecture/current/verification-and-release-gates.md +0 -391
  424. package/docs/forge-autonomous-goal-loop.md +0 -9
  425. package/docs/forge-provider-configuration.md +0 -7
  426. package/docs/operations/chatgpt-personal-assistant-runtime.md +0 -93
  427. package/docs/operations/gmail-assistant-routines.md +0 -186
  428. package/docs/operations/personal-assistant-operator-guide.md +0 -61
  429. package/docs/operations/provider-configuration.md +0 -31
  430. package/scripts/harness-trace-grade.sh +0 -150
  431. package/scripts/heartbeat-triage.sh +0 -361
  432. package/scripts/maintenance-triage.sh +0 -167
  433. package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +0 -2
  434. package/src/cli/repositories/goal-registry.ts +0 -239
  435. package/src/runtime/assistant/action-proposals.ts +0 -279
  436. package/src/runtime/assistant/intent.ts +0 -395
  437. package/src/runtime/assistant/model-provider.ts +0 -460
  438. package/src/runtime/assistant/openapi.ts +0 -223
  439. package/src/runtime/assistant/policy.ts +0 -89
  440. package/src/runtime/assistant/readiness.ts +0 -258
  441. package/src/runtime/assistant/routine-runtime.ts +0 -477
  442. package/src/runtime/assistant/schedule-binding.ts +0 -171
  443. package/src/runtime/assistant/standing-grants.ts +0 -375
  444. package/src/runtime/assistant/store.ts +0 -243
  445. package/src/runtime/assistant/types.ts +0 -135
  446. package/src/runtime/control-plane/goal-loop/config-facade.ts +0 -700
  447. package/src/runtime/control-plane/goal-loop/config-store.ts +0 -570
  448. package/src/runtime/control-plane/goal-loop/config-types.ts +0 -213
  449. package/src/runtime/control-plane/goal-loop/executor-router.ts +0 -109
  450. package/src/runtime/control-plane/goal-loop/failure-taxonomy.ts +0 -149
  451. package/src/runtime/control-plane/goal-loop/goal-contract-store.ts +0 -304
  452. package/src/runtime/control-plane/goal-loop/goal-loop-engine.ts +0 -1001
  453. package/src/runtime/control-plane/goal-loop/handoff-packet.ts +0 -160
  454. package/src/runtime/control-plane/goal-loop/index.ts +0 -13
  455. package/src/runtime/control-plane/goal-loop/local-tools.ts +0 -290
  456. package/src/runtime/control-plane/goal-loop/policy-gates.ts +0 -203
  457. package/src/runtime/control-plane/goal-loop/provider-dispatch.ts +0 -93
  458. package/src/runtime/control-plane/goal-loop/provider-registry.ts +0 -545
  459. package/src/runtime/control-plane/goal-loop/types.ts +0 -378
  460. package/src/runtime/control-plane/startup-recovery.ts +0 -198
  461. package/src/runtime/gateway/mcp/resource-policy.ts +0 -186
  462. package/src/runtime/personal-assistant/gmail-triage-manager.ts +0 -231
  463. package/src/runtime/personal-assistant/reporting-runtime.ts +0 -344
  464. package/src/runtime/personal-assistant/triage-runtime.ts +0 -748
  465. package/src/runtime/workflow/findings/store.ts +0 -204
  466. package/src/runtime/workflow/findings/types.ts +0 -58
  467. package/src/runtime/workflow/portfolio/engine.ts +0 -20
  468. package/src/runtime/workflow/portfolio/store.ts +0 -185
  469. package/src/runtime/workflow/portfolio/types.ts +0 -29
@@ -1,429 +1,107 @@
1
- import { existsSync, lstatSync, readdirSync, statSync } from "fs";
2
- import { gitSnapshot, readRepositoryRange, searchRepositoryMany } from "../repository/inspector";
3
- import { resolveMcpPath, globMatches } from "../mcp/paths";
1
+ import {
2
+ gitSnapshot,
3
+ getRepositoryReadSessionCache,
4
+ searchRepositoryMany,
5
+ searchRepositoryManyAsync,
6
+ searchRepositoryManyCacheIdentity,
7
+ } from "../repository/inspector";
8
+ import { globMatches } from "../mcp/paths";
9
+ import { posix } from "path";
4
10
  import type { McpPolicy } from "../mcp/types";
5
- import { redactMcpText } from "../mcp/redaction";
11
+ import { materializeSource } from "./context/source-materializer";
6
12
  import { projectBoard } from "./issue-store";
7
- import { buildControllerTaskLedgerProjection, type TaskLedgerTaskProjection } from "./task-ledger";
13
+ import { buildControllerTaskLedgerProjection } from "./task-ledger";
8
14
  import { legacyIssueAuthorityRetired } from "./legacy-issue-cutover";
9
15
  import {
10
16
  queryCodeGraphReadProvider,
11
- type CodeGraphIndexMetadata,
17
+ queryCodeGraphReadProviderAsync,
12
18
  type CodeGraphNodeSummary,
13
19
  type CodeGraphReadProviderResponse,
14
20
  } from "../../runtime/context/codegraph-read-provider";
21
+ import {
22
+ CONTEXT_PACK_SCHEMA_VERSION,
23
+ CONTROLLER_CONTEXT_IMPACT_DOMAINS,
24
+ type ControllerContextImpactDomain,
25
+ type ControllerContextPackDependencies,
26
+ type ControllerContextPackFile,
27
+ type ControllerContextPackOptions,
28
+ type ControllerContextPackProjection,
29
+ type ControllerContextPackSnippet,
30
+ } from './context/types';
31
+ import {
32
+ DEFAULT_MAX_CHARS_PER_SNIPPET,
33
+ DEFAULT_MAX_FILES,
34
+ DEFAULT_MAX_SNIPPETS,
35
+ DEFAULT_SEARCH_EXCLUDE_GLOBS,
36
+ IMPACT_DOMAIN_TERMS,
37
+ MAX_TOTAL_SEARCHED_FILES,
38
+ clamp,
39
+ cleanList,
40
+ gitStatusChangedPaths,
41
+ isCodeShapedTerm,
42
+ pathNoisePenalty,
43
+ structuralIntentQuery,
44
+ textTokens,
45
+ } from './context/query-planning';
46
+ import { issueTaskFocus, ledgerTask } from './context/focus';
47
+ import { addReason, coveredByGlob, expandKnownPath, looksLikeGlob, readableFile } from './context/known-paths';
48
+ import { structuralResult } from './context/structural';
49
+ import { buildRepositoryInstructionContext } from './context/instruction-context';
50
+ import { resolveRepositoryGovernance } from './context/rule-resolution';
51
+ import { controllerCheckSelection, listControllerChecks } from './check-runner';
15
52
 
16
- const CONTEXT_PACK_SCHEMA_VERSION = 6;
17
- const DEFAULT_MAX_FILES = 8;
18
- const DEFAULT_MAX_SNIPPETS = 20;
19
- const DEFAULT_SNIPPET_CONTEXT_BEFORE = 12;
20
- const DEFAULT_SNIPPET_CONTEXT_AFTER = 28;
21
- const DEFAULT_MAX_CHARS_PER_SNIPPET = 8000;
22
- const DEFAULT_SEARCH_EXCLUDE_GLOBS = [
23
- ".git/**",
24
- "_ops/**",
25
- ".forge/**",
26
- ".ai/harness/**",
27
- "node_modules/**",
28
- "dist/**",
29
- "coverage/**",
30
- "**/*.bak",
31
- ] as const;
32
- const MAX_TOTAL_SEARCHED_FILES = 800;
33
-
34
- const STOPWORDS = new Set([
35
- "about", "after", "again", "also", "and", "around", "because", "before", "between", "change", "code", "config",
36
- "context", "current", "does", "file", "from", "have", "into", "issue", "make", "need", "needs", "only", "path",
37
- "repo", "repository", "runtime", "should", "task", "that", "this", "through", "todo", "update", "when", "with",
38
- ]);
39
-
40
- export type StructuralContextMode = "off" | "auto" | "required";
41
- export type ControllerContextRetrievalMode = "implementation" | "plan" | "debug" | "review";
42
- export const CONTROLLER_CONTEXT_IMPACT_DOMAINS = [
43
- "persistence", "scheduler", "notification", "timeline", "events", "cache", "api", "concurrency",
44
- ] as const;
45
- export type ControllerContextImpactDomain = (typeof CONTROLLER_CONTEXT_IMPACT_DOMAINS)[number];
46
-
47
- const IMPACT_DOMAIN_TERMS: Record<ControllerContextImpactDomain, readonly string[]> = {
48
- persistence: ["persist", "database", "repository"],
49
- scheduler: ["scheduler", "schedule", "reminder"],
50
- notification: ["notification", "notify", "push"],
51
- timeline: ["timeline", "history", "activity"],
52
- events: ["event", "publish", "subscribe"],
53
- cache: ["cache", "invalidate", "memo"],
54
- api: ["api", "dto", "controller"],
55
- concurrency: ["transaction", "lock", "atomic"],
56
- };
57
-
58
- export interface ControllerContextPackOptions {
59
- description?: string;
60
- issueId?: string;
61
- taskId?: string;
62
- knownPaths?: string[];
63
- includeGlobs?: string[];
64
- excludeGlobs?: string[];
65
- searchTerms?: string[];
66
- maxFiles?: number;
67
- maxSnippets?: number;
68
- maxCharsPerSnippet?: number;
69
- structuralContext?: StructuralContextMode;
70
- /** Optional repository-level CodeGraph root used as structural baseline when the selected checkout has no local index. */
71
- structuralIndexRoot?: string;
72
- retrievalMode?: ControllerContextRetrievalMode;
73
- /** GPT-selected cross-cutting evidence dimensions. Forge expands mechanically and never treats them as semantic completeness proof. */
74
- impactDomains?: ControllerContextImpactDomain[];
75
- }
76
-
77
- export interface ControllerContextPackDependencies {
78
- queryCodeGraph?: typeof queryCodeGraphReadProvider;
79
- }
80
-
81
- export interface ControllerContextPackSnippet {
82
- path: string;
83
- startLine: number;
84
- endLine: number;
85
- totalLines: number;
86
- sha256: string;
87
- content: string;
88
- truncated: boolean;
89
- redactions: Array<{ type: string; count: number }>;
90
- reason: string;
91
- }
92
-
93
- export interface ControllerContextPackFile {
94
- path: string;
95
- reasons: string[];
96
- hitLines: number[];
97
- snippetCount: number;
98
- snippets: ControllerContextPackSnippet[];
99
- }
100
-
101
- export interface ControllerContextPackProjection {
102
- schemaVersion: typeof CONTEXT_PACK_SCHEMA_VERSION;
103
- generatedAt: string;
104
- source: "controller-context-pack";
105
- focus: {
106
- issueId?: string;
107
- issueTitle?: string;
108
- taskId?: string;
109
- taskTitle?: string;
110
- taskStatus?: string;
111
- };
112
- goal: string;
113
- git: {
114
- branch: string | null;
115
- status: string;
116
- diffStat: string;
117
- dirty: boolean;
118
- };
119
- search: {
120
- terms: string[];
121
- impactDomains: ControllerContextImpactDomain[];
122
- impactCoverage: Array<{
123
- domain: ControllerContextImpactDomain;
124
- queries: string[];
125
- matchedFiles: string[];
126
- selectedFiles: string[];
127
- omittedFileCount: number;
128
- status: "selected" | "matched_not_selected" | "no_evidence";
129
- }>;
130
- includeGlobs: string[];
131
- excludeGlobs: string[];
132
- scannedFiles: number;
133
- policyDeniedFiles: number;
134
- skippedLargeFiles: number;
135
- skippedBinaryFiles: number;
136
- truncated: boolean;
137
- };
138
- structuralContext: {
139
- provider: "codegraph";
140
- requestedMode: StructuralContextMode;
141
- status: "disabled" | "ready" | "stale" | "unavailable" | "degraded";
142
- requiredSatisfied: boolean;
143
- indexSource: "current_checkout" | "repository_baseline";
144
- overlayChangedFiles: string[];
145
- baselineRevisionMatches: boolean;
146
- query?: string;
147
- entryPoints: Array<Pick<CodeGraphNodeSummary, "id" | "kind" | "name" | "filePath" | "startLine" | "endLine">>;
148
- relatedFiles: string[];
149
- metadata?: Pick<CodeGraphIndexMetadata, "initialized" | "lastIndexedAt" | "buildVersion" | "extractionVersion" | "staleEngine" | "changedFiles" | "ignoredChangedFileCount">;
150
- fallbackReason?: string;
151
- timingsMs?: CodeGraphReadProviderResponse["timingsMs"];
152
- truncated: boolean;
153
- };
154
- impactContext: {
155
- status: "not_requested" | "ready" | "degraded";
156
- confidence: "low" | "medium" | "high";
157
- primaryTargets: string[];
158
- mustInspect: string[];
159
- likelyAffected: string[];
160
- relevantTests: string[];
161
- relevantChecks: string[];
162
- architectureContracts: string[];
163
- coverageGaps: string[];
164
- relationSources: Array<"codegraph" | "lexical" | "forge_checks">;
165
- freshness: {
166
- structuralStatus: "disabled" | "ready" | "stale" | "unavailable" | "degraded";
167
- lastIndexedAt?: number | null;
168
- changedFileCount: number;
169
- indexSource: "current_checkout" | "repository_baseline";
170
- overlayChangedFileCount: number;
171
- baselineRevisionMatches: boolean;
172
- };
173
- };
174
- files: ControllerContextPackFile[];
175
- deniedPaths: Array<{ path: string; reason: string }>;
176
- omitted: Array<{ path: string; reason: string }>;
177
- limits: {
178
- maxFiles: number;
179
- maxSnippets: number;
180
- maxCharsPerSnippet: number;
181
- };
182
- validation: {
183
- policy: "task-targeted" | "minimal";
184
- checks: string[];
185
- };
186
- contextContract: {
187
- strategy: string;
188
- retrievalMode: ControllerContextRetrievalMode;
189
- semanticSufficiencyAuthority: "chatgpt";
190
- rawCodeRequiredForImplementation: boolean;
191
- expansionSignals: string[];
192
- notes: string[];
193
- };
194
- next: string[];
195
- }
196
-
197
- function cleanList(value: string[] | undefined): string[] {
198
- return Array.from(new Set((value ?? []).map((entry) => entry.trim()).filter(Boolean)));
199
- }
200
-
201
- function clamp(value: number | undefined, fallback: number, min: number, max: number): number {
202
- if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
203
- return Math.min(Math.max(Math.trunc(value), min), max);
204
- }
205
-
206
- function textTokens(value: string): string[] {
207
- const split = value
208
- .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
209
- .split(/[^\p{L}\p{N}_./:-]+/u)
210
- .map((entry) => entry.trim())
211
- .filter(Boolean);
212
- const tokens = split
213
- .map((entry) => entry.replace(/^['"`]+|['"`]+$/g, ""))
214
- .filter((entry) => entry.length >= 3)
215
- .filter((entry) => !STOPWORDS.has(entry.toLowerCase()))
216
- .filter((entry) => !/^\d+$/.test(entry));
217
- return Array.from(new Set(tokens)).slice(0, 12);
218
- }
219
-
220
- interface ContextPackIssueFocus {
221
- id?: string;
222
- title?: string;
223
- summary?: string;
224
- tasks: ContextPackTaskFocus[];
225
- }
226
-
227
- interface ContextPackTaskFocus {
228
- id?: string;
229
- title?: string;
230
- objective?: string;
231
- status?: string;
232
- }
233
-
234
- function unknownRecord(value: unknown): Record<string, unknown> {
235
- return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
236
- }
237
-
238
- function unknownString(value: unknown): string | undefined {
239
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
240
- }
241
-
242
- function unknownRecordArray(value: unknown): Array<Record<string, unknown>> {
243
- if (!Array.isArray(value)) return [];
244
- return value.map(unknownRecord).filter((entry) => Object.keys(entry).length > 0);
245
- }
246
-
247
- function contextPackTaskFocus(value: unknown): ContextPackTaskFocus {
248
- const record = unknownRecord(value);
249
- return {
250
- id: unknownString(record.id),
251
- title: unknownString(record.title),
252
- objective: unknownString(record.objective),
253
- status: unknownString(record.status),
254
- };
255
- }
256
-
257
- function contextPackIssueFocus(value: unknown): ContextPackIssueFocus {
258
- const record = unknownRecord(value);
259
- return {
260
- id: unknownString(record.id),
261
- title: unknownString(record.title),
262
- summary: unknownString(record.summary),
263
- tasks: unknownRecordArray(record.tasks).map(contextPackTaskFocus),
264
- };
265
- }
266
-
267
- function issueTaskFocus(board: ReturnType<typeof projectBoard>, issueId?: string, taskId?: string): { issue?: ContextPackIssueFocus; task?: ContextPackTaskFocus } {
268
- const issues = board.issues.map(contextPackIssueFocus);
269
- const resolvedIssue = issueId
270
- ? issues.find((issue) => issue.id === issueId)
271
- : board.currentIssueId
272
- ? issues.find((issue) => issue.id === board.currentIssueId)
273
- : undefined;
274
- const resolvedTask = resolvedIssue?.tasks.find((task) => task.id === taskId) ?? resolvedIssue?.tasks[0];
275
- return { issue: resolvedIssue, task: resolvedTask };
276
- }
277
-
278
- function ledgerTask(ledger: ReturnType<typeof buildControllerTaskLedgerProjection>, issueId?: string, taskId?: string): TaskLedgerTaskProjection | undefined {
279
- const tasks = ledger.issues.flatMap((issue) => issue.tasks);
280
- const findTask = (candidateIssueId?: string, candidateTaskId?: string) => tasks
281
- .find((task) => (!candidateIssueId || task.issueId === candidateIssueId) && (!candidateTaskId || task.taskId === candidateTaskId));
282
-
283
- if (!issueId && !taskId) {
284
- const readyTask = ledger.readyTasks[0];
285
- return ledger.attention[0]
286
- ?? findTask(readyTask?.issueId, readyTask?.taskId)
287
- ?? tasks.find((task) => task.dispatchable || task.queueable);
288
- }
289
-
290
- return findTask(issueId, taskId);
291
- }
292
-
293
- function looksLikeGlob(path: string): boolean {
294
- return /[*?{[]/.test(path);
295
- }
296
-
297
- function coveredByGlob(path: string, globs: string[]): boolean {
298
- return globs.length === 0 || globs.some((glob) => globMatches(glob, path));
299
- }
300
-
301
- function gitStatusChangedPaths(status: string): string[] {
302
- const paths: string[] = [];
303
- for (const line of status.split(/\r?\n/)) {
304
- if (!line.trim() || line.startsWith("##") || line.length < 4) continue;
305
- let path = line.slice(3).trim();
306
- if (path.includes(" -> ")) path = path.slice(path.lastIndexOf(" -> ") + 4).trim();
307
- if (path.startsWith('"') && path.endsWith('"')) path = path.slice(1, -1);
308
- if (path && !paths.includes(path)) paths.push(path);
309
- }
310
- return paths;
311
- }
312
-
313
- function addReason(map: Map<string, { reasons: Set<string>; lines: Set<number> }>, path: string, reason: string, line?: number): void {
314
- const entry = map.get(path) ?? { reasons: new Set<string>(), lines: new Set<number>() };
315
- entry.reasons.add(reason);
316
- if (typeof line === "number" && Number.isFinite(line)) entry.lines.add(Math.max(1, Math.trunc(line)));
317
- map.set(path, entry);
318
- }
319
-
320
- function readableFile(repoRoot: string, policy: McpPolicy, path: string): { ok: true; path: string } | { ok: false; path: string; reason: string } {
321
- const decision = resolveMcpPath(repoRoot, path, policy, "read");
322
- if (!decision.ok || !decision.relativePath || !decision.absolutePath) {
323
- return { ok: false, path: decision.relativePath ?? path, reason: decision.reason ?? "path denied" };
324
- }
325
- if (!existsSync(decision.absolutePath)) return { ok: false, path: decision.relativePath, reason: "path does not exist" };
326
- if (!statSync(decision.absolutePath).isFile()) return { ok: false, path: decision.relativePath, reason: "path is not a file" };
327
- return { ok: true, path: decision.relativePath };
328
- }
329
-
330
- interface ExpandedKnownPath {
331
- files: string[];
332
- denied: Array<{ path: string; reason: string }>;
333
- directory?: string;
334
- truncated: boolean;
335
- }
53
+ export { CONTROLLER_CONTEXT_IMPACT_DOMAINS } from './context/types';
54
+ export type {
55
+ ControllerContextImpactDomain,
56
+ ControllerContextPackDependencies,
57
+ ControllerContextPackFile,
58
+ ControllerContextPackOptions,
59
+ ControllerContextPackProjection,
60
+ ControllerContextPackSnippet,
61
+ ControllerContextRetrievalMode,
62
+ ControllerEvidenceReadiness,
63
+ ControllerContextExpansionState,
64
+ StructuralContextMode,
65
+ } from './context/types';
336
66
 
337
- /**
338
- * Expand an explicit file or directory without following symlinks. Every file
339
- * is re-checked through resolveMcpPath so directory support never broadens the
340
- * policy boundary. Enumeration is deterministic and bounded.
341
- */
342
- function expandKnownPath(
343
- repoRoot: string,
344
- policy: McpPolicy,
345
- path: string,
346
- maxFiles: number,
347
- ): ExpandedKnownPath {
348
- const decision = resolveMcpPath(repoRoot, path, policy, "read");
349
- if (!decision.ok || !decision.relativePath || !decision.absolutePath) {
350
- return { files: [], denied: [{ path: decision.relativePath ?? path, reason: decision.reason ?? "path denied" }], truncated: false };
351
- }
352
- if (!existsSync(decision.absolutePath)) {
353
- return { files: [], denied: [{ path: decision.relativePath, reason: "path does not exist" }], truncated: false };
354
- }
355
- const rootStat = lstatSync(decision.absolutePath);
356
- if (rootStat.isSymbolicLink()) {
357
- return { files: [], denied: [{ path: decision.relativePath, reason: "symbolic links are not followed" }], truncated: false };
358
- }
359
- if (rootStat.isFile()) return { files: [decision.relativePath], denied: [], truncated: false };
360
- if (!rootStat.isDirectory()) {
361
- return { files: [], denied: [{ path: decision.relativePath, reason: "path is neither a regular file nor directory" }], truncated: false };
362
- }
67
+ const MAX_CONTEXT_EVIDENCE_WAVES = 3;
68
+ const SOURCE_REFERENCE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json'] as const;
363
69
 
364
- const files: string[] = [];
365
- const denied: Array<{ path: string; reason: string }> = [];
366
- let truncated = false;
367
- const walk = (relativeDirectory: string, depth: number): void => {
368
- if (files.length >= maxFiles) { truncated = true; return; }
369
- if (depth > 8) { denied.push({ path: relativeDirectory, reason: "directory recursion depth exceeded" }); return; }
370
- const directoryDecision = resolveMcpPath(repoRoot, relativeDirectory, policy, "read");
371
- if (!directoryDecision.ok || !directoryDecision.absolutePath || !directoryDecision.relativePath) {
372
- denied.push({ path: directoryDecision.relativePath ?? relativeDirectory, reason: directoryDecision.reason ?? "path denied" });
373
- return;
374
- }
375
- let entries;
376
- try {
377
- entries = readdirSync(directoryDecision.absolutePath, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name));
378
- } catch (error) {
379
- denied.push({ path: directoryDecision.relativePath, reason: error instanceof Error ? error.message : String(error) });
380
- return;
381
- }
382
- for (const entry of entries) {
383
- if (files.length >= maxFiles) { truncated = true; break; }
384
- const child = `${directoryDecision.relativePath}/${entry.name}`.replace(/^\.\//, "");
385
- if (entry.isSymbolicLink()) {
386
- denied.push({ path: child, reason: "symbolic links are not followed" });
387
- } else if (entry.isDirectory()) {
388
- walk(child, depth + 1);
389
- } else if (entry.isFile()) {
390
- const readable = readableFile(repoRoot, policy, child);
391
- if (readable.ok) files.push(readable.path);
392
- else denied.push(readable);
393
- }
70
+ function sourceReferenceSpecifiers(contents: string): string[] {
71
+ const specifiers = new Set<string>();
72
+ const patterns = [
73
+ /\b(?:import|export)\s+(?:type\s+)?(?:[^'\"]*?\s+from\s+)?['\"]([^'\"]+)['\"]/g,
74
+ /\brequire\s*\(\s*['\"]([^'\"]+)['\"]\s*\)/g,
75
+ /\bimport\s*\(\s*['\"]([^'\"]+)['\"]\s*\)/g,
76
+ ];
77
+ for (const pattern of patterns) {
78
+ for (const match of contents.matchAll(pattern)) {
79
+ const value = match[1]?.trim();
80
+ if (value?.startsWith('.')) specifiers.add(value);
394
81
  }
395
- };
396
- walk(decision.relativePath, 0);
397
- return { files, denied, directory: decision.relativePath, truncated };
398
- }
399
-
400
- function boundedSnippet(content: string, maxChars: number): { content: string; truncated: boolean } {
401
- if (content.length <= maxChars) return { content, truncated: false };
402
- return { content: `${content.slice(0, maxChars)}\n... <snippet truncated>`, truncated: true };
82
+ }
83
+ return Array.from(specifiers).slice(0, 24);
403
84
  }
404
85
 
405
- function mergeHitLines(lines: number[]): number[] {
406
- const sorted = Array.from(new Set(lines.filter((line) => line > 0))).sort((a, b) => a - b);
407
- const merged: number[] = [];
408
- for (const line of sorted) {
409
- const previous = merged[merged.length - 1];
410
- if (previous !== undefined && line - previous <= DEFAULT_SNIPPET_CONTEXT_BEFORE + DEFAULT_SNIPPET_CONTEXT_AFTER) continue;
411
- merged.push(line);
86
+ function resolveSourceReference(repoRoot: string, policy: McpPolicy, sourcePath: string, specifier: string): string | undefined {
87
+ const base = posix.normalize(posix.join(posix.dirname(sourcePath), specifier));
88
+ if (!base || base === '..' || base.startsWith('../') || posix.isAbsolute(base)) return undefined;
89
+ const hasExtension = /\.[A-Za-z0-9]+$/.test(posix.basename(base));
90
+ const candidates = hasExtension
91
+ ? [base]
92
+ : [base, ...SOURCE_REFERENCE_EXTENSIONS.map((extension) => `${base}${extension}`), ...SOURCE_REFERENCE_EXTENSIONS.map((extension) => `${base}/index${extension}`)];
93
+ for (const candidate of candidates) {
94
+ const readable = readableFile(repoRoot, policy, candidate);
95
+ if (readable.ok) return readable.path;
412
96
  }
413
- return merged;
97
+ return undefined;
414
98
  }
415
99
 
416
- function structuralResult(response: CodeGraphReadProviderResponse): {
417
- nodes: CodeGraphNodeSummary[];
418
- entryPoints: CodeGraphNodeSummary[];
419
- relatedFiles: string[];
420
- truncated: boolean;
421
- } {
422
- const result = response.result ?? {};
423
- const nodes = Array.isArray(result.nodes) ? result.nodes as CodeGraphNodeSummary[] : [];
424
- const entryPoints = Array.isArray(result.entryPoints) ? result.entryPoints as CodeGraphNodeSummary[] : [];
425
- const relatedFiles = Array.isArray(result.relatedFiles) ? result.relatedFiles.filter((value): value is string => typeof value === "string") : [];
426
- return { nodes, entryPoints, relatedFiles, truncated: result.truncated === true };
100
+ function contextPathPriority(path: string, reasons: string[]): number {
101
+ const testBonus = /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(path) ? 6 : 0;
102
+ const sourceBonus = reasons.some((reason) => reason.startsWith('source-reference:')) ? 5 : 0;
103
+ const structuralBonus = reasons.some((reason) => reason.startsWith('codegraph:')) ? 3 : 0;
104
+ return testBonus + sourceBonus + structuralBonus;
427
105
  }
428
106
 
429
107
  export function buildControllerContextPack(
@@ -432,9 +110,13 @@ export function buildControllerContextPack(
432
110
  options: ControllerContextPackOptions = {},
433
111
  dependencies: ControllerContextPackDependencies = {},
434
112
  ): ControllerContextPackProjection {
113
+ const contextStartedAt = performance.now();
114
+ const timingsMs = { gitAndFocus: 0, structural: 0, lexical: 0, materialization: 0, governance: 0, total: 0, waveCount: 1, expansionBudgetUsed: 0, expansionBudgetMax: 2 };
435
115
  const retrievalMode = options.retrievalMode ?? "implementation";
436
- const maxFiles = clamp(options.maxFiles, DEFAULT_MAX_FILES, 1, 30);
437
- const maxSnippets = clamp(options.maxSnippets, DEFAULT_MAX_SNIPPETS, 1, 80);
116
+ const requestedMaxFiles = clamp(options.maxFiles, DEFAULT_MAX_FILES, 1, 30);
117
+ const requestedMaxSnippets = clamp(options.maxSnippets, DEFAULT_MAX_SNIPPETS, 1, 80);
118
+ let maxFiles = requestedMaxFiles;
119
+ let maxSnippets = requestedMaxSnippets;
438
120
  const maxCharsPerSnippet = clamp(options.maxCharsPerSnippet, DEFAULT_MAX_CHARS_PER_SNIPPET, 500, 50_000);
439
121
  const knownPaths = cleanList(options.knownPaths);
440
122
  const knownGlobs = knownPaths.filter(looksLikeGlob);
@@ -447,13 +129,16 @@ export function buildControllerContextPack(
447
129
  // Independent investigations must not inherit an unrelated repository focus.
448
130
  // Only bind Issue/Task context when the caller explicitly requests it.
449
131
  const hasExplicitFocus = Boolean(options.issueId || options.taskId);
450
- const git = gitSnapshot(repoRoot);
132
+ const git = gitSnapshot(repoRoot, options.session);
451
133
  const board = hasExplicitFocus && !legacyIssueAuthorityRetired(repoRoot) ? projectBoard(repoRoot) : undefined;
452
134
  const focus = board ? issueTaskFocus(board, options.issueId, options.taskId) : {};
453
135
  const ledger = board ? buildControllerTaskLedgerProjection(repoRoot, board) : undefined;
454
136
  const compactTask = ledger ? ledgerTask(ledger, focus.issue?.id, focus.task?.id) : undefined;
455
- const allowedPathGlobs = cleanList(compactTask?.allowedPaths);
456
- const searchIncludeGlobs = includeGlobs.length > 0 ? includeGlobs : allowedPathGlobs;
137
+ timingsMs.gitAndFocus = Math.round((performance.now() - contextStartedAt) * 100) / 100;
138
+ // Work allowed paths are an authorization boundary, not a claim that early
139
+ // investigation already discovered the complete semantic scope. Only an
140
+ // explicit retrieval include narrows discovery.
141
+ const searchIncludeGlobs = includeGlobs;
457
142
  const taskChecks = cleanList(compactTask?.checks);
458
143
  const goalParts = [
459
144
  options.description,
@@ -467,6 +152,7 @@ export function buildControllerContextPack(
467
152
  const impactDomains = cleanList(options.impactDomains)
468
153
  .filter((domain): domain is ControllerContextImpactDomain => CONTROLLER_CONTEXT_IMPACT_DOMAINS.includes(domain as ControllerContextImpactDomain))
469
154
  .slice(0, 6);
155
+ const retrievalIntent = [goal, ...terms].filter(Boolean).join(" ");
470
156
  const impactTermDomains = new Map<string, ControllerContextImpactDomain[]>();
471
157
  for (const domain of impactDomains) {
472
158
  for (const term of IMPACT_DOMAIN_TERMS[domain]) {
@@ -480,6 +166,11 @@ export function buildControllerContextPack(
480
166
  ...impactDomains.flatMap((domain) => IMPACT_DOMAIN_TERMS[domain]),
481
167
  ...terms.slice(1),
482
168
  ]).slice(0, 32);
169
+ // Preserve the guarantee for explicit code symbols while broad natural-
170
+ // language terms remain eligible for early discovery completion.
171
+ const requiredSearchQueries = cleanList(options.searchTerms)
172
+ .filter(isCodeShapedTerm)
173
+ .slice(0, 2);
483
174
  const impactCandidatePaths = new Map<ControllerContextImpactDomain, Set<string>>(impactDomains.map((domain) => [domain, new Set<string>()]));
484
175
  const candidates = new Map<string, { reasons: Set<string>; lines: Set<number> }>();
485
176
  const deniedPaths: Array<{ path: string; reason: string }> = [];
@@ -489,10 +180,15 @@ export function buildControllerContextPack(
489
180
  let skippedLargeFiles = 0;
490
181
  let skippedBinaryFiles = 0;
491
182
  let searchTruncated = false;
183
+ let lexicalCacheHit = false;
184
+ let structuralCacheHits = 0;
185
+ let structuralCacheMisses = 0;
186
+ let rangeCacheHits = 0;
187
+ let rangeCacheMisses = 0;
492
188
  const structuralMode = options.structuralContext ?? "off";
493
189
  const structuralIndexRoot = options.structuralIndexRoot?.trim() || repoRoot;
494
190
  const indexSource = structuralIndexRoot === repoRoot ? "current_checkout" : "repository_baseline";
495
- const overlayChangedFiles = indexSource === "repository_baseline" ? gitStatusChangedPaths(git.status) : [];
191
+ let overlayChangedFiles = indexSource === "repository_baseline" ? gitStatusChangedPaths(git.status) : [];
496
192
  const baselineRevisionMatches = indexSource === "current_checkout" || gitSnapshot(structuralIndexRoot).head === git.head;
497
193
  const graphImpactFiles = new Set<string>();
498
194
  let structuralContext: ControllerContextPackProjection["structuralContext"] = {
@@ -507,9 +203,26 @@ export function buildControllerContextPack(
507
203
  relatedFiles: [],
508
204
  truncated: false,
509
205
  };
206
+ const repositorySessionCache = getRepositoryReadSessionCache(repoRoot, options.session);
207
+ const queryCodeGraph = dependencies.queryCodeGraph ?? queryCodeGraphReadProvider;
208
+ const queryStructural = (queryRoot: string, request: Parameters<typeof queryCodeGraphReadProvider>[1]): CodeGraphReadProviderResponse => {
209
+ const key = JSON.stringify({ queryRoot, request });
210
+ const cached = repositorySessionCache?.getStructural(key);
211
+ if (cached && typeof cached === "object") {
212
+ structuralCacheHits += 1;
213
+ return cached as CodeGraphReadProviderResponse;
214
+ }
215
+ structuralCacheMisses += 1;
216
+ const response = queryCodeGraph(queryRoot, request);
217
+ if (response.error?.code !== CODEGRAPH_PREFETCH_DEFERRED_CODE) {
218
+ repositorySessionCache?.putStructural(key, response);
219
+ }
220
+ return response;
221
+ };
510
222
 
223
+ const structuralStartedAt = performance.now();
511
224
  if (structuralMode !== "off") {
512
- const structuralQuery = [goal || terms.join(" "), ...impactDomains].filter(Boolean).join(" ");
225
+ const structuralQuery = structuralIntentQuery(goal, terms, impactDomains);
513
226
  if (!structuralQuery) {
514
227
  structuralContext = {
515
228
  ...structuralContext,
@@ -518,8 +231,7 @@ export function buildControllerContextPack(
518
231
  fallbackReason: "No task description or search term was available for structural context.",
519
232
  };
520
233
  } else {
521
- const queryCodeGraph = dependencies.queryCodeGraph ?? queryCodeGraphReadProvider;
522
- const structural = queryCodeGraph(structuralIndexRoot, {
234
+ const structural = queryStructural(structuralIndexRoot, {
523
235
  operation: "context",
524
236
  query: structuralQuery,
525
237
  limit: Math.min(Math.max(maxFiles, 4), 12),
@@ -538,16 +250,28 @@ export function buildControllerContextPack(
538
250
  ignoredChangedFileCount: structural.metadata.ignoredChangedFileCount,
539
251
  }
540
252
  : undefined;
253
+ const providerChangedFiles = metadata
254
+ ? [...metadata.changedFiles.added, ...metadata.changedFiles.modified]
255
+ : [];
256
+ overlayChangedFiles = Array.from(new Set([...overlayChangedFiles, ...providerChangedFiles]));
257
+ const structuralEvidenceCurrent = structural.status === "ready"
258
+ && baselineRevisionMatches
259
+ && overlayChangedFiles.length === 0;
260
+ const structuralStatus = structural.status === "ready" && !structuralEvidenceCurrent
261
+ ? "stale"
262
+ : structural.status;
541
263
  structuralContext = {
542
264
  provider: "codegraph",
543
265
  requestedMode: structuralMode,
544
- status: indexSource === "repository_baseline" && (!baselineRevisionMatches || overlayChangedFiles.length > 0) && structural.status === "ready" ? "stale" : structural.status,
545
- requiredSatisfied: structuralMode !== "required" || (structural.status === "ready" && baselineRevisionMatches && overlayChangedFiles.length === 0),
266
+ status: structuralStatus,
267
+ requiredSatisfied: structuralMode !== "required" || structuralEvidenceCurrent,
546
268
  indexSource,
547
269
  overlayChangedFiles,
548
270
  baselineRevisionMatches,
549
271
  query: structuralQuery,
550
- entryPoints: graph.entryPoints.slice(0, 12).map((node) => ({
272
+ entryPoints: graph.entryPoints
273
+ .filter((node) => pathNoisePenalty(node.filePath, retrievalIntent, retrievalMode) < 15)
274
+ .slice(0, 12).map((node) => ({
551
275
  id: node.id,
552
276
  kind: node.kind,
553
277
  name: node.name,
@@ -555,7 +279,9 @@ export function buildControllerContextPack(
555
279
  startLine: node.startLine,
556
280
  endLine: node.endLine,
557
281
  })),
558
- relatedFiles: Array.from(new Set(graph.relatedFiles)).slice(0, 80),
282
+ relatedFiles: Array.from(new Set(graph.relatedFiles))
283
+ .filter((path) => pathNoisePenalty(path, retrievalIntent, retrievalMode) < 50)
284
+ .slice(0, 80),
559
285
  metadata,
560
286
  ...(structural.error ? { fallbackReason: `${structural.error.code}: ${structural.error.message}` } : {}),
561
287
  timingsMs: structural.timingsMs,
@@ -565,27 +291,31 @@ export function buildControllerContextPack(
565
291
  const addStructuralPath = (path: string, reason: string, line?: number): void => {
566
292
  if (!path || !coveredByGlob(path, searchIncludeGlobs) || excludeGlobs.some((glob) => globMatches(glob, path))) return;
567
293
  const readable = readableFile(repoRoot, policy, path);
568
- if (!readable.ok) {
294
+ if (readable.ok === false) {
569
295
  deniedPaths.push({ path: readable.path, reason: readable.reason });
570
296
  return;
571
297
  }
572
298
  addReason(candidates, readable.path, reason, line);
573
299
  };
574
- for (const node of graph.nodes.slice(0, maxFiles * 6)) {
575
- addStructuralPath(node.filePath, `codegraph:context:${node.kind}:${node.name}`, node.startLine);
576
- }
577
- for (const node of graph.entryPoints) {
578
- addStructuralPath(node.filePath, `codegraph:entry:${node.name}`, node.startLine);
579
- }
580
- for (const path of graph.relatedFiles.slice(0, maxFiles * 6)) addStructuralPath(path, "codegraph:related");
581
- // Context search identifies the structural entry points. For a bounded
582
- // number of those files, enrich the same Context Plane call with direct
583
- // file dependencies/dependents. This is impact evidence, not a second
584
- // routing/tool surface, and remains completely off for ordinary Direct.
585
- if (structural.status === "ready") {
300
+ // Structural relationships may only influence discovery/materialization
301
+ // when the graph is current for this exact source identity. Stale graph
302
+ // nodes remain visible through structuralContext/impactContext hints, but
303
+ // must never steer which raw source GPT sees first.
304
+ if (structuralEvidenceCurrent) {
305
+ for (const node of graph.nodes.slice(0, maxFiles * 6)) {
306
+ addStructuralPath(node.filePath, `codegraph:context:${node.kind}:${node.name}`, node.startLine);
307
+ }
308
+ for (const node of graph.entryPoints) {
309
+ addStructuralPath(node.filePath, `codegraph:entry:${node.name}`, node.startLine);
310
+ }
311
+ for (const path of graph.relatedFiles.slice(0, maxFiles * 6)) addStructuralPath(path, "codegraph:related");
312
+ // Context search identifies the structural entry points. For a bounded
313
+ // number of those files, enrich the same Context Plane call with direct
314
+ // file dependencies/dependents. This is impact evidence, not a second
315
+ // routing/tool surface, and remains completely off for ordinary Direct.
586
316
  const primaryFiles = Array.from(new Set(graph.entryPoints.map((node) => node.filePath).filter(Boolean))).slice(0, 4);
587
317
  for (const filePath of primaryFiles) {
588
- const adjacency = queryCodeGraph(structuralIndexRoot, { operation: "file_dependencies", filePath, limit: 40 });
318
+ const adjacency = queryStructural(structuralIndexRoot, { operation: "file_dependencies", filePath, limit: 40 });
589
319
  if (!adjacency.ok || adjacency.status !== "ready") continue;
590
320
  const dependencies = Array.isArray(adjacency.result?.dependencies)
591
321
  ? adjacency.result.dependencies.filter((value): value is string => typeof value === "string")
@@ -603,19 +333,23 @@ export function buildControllerContextPack(
603
333
  }
604
334
  }
605
335
  }
606
- for (const path of metadata?.changedFiles.added ?? []) addStructuralPath(path, "codegraph:changed-file", 1);
607
- for (const path of metadata?.changedFiles.modified ?? []) addStructuralPath(path, "codegraph:changed-file", 1);
608
336
  for (const path of metadata?.changedFiles.removed ?? []) omitted.push({ path, reason: "codegraph reports file removed since index" });
609
- for (const path of overlayChangedFiles) addStructuralPath(path, "worktree:changed-file", 1);
337
+ // A repository-baseline graph deliberately runs against another checkout.
338
+ // Current worktree changes are still authoritative raw-source evidence,
339
+ // but they come from Git rather than stale CodeGraph metadata.
340
+ if (indexSource === "repository_baseline") {
341
+ for (const path of gitStatusChangedPaths(git.status)) addStructuralPath(path, "worktree:changed-file", 1);
342
+ }
610
343
  }
611
344
  }
612
345
 
346
+ timingsMs.structural = Math.round((performance.now() - structuralStartedAt) * 100) / 100;
613
347
  const exactKnownFiles: string[] = [];
614
348
  let exactKnownFileScope = explicitKnownPaths.length > 0;
615
349
  for (const path of explicitKnownPaths) {
616
350
  const expanded = expandKnownPath(repoRoot, policy, path, Math.max(maxFiles * 4, 40));
617
351
  for (const file of expanded.files) {
618
- addReason(candidates, file, expanded.directory ? `explicit-known-directory:${expanded.directory}` : "explicit-known-path", 1);
352
+ addReason(candidates, file, expanded.directory ? `explicit-known-directory:${expanded.directory}` : "explicit-known-path");
619
353
  }
620
354
  if (!expanded.directory && expanded.files.length === 1 && expanded.denied.length === 0 && !expanded.truncated) {
621
355
  exactKnownFiles.push(expanded.files[0]!);
@@ -626,10 +360,10 @@ export function buildControllerContextPack(
626
360
  scannedFiles += expanded.files.length;
627
361
  searchTruncated = searchTruncated || expanded.truncated;
628
362
  }
629
-
630
- for (const glob of allowedPathGlobs) {
631
- if (!includeGlobs.includes(glob)) includeGlobs.push(glob);
632
- }
363
+ // Exact caller-selected files own a reserved file slot. The inferred-file
364
+ // budget may be smaller, but another ranked candidate must never displace an
365
+ // exact known file. The overall response remains bounded by the hard cap.
366
+ maxFiles = Math.min(30, Math.max(requestedMaxFiles, exactKnownFiles.length));
633
367
 
634
368
  // Exact known files are already a caller-selected implementation scope. When
635
369
  // no broader impact/structural/include scope was requested, search those files
@@ -644,7 +378,9 @@ export function buildControllerContextPack(
644
378
  // Lexical retrieval is one bounded pass across candidate files for all terms.
645
379
  // The old per-term loop reread the same source files up to 14 times even
646
380
  // though inventory itself was cached.
647
- if (searchQueries.length > 0 && candidates.size < maxFiles * 3) {
381
+ const lexicalStartedAt = performance.now();
382
+ const requiredStructuralFallbackNeedsLexical = structuralMode === "required" && !structuralContext.requiredSatisfied;
383
+ if (searchQueries.length > 0 && (requiredStructuralFallbackNeedsLexical || candidates.size < maxFiles * 3)) {
648
384
  const search = searchRepositoryMany(repoRoot, policy, {
649
385
  queries: searchQueries,
650
386
  files: scopedExactKnownFileSearch ? exactKnownFiles : undefined,
@@ -654,7 +390,15 @@ export function buildControllerContextPack(
654
390
  maxFiles: scopedExactKnownFileSearch ? exactKnownFiles.length : MAX_TOTAL_SEARCHED_FILES,
655
391
  caseSensitive: false,
656
392
  cacheKey: JSON.stringify({ head: git.head, status: git.status, diffStat: git.diffStat }),
393
+ ...(!scopedExactKnownFileSearch ? {
394
+ completionMode: 'discovery' as const,
395
+ discoveryTargetFiles: Math.max(maxFiles * 2, 12),
396
+ discoveryMinQueryCoverage: Math.min(3, searchQueries.length),
397
+ requiredQueries: requiredSearchQueries,
398
+ } : {}),
399
+ session: options.session,
657
400
  });
401
+ lexicalCacheHit = search.cacheHit === true;
658
402
  scannedFiles += search.scannedFiles;
659
403
  policyDeniedFiles += search.policyDeniedFiles;
660
404
  skippedLargeFiles += search.skippedLargeFiles;
@@ -676,21 +420,50 @@ export function buildControllerContextPack(
676
420
  }
677
421
  }
678
422
 
423
+ timingsMs.lexical = Math.round((performance.now() - lexicalStartedAt) * 100) / 100;
679
424
  const primarySearchReason = terms.length > 0 ? `search:${terms[0]}` : undefined;
680
425
  const rankedCandidates = Array.from(candidates.entries())
681
426
  .map(([path, entry]) => ({ path, reasons: Array.from(entry.reasons), lines: Array.from(entry.lines) }))
682
427
  .sort((left, right) => {
683
- const leftExplicit = left.reasons.some((reason) => reason === "explicit-known-path" || reason.startsWith("explicit-known-directory:")) ? 1 : 0;
684
- const rightExplicit = right.reasons.some((reason) => reason === "explicit-known-path" || reason.startsWith("explicit-known-directory:")) ? 1 : 0;
428
+ const leftExplicit = left.reasons.includes("explicit-known-path")
429
+ ? 2
430
+ : left.reasons.some((reason) => reason.startsWith("explicit-known-directory:")) ? 1 : 0;
431
+ const rightExplicit = right.reasons.includes("explicit-known-path")
432
+ ? 2
433
+ : right.reasons.some((reason) => reason.startsWith("explicit-known-directory:")) ? 1 : 0;
685
434
  if (leftExplicit !== rightExplicit) return rightExplicit - leftExplicit;
686
435
  const leftPrimary = primarySearchReason && left.reasons.includes(primarySearchReason) ? 1 : 0;
687
436
  const rightPrimary = primarySearchReason && right.reasons.includes(primarySearchReason) ? 1 : 0;
688
437
  if (leftPrimary !== rightPrimary) return rightPrimary - leftPrimary;
438
+ const noise = pathNoisePenalty(left.path, retrievalIntent, retrievalMode)
439
+ - pathNoisePenalty(right.path, retrievalIntent, retrievalMode);
440
+ if (noise !== 0) return noise;
689
441
  if (left.reasons.length !== right.reasons.length) return right.reasons.length - left.reasons.length;
690
442
  if (left.lines.length !== right.lines.length) return right.lines.length - left.lines.length;
691
443
  return left.path.localeCompare(right.path);
692
444
  });
693
- const selected = rankedCandidates.slice(0, maxFiles);
445
+ const isExactKnownCandidate = (entry: { reasons: string[] }): boolean => entry.reasons.includes("explicit-known-path");
446
+ // Adaptive closure is enabled whenever the caller asks for investigation/review
447
+ // evidence or leaves structural discovery enabled. A deliberately narrow
448
+ // implementation read with structural discovery disabled stays single-wave.
449
+ // This keeps the common exact-file edit path cheap without making the default
450
+ // implementation mode a permanent evidence-closure blind spot.
451
+ const multiWaveEligible = retrievalMode !== "implementation"
452
+ || structuralMode !== "off"
453
+ || impactDomains.length > 0
454
+ || explicitKnownPaths.length > 1;
455
+ timingsMs.expansionBudgetMax = multiWaveEligible ? Math.min(6, Math.max(2, Math.ceil(maxFiles / 2))) : 0;
456
+ const rankedExactKnownCount = rankedCandidates.filter(isExactKnownCandidate).length;
457
+ const initialCandidateLimit = multiWaveEligible
458
+ ? Math.min(maxFiles, Math.max(rankedExactKnownCount, Math.max(1, maxFiles - timingsMs.expansionBudgetMax)))
459
+ : maxFiles;
460
+ const selected = rankedCandidates.slice(0, initialCandidateLimit);
461
+ const deferredSelected = rankedCandidates.slice(initialCandidateLimit, maxFiles);
462
+ const selectedExactKnownCount = selected.filter(isExactKnownCandidate).length;
463
+ // Reserve at least one materialization for every selected exact known file.
464
+ // This may raise a caller's inferred snippet budget, but never beyond the
465
+ // existing hard response cap.
466
+ maxSnippets = Math.min(80, Math.max(requestedMaxSnippets, selectedExactKnownCount));
694
467
  const selectedPaths = new Set(selected.map((entry) => entry.path));
695
468
  const impactCoverage = impactDomains.map((domain) => {
696
469
  const matchedFiles = [...(impactCandidatePaths.get(domain) ?? new Set<string>())].sort();
@@ -708,40 +481,45 @@ export function buildControllerContextPack(
708
481
 
709
482
  let remainingSnippets = maxSnippets;
710
483
  const files: ControllerContextPackFile[] = [];
711
- for (const entry of selected) {
484
+ const expansionDiscoveredPaths: string[] = [];
485
+ const expansionMaterializedPaths: string[] = [];
486
+ const materializationStartedAt = performance.now();
487
+ for (let selectedIndex = 0; selectedIndex < selected.length; selectedIndex += 1) {
488
+ const entry = selected[selectedIndex]!;
712
489
  if (remainingSnippets <= 0) {
713
490
  omitted.push({ path: entry.path, reason: "max_snippets" });
714
491
  continue;
715
492
  }
716
- const hitLines = mergeHitLines(entry.lines.length > 0 ? entry.lines : [1]).slice(0, remainingSnippets);
717
- const snippets: ControllerContextPackSnippet[] = [];
718
- for (const line of hitLines) {
719
- if (remainingSnippets <= 0) break;
720
- try {
721
- const raw = readRepositoryRange(
722
- repoRoot,
723
- policy,
724
- entry.path,
725
- Math.max(1, line - DEFAULT_SNIPPET_CONTEXT_BEFORE),
726
- line + DEFAULT_SNIPPET_CONTEXT_AFTER,
727
- );
728
- const redacted = redactMcpText(raw.content);
729
- const bounded = boundedSnippet(redacted.text, maxCharsPerSnippet);
730
- snippets.push({
731
- path: raw.path,
732
- startLine: raw.startLine,
733
- endLine: raw.endLine,
734
- totalLines: raw.totalLines,
735
- sha256: raw.sha256,
736
- content: bounded.content,
737
- truncated: bounded.truncated,
738
- redactions: redacted.redactions,
739
- reason: entry.reasons.join(", "),
740
- });
741
- remainingSnippets -= 1;
742
- } catch (error) {
743
- deniedPaths.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
493
+ const exactKnown = isExactKnownCandidate(entry);
494
+ const exactKnownAfter = selected
495
+ .slice(selectedIndex + 1)
496
+ .filter(isExactKnownCandidate)
497
+ .length;
498
+ const waveOnePerFileCap = multiWaveEligible
499
+ ? Math.max(1, Math.ceil(maxSnippets / Math.max(1, initialCandidateLimit + timingsMs.expansionBudgetMax)))
500
+ : remainingSnippets;
501
+ const fileSnippetBudget = exactKnown
502
+ ? Math.max(1, remainingSnippets - exactKnownAfter)
503
+ : Math.min(remainingSnippets, waveOnePerFileCap);
504
+ let snippets: ControllerContextPackSnippet[] = [];
505
+ try {
506
+ snippets = materializeSource({
507
+ repoRoot,
508
+ policy,
509
+ path: entry.path,
510
+ hitLines: entry.lines.length > 0 ? entry.lines : [1],
511
+ reasons: entry.reasons,
512
+ maxSnippets: Math.min(fileSnippetBudget, remainingSnippets),
513
+ maxCharsPerSnippet,
514
+ session: options.session,
515
+ });
516
+ for (const snippet of snippets) {
517
+ if (snippet.cacheHit) rangeCacheHits += 1;
518
+ else rangeCacheMisses += 1;
744
519
  }
520
+ remainingSnippets -= snippets.length;
521
+ } catch (error) {
522
+ deniedPaths.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
745
523
  }
746
524
  if (snippets.length > 0) {
747
525
  files.push({
@@ -754,11 +532,152 @@ export function buildControllerContextPack(
754
532
  }
755
533
  }
756
534
 
535
+ // Evidence-closure eligible requests perform bounded internal waves after
536
+ // Wave 1 raw source materialization. Each later wave derives concrete paths
537
+ // from the source that was just read plus current CodeGraph adjacency, then
538
+ // materializes those paths in the same context request.
539
+ const materializedPathSet = new Set(files.map((file) => file.path));
540
+ let frontier = files.slice();
541
+ if (multiWaveEligible && frontier.length > 0 && timingsMs.expansionBudgetMax > 0) {
542
+ for (let wave = 2; wave <= MAX_CONTEXT_EVIDENCE_WAVES; wave += 1) {
543
+ if (frontier.length === 0 || timingsMs.expansionBudgetUsed >= timingsMs.expansionBudgetMax || files.length >= maxFiles || remainingSnippets <= 0) break;
544
+ const expansionReasons = new Map<string, { reasons: Set<string>; lines: Set<number> }>();
545
+ const addExpansionCandidate = (path: string, reason: string, line = 1): void => {
546
+ if (!path || materializedPathSet.has(path) || !coveredByGlob(path, searchIncludeGlobs) || excludeGlobs.some((glob) => globMatches(glob, path))) return;
547
+ const readable = readableFile(repoRoot, policy, path);
548
+ if (!readable.ok) {
549
+ if (/denied|policy/i.test(readable.reason)) deniedPaths.push({ path: readable.path, reason: readable.reason });
550
+ return;
551
+ }
552
+ const current = expansionReasons.get(readable.path) ?? { reasons: new Set<string>(), lines: new Set<number>() };
553
+ current.reasons.add(reason);
554
+ current.lines.add(line);
555
+ expansionReasons.set(readable.path, current);
556
+ };
557
+
558
+ for (const file of frontier.slice(0, 6)) {
559
+ const contents = file.snippets.map((snippet) => snippet.content).join('\n');
560
+ for (const specifier of sourceReferenceSpecifiers(contents)) {
561
+ const resolved = resolveSourceReference(repoRoot, policy, file.path, specifier);
562
+ if (resolved) addExpansionCandidate(resolved, `source-reference:${file.path}`);
563
+ }
564
+ if (structuralContext.status !== 'ready') continue;
565
+ const adjacency = queryStructural(structuralIndexRoot, { operation: 'file_dependencies', filePath: file.path, limit: 40 });
566
+ if (!adjacency.ok || adjacency.status !== 'ready') continue;
567
+ const dependencies = Array.isArray(adjacency.result?.dependencies)
568
+ ? adjacency.result.dependencies.filter((value): value is string => typeof value === 'string')
569
+ : [];
570
+ const dependents = Array.isArray(adjacency.result?.dependents)
571
+ ? adjacency.result.dependents.filter((value): value is string => typeof value === 'string')
572
+ : [];
573
+ for (const path of dependencies.slice(0, 40)) {
574
+ graphImpactFiles.add(path);
575
+ addExpansionCandidate(path, `codegraph:dependency:${file.path}`);
576
+ }
577
+ for (const path of dependents.slice(0, 40)) {
578
+ graphImpactFiles.add(path);
579
+ addExpansionCandidate(path, `codegraph:dependent:${file.path}`);
580
+ }
581
+ }
582
+
583
+ const expansionCandidates = Array.from(expansionReasons.entries())
584
+ .map(([path, entry]) => ({ path, reasons: Array.from(entry.reasons), lines: Array.from(entry.lines) }))
585
+ .sort((left, right) => {
586
+ const priority = contextPathPriority(right.path, right.reasons) - contextPathPriority(left.path, left.reasons);
587
+ if (priority !== 0) return priority;
588
+ if (left.reasons.length !== right.reasons.length) return right.reasons.length - left.reasons.length;
589
+ return left.path.localeCompare(right.path);
590
+ });
591
+ expansionDiscoveredPaths.push(...expansionCandidates.map((entry) => entry.path));
592
+ if (expansionCandidates.length === 0) break;
593
+
594
+ const nextFrontier: ControllerContextPackFile[] = [];
595
+ for (const entry of expansionCandidates) {
596
+ if (timingsMs.expansionBudgetUsed >= timingsMs.expansionBudgetMax || files.length >= maxFiles || remainingSnippets <= 0) break;
597
+ try {
598
+ const snippets = materializeSource({
599
+ repoRoot,
600
+ policy,
601
+ path: entry.path,
602
+ hitLines: entry.lines.length > 0 ? entry.lines : [1],
603
+ reasons: entry.reasons,
604
+ maxSnippets: Math.min(3, remainingSnippets),
605
+ maxCharsPerSnippet,
606
+ session: options.session,
607
+ });
608
+ for (const snippet of snippets) {
609
+ if (snippet.cacheHit) rangeCacheHits += 1;
610
+ else rangeCacheMisses += 1;
611
+ }
612
+ remainingSnippets -= snippets.length;
613
+ if (snippets.length === 0) continue;
614
+ const materialized: ControllerContextPackFile = {
615
+ path: entry.path,
616
+ reasons: entry.reasons,
617
+ hitLines: Array.from(new Set(entry.lines)).sort((a, b) => a - b).slice(0, 30),
618
+ snippetCount: snippets.length,
619
+ snippets,
620
+ };
621
+ files.push(materialized);
622
+ materializedPathSet.add(entry.path);
623
+ expansionMaterializedPaths.push(entry.path);
624
+ nextFrontier.push(materialized);
625
+ timingsMs.expansionBudgetUsed += 1;
626
+ } catch (error) {
627
+ deniedPaths.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
628
+ }
629
+ }
630
+ if (nextFrontier.length === 0) break;
631
+ timingsMs.waveCount = wave;
632
+ frontier = nextFrontier;
633
+ }
634
+ }
635
+
636
+ // Fill any reserved first-pass candidates only after expansion has had a
637
+ // chance to use its bounded slots; this preserves ordinary discovery evidence
638
+ // when no higher-value relationship is found.
639
+ for (const entry of deferredSelected) {
640
+ if (files.length >= maxFiles || remainingSnippets <= 0 || materializedPathSet.has(entry.path)) continue;
641
+ try {
642
+ const snippets = materializeSource({
643
+ repoRoot,
644
+ policy,
645
+ path: entry.path,
646
+ hitLines: entry.lines.length > 0 ? entry.lines : [1],
647
+ reasons: entry.reasons,
648
+ maxSnippets: remainingSnippets,
649
+ maxCharsPerSnippet,
650
+ session: options.session,
651
+ });
652
+ for (const snippet of snippets) {
653
+ if (snippet.cacheHit) rangeCacheHits += 1;
654
+ else rangeCacheMisses += 1;
655
+ }
656
+ remainingSnippets -= snippets.length;
657
+ if (snippets.length > 0) {
658
+ files.push({
659
+ path: entry.path,
660
+ reasons: entry.reasons,
661
+ hitLines: Array.from(new Set(entry.lines)).sort((a, b) => a - b).slice(0, 30),
662
+ snippetCount: snippets.length,
663
+ snippets,
664
+ });
665
+ materializedPathSet.add(entry.path);
666
+ }
667
+ } catch (error) {
668
+ deniedPaths.push({ path: entry.path, reason: error instanceof Error ? error.message : String(error) });
669
+ }
670
+ }
671
+
672
+ timingsMs.materialization = Math.round((performance.now() - materializationStartedAt) * 100) / 100;
757
673
  if (files.length === 0 && terms.length === 0 && explicitKnownPaths.length === 0) {
758
674
  omitted.push({ path: "<none>", reason: "No description, search_terms, issue/task focus, or known_paths produced search terms." });
759
675
  }
760
676
 
761
677
  const rawSnippetTruncated = files.some((file) => file.snippets.some((snippet) => snippet.truncated));
678
+ const unresolvedExpansionPaths = Array.from(new Set(expansionDiscoveredPaths)).filter((path) => !files.some((file) => file.path === path));
679
+ const expansionBudgetExhausted = unresolvedExpansionPaths.length > 0
680
+ && (timingsMs.expansionBudgetUsed >= timingsMs.expansionBudgetMax || files.length >= maxFiles || remainingSnippets <= 0);
762
681
  const expansionSignals = [
763
682
  ...(files.length === 0 ? ["no_current_raw_source"] : []),
764
683
  ...(rawSnippetTruncated ? ["raw_snippet_truncated"] : []),
@@ -768,6 +687,7 @@ export function buildControllerContextPack(
768
687
  ...(structuralMode === "required" && !structuralContext.requiredSatisfied ? ["required_structural_context_unavailable"] : []),
769
688
  ...(policyDeniedFiles > 0 || deniedPaths.length > 0 ? ["policy_denied_candidates"] : []),
770
689
  ...(skippedLargeFiles > 0 ? ["large_candidates_skipped"] : []),
690
+ ...(expansionBudgetExhausted ? ["evidence_expansion_budget_exhausted"] : []),
771
691
  ...impactCoverage.flatMap((coverage) => coverage.status === "no_evidence"
772
692
  ? [`impact_domain_without_evidence:${coverage.domain}`]
773
693
  : coverage.status === "matched_not_selected"
@@ -777,12 +697,18 @@ export function buildControllerContextPack(
777
697
  const rawCodeRequiredForImplementation = retrievalMode !== "implementation"
778
698
  || files.length === 0
779
699
  || rawSnippetTruncated;
780
- const primaryTargets = Array.from(new Set(structuralContext.entryPoints.map((entry) => entry.filePath).filter(Boolean))).slice(0, 20);
700
+ const graphEntryPaths = Array.from(new Set(structuralContext.entryPoints.map((entry) => entry.filePath).filter(Boolean))).slice(0, 20);
701
+ const structuralEvidenceCurrent = structuralContext.status === "ready";
702
+ const primaryTargets = structuralEvidenceCurrent ? graphEntryPaths : [];
703
+ const structuralHints = structuralEvidenceCurrent
704
+ ? []
705
+ : Array.from(new Set([...graphEntryPaths, ...graphImpactFiles, ...structuralContext.relatedFiles])).slice(0, 80);
781
706
  const structuralUniverse = Array.from(new Set([
782
- ...primaryTargets,
707
+ ...graphEntryPaths,
783
708
  ...graphImpactFiles,
784
709
  ...structuralContext.relatedFiles,
785
710
  ...selected.map((entry) => entry.path),
711
+ ...files.map((file) => file.path),
786
712
  ])).slice(0, 160);
787
713
  const relevantTests = structuralUniverse.filter((path) => /(^|\/)(__tests__|tests?|spec)(\/|$)|\.(test|spec)\.[^.]+$/i.test(path)).slice(0, 40);
788
714
  const architectureContracts = structuralUniverse.filter((path) =>
@@ -791,7 +717,9 @@ export function buildControllerContextPack(
791
717
  || path.startsWith("docs/architecture/")
792
718
  || path.startsWith(".ai/context/"),
793
719
  ).slice(0, 40);
794
- const mustInspect = Array.from(new Set([...primaryTargets, ...graphImpactFiles, ...relevantTests, ...architectureContracts])).slice(0, 80);
720
+ const mustInspect = structuralEvidenceCurrent
721
+ ? Array.from(new Set([...primaryTargets, ...graphImpactFiles, ...relevantTests, ...architectureContracts])).slice(0, 80)
722
+ : [];
795
723
  const mustInspectSet = new Set(mustInspect);
796
724
  const likelyAffected = structuralUniverse.filter((path) => !mustInspectSet.has(path)).slice(0, 80);
797
725
  const changedFileCount = structuralContext.metadata
@@ -816,6 +744,7 @@ export function buildControllerContextPack(
816
744
  ? "medium"
817
745
  : "low",
818
746
  primaryTargets,
747
+ structuralHints,
819
748
  mustInspect,
820
749
  likelyAffected,
821
750
  relevantTests,
@@ -836,6 +765,111 @@ export function buildControllerContextPack(
836
765
  baselineRevisionMatches: structuralContext.baselineRevisionMatches,
837
766
  },
838
767
  };
768
+ const inspectedFiles = files.map((file) => file.path);
769
+ const instructionContext = buildRepositoryInstructionContext(
770
+ repoRoot,
771
+ policy,
772
+ [...exactKnownFiles, ...inspectedFiles],
773
+ { maxCharsPerContract: Math.min(maxCharsPerSnippet, 12_000) },
774
+ );
775
+ const governanceStartedAt = performance.now();
776
+ const governanceResolution = resolveRepositoryGovernance(repoRoot, policy, {
777
+ goal,
778
+ targetPaths: [...exactKnownFiles, ...inspectedFiles],
779
+ changedPaths: gitStatusChangedPaths(git.status),
780
+ });
781
+ const availableChecks = governanceResolution.recommendedCheckIds.length > 0
782
+ ? new Map(listControllerChecks(repoRoot).map((check) => [check.id, check]))
783
+ : new Map();
784
+ const governanceContext: ControllerContextPackProjection['governanceContext'] = {
785
+ ...governanceResolution,
786
+ recommendedChecks: governanceResolution.recommendedCheckIds.map((checkId) => {
787
+ const check = availableChecks.get(checkId);
788
+ const ruleIds = governanceResolution.activeRules.filter((rule) => rule.checkIds.includes(checkId)).map((rule) => rule.id).sort();
789
+ if (!check) return { checkId, ruleIds, available: false };
790
+ const selection = controllerCheckSelection(check);
791
+ return { checkId, ruleIds, available: true, ...selection };
792
+ }),
793
+ coverageGaps: [
794
+ ...governanceResolution.coverageGaps,
795
+ ...governanceResolution.recommendedCheckIds
796
+ .filter((checkId) => !availableChecks.has(checkId))
797
+ .map((checkId) => `governance_check_unregistered:${checkId}`),
798
+ ].filter((value, index, values) => values.indexOf(value) === index).slice(0, 80),
799
+ };
800
+ if (governanceContext.coverageGaps.length > 0 && governanceContext.status === 'ready') governanceContext.status = 'degraded';
801
+ timingsMs.governance = Math.round((performance.now() - governanceStartedAt) * 100) / 100;
802
+ const inspectedSet = new Set(inspectedFiles);
803
+ const likelyRelatedNotInspected = Array.from(new Set([
804
+ ...rankedCandidates.map((entry) => entry.path),
805
+ ...structuralContext.relatedFiles,
806
+ ...graphImpactFiles,
807
+ ...expansionDiscoveredPaths,
808
+ ])).filter((path) => !inspectedSet.has(path)).slice(0, 80);
809
+ const materializedExactKnownPaths = files
810
+ .filter((file) => file.reasons.includes("explicit-known-path"))
811
+ .map((file) => file.path);
812
+ const materializedExactKnownSet = new Set(materializedExactKnownPaths);
813
+ const unresolvedRelationships = Array.from(new Set([
814
+ ...coverageGaps,
815
+ ...expansionSignals,
816
+ ...(likelyRelatedNotInspected.length > 0 ? ["likely_related_files_not_inspected"] : []),
817
+ ])).slice(0, 80);
818
+ const materialRelationshipUnresolved = unresolvedExpansionPaths.length > 0;
819
+ const readinessReasonCodes = Array.from(new Set([
820
+ ...(files.length === 0 ? ["raw_source_unavailable"] : []),
821
+ ...(rawSnippetTruncated ? ["raw_source_truncated"] : []),
822
+ ...(searchTruncated ? ["retrieval_truncated"] : []),
823
+ ...(structuralMode === "required" && !structuralContext.requiredSatisfied ? ["required_structural_context_unsatisfied"] : []),
824
+ ...(structuralContext.status === "stale" ? ["structural_context_stale"] : []),
825
+ ...(structuralContext.status === "unavailable" || structuralContext.status === "degraded" ? ["structural_provider_degraded"] : []),
826
+ ...(policyDeniedFiles > 0 || deniedPaths.length > 0 ? ["policy_denied_evidence"] : []),
827
+ ...(skippedLargeFiles > 0 ? ["large_evidence_skipped"] : []),
828
+ ...(expansionBudgetExhausted ? ["expansion_budget_exhausted"] : []),
829
+ ...(materialRelationshipUnresolved ? ["material_relationship_unresolved"] : []),
830
+ ...(likelyRelatedNotInspected.length > 0 ? ["related_evidence_not_materialized"] : []),
831
+ ])).slice(0, 40);
832
+ const readinessStatus = files.length === 0
833
+ || (structuralMode === "required" && !structuralContext.requiredSatisfied)
834
+ || materialRelationshipUnresolved
835
+ ? "insufficient" as const
836
+ : readinessReasonCodes.length > 0
837
+ ? "degraded" as const
838
+ : "ready" as const;
839
+ const readiness: ControllerContextPackProjection["readiness"] = {
840
+ status: readinessStatus,
841
+ sourceRevision: git.head,
842
+ rawSource: {
843
+ status: files.length === 0 ? "unavailable" : rawSnippetTruncated ? "partial" : "current",
844
+ materializedFileCount: files.length,
845
+ snippetTruncated: rawSnippetTruncated,
846
+ },
847
+ structural: {
848
+ requested: structuralMode,
849
+ status: structuralContext.status,
850
+ requiredSatisfied: structuralContext.requiredSatisfied,
851
+ baselineRevisionMatches: structuralContext.baselineRevisionMatches,
852
+ overlayChangedFileCount: structuralContext.overlayChangedFiles.length,
853
+ },
854
+ semantic: { status: "not_requested", reasonCodes: ["semantic_navigation_not_requested"] },
855
+ retrieval: {
856
+ searchTruncated,
857
+ omittedCandidateCount: rankedCandidates.filter((entry) => !inspectedSet.has(entry.path)).length,
858
+ policyDeniedCandidateCount: policyDeniedFiles + deniedPaths.length,
859
+ likelyRelatedNotInspectedCount: likelyRelatedNotInspected.length,
860
+ },
861
+ unresolvedReasonCodes: readinessReasonCodes,
862
+ readyForHighConfidenceMutation: readinessStatus === "ready",
863
+ };
864
+ const expansion: ControllerContextPackProjection["expansion"] = {
865
+ waveCount: timingsMs.waveCount,
866
+ expansionPerformed: timingsMs.waveCount > 1,
867
+ expansionBudgetUsed: timingsMs.expansionBudgetUsed,
868
+ expansionBudgetMax: timingsMs.expansionBudgetMax,
869
+ discoveredPaths: Array.from(new Set(expansionDiscoveredPaths)).slice(0, 30),
870
+ materializedPaths: Array.from(new Set(expansionMaterializedPaths)).slice(0, 30),
871
+ };
872
+ timingsMs.total = Math.round((performance.now() - contextStartedAt) * 100) / 100;
839
873
 
840
874
  return {
841
875
  schemaVersion: CONTEXT_PACK_SCHEMA_VERSION,
@@ -861,31 +895,83 @@ export function buildControllerContextPack(
861
895
  skippedLargeFiles,
862
896
  skippedBinaryFiles,
863
897
  truncated: searchTruncated,
898
+ cacheHit: lexicalCacheHit,
864
899
  },
900
+ instructionContext,
901
+ governanceContext,
865
902
  structuralContext,
866
903
  impactContext,
904
+ readiness,
905
+ expansion,
867
906
  files,
907
+ coverage: {
908
+ inspectedFiles,
909
+ likelyRelatedNotInspected,
910
+ unresolvedRelationships,
911
+ relevantTests,
912
+ exactKnownPaths: {
913
+ requested: exactKnownFiles,
914
+ materialized: materializedExactKnownPaths,
915
+ missing: exactKnownFiles.filter((path) => !materializedExactKnownSet.has(path)),
916
+ },
917
+ skippedCandidates: {
918
+ omittedFiles: Math.max(0, rankedCandidates.length - selected.length),
919
+ policyDeniedFiles: policyDeniedFiles + deniedPaths.length,
920
+ largeFiles: skippedLargeFiles,
921
+ binaryFiles: skippedBinaryFiles,
922
+ searchTruncated,
923
+ structuralTruncated: structuralContext.truncated,
924
+ },
925
+ materialization: {
926
+ completeFiles: files.flatMap((file) => file.snippets).filter((snippet) => snippet.materialization === "complete_file").length,
927
+ symbols: files.flatMap((file) => file.snippets).filter((snippet) => snippet.materialization === "symbol").length,
928
+ fallbackWindows: files.flatMap((file) => file.snippets).filter((snippet) => snippet.materialization === "line_window").length,
929
+ },
930
+ },
931
+ cache: {
932
+ sessionBound: Boolean(options.session),
933
+ lexicalHit: lexicalCacheHit,
934
+ structuralHits: structuralCacheHits,
935
+ structuralMisses: structuralCacheMisses,
936
+ rangeHits: rangeCacheHits,
937
+ rangeMisses: rangeCacheMisses,
938
+ reused: lexicalCacheHit || structuralCacheHits > 0 || rangeCacheHits > 0,
939
+ },
940
+ timingsMs,
868
941
  deniedPaths: deniedPaths.slice(0, 20),
869
942
  omitted: omitted.slice(0, 30),
870
- limits: { maxFiles, maxSnippets, maxCharsPerSnippet },
943
+ limits: {
944
+ maxFiles,
945
+ maxSnippets,
946
+ maxCharsPerSnippet,
947
+ requestedMaxFiles,
948
+ requestedMaxSnippets,
949
+ reservedExactKnownFiles: selectedExactKnownCount,
950
+ },
871
951
  validation: {
872
952
  policy: taskChecks.length > 0 ? "task-targeted" : "minimal",
873
953
  checks: taskChecks,
874
954
  },
875
955
  contextContract: {
876
956
  strategy: retrievalMode === "implementation"
877
- ? "Use the returned current raw snippets plus bounded impact evidence as the implementation decision input. ChatGPT owns semantic sufficiency; do not re-read source solely because it arrived through search."
878
- : "Use this pack as investigation evidence and deliberately expand the evidence surface when the selected mode requires planning, debugging, or review.",
957
+ ? "Use one broad current-source pack to discover credible entry points, then progressively expand from returned paths and symbols. Lexical terms are heuristic hints rather than completeness requirements. ChatGPT owns semantic sufficiency and should prefer targeted known_paths, compiler semantic navigation for concrete TypeScript symbols, or structural relationships before repeating broad lexical discovery."
958
+ : "Use the bounded evidence-closure waves already performed inside this rh_context call. Repeat rh_context only when unresolved material relationships remain after the internal expansion budget, or when the Controller intentionally changes evidence scope.",
879
959
  retrievalMode,
880
960
  semanticSufficiencyAuthority: "chatgpt",
881
961
  rawCodeRequiredForImplementation,
882
962
  expansionSignals,
883
963
  notes: [
884
964
  retrievalMode === "implementation"
885
- ? "The pack already contains policy-approved current raw source snippets with file SHA identities. Request wider/exact ranges only when ChatGPT judges the impact surface ambiguous or an expansion signal makes the raw evidence mechanically incomplete."
886
- : "Investigation modes may intentionally expand exact ranges, structural relationships, tests, and neighboring modules before any implementation decision.",
887
- "Search/CodeGraph ranking is discovery evidence, not a business-semantics authority. impactContext makes the bounded evidence surface and coverage gaps explicit; ChatGPT still decides semantic sufficiency.",
965
+ ? "The pack contains policy-approved current raw source with file SHA identities, but returned snippets are bounded evidence rather than proof of semantic completeness. After reading this evidence, derive new exact paths, symbols, tests, or relationships from the source itself. A guessed lexical term with no result is not by itself a reason to keep scanning or repeat the same broad query."
966
+ : `Investigation mode performed ${timingsMs.waveCount} bounded evidence wave(s) inside this request; expansion budget ${timingsMs.expansionBudgetUsed}/${timingsMs.expansionBudgetMax}.`,
967
+ "Search/CodeGraph ranking is discovery evidence, not a business-semantics authority. readiness and impactContext make the bounded evidence surface and unresolved gaps explicit; ChatGPT still decides semantic sufficiency.",
968
+ instructionContext.contracts.length > 0
969
+ ? `Applicable repository guidance was resolved hierarchically for selected source paths: ${instructionContext.contracts.map((entry) => entry.path).join(", ")}. These AGENTS.md/CLAUDE.md files are guidance-only evidence and do not define semantic scope.`
970
+ : "No applicable AGENTS.md/CLAUDE.md guidance file was found for the selected source paths.",
888
971
  impactDomains.length > 0 ? `Impact domains were selected by ChatGPT and expanded mechanically in this same retrieval call: ${impactDomains.join(", ")}. Missing or omitted domain evidence is an ambiguity signal, not proof that the domain is irrelevant.` : "No explicit cross-cutting impact domains were requested; ChatGPT may add them when state, scheduling, notifications, events, caching, API, or concurrency could materially change the implementation.",
972
+ governanceContext.status === 'none'
973
+ ? "No repository governance rule registry is present; existing guidance and explicit Task checks behave as before."
974
+ : `Repository governance resolved ${governanceContext.activeRules.length} active rule(s), ${governanceContext.suppressedRules.length} suppressed rule(s), and ${governanceContext.recommendedCheckIds.length} recommended check(s) through bounded local matching. Recommendations are evidence only and never execute checks or create Work by themselves.`,
889
975
  "CodeGraph structural evidence is discovery evidence. Raw source access still passes through Forge repository policy and current-file reads.",
890
976
  structuralContext.status === "stale" ? "CodeGraph reports stale structural evidence. Treat graph relationships as hints and prefer the returned current raw source for changed files." : structuralContext.status === "unavailable" || structuralContext.status === "degraded" ? "Structural context was unavailable or degraded; bounded text search remains the fallback." : structuralContext.status === "ready" ? "CodeGraph structural context was queried read-only with index sync disabled." : "Structural context was not requested.",
891
977
  "Run focused validation after a coherent edit batch; expensive full-suite checks belong at candidate/release boundaries unless debugging specifically needs them earlier.",
@@ -898,7 +984,7 @@ export function buildControllerContextPack(
898
984
  : []),
899
985
  files.length > 0
900
986
  ? retrievalMode === "implementation"
901
- ? "ChatGPT should decide whether the impact coverage is sufficient; if yes, edit directly from the returned current raw snippets and SHA identities instead of issuing a mandatory second read."
987
+ ? "ChatGPT should decide whether impact coverage is sufficient. Edit when it is; otherwise derive the next exact paths/symbols/tests from this source and repeat rh_context narrowly. For concrete TypeScript symbols prefer semantic navigation; use another broad lexical pass only when no credible entry point exists or a dynamic/string/config edge still needs discovery."
902
988
  : "Expand exact ranges or neighboring evidence when that investigation can materially change the plan, diagnosis, or review conclusion."
903
989
  : "Provide known_paths or narrower search_terms before attempting implementation.",
904
990
  "After editing, review the bounded diff/evidence for the coherent edit batch.",
@@ -906,3 +992,258 @@ export function buildControllerContextPack(
906
992
  ],
907
993
  };
908
994
  }
995
+
996
+ /**
997
+ * Broad first-call fan-in for the canonical rh_context path. Lexical retrieval
998
+ * remains required evidence. Auto CodeGraph is opportunistic enrichment: it may
999
+ * finish inside the first-call latency budget, otherwise the request returns
1000
+ * bounded lexical/raw evidence while the same source-bound structural request
1001
+ * continues warming the session cache. Required structural mode still waits.
1002
+ */
1003
+ export const AUTO_STRUCTURAL_PREFETCH_BUDGET_MS = 100;
1004
+ export const AUTO_STRUCTURAL_PREFETCH_TIMEOUT_MS = 1_000;
1005
+ const CODEGRAPH_PREFETCH_DEFERRED_CODE = 'CODEGRAPH_PREFETCH_DEFERRED';
1006
+ const structuralPrefetchInflight = new Map<string, Promise<CodeGraphReadProviderResponse>>();
1007
+
1008
+ function codeGraphPrefetchDeferredResponse(elapsedMs: number): CodeGraphReadProviderResponse {
1009
+ return {
1010
+ schemaVersion: 1,
1011
+ provider: 'codegraph',
1012
+ operation: 'context',
1013
+ ok: false,
1014
+ status: 'degraded',
1015
+ error: {
1016
+ code: CODEGRAPH_PREFETCH_DEFERRED_CODE,
1017
+ message: `Optional CodeGraph enrichment exceeded the ${AUTO_STRUCTURAL_PREFETCH_BUDGET_MS}ms first-call budget and continues warming the source-bound session cache.`,
1018
+ },
1019
+ timingsMs: { total: Math.round(elapsedMs * 100) / 100 },
1020
+ };
1021
+ }
1022
+
1023
+ async function settleWithinBudget<T>(promise: Promise<T>, budgetMs: number): Promise<{ settled: true; value: T } | { settled: false }> {
1024
+ if (budgetMs <= 0) return { settled: false };
1025
+ let timer: NodeJS.Timeout | undefined;
1026
+ try {
1027
+ return await Promise.race([
1028
+ promise.then((value) => ({ settled: true as const, value })),
1029
+ new Promise<{ settled: false }>((resolveBudget) => {
1030
+ timer = setTimeout(() => resolveBudget({ settled: false }), budgetMs);
1031
+ timer.unref();
1032
+ }),
1033
+ ]);
1034
+ } finally {
1035
+ if (timer) clearTimeout(timer);
1036
+ }
1037
+ }
1038
+
1039
+ function cacheDeferredStructuralResult(
1040
+ sessionCache: ReturnType<typeof getRepositoryReadSessionCache>,
1041
+ structuralCacheKey: string,
1042
+ sourceIdentity: string | undefined,
1043
+ response: CodeGraphReadProviderResponse,
1044
+ ): void {
1045
+ if (!sessionCache || !sourceIdentity) return;
1046
+ if (response.error?.code === 'CODEGRAPH_TIMEOUT' || response.error?.code === CODEGRAPH_PREFETCH_DEFERRED_CODE) return;
1047
+ if (JSON.stringify(sessionCache.currentIdentity()) !== sourceIdentity) return;
1048
+ sessionCache.putStructural(structuralCacheKey, response);
1049
+ }
1050
+
1051
+ export async function buildControllerContextPackAsync(
1052
+ repoRoot: string,
1053
+ policy: McpPolicy,
1054
+ options: ControllerContextPackOptions = {},
1055
+ dependencies: ControllerContextPackDependencies = {},
1056
+ ): Promise<ControllerContextPackProjection> {
1057
+ const structuralMode = options.structuralContext ?? 'off';
1058
+ if ((dependencies.queryCodeGraph && !dependencies.queryCodeGraphAsync) || !options.session || options.issueId || options.taskId) {
1059
+ return buildControllerContextPack(repoRoot, policy, options, dependencies);
1060
+ }
1061
+ const prefetchStartedAt = performance.now();
1062
+ const retrievalMode = options.retrievalMode ?? 'implementation';
1063
+ const requestedMaxFiles = clamp(options.maxFiles, DEFAULT_MAX_FILES, 1, 30);
1064
+ const knownPaths = cleanList(options.knownPaths);
1065
+ const knownGlobs = knownPaths.filter(looksLikeGlob);
1066
+ const explicitKnownPaths = knownPaths.filter((path) => !looksLikeGlob(path));
1067
+ const includeGlobs = cleanList([...(options.includeGlobs ?? []), ...knownGlobs]);
1068
+ const excludeGlobs = cleanList([...DEFAULT_SEARCH_EXCLUDE_GLOBS, ...(options.excludeGlobs ?? [])]);
1069
+ const terms = cleanList([...(options.searchTerms ?? []), ...textTokens(options.description ?? '')]).slice(0, 14);
1070
+ const impactDomains = cleanList(options.impactDomains)
1071
+ .filter((domain): domain is ControllerContextImpactDomain => CONTROLLER_CONTEXT_IMPACT_DOMAINS.includes(domain as ControllerContextImpactDomain))
1072
+ .slice(0, 6);
1073
+ const exactKnownFiles: string[] = [];
1074
+ let exactKnownFileScope = explicitKnownPaths.length > 0;
1075
+ for (const path of explicitKnownPaths) {
1076
+ const expanded = expandKnownPath(repoRoot, policy, path, Math.max(requestedMaxFiles * 4, 40));
1077
+ if (!expanded.directory && expanded.files.length === 1 && expanded.denied.length === 0 && !expanded.truncated) {
1078
+ exactKnownFiles.push(expanded.files[0]!);
1079
+ } else {
1080
+ exactKnownFileScope = false;
1081
+ }
1082
+ }
1083
+ const maxFiles = Math.min(30, Math.max(requestedMaxFiles, exactKnownFiles.length));
1084
+ const scopedExactKnownFileSearch = exactKnownFileScope
1085
+ && retrievalMode === 'implementation'
1086
+ && structuralMode === 'off'
1087
+ && impactDomains.length === 0
1088
+ && includeGlobs.length === 0;
1089
+ const searchQueries = cleanList([
1090
+ ...(terms[0] ? [terms[0]] : []),
1091
+ ...impactDomains.flatMap((domain) => IMPACT_DOMAIN_TERMS[domain]),
1092
+ ...terms.slice(1),
1093
+ ]).slice(0, 32);
1094
+ const requiredSearchQueries = cleanList(options.searchTerms)
1095
+ .filter(isCodeShapedTerm)
1096
+ .slice(0, 2);
1097
+ const structuralQuery = structuralMode === 'off'
1098
+ ? ''
1099
+ : structuralIntentQuery(options.description ?? '', terms, impactDomains);
1100
+ const structuralIndexRoot = options.structuralIndexRoot?.trim() || repoRoot;
1101
+ const structuralRequest = {
1102
+ operation: 'context' as const,
1103
+ query: structuralQuery,
1104
+ limit: Math.min(Math.max(maxFiles, 4), 12),
1105
+ maxNodes: Math.min(Math.max(maxFiles * 5, 20), 60),
1106
+ maxDepth: 2,
1107
+ };
1108
+ const structuralCacheKey = JSON.stringify({ queryRoot: structuralIndexRoot, request: structuralRequest });
1109
+ const sessionCache = getRepositoryReadSessionCache(repoRoot, options.session);
1110
+ const structuralSourceIdentity = sessionCache ? JSON.stringify(sessionCache.currentIdentity()) : undefined;
1111
+ const cachedStructural = structuralQuery
1112
+ ? sessionCache?.peekStructural(structuralCacheKey) as CodeGraphReadProviderResponse | undefined
1113
+ : undefined;
1114
+ const lexicalOptions = searchQueries.length > 0
1115
+ ? (() => {
1116
+ const git = gitSnapshot(repoRoot, options.session);
1117
+ return {
1118
+ queries: searchQueries,
1119
+ files: scopedExactKnownFileSearch ? exactKnownFiles : undefined,
1120
+ includeGlobs: scopedExactKnownFileSearch ? undefined : includeGlobs,
1121
+ excludeGlobs,
1122
+ maxResultsPerQuery: Math.max(maxFiles * 4, 12),
1123
+ maxFiles: scopedExactKnownFileSearch ? exactKnownFiles.length : MAX_TOTAL_SEARCHED_FILES,
1124
+ caseSensitive: false,
1125
+ cacheKey: JSON.stringify({ head: git.head, status: git.status, diffStat: git.diffStat }),
1126
+ ...(!scopedExactKnownFileSearch ? {
1127
+ completionMode: 'discovery' as const,
1128
+ discoveryTargetFiles: Math.max(maxFiles * 2, 12),
1129
+ discoveryMinQueryCoverage: Math.min(3, searchQueries.length),
1130
+ requiredQueries: requiredSearchQueries,
1131
+ } : {}),
1132
+ };
1133
+ })()
1134
+ : undefined;
1135
+ const lexicalIdentity = lexicalOptions ? searchRepositoryManyCacheIdentity(lexicalOptions) : undefined;
1136
+ const cachedLexical = lexicalIdentity && sessionCache
1137
+ ? sessionCache.getSearch(lexicalIdentity.batchQueryKey, lexicalIdentity.includeKey)
1138
+ : undefined;
1139
+ if ((!structuralQuery || cachedStructural) && (!lexicalIdentity || cachedLexical?.result)) {
1140
+ const pack = buildControllerContextPack(repoRoot, policy, options, dependencies);
1141
+ pack.timingsMs.parallelFirstPass = false;
1142
+ pack.timingsMs.parallelPrefetch = 0;
1143
+ pack.timingsMs.structuralPrefetchBudgetMs = structuralMode === 'auto' && structuralQuery ? AUTO_STRUCTURAL_PREFETCH_BUDGET_MS : 0;
1144
+ pack.timingsMs.structuralPrefetchDeferred = false;
1145
+ pack.timingsMs.structuralPrefetchReusedInFlight = false;
1146
+ return pack;
1147
+ }
1148
+
1149
+ const noStructuralResponse: CodeGraphReadProviderResponse = {
1150
+ schemaVersion: 1,
1151
+ provider: 'codegraph',
1152
+ operation: 'context',
1153
+ ok: false,
1154
+ status: 'degraded',
1155
+ error: { code: 'CODEGRAPH_QUERY_REQUIRED', message: 'No context query was provided.' },
1156
+ timingsMs: { total: 0 },
1157
+ };
1158
+ let structuralSettled = Boolean(cachedStructural) || !structuralQuery;
1159
+ let structuralSettledValue: CodeGraphReadProviderResponse | undefined = cachedStructural || (!structuralQuery ? noStructuralResponse : undefined);
1160
+ const queryCodeGraphAsync = dependencies.queryCodeGraphAsync ?? queryCodeGraphReadProviderAsync;
1161
+ const structuralInflightKey = structuralQuery && structuralSourceIdentity
1162
+ ? JSON.stringify({ structuralCacheKey, sourceIdentity: structuralSourceIdentity, structuralMode })
1163
+ : undefined;
1164
+ const existingStructuralInflight = structuralInflightKey ? structuralPrefetchInflight.get(structuralInflightKey) : undefined;
1165
+ const structuralPrefetchReusedInFlight = Boolean(existingStructuralInflight);
1166
+ const launchedStructuralPromise = cachedStructural
1167
+ ? Promise.resolve(cachedStructural)
1168
+ : structuralQuery
1169
+ ? existingStructuralInflight ?? queryCodeGraphAsync(
1170
+ structuralIndexRoot,
1171
+ structuralRequest,
1172
+ structuralMode === 'auto' ? { timeoutMs: AUTO_STRUCTURAL_PREFETCH_TIMEOUT_MS } : {},
1173
+ )
1174
+ : Promise.resolve(noStructuralResponse);
1175
+ if (structuralInflightKey && structuralQuery && !cachedStructural && !existingStructuralInflight) {
1176
+ structuralPrefetchInflight.set(structuralInflightKey, launchedStructuralPromise);
1177
+ void launchedStructuralPromise.then(
1178
+ () => { if (structuralPrefetchInflight.get(structuralInflightKey) === launchedStructuralPromise) structuralPrefetchInflight.delete(structuralInflightKey); },
1179
+ () => { if (structuralPrefetchInflight.get(structuralInflightKey) === launchedStructuralPromise) structuralPrefetchInflight.delete(structuralInflightKey); },
1180
+ );
1181
+ }
1182
+ const structuralPromise = launchedStructuralPromise.then((response) => {
1183
+ structuralSettled = true;
1184
+ structuralSettledValue = response;
1185
+ return response;
1186
+ });
1187
+ const lexicalPromise = lexicalOptions && lexicalIdentity && !cachedLexical?.result
1188
+ ? searchRepositoryManyAsync(repoRoot, policy, lexicalOptions).then((result) => {
1189
+ sessionCache?.putSearch({
1190
+ query: lexicalIdentity.batchQueryKey,
1191
+ includeKey: lexicalIdentity.includeKey,
1192
+ result,
1193
+ scannedFiles: result.scannedFiles,
1194
+ });
1195
+ return result;
1196
+ })
1197
+ : Promise.resolve(cachedLexical?.result);
1198
+
1199
+ // Lexical/raw discovery is required first-call evidence. Auto structural work is
1200
+ // allowed to consume only the remainder of its budget measured from the start
1201
+ // of the parallel prefetch, so a slow lexical pass never gains an extra 100ms
1202
+ // structural tax after it completes.
1203
+ await lexicalPromise;
1204
+ let prefetched = structuralSettledValue ?? noStructuralResponse;
1205
+ let structuralPrefetchDeferred = false;
1206
+ if (structuralQuery && !cachedStructural) {
1207
+ if (structuralMode === 'required') {
1208
+ prefetched = await structuralPromise;
1209
+ } else if (structuralSettled && structuralSettledValue) {
1210
+ prefetched = structuralSettledValue;
1211
+ } else {
1212
+ const elapsedMs = performance.now() - prefetchStartedAt;
1213
+ // Reusing an already-deferred in-flight request should be effectively free:
1214
+ // do not make a progressive follow-up repay the first call's latency budget.
1215
+ const remainingBudgetMs = structuralPrefetchReusedInFlight
1216
+ ? 0
1217
+ : Math.max(0, AUTO_STRUCTURAL_PREFETCH_BUDGET_MS - elapsedMs);
1218
+ const outcome = await settleWithinBudget(structuralPromise, remainingBudgetMs);
1219
+ if (outcome.settled) {
1220
+ prefetched = outcome.value;
1221
+ } else {
1222
+ structuralPrefetchDeferred = true;
1223
+ prefetched = codeGraphPrefetchDeferredResponse(performance.now() - prefetchStartedAt);
1224
+ }
1225
+ }
1226
+ }
1227
+
1228
+ const fallbackQueryCodeGraph = dependencies.queryCodeGraph ?? queryCodeGraphReadProvider;
1229
+ const pack = buildControllerContextPack(repoRoot, policy, options, {
1230
+ queryCodeGraph: (queryRoot, request) => request.operation === 'context' && queryRoot === structuralIndexRoot
1231
+ ? prefetched
1232
+ : fallbackQueryCodeGraph(queryRoot, request),
1233
+ });
1234
+ pack.timingsMs.parallelFirstPass = true;
1235
+ pack.timingsMs.parallelPrefetch = Math.round((performance.now() - prefetchStartedAt) * 100) / 100;
1236
+ pack.timingsMs.structuralPrefetchBudgetMs = structuralMode === 'auto' && structuralQuery ? AUTO_STRUCTURAL_PREFETCH_BUDGET_MS : 0;
1237
+ pack.timingsMs.structuralPrefetchDeferred = structuralPrefetchDeferred;
1238
+ pack.timingsMs.structuralPrefetchReusedInFlight = structuralPrefetchReusedInFlight;
1239
+
1240
+ if (structuralPrefetchDeferred) {
1241
+ // The provider request is intentionally not cancelled. Cache only if the
1242
+ // repository source identity still matches the request that launched it;
1243
+ // a mutation must never let late structural evidence attach to newer source.
1244
+ void structuralPromise
1245
+ .then((response) => cacheDeferredStructuralResult(sessionCache, structuralCacheKey, structuralSourceIdentity, response))
1246
+ .catch(() => undefined);
1247
+ }
1248
+ return pack;
1249
+ }