@moretea-labs/forge 1.5.1 → 1.7.0

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