@adhdev/daemon-core 0.9.82-rc.49 → 0.9.82-rc.490

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 (473) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +9 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +68 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +220 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +137 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
  14. package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
  15. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  16. package/dist/cli-adapters/resolve-executable.d.ts +69 -0
  17. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  18. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  19. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  20. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  21. package/dist/commands/chat-commands-read.d.ts +23 -0
  22. package/dist/commands/chat-commands-scope.d.ts +39 -0
  23. package/dist/commands/chat-commands-shared.d.ts +33 -0
  24. package/dist/commands/chat-commands-write.d.ts +14 -0
  25. package/dist/commands/chat-commands.d.ts +10 -23
  26. package/dist/commands/cli-manager.d.ts +71 -1
  27. package/dist/commands/handler.d.ts +125 -0
  28. package/dist/commands/high-family/index.d.ts +3 -0
  29. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  30. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  31. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  32. package/dist/commands/high-family/types.d.ts +64 -0
  33. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  34. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  35. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  36. package/dist/commands/low-family/index.d.ts +3 -0
  37. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  38. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  39. package/dist/commands/low-family/notification.d.ts +2 -0
  40. package/dist/commands/low-family/refine-config.d.ts +2 -0
  41. package/dist/commands/low-family/session-host.d.ts +2 -0
  42. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  43. package/dist/commands/low-family/status-meta.d.ts +2 -0
  44. package/dist/commands/low-family/types.d.ts +33 -0
  45. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  46. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  47. package/dist/commands/med-family/ide.d.ts +10 -0
  48. package/dist/commands/med-family/index.d.ts +3 -0
  49. package/dist/commands/med-family/mesh-crud.d.ts +48 -0
  50. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  51. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  52. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  53. package/dist/commands/med-family/types.d.ts +152 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router-aggregate-status.d.ts +24 -0
  56. package/dist/commands/router-mesh-session-owner.d.ts +59 -0
  57. package/dist/commands/router-refine.d.ts +151 -0
  58. package/dist/commands/router-worktree-cleanup.d.ts +133 -0
  59. package/dist/commands/router.d.ts +203 -15
  60. package/dist/commands/upgrade-helper.d.ts +41 -1
  61. package/dist/config/chat-history.d.ts +11 -0
  62. package/dist/config/config.d.ts +19 -0
  63. package/dist/config/mesh-config.d.ts +65 -0
  64. package/dist/config/mesh-json-config.d.ts +199 -0
  65. package/dist/config/registry-resolver.d.ts +54 -0
  66. package/dist/config/repo-settings.d.ts +77 -0
  67. package/dist/config/state-store.d.ts +30 -0
  68. package/dist/daemon/dev-server.d.ts +0 -2
  69. package/dist/detection/cli-detector.d.ts +17 -0
  70. package/dist/detection/ide-detector.d.ts +13 -0
  71. package/dist/detection/win32-ide-version.d.ts +37 -0
  72. package/dist/git/change-impact-config.d.ts +159 -0
  73. package/dist/git/git-commands.d.ts +26 -2
  74. package/dist/git/git-diff.d.ts +6 -0
  75. package/dist/git/git-executor.d.ts +11 -0
  76. package/dist/git/git-status.d.ts +80 -0
  77. package/dist/git/git-types.d.ts +2 -50
  78. package/dist/git/git-worktree.d.ts +71 -1
  79. package/dist/git/index.d.ts +3 -1
  80. package/dist/index.d.ts +70 -17
  81. package/dist/index.js +53532 -19073
  82. package/dist/index.js.map +1 -1
  83. package/dist/index.mjs +53283 -18989
  84. package/dist/index.mjs.map +1 -1
  85. package/dist/installer.d.ts +1 -4
  86. package/dist/ipc/local-ipc-server.d.ts +91 -0
  87. package/dist/launch.d.ts +1 -1
  88. package/dist/logging/async-batch-writer.d.ts +10 -0
  89. package/dist/logging/debug-config.d.ts +16 -0
  90. package/dist/logging/log-redactor.d.ts +24 -0
  91. package/dist/logging/log-tail-reader.d.ts +81 -0
  92. package/dist/logging/logger.d.ts +1 -1
  93. package/dist/mesh/contracts.d.ts +256 -0
  94. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  95. package/dist/mesh/coordinator-registry.d.ts +59 -0
  96. package/dist/mesh/mesh-active-work.d.ts +203 -0
  97. package/dist/mesh/mesh-clone-grace.d.ts +8 -0
  98. package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
  99. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  100. package/dist/mesh/mesh-delivery-policy.d.ts +104 -0
  101. package/dist/mesh/mesh-event-classify.d.ts +7 -0
  102. package/dist/mesh/mesh-event-forwarding.d.ts +58 -0
  103. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  104. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  105. package/dist/mesh/mesh-events-pending.d.ts +258 -0
  106. package/dist/mesh/mesh-events-stale.d.ts +61 -0
  107. package/dist/mesh/mesh-events-utils.d.ts +75 -0
  108. package/dist/mesh/mesh-events.d.ts +6 -49
  109. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  110. package/dist/mesh/mesh-host-ownership.d.ts +21 -1
  111. package/dist/mesh/mesh-init.d.ts +128 -0
  112. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  113. package/dist/mesh/mesh-ledger.d.ts +139 -1
  114. package/dist/mesh/mesh-magi-status.d.ts +63 -0
  115. package/dist/mesh/mesh-missions.d.ts +281 -0
  116. package/dist/mesh/mesh-node-identity.d.ts +310 -0
  117. package/dist/mesh/mesh-queue-assignment.d.ts +162 -0
  118. package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
  119. package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
  120. package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
  121. package/dist/mesh/mesh-reconcile-loop.d.ts +84 -0
  122. package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
  123. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  124. package/dist/mesh/mesh-refine-gates.d.ts +457 -0
  125. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  126. package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
  127. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  128. package/dist/mesh/mesh-routing.d.ts +70 -0
  129. package/dist/mesh/mesh-runtime-store.d.ts +567 -0
  130. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  131. package/dist/mesh/mesh-task-inflight.d.ts +46 -0
  132. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  133. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +34 -0
  134. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  135. package/dist/mesh/mesh-work-queue.d.ts +392 -1
  136. package/dist/mesh/preview-freshness.d.ts +28 -0
  137. package/dist/mesh/refine-config.d.ts +97 -0
  138. package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
  139. package/dist/providers/acp-provider-instance.d.ts +5 -0
  140. package/dist/providers/approval-utils.d.ts +46 -0
  141. package/dist/providers/chat-message-normalization.d.ts +58 -1
  142. package/dist/providers/cli-provider-effect-format.d.ts +30 -0
  143. package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
  144. package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
  145. package/dist/providers/cli-provider-instance-types.d.ts +45 -0
  146. package/dist/providers/cli-provider-instance.d.ts +426 -30
  147. package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
  148. package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
  149. package/dist/providers/contracts.d.ts +196 -6
  150. package/dist/providers/external-sources.d.ts +71 -0
  151. package/dist/providers/manual-attendance.d.ts +79 -0
  152. package/dist/providers/native-history/antigravity-claim-registry.d.ts +39 -0
  153. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
  154. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  155. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  156. package/dist/providers/native-history/constants.d.ts +12 -0
  157. package/dist/providers/native-history/dispatcher.d.ts +44 -0
  158. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  159. package/dist/providers/native-history/index.d.ts +13 -0
  160. package/dist/providers/provider-instance-manager.d.ts +30 -0
  161. package/dist/providers/provider-instance.d.ts +31 -1
  162. package/dist/providers/provider-loader.d.ts +42 -5
  163. package/dist/providers/provider-trust.d.ts +31 -0
  164. package/dist/providers/read-chat-contract.d.ts +29 -0
  165. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  166. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  167. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  168. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  169. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  170. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  171. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  172. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  173. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  174. package/dist/providers/sdk/v1/index.d.ts +30 -0
  175. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  176. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  177. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  178. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  179. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  180. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  181. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  182. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  183. package/dist/providers/spec/adapter.d.ts +98 -0
  184. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  185. package/dist/providers/spec/evaluator.d.ts +45 -0
  186. package/dist/providers/spec/fsm-driver.d.ts +449 -0
  187. package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
  188. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  189. package/dist/providers/spec/fsm-types.d.ts +202 -0
  190. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  191. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  192. package/dist/providers/spec/route.d.ts +4 -0
  193. package/dist/providers/spec/types.d.ts +244 -0
  194. package/dist/providers/status-monitor.d.ts +7 -7
  195. package/dist/providers/transcript-v2.d.ts +176 -0
  196. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  197. package/dist/providers/working-dir.d.ts +17 -0
  198. package/dist/repo-mesh-types.d.ts +616 -12
  199. package/dist/runtime-defaults.d.ts +2 -0
  200. package/dist/session-host/managed-host.d.ts +64 -0
  201. package/dist/session-host/runtime-surface.d.ts +10 -16
  202. package/dist/sessions/registry.d.ts +26 -0
  203. package/dist/shared-types-extra.d.ts +2 -4
  204. package/dist/shared-types.d.ts +110 -55
  205. package/dist/status/chat-tail-hot-sessions.d.ts +40 -0
  206. package/dist/status/normalize.d.ts +1 -1
  207. package/dist/status/normalize.js +1 -0
  208. package/dist/status/normalize.js.map +1 -1
  209. package/dist/status/normalize.mjs +1 -0
  210. package/dist/status/normalize.mjs.map +1 -1
  211. package/dist/status/reporter.d.ts +2 -0
  212. package/dist/status/snapshot.d.ts +27 -0
  213. package/dist/system/hash.d.ts +8 -0
  214. package/dist/system/load-better-sqlite3.d.ts +21 -0
  215. package/dist/types.d.ts +20 -6
  216. package/package.json +11 -4
  217. package/src/agent-stream/poller.ts +2 -3
  218. package/src/agent-stream/provider-adapter.ts +1 -1
  219. package/src/boot/daemon-lifecycle.ts +80 -14
  220. package/src/boot/process-hardening.ts +89 -0
  221. package/src/build-info.ts +73 -0
  222. package/src/chat/source-machine.ts +534 -0
  223. package/src/chat/source-resolver.ts +0 -0
  224. package/src/chat/subscription-updates.ts +20 -1
  225. package/src/cli-adapter-types.d.ts +3 -1
  226. package/src/cli-adapter-types.ts +68 -2
  227. package/src/cli-adapters/cli-script-runner.ts +421 -0
  228. package/src/cli-adapters/cli-state-engine.ts +1282 -0
  229. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  230. package/src/cli-adapters/provider-cli-adapter.ts +1048 -1143
  231. package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
  232. package/src/cli-adapters/provider-cli-parse.ts +19 -0
  233. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  234. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  235. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  236. package/src/cli-adapters/pty-transport.ts +2 -1
  237. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  238. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  239. package/src/cli-adapters/resolve-executable.ts +204 -0
  240. package/src/cli-adapters/session-host-transport.ts +2 -1
  241. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
  242. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  243. package/src/cli-adapters/terminal-screen.ts +16 -81
  244. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  245. package/src/commands/chat-commands-read.ts +2788 -0
  246. package/src/commands/chat-commands-scope.ts +54 -0
  247. package/src/commands/chat-commands-shared.ts +114 -0
  248. package/src/commands/chat-commands-write.ts +891 -0
  249. package/src/commands/chat-commands.ts +19 -1841
  250. package/src/commands/cli-manager.d.ts +2 -0
  251. package/src/commands/cli-manager.ts +724 -27
  252. package/src/commands/handler.ts +865 -2
  253. package/src/commands/high-family/index.ts +28 -0
  254. package/src/commands/high-family/mesh-coordinator-launch.ts +696 -0
  255. package/src/commands/high-family/mesh-events.ts +102 -0
  256. package/src/commands/high-family/mesh-status.ts +857 -0
  257. package/src/commands/high-family/types.ts +80 -0
  258. package/src/commands/low-family/coordinator-prompt.ts +125 -0
  259. package/src/commands/low-family/daemon-lifecycle.ts +120 -0
  260. package/src/commands/low-family/diagnostics.ts +57 -0
  261. package/src/commands/low-family/index.ts +37 -0
  262. package/src/commands/low-family/mesh-ledger.ts +62 -0
  263. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  264. package/src/commands/low-family/notification.ts +116 -0
  265. package/src/commands/low-family/refine-config.ts +106 -0
  266. package/src/commands/low-family/session-host.ts +282 -0
  267. package/src/commands/low-family/spec-providerdev.ts +217 -0
  268. package/src/commands/low-family/status-meta.ts +112 -0
  269. package/src/commands/low-family/types.ts +39 -0
  270. package/src/commands/med-family/cli-agent.ts +270 -0
  271. package/src/commands/med-family/fast-forward.ts +230 -0
  272. package/src/commands/med-family/ide.ts +163 -0
  273. package/src/commands/med-family/index.ts +37 -0
  274. package/src/commands/med-family/mesh-crud.ts +1268 -0
  275. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  276. package/src/commands/med-family/mesh-queue.ts +167 -0
  277. package/src/commands/med-family/mesh-restart.ts +92 -0
  278. package/src/commands/med-family/types.ts +153 -0
  279. package/src/commands/mesh-coordinator.ts +334 -124
  280. package/src/commands/router-aggregate-status.ts +209 -0
  281. package/src/commands/router-mesh-session-owner.ts +114 -0
  282. package/src/commands/router-refine.ts +1794 -0
  283. package/src/commands/router-worktree-cleanup.ts +870 -0
  284. package/src/commands/router.ts +567 -4149
  285. package/src/commands/stream-commands.ts +8 -0
  286. package/src/commands/upgrade-helper.ts +310 -45
  287. package/src/config/chat-history.ts +517 -24
  288. package/src/config/config.ts +30 -0
  289. package/src/config/mesh-config.ts +300 -24
  290. package/src/config/mesh-json-config.ts +376 -0
  291. package/src/config/recent-activity.ts +8 -2
  292. package/src/config/registry-resolver.ts +100 -0
  293. package/src/config/repo-settings.ts +111 -0
  294. package/src/config/state-store.ts +55 -0
  295. package/src/daemon/dev-auto-implement.ts +3 -2
  296. package/src/daemon/dev-cli-debug.ts +10 -1
  297. package/src/daemon/dev-server.ts +0 -541
  298. package/src/detection/cli-detector.ts +94 -9
  299. package/src/detection/ide-detector.ts +55 -16
  300. package/src/detection/win32-ide-version.ts +106 -0
  301. package/src/git/change-impact-config.ts +354 -0
  302. package/src/git/git-commands.ts +112 -16
  303. package/src/git/git-diff.ts +81 -11
  304. package/src/git/git-executor.ts +12 -0
  305. package/src/git/git-status.ts +822 -48
  306. package/src/git/git-types.ts +14 -62
  307. package/src/git/git-worktree.ts +261 -4
  308. package/src/git/index.ts +17 -0
  309. package/src/index.ts +190 -13
  310. package/src/installer.d.ts +1 -1
  311. package/src/installer.ts +8 -6
  312. package/src/ipc/local-ipc-server.ts +278 -0
  313. package/src/launch.d.ts +1 -1
  314. package/src/launch.ts +37 -28
  315. package/src/logging/async-batch-writer.ts +55 -0
  316. package/src/logging/command-log.ts +7 -5
  317. package/src/logging/debug-config.ts +25 -0
  318. package/src/logging/debug-trace.ts +7 -2
  319. package/src/logging/log-redactor.ts +100 -0
  320. package/src/logging/log-tail-reader.ts +341 -0
  321. package/src/logging/logger.ts +14 -7
  322. package/src/mesh/contracts.ts +510 -0
  323. package/src/mesh/coordinator-prompt.ts +627 -34
  324. package/src/mesh/coordinator-registry.ts +121 -0
  325. package/src/mesh/mesh-active-work.ts +706 -0
  326. package/src/mesh/mesh-clone-grace.ts +68 -0
  327. package/src/mesh/mesh-completion-synthesis.ts +416 -0
  328. package/src/mesh/mesh-coordinator-config.ts +97 -0
  329. package/src/mesh/mesh-delivery-policy.ts +284 -0
  330. package/src/mesh/mesh-event-classify.ts +78 -0
  331. package/src/mesh/mesh-event-forwarding.ts +2030 -0
  332. package/src/mesh/mesh-event-trace.ts +67 -0
  333. package/src/mesh/mesh-events-coordinator.ts +32 -0
  334. package/src/mesh/mesh-events-pending.ts +1548 -0
  335. package/src/mesh/mesh-events-stale.ts +452 -0
  336. package/src/mesh/mesh-events-utils.ts +443 -0
  337. package/src/mesh/mesh-events.ts +43 -1035
  338. package/src/mesh/mesh-fast-forward.ts +856 -0
  339. package/src/mesh/mesh-host-ownership.ts +41 -3
  340. package/src/mesh/mesh-init.ts +350 -0
  341. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  342. package/src/mesh/mesh-ledger.ts +897 -102
  343. package/src/mesh/mesh-magi-status.ts +223 -0
  344. package/src/mesh/mesh-missions.ts +695 -0
  345. package/src/mesh/mesh-node-identity.ts +2065 -0
  346. package/src/mesh/mesh-queue-assignment.ts +2498 -0
  347. package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
  348. package/src/mesh/mesh-reconcile-config.ts +66 -0
  349. package/src/mesh/mesh-reconcile-identity.ts +103 -0
  350. package/src/mesh/mesh-reconcile-loop.ts +1696 -0
  351. package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
  352. package/src/mesh/mesh-refine-batch.ts +205 -0
  353. package/src/mesh/mesh-refine-gates.ts +1792 -0
  354. package/src/mesh/mesh-refine-status.ts +231 -0
  355. package/src/mesh/mesh-remote-event-pull.ts +279 -0
  356. package/src/mesh/mesh-review-inbox.ts +308 -0
  357. package/src/mesh/mesh-routing.ts +291 -0
  358. package/src/mesh/mesh-runtime-store.ts +2292 -0
  359. package/src/mesh/mesh-scheduling-runtime.ts +198 -0
  360. package/src/mesh/mesh-task-inflight.ts +70 -0
  361. package/src/mesh/mesh-task-stats.ts +161 -0
  362. package/src/mesh/mesh-unresolved-forward-outbox.ts +215 -0
  363. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  364. package/src/mesh/mesh-work-queue.ts +1489 -145
  365. package/src/mesh/preview-freshness.ts +163 -0
  366. package/src/mesh/refine-config.ts +129 -12
  367. package/src/mesh/worktree-bootstrap-config.ts +599 -0
  368. package/src/providers/acp-provider-instance.ts +43 -10
  369. package/src/providers/approval-utils.d.ts +5 -0
  370. package/src/providers/approval-utils.ts +100 -6
  371. package/src/providers/chat-message-normalization.ts +126 -4
  372. package/src/providers/cli-provider-effect-format.ts +53 -0
  373. package/src/providers/cli-provider-history-dedup.ts +75 -0
  374. package/src/providers/cli-provider-input-prompt.ts +133 -0
  375. package/src/providers/cli-provider-instance-types.ts +131 -0
  376. package/src/providers/cli-provider-instance.ts +2771 -499
  377. package/src/providers/cli-provider-status-helpers.ts +123 -0
  378. package/src/providers/cli-provider-transcript-merge.ts +114 -0
  379. package/src/providers/contracts.d.ts +55 -0
  380. package/src/providers/contracts.ts +207 -6
  381. package/src/providers/extension-provider-instance.ts +12 -7
  382. package/src/providers/external-sources.ts +218 -0
  383. package/src/providers/ide-provider-instance.ts +35 -12
  384. package/src/providers/manual-attendance.ts +105 -0
  385. package/src/providers/native-history/antigravity-claim-registry.ts +138 -0
  386. package/src/providers/native-history/antigravity-cli-transcript.ts +1275 -0
  387. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  388. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  389. package/src/providers/native-history/constants.ts +19 -0
  390. package/src/providers/native-history/dispatcher.ts +617 -0
  391. package/src/providers/native-history/hermes-cli-transcript.ts +312 -0
  392. package/src/providers/native-history/index.ts +30 -0
  393. package/src/providers/provider-instance-manager.ts +71 -0
  394. package/src/providers/provider-instance.ts +24 -1
  395. package/src/providers/provider-loader.ts +700 -55
  396. package/src/providers/provider-schema.ts +93 -14
  397. package/src/providers/provider-trust.ts +114 -0
  398. package/src/providers/read-chat-contract.ts +76 -16
  399. package/src/providers/sdk/README.md +49 -0
  400. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  401. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  402. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  403. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
  404. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  405. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  406. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  407. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  408. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  409. package/src/providers/sdk/v1/index.ts +152 -0
  410. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  411. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  412. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  413. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +538 -0
  414. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  415. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  416. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  417. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  418. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  419. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  420. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  421. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  422. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  423. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  424. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  425. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  426. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  427. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  428. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  429. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  430. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  431. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  432. package/src/providers/sdk/v1/validators/index.ts +19 -0
  433. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  434. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  435. package/src/providers/spec/adapter.ts +246 -0
  436. package/src/providers/spec/cli-adapter.ts +1064 -0
  437. package/src/providers/spec/evaluator.ts +407 -0
  438. package/src/providers/spec/fsm-driver.ts +1528 -0
  439. package/src/providers/spec/fsm-evaluator.ts +290 -0
  440. package/src/providers/spec/fsm-loader.ts +128 -0
  441. package/src/providers/spec/fsm-types.ts +301 -0
  442. package/src/providers/spec/native-history-executor.ts +1241 -0
  443. package/src/providers/spec/pre-launch-trust.ts +104 -0
  444. package/src/providers/spec/route.ts +51 -0
  445. package/src/providers/spec/types.ts +284 -0
  446. package/src/providers/status-monitor.d.ts +7 -7
  447. package/src/providers/status-monitor.ts +37 -22
  448. package/src/providers/transcript-v2.ts +567 -0
  449. package/src/providers/types/interactive-prompt.ts +536 -0
  450. package/src/providers/version-archive.ts +64 -24
  451. package/src/providers/working-dir.ts +23 -0
  452. package/src/repo-mesh-types.ts +861 -14
  453. package/src/runtime-defaults.ts +39 -0
  454. package/src/session-host/managed-host.ts +218 -0
  455. package/src/session-host/runtime-surface.ts +20 -80
  456. package/src/sessions/registry.ts +44 -0
  457. package/src/shared-types-extra.ts +2 -4
  458. package/src/shared-types.d.ts +8 -0
  459. package/src/shared-types.ts +148 -55
  460. package/src/status/builders.ts +69 -6
  461. package/src/status/chat-tail-hot-sessions.ts +117 -2
  462. package/src/status/normalize.ts +2 -0
  463. package/src/status/reporter.ts +19 -1
  464. package/src/status/snapshot.ts +100 -27
  465. package/src/system/hash.ts +23 -0
  466. package/src/system/host-memory.ts +29 -12
  467. package/src/system/load-better-sqlite3.ts +68 -0
  468. package/src/types.ts +26 -6
  469. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  470. package/dist/mesh/mesh-sync.d.ts +0 -53
  471. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  472. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  473. package/src/mesh/mesh-sync.ts +0 -111
@@ -1,8 +1,122 @@
1
- import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
- import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
1
+ import type { DaemonBuildBehind, GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
2
+ import { GIT_STATUS_TIMEOUT_MS, GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
3
+ import { getDaemonBuildInfo, type DaemonBuildInfo } from '../build-info.js';
4
+ import {
5
+ type ChangeImpactConfig,
6
+ type ChangeImpactKind,
7
+ type ChangeImpactTarget,
8
+ globToRegExp,
9
+ loadChangeImpactConfig,
10
+ } from './change-impact-config.js';
3
11
 
4
12
  type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
5
13
 
14
+ /**
15
+ * TTL for the happy-path result cache (C1). reconcile fires every 4s and mesh_status
16
+ * is polled; a single getGitRepoStatus on a 1-submodule repo spawns ~13-15 git
17
+ * processes (+ optional network fetch) which on Windows is ~10-15s. Within this short
18
+ * window, repeat callers that share the same option shape are served the already
19
+ * collected status instead of re-shelling. Kept well under the 4s reconcile cadence so
20
+ * a genuinely fresh probe still happens at least roughly once per reconcile tick.
21
+ */
22
+ export const GIT_STATUS_CACHE_TTL_MS = 1500;
23
+
24
+ /**
25
+ * Last successfully-collected status per (workspace, option-shape), used for two things:
26
+ * 1. C1 TTL result cache — on the happy path, a cached entry younger than
27
+ * GIT_STATUS_CACHE_TTL_MS is returned directly (gated by `cachedAt`).
28
+ * 2. Transient-failure fallback — survive a transient git failure (timeout, slow
29
+ * Windows spawn under load, a momentary lock) WITHOUT dropping the node out of the
30
+ * mesh graph by re-serving the last good status, re-stamped as stale.
31
+ * A genuine "not a git repository" answer is NOT a transient failure — it never
32
+ * populates this cache and always reports isGitRepo:false. Only a successful, fully
33
+ * populated status is ever cached; error/empty results never are.
34
+ *
35
+ * The key folds in the option fields that change the SHAPE of the collected result
36
+ * (includeSubmodules, refreshUpstream) so different callers don't read each other's
37
+ * partial results. Fields that only affect timing/policy (timeoutMs, daemonBuildInfo,
38
+ * changeImpactConfig, submoduleIgnorePaths) are NOT part of the key — they don't change
39
+ * what a fresh same-shape collection would currently return for the happy path, and the
40
+ * sub-caches (changeImpactEvalCache) already key on their own invalidators.
41
+ */
42
+ interface CachedStatusEntry {
43
+ status: GitRepoStatus;
44
+ cachedAt: number;
45
+ }
46
+ const lastKnownGoodStatus = new Map<string, CachedStatusEntry>();
47
+
48
+ /** Cache key folding in only the option fields that change the result shape. */
49
+ function statusCacheKey(workspace: string, options: GitStatusOptions): string {
50
+ const includeSubmodules = options.includeSubmodules !== false;
51
+ const refreshUpstream = options.refreshUpstream === true;
52
+ return `${workspace}\0sub=${includeSubmodules ? 1 : 0}\0up=${refreshUpstream ? 1 : 0}`;
53
+ }
54
+
55
+ /**
56
+ * Memoized Change Impact evaluation, keyed by the inputs that can change the
57
+ * verdict: the scope repo path, the buildCommit..HEAD pair, and the resolved
58
+ * config source. The daemonBuildBehind probe runs on every mesh_status /
59
+ * mesh_git_status / fast_forward / git-monitor hit; without this, each hit
60
+ * re-shells `git diff` for the identical commit range. The cache stays correct
61
+ * because HEAD or a config edit perturbs the key, forcing re-evaluation.
62
+ */
63
+ interface ChangeImpactEvalEntry {
64
+ isDaemonAffecting: boolean;
65
+ affectedPackages: string[];
66
+ }
67
+ const changeImpactEvalCache = new Map<string, ChangeImpactEvalEntry>();
68
+
69
+ /**
70
+ * Best-effort cache of the loaded Change Impact config per repo root, keyed by the
71
+ * config sourceKey (path+mtime). A new sourceKey (config edited/added/removed)
72
+ * supersedes the entry. Avoids re-reading + re-parsing the config file on every
73
+ * status probe while still honoring on-disk edits.
74
+ */
75
+ interface ChangeImpactConfigCacheEntry {
76
+ sourceKey: string;
77
+ config: ChangeImpactConfig | null;
78
+ }
79
+ const changeImpactConfigCache = new Map<string, ChangeImpactConfigCacheEntry>();
80
+
81
+ /**
82
+ * C3: memoized ancestry verdict for the daemon-build-behind probe, keyed by
83
+ * `${repoPath}::${buildCommit}::${headOid}`. For a fixed (build commit, HEAD oid) pair
84
+ * the ancestry relationship (is build an ancestor of HEAD? equal? unrelated?) is
85
+ * immutable, so the verdict can be cached indefinitely — it auto-invalidates the moment
86
+ * HEAD moves (new oid → new key). This removes the steady-state `cat-file` +
87
+ * `rev-parse` + `merge-base` spawns (2-3 per scope) when HEAD has not moved between
88
+ * probes. A value of `false` means "build is NOT a strict ancestor of HEAD" (current,
89
+ * ahead, or unrelated — no warning); `true` means a strict-ancestor relationship was
90
+ * proven for that pair.
91
+ */
92
+ const buildBehindAncestryCache = new Map<string, boolean>();
93
+
94
+ /** Test seam: clear the last-known-good status cache between cases. */
95
+ export function __resetGitStatusCacheForTests(): void {
96
+ lastKnownGoodStatus.clear();
97
+ changeImpactEvalCache.clear();
98
+ changeImpactConfigCache.clear();
99
+ upstreamFetchedAt.clear();
100
+ buildBehindAncestryCache.clear();
101
+ }
102
+
103
+ /** Alias matching the task's requested name. */
104
+ export const __resetGitStatusCache = __resetGitStatusCacheForTests;
105
+
106
+ /** Test-only introspection: number of memoized Change Impact evaluations. */
107
+ export function __changeImpactEvalCacheSizeForTests(): number {
108
+ return changeImpactEvalCache.size;
109
+ }
110
+
111
+ /**
112
+ * git failure reasons that are transient/environmental rather than a real statement
113
+ * that the workspace is not a repo. On these we prefer the last-known-good status so a
114
+ * single slow git call cannot make a healthy node vanish from the graph.
115
+ */
116
+ function isTransientGitFailure(error: GitCommandError): boolean {
117
+ return error.reason === 'timeout' || error.reason === 'git_command_failed';
118
+ }
119
+
6
120
  export interface GitStatusOptions {
7
121
  timeoutMs?: number;
8
122
  /** When true, include submodule status in the result. Defaults to true. */
@@ -14,12 +128,56 @@ export interface GitStatusOptions {
14
128
  * Callers should opt into this only for convergence-critical surfaces.
15
129
  */
16
130
  refreshUpstream?: boolean;
131
+ /**
132
+ * When true, bypass the C1 TTL result cache: always re-collect a fresh status
133
+ * (ignoring any cached entry younger than GIT_STATUS_CACHE_TTL_MS). The freshly
134
+ * collected result still UPDATES the cache so subsequent normal callers benefit.
135
+ *
136
+ * Mutating / decision callers (mesh_fast_forward preflight + post-merge re-read,
137
+ * refine submodule alignment pre/post status) MUST set this — acting on a stale
138
+ * ahead/behind or submodule sync verdict is the primary correctness hazard of the
139
+ * cache. Read-only/observe callers (mesh_status, git-monitor, reconcile) leave it
140
+ * unset and enjoy the dedup.
141
+ */
142
+ forceFresh?: boolean;
143
+ /**
144
+ * Test/override seam for the daemon build stamp used by the stale-build
145
+ * detector. Production callers omit this so the real baked-in build commit
146
+ * (getDaemonBuildInfo) is used.
147
+ */
148
+ daemonBuildInfo?: DaemonBuildInfo;
149
+ /**
150
+ * Change Impact policy override. When provided, the daemonBuildBehind
151
+ * classifier uses this declarative config instead of auto-loading the repo's
152
+ * `.adhdev/change-impact.*` file. When omitted, getGitRepoStatus auto-loads the
153
+ * repo config (cached); when neither is present the built-in ADHDev default
154
+ * policy applies, preserving the legacy behavior exactly.
155
+ *
156
+ * Pass `null` to force the built-in default policy and skip auto-loading (used
157
+ * to assert legacy parity in tests).
158
+ */
159
+ changeImpactConfig?: ChangeImpactConfig | null;
17
160
  }
18
161
 
162
+ /**
163
+ * C2: minimum interval between actual `git fetch` network calls per workspace. A
164
+ * refreshUpstream caller within this window does NOT re-fetch; it serves ahead/behind
165
+ * from the locally-re-read porcelain (which is always fresh every call). So
166
+ * refreshUpstream becomes "fetch if the local remote-tracking ref is stale" rather than
167
+ * "always pay a network round trip". Only the upstream ahead/behind can age up to this
168
+ * throttle; local working-tree status never does.
169
+ */
170
+ export const GIT_FETCH_THROTTLE_MS = 30_000;
171
+
172
+ /** Wall-clock of the last successful `git fetch` per workspace (C2 throttle gate). */
173
+ const upstreamFetchedAt = new Map<string, number>();
174
+
19
175
  interface GitUpstreamProbe {
20
176
  upstreamStatus: GitUpstreamFreshness;
21
177
  upstreamFetchedAt?: number;
22
178
  upstreamFetchError?: string;
179
+ /** True only when this probe performed an actual network fetch (porcelain re-read needed). */
180
+ didFetch?: boolean;
23
181
  }
24
182
 
25
183
  export async function getGitRepoStatus(
@@ -28,15 +186,76 @@ export async function getGitRepoStatus(
28
186
  ): Promise<GitRepoStatus> {
29
187
  const lastCheckedAt = Date.now();
30
188
  const includeSubmodules = options.includeSubmodules !== false;
189
+ // Status collection fans out into several git subprocesses (status, head, stash,
190
+ // submodule, optionally fetch). On Windows the per-spawn cost alone can exceed the
191
+ // 5s default, so unless the caller pinned a timeout, give the whole collection path
192
+ // the larger status budget. A caller that explicitly sets timeoutMs (e.g. a test
193
+ // injecting 1ms to exercise the transient-failure path) is respected.
194
+ const effectiveOptions: GitStatusOptions =
195
+ options.timeoutMs === undefined ? { ...options, timeoutMs: GIT_STATUS_TIMEOUT_MS } : options;
196
+
197
+ const cacheKey = statusCacheKey(workspace, options);
198
+
199
+ // C1: happy-path TTL result cache. A non-forceFresh caller within the TTL window is
200
+ // served the last successfully-collected status for this exact option shape instead
201
+ // of re-shelling ~14 git processes. Only successful, fully-populated results are ever
202
+ // stored (see the .set below + the never-cache-error rule in the catch), so a cache
203
+ // hit can never serve an error/empty status.
204
+ if (!options.forceFresh) {
205
+ const cached = lastKnownGoodStatus.get(cacheKey);
206
+ if (cached && lastCheckedAt - cached.cachedAt < GIT_STATUS_CACHE_TTL_MS) {
207
+ return cached.status;
208
+ }
209
+ }
31
210
 
32
211
  try {
33
- const repo = await resolveGitRepository(workspace, options);
212
+ const repo = await resolveGitRepository(workspace, effectiveOptions);
213
+ const status = await collectGitRepoStatus(repo, includeSubmodules, lastCheckedAt, effectiveOptions);
214
+ // Cache the fresh, fully-populated success. forceFresh callers still refresh the
215
+ // cache so the next normal caller benefits from their freshly-collected status.
216
+ lastKnownGoodStatus.set(cacheKey, { status, cachedAt: lastCheckedAt });
217
+ return status;
218
+ } catch (error) {
219
+ const gitError = error instanceof GitCommandError
220
+ ? error
221
+ : new GitCommandError('git_command_failed', 'Failed to read Git status', { cause: error });
222
+
223
+ // A transient/environmental failure (timeout, slow-spawn-under-load) must NOT make
224
+ // a healthy node lose its repo identity and drop out of the mesh graph. Prefer the
225
+ // last status we successfully collected for this workspace, re-stamped as stale.
226
+ if (isTransientGitFailure(gitError)) {
227
+ const cached = lastKnownGoodStatus.get(cacheKey)?.status;
228
+ if (cached) {
229
+ return {
230
+ ...cached,
231
+ lastCheckedAt,
232
+ upstreamStatus: 'unavailable',
233
+ error: gitError.stderr || gitError.message,
234
+ reason: gitError.reason,
235
+ };
236
+ }
237
+ }
238
+
239
+ return emptyStatus(workspace, lastCheckedAt, gitError);
240
+ }
241
+ }
242
+
243
+ async function collectGitRepoStatus(
244
+ repo: ResolvedGitRepo,
245
+ includeSubmodules: boolean,
246
+ lastCheckedAt: number,
247
+ options: GitStatusOptions,
248
+ ): Promise<GitRepoStatus> {
34
249
  let parsed = await readPorcelainStatus(repo, options);
35
250
  let upstreamProbe: GitUpstreamProbe = getInitialUpstreamProbe(parsed);
36
251
 
37
252
  if (options.refreshUpstream) {
38
253
  upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
39
- if (upstreamProbe.upstreamStatus === 'fresh') {
254
+ // Re-read the porcelain (to pick up the updated ahead/behind) ONLY when this probe
255
+ // actually fetched. When the fetch was throttled (C2), the remote-tracking ref is
256
+ // unchanged, so the ahead/behind already parsed is still correct — re-reading would
257
+ // be a wasted spawn.
258
+ if (upstreamProbe.upstreamStatus === 'fresh' && upstreamProbe.didFetch) {
40
259
  parsed = await readPorcelainStatus(repo, options);
41
260
  }
42
261
  }
@@ -45,9 +264,22 @@ export async function getGitRepoStatus(
45
264
  const stashCount = await readStashCount(repo, options);
46
265
 
47
266
  let submodules: GitSubmoduleStatus[] | undefined;
267
+ let submoduleHeadOids = new Map<string, string>();
48
268
  if (includeSubmodules) {
49
- submodules = await getSubmoduleStatuses(repo, options);
269
+ const subResult = await getSubmoduleStatuses(repo, options);
270
+ submodules = subResult.submodules;
271
+ submoduleHeadOids = subResult.headOidByPath;
50
272
  }
273
+ const submoduleDirty = (submodules || []).some(submodule => submodule.dirty || submodule.outOfSync || !!submodule.error);
274
+ const dirty = parsed.staged + parsed.modified + parsed.untracked + parsed.deleted + parsed.renamed > 0
275
+ || parsed.conflictFiles.length > 0
276
+ || stashCount > 0
277
+ || submoduleDirty;
278
+
279
+ const daemonBuildBehind = await detectDaemonBuildBehind(repo, submodules, options, {
280
+ rootHeadOid: parsed.headOid,
281
+ submoduleHeadOids,
282
+ });
51
283
 
52
284
  return {
53
285
  workspace: repo.workspace,
@@ -67,26 +299,423 @@ export async function getGitRepoStatus(
67
299
  untracked: parsed.untracked,
68
300
  deleted: parsed.deleted,
69
301
  renamed: parsed.renamed,
302
+ dirty,
70
303
  hasConflicts: parsed.conflictFiles.length > 0,
71
304
  conflictFiles: parsed.conflictFiles,
72
305
  stashCount,
73
306
  lastCheckedAt,
74
307
  submodules,
308
+ ...(daemonBuildBehind ? { daemonBuildBehind } : {}),
75
309
  };
76
- } catch (error) {
77
- if (error instanceof GitCommandError) {
78
- return emptyStatus(workspace, lastCheckedAt, error);
310
+ }
311
+
312
+ /**
313
+ * Detect whether the running daemon's build commit is a STRICT ancestor of this
314
+ * workspace's HEAD (root) or any of its submodules' HEAD. This surfaces the
315
+ * "merged a fix to main but the live daemon still ships the old bundle" gap:
316
+ * once the fix is committed, the workspace HEAD advances past the daemon's
317
+ * baked-in build commit, but the daemon keeps the old behavior until it is
318
+ * rebuilt/redeployed and restarted.
319
+ *
320
+ * Conservative by construction — returns undefined unless ancestry is provable:
321
+ * - build commit unknown → undefined
322
+ * - build commit not an object in this repo/submodule (different repo) → skip
323
+ * - build commit === HEAD (daemon is current) → undefined
324
+ * - build commit NOT an ancestor of HEAD (daemon ahead / diverged) → undefined
325
+ * Any git error is swallowed (no warning) so a flaky probe never over-warns.
326
+ */
327
+ /**
328
+ * Package names that, when changed, mean the daemon runtime is stale and must be
329
+ * rebuilt/redeployed + restarted. Everything NOT in this set (web-core,
330
+ * web-standalone, web-devconsole, terminal-render-web) is web-only — a daemon
331
+ * restart is not required for those, only a web redeploy. mcp-server runs in the
332
+ * same process surface as the daemon tooling, so it is classified as
333
+ * daemon-affecting (conservative). Unknown package → daemon-affecting.
334
+ */
335
+ const DEFAULT_DAEMON_RUNTIME_PACKAGES = [
336
+ 'daemon-core',
337
+ 'daemon-standalone',
338
+ 'session-host-core',
339
+ 'session-host-daemon',
340
+ 'terminal-mux-core',
341
+ 'terminal-mux-control',
342
+ 'terminal-mux-cli',
343
+ 'ghostty-vt-node',
344
+ 'mcp-server',
345
+ ];
346
+
347
+ const DEFAULT_WEB_ONLY_PACKAGES = [
348
+ 'web-core',
349
+ 'web-standalone',
350
+ 'web-devconsole',
351
+ 'terminal-render-web',
352
+ ];
353
+
354
+ /**
355
+ * Built-in recommended action/command per impact classification. A change-impact
356
+ * config may override any of these via `impactTargets`; missing keys fall back here.
357
+ */
358
+ const DEFAULT_IMPACT_TARGETS: Record<ChangeImpactKind, ChangeImpactTarget> = {
359
+ daemon: {
360
+ recommendedCommand: 'Redeploy + restart the daemon (a local dist rebuild alone does not update a cloud daemon).',
361
+ },
362
+ web: {
363
+ recommendedCommand: 'Redeploy the web app (no daemon restart required).',
364
+ },
365
+ none: {
366
+ recommendedCommand: 'No action required.',
367
+ },
368
+ };
369
+
370
+ /**
371
+ * The resolved Change Impact policy: the built-in ADHDev defaults merged with any
372
+ * config override. This is what the classifier consults — git-status only knows the
373
+ * facts (changed files/packages), policy is data.
374
+ */
375
+ interface ResolvedChangeImpactPolicy {
376
+ daemonRuntimePackages: Set<string>;
377
+ webOnlyPackages: Set<string>;
378
+ /** Compiled config globs for additional non-runtime root files (on top of built-ins). */
379
+ nonRuntimeRootFilePatterns: RegExp[];
380
+ impactTargets: Record<ChangeImpactKind, ChangeImpactTarget>;
381
+ }
382
+
383
+ function resolveChangeImpactPolicy(config: ChangeImpactConfig | null | undefined): ResolvedChangeImpactPolicy {
384
+ // A field provided in config REPLACES the built-in default for that field; an
385
+ // omitted field falls back to the ADHDev default, so a repo with no config (or a
386
+ // partial config) behaves exactly as before.
387
+ const daemonRuntimePackages = new Set(
388
+ config?.daemonRuntimePackages && config.daemonRuntimePackages.length
389
+ ? config.daemonRuntimePackages
390
+ : DEFAULT_DAEMON_RUNTIME_PACKAGES,
391
+ );
392
+ const webOnlyPackages = new Set(
393
+ config?.webOnlyPackages && config.webOnlyPackages.length
394
+ ? config.webOnlyPackages
395
+ : DEFAULT_WEB_ONLY_PACKAGES,
396
+ );
397
+ const nonRuntimeRootFilePatterns = (config?.nonRuntimeRootFilePatterns || []).map(globToRegExp);
398
+ const impactTargets: Record<ChangeImpactKind, ChangeImpactTarget> = {
399
+ daemon: config?.impactTargets?.daemon ?? DEFAULT_IMPACT_TARGETS.daemon,
400
+ web: config?.impactTargets?.web ?? DEFAULT_IMPACT_TARGETS.web,
401
+ none: config?.impactTargets?.none ?? DEFAULT_IMPACT_TARGETS.none,
402
+ };
403
+ return { daemonRuntimePackages, webOnlyPackages, nonRuntimeRootFilePatterns, impactTargets };
404
+ }
405
+
406
+ /**
407
+ * Root-level (non-package) files that demonstrably cannot change what the daemon
408
+ * runtime executes: convergence/verify markers, documentation, and license/notice
409
+ * text. A root commit that moves the oss gitlink while the oss commit only touched
410
+ * one of these is NOT a reason to rebuild/restart the daemon — flagging it produces
411
+ * the staleDaemonBuild false-positive this guard exists to suppress.
412
+ *
413
+ * Deliberately conservative: anything NOT matched here (root config like
414
+ * package.json / tsconfig / build scripts, `.txt` fixtures, lockfiles, unknown
415
+ * dotfiles) stays daemon-affecting, because a false-negative — staying silent when
416
+ * the daemon really IS stale — is worse than an over-warn. Markers are the common
417
+ * real-world case (e.g. `.verify-patch-equiv-rc292`), so they are matched broadly;
418
+ * docs are matched by the `docs/` prefix or a markdown/text-doc extension at a
419
+ * filename we recognize as documentation (README/CHANGELOG/LICENSE/NOTICE).
420
+ *
421
+ * Config-supplied `nonRuntimeRootFilePatterns` are matched in ADDITION to these
422
+ * built-ins, so a repo can extend (never narrow) the benign set declaratively.
423
+ */
424
+ function isNonRuntimeRootFile(file: string, policy: ResolvedChangeImpactPolicy): boolean {
425
+ const base = file.slice(file.lastIndexOf('/') + 1);
426
+ // Verify/convergence markers: dotfiles whose name signals a transient marker.
427
+ if (/^\.(?:verify|marker|converge|ff-verify|patch-equiv|live-verify)\b/i.test(base)) return true;
428
+ // Documentation living under a docs/ tree (any depth, root or nested).
429
+ if (/(?:^|\/)docs\//i.test(file)) return true;
430
+ // Recognized top-level documentation / license files.
431
+ if (/^(?:README|CHANGELOG|LICENSE|NOTICE|AUTHORS|CONTRIBUTING|CODEOWNERS)(?:\.[A-Za-z0-9]+)?$/i.test(base)) {
432
+ return true;
433
+ }
434
+ // Config-declared additional non-runtime root globs.
435
+ for (const re of policy.nonRuntimeRootFilePatterns) {
436
+ if (re.test(file)) return true;
437
+ }
438
+ return false;
439
+ }
440
+
441
+ /** Coarse change-impact verdict produced from a changed-file list. */
442
+ export interface ChangedPackageClassification {
443
+ isDaemonAffecting: boolean;
444
+ affectedPackages: string[];
445
+ }
446
+
447
+ /**
448
+ * Pure bucketer: classify an already-collected changed-file list into the coarse
449
+ * daemon-vs-web verdict, per the resolved policy. Shared by classifyDaemonBuildChange
450
+ * (buildCommit..HEAD) and classifyChangedPackages (arbitrary ref range) so the
451
+ * daemon/web boundary logic lives in exactly one place. An empty list stays
452
+ * conservative (daemon-affecting) so an actionable warning is never suppressed.
453
+ */
454
+ function classifyChangedFileList(
455
+ files: string[],
456
+ policy: ResolvedChangeImpactPolicy,
457
+ ): ChangedPackageClassification {
458
+ if (files.length === 0) {
459
+ // No file diff (e.g. only merge metadata) — nothing actionable, but stay
460
+ // conservative and treat as daemon-affecting so we don't suppress a real warning.
461
+ return { isDaemonAffecting: true, affectedPackages: [] };
462
+ }
463
+ const pkgs = new Set<string>();
464
+ // A non-package path that is NOT a recognized benign root file (marker/doc).
465
+ // Only these force daemon-affecting; benign markers/docs are ignored so a
466
+ // gitlink-moving root commit over a marker-only oss commit no longer over-warns.
467
+ let sawRuntimeAmbiguousNonPackage = false;
468
+ for (const file of files) {
469
+ const match = file.match(/(?:^|\/)packages\/([^/]+)\//);
470
+ if (!match) {
471
+ if (!isNonRuntimeRootFile(file, policy)) sawRuntimeAmbiguousNonPackage = true;
472
+ continue;
473
+ }
474
+ pkgs.add(match[1]);
475
+ }
476
+ const affectedPackages = [...pkgs].sort();
477
+ // Daemon-affecting if: any runtime-ambiguous non-package file changed, any
478
+ // unknown package changed, or any explicit daemon-runtime package changed.
479
+ // The daemon is unaffected only when every changed file is either a known
480
+ // web-only package or a recognized benign root file (and at least one such
481
+ // file changed) — i.e. nothing runtime-ambiguous remains. Unlisted/new packages
482
+ // therefore stay daemon-affecting (fail-safe default preserved).
483
+ const allBenign =
484
+ !sawRuntimeAmbiguousNonPackage &&
485
+ affectedPackages.every((p) => policy.webOnlyPackages.has(p) && !policy.daemonRuntimePackages.has(p));
486
+ return { isDaemonAffecting: !allBenign, affectedPackages };
487
+ }
488
+
489
+ /**
490
+ * Determine whether the changes between buildCommit..HEAD touch any daemon-runtime
491
+ * package, per the resolved policy. Returns isDaemonAffecting:true conservatively
492
+ * when the changed-file set can't be obtained or any changed path is outside the
493
+ * known web-only package set AND is not a recognized non-runtime root file.
494
+ */
495
+ async function classifyDaemonBuildChange(
496
+ repoPath: string,
497
+ buildCommit: string,
498
+ options: GitStatusOptions,
499
+ policy: ResolvedChangeImpactPolicy,
500
+ ): Promise<ChangedPackageClassification> {
501
+ try {
502
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${buildCommit}..HEAD`], options);
503
+ const files = diff.stdout
504
+ .split('\n')
505
+ .map((line) => line.trim())
506
+ .filter(Boolean);
507
+ return classifyChangedFileList(files, policy);
508
+ } catch {
509
+ // diff probe failed → can't prove web-only; stay conservative.
510
+ return { isDaemonAffecting: true, affectedPackages: [] };
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Ref-parameterized change-impact classification for a repo/worktree, reusing the
516
+ * exact daemon-vs-web bucketing that the stale-build detector uses — but over an
517
+ * arbitrary `fromRef..toRef` range (e.g. a refine base head → branch head) instead
518
+ * of the live daemon's build commit → HEAD, and WITHOUT any daemonBuildInfo caching.
519
+ *
520
+ * Policy is resolved the same way as getGitRepoStatus: an explicit
521
+ * `options.changeImpactConfig` wins; otherwise the repo's `.adhdev/change-impact.*`
522
+ * is auto-loaded; otherwise the built-in ADHDev default policy applies. The
523
+ * classification uses `git diff --name-only fromRef..toRef`.
524
+ *
525
+ * FAIL-OPEN on error: if the diff can't be collected (bad ref, not a repo), the
526
+ * caller should treat "no verdict" as "run everything" — so we throw rather than
527
+ * returning a misleading benign verdict. Callers wrap this in try/catch and leave
528
+ * changeImpact undefined on failure. Unclassified/new packages still default to
529
+ * isDaemonAffecting:true (never silently skipped).
530
+ */
531
+ export async function classifyChangedPackages(
532
+ repoPath: string,
533
+ fromRef: string,
534
+ toRef: string,
535
+ options: GitStatusOptions = {},
536
+ ): Promise<ChangedPackageClassification> {
537
+ const repo = await resolveGitRepository(repoPath, options);
538
+ const { config } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
539
+ const policy = resolveChangeImpactPolicy(config);
540
+ const diff = await runGit(repoPath, ['diff', '--name-only', `${fromRef}..${toRef}`], options);
541
+ const files = diff.stdout
542
+ .split('\n')
543
+ .map((line) => line.trim())
544
+ .filter(Boolean);
545
+ return classifyChangedFileList(files, policy);
546
+ }
547
+
548
+ /**
549
+ * Resolve the Change Impact config to apply for this status read. Priority:
550
+ * - options.changeImpactConfig === null → force built-in default policy (no load).
551
+ * - options.changeImpactConfig provided → use it verbatim (override seam).
552
+ * - otherwise → auto-load the repo's `.adhdev/change-impact.*` (cached by sourceKey).
553
+ * Returns the (possibly null) config plus the sourceKey used for cache invalidation.
554
+ */
555
+ function resolveChangeImpactConfigForRepo(
556
+ repoRoot: string | null,
557
+ options: GitStatusOptions,
558
+ ): { config: ChangeImpactConfig | null; sourceKey: string } {
559
+ if (options.changeImpactConfig === null) {
560
+ return { config: null, sourceKey: 'forced-default' };
561
+ }
562
+ if (options.changeImpactConfig !== undefined) {
563
+ // Injected override — key it to its content so a different injected config
564
+ // re-evaluates rather than reusing a prior verdict.
565
+ let key = 'injected';
566
+ try {
567
+ key = `injected:${JSON.stringify(options.changeImpactConfig)}`;
568
+ } catch {
569
+ // Non-serializable override (shouldn't happen) — fall back to a constant key.
570
+ }
571
+ return { config: options.changeImpactConfig, sourceKey: key };
572
+ }
573
+ if (!repoRoot) {
574
+ return { config: null, sourceKey: 'no-repo-root' };
575
+ }
576
+ const loaded = loadChangeImpactConfig(repoRoot);
577
+ const cached = changeImpactConfigCache.get(repoRoot);
578
+ if (cached && cached.sourceKey === loaded.sourceKey) {
579
+ return { config: cached.config, sourceKey: loaded.sourceKey };
580
+ }
581
+ // An invalid config is conservatively ignored (built-in default policy applies),
582
+ // mirroring the "fail safe" rule — never let a malformed config silence warnings.
583
+ const config = loaded.sourceType === 'repo_file' ? loaded.config ?? null : null;
584
+ changeImpactConfigCache.set(repoRoot, { sourceKey: loaded.sourceKey, config });
585
+ return { config, sourceKey: loaded.sourceKey };
586
+ }
587
+
588
+ interface DaemonBuildBehindHeadOids {
589
+ /** Root repo HEAD oid from porcelain `# branch.oid` (avoids a rev-parse spawn). */
590
+ rootHeadOid: string | null;
591
+ /** Per-submodule-path actual HEAD oid (already read during submodule collection). */
592
+ submoduleHeadOids: Map<string, string>;
593
+ }
594
+
595
+ async function detectDaemonBuildBehind(
596
+ repo: ResolvedGitRepo,
597
+ submodules: GitSubmoduleStatus[] | undefined,
598
+ options: GitStatusOptions,
599
+ headOids: DaemonBuildBehindHeadOids = { rootHeadOid: null, submoduleHeadOids: new Map() },
600
+ ): Promise<DaemonBuildBehind | undefined> {
601
+ const build = options.daemonBuildInfo ?? getDaemonBuildInfo();
602
+ if (!build.commit || build.commit === 'unknown') return undefined;
603
+
604
+ const { config, sourceKey: configKey } = resolveChangeImpactConfigForRepo(repo.repoRoot, options);
605
+ const policy = resolveChangeImpactPolicy(config);
606
+
607
+ // Check the root repo first, then each submodule. The daemon build commit is
608
+ // baked from the daemon-core (oss submodule) HEAD, so on an adhdev
609
+ // superproject worktree the match is expected on the `oss` submodule, not the
610
+ // root — checking both keeps the helper repo-agnostic. The known HEAD oid (from
611
+ // porcelain for root, from the submodule rev-parse already paid during collection)
612
+ // is threaded in so the C3 ancestry cache can short-circuit before any spawn.
613
+ const scopes: Array<{ scope: string; repoPath: string; knownHeadOid: string | null }> = [
614
+ { scope: 'root', repoPath: repo.repoRoot || repo.workspace, knownHeadOid: headOids.rootHeadOid },
615
+ ];
616
+ for (const sub of submodules || []) {
617
+ if (sub.repoPath && !sub.error) {
618
+ scopes.push({ scope: sub.path, repoPath: sub.repoPath, knownHeadOid: headOids.submoduleHeadOids.get(sub.path) ?? null });
79
619
  }
80
- return emptyStatus(
81
- workspace,
82
- lastCheckedAt,
83
- new GitCommandError('git_command_failed', 'Failed to read Git status', { cause: error }),
84
- );
85
620
  }
621
+
622
+ for (const { scope, repoPath, knownHeadOid } of scopes) {
623
+ try {
624
+ // C3 fast path: if we already know this scope's HEAD oid and have a cached
625
+ // ancestry verdict for (repoPath, buildCommit, headOid), reuse it. A `false`
626
+ // verdict (not a strict ancestor — current/ahead/unrelated) lets us skip the
627
+ // cat-file + merge-base spawns entirely; the verdict auto-invalidates when HEAD
628
+ // moves (new oid → new key). A `true` verdict still needs the diff
629
+ // classification below, which is itself memoized on the same head oid.
630
+ let head = knownHeadOid;
631
+ const ancestryKey = head ? `${repoPath}::${build.commit}::${head}` : null;
632
+ if (ancestryKey) {
633
+ const cachedVerdict = buildBehindAncestryCache.get(ancestryKey);
634
+ if (cachedVerdict === false) continue;
635
+ if (cachedVerdict === undefined) {
636
+ if (head === build.commit) {
637
+ buildBehindAncestryCache.set(ancestryKey, false);
638
+ continue;
639
+ }
640
+ // Build commit must be a real object in THIS repo, else it's a different repo.
641
+ await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
642
+ try {
643
+ await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
644
+ } catch {
645
+ // Not a strict ancestor — cache the negative verdict so the next probe at
646
+ // this same HEAD short-circuits, then move on to the next scope.
647
+ buildBehindAncestryCache.set(ancestryKey, false);
648
+ continue;
649
+ }
650
+ buildBehindAncestryCache.set(ancestryKey, true);
651
+ }
652
+ // cachedVerdict === true (or just proven true) → fall through to classification.
653
+ } else {
654
+ // No known HEAD oid for this scope — fall back to the original probe (resolve
655
+ // HEAD via rev-parse). This keeps correctness when porcelain/submodule oid is
656
+ // unavailable; the verdict is still cached once HEAD is known.
657
+ await runGit(repoPath, ['cat-file', '-e', `${build.commit}^{commit}`], options);
658
+ const headResult = await runGit(repoPath, ['rev-parse', 'HEAD'], options);
659
+ head = headResult.stdout.trim();
660
+ if (!head || head === build.commit) continue;
661
+ await runGit(repoPath, ['merge-base', '--is-ancestor', build.commit, 'HEAD'], options);
662
+ buildBehindAncestryCache.set(`${repoPath}::${build.commit}::${head}`, true);
663
+ }
664
+ // No throw → build commit IS an ancestor of HEAD → daemon is behind.
665
+ // Inspect WHICH packages changed in buildCommit..HEAD. A daemon rebuild/restart
666
+ // is only actually required when a daemon-runtime package changed; if only web /
667
+ // render packages changed, the daemon is unaffected and just the web deploy is
668
+ // pending. Conservative: any probe failure → treat as daemon-affecting. The
669
+ // verdict is memoized on (repoPath, buildCommit, head, config) to suppress
670
+ // re-evaluation on the hot status path.
671
+ const evalKey = `${repoPath}${build.commit}${head}${configKey}`;
672
+ if (!head) continue; // proven non-empty oid here; every other branch above continued
673
+ let evaluated = changeImpactEvalCache.get(evalKey);
674
+ if (!evaluated) {
675
+ evaluated = await classifyDaemonBuildChange(repoPath, build.commit, options, policy);
676
+ changeImpactEvalCache.set(evalKey, evaluated);
677
+ }
678
+ const { isDaemonAffecting, affectedPackages } = evaluated;
679
+ const kind: ChangeImpactKind = isDaemonAffecting
680
+ ? 'daemon'
681
+ : affectedPackages.length > 0
682
+ ? 'web'
683
+ : 'none';
684
+ const target = policy.impactTargets[kind];
685
+ const scopeLabel = scope === 'root' ? 'workspace' : scope;
686
+ const benignDetail = affectedPackages.length > 0
687
+ ? `only web packages changed (${affectedPackages.join(', ')})`
688
+ : 'only non-runtime files changed (markers/docs)';
689
+ const warning = isDaemonAffecting
690
+ ? `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}. ` +
691
+ `Merged code is NOT live until the daemon is rebuilt/redeployed and restarted — a local dist rebuild alone does not update a cloud daemon.`
692
+ : `Live daemon was built from ${build.commitShort} which is behind ${scopeLabel} HEAD ${head.slice(0, 7)}, ` +
693
+ `but ${benignDetail}. ` +
694
+ `Daemon restart NOT required — redeploy the web app to reflect the change.`;
695
+ return {
696
+ buildCommit: build.commit,
697
+ buildCommitShort: build.commitShort,
698
+ head,
699
+ scope,
700
+ isDaemonAffecting,
701
+ ...(affectedPackages && affectedPackages.length > 0 ? { affectedPackages } : {}),
702
+ recommendedAction: kind,
703
+ recommendedCommand: target.recommendedCommand,
704
+ warning,
705
+ };
706
+ } catch {
707
+ // cat-file / merge-base non-zero exit (commit absent or not an ancestor)
708
+ // or any git error → not a provable staleness for this scope; try next.
709
+ continue;
710
+ }
711
+ }
712
+ return undefined;
86
713
  }
87
714
 
88
715
  interface ParsedPorcelainStatus {
89
716
  branch: string | null;
717
+ /** Full HEAD object id from `# branch.oid`, or null when detached/unborn. */
718
+ headOid: string | null;
90
719
  upstream: string | null;
91
720
  ahead: number;
92
721
  behind: number;
@@ -118,6 +747,20 @@ async function refreshTrackedUpstream(
118
747
  return { upstreamStatus: 'no_upstream' };
119
748
  }
120
749
 
750
+ // C2: throttle the actual network fetch. If we fetched this workspace within
751
+ // GIT_FETCH_THROTTLE_MS, skip the fetch and serve ahead/behind from the local
752
+ // porcelain (re-read fresh every call by the caller). forceFresh callers
753
+ // (convergence-critical: ff / refine) always re-fetch — they need true upstream.
754
+ const now = Date.now();
755
+ const lastFetch = upstreamFetchedAt.get(repo.workspace);
756
+ if (!options.forceFresh && lastFetch !== undefined && now - lastFetch < GIT_FETCH_THROTTLE_MS) {
757
+ return {
758
+ upstreamStatus: 'fresh',
759
+ upstreamFetchedAt: lastFetch,
760
+ didFetch: false,
761
+ };
762
+ }
763
+
121
764
  const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
122
765
  if (!remoteName) {
123
766
  return {
@@ -128,9 +771,12 @@ async function refreshTrackedUpstream(
128
771
 
129
772
  try {
130
773
  await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
774
+ const fetchedAt = Date.now();
775
+ upstreamFetchedAt.set(repo.workspace, fetchedAt);
131
776
  return {
132
777
  upstreamStatus: 'fresh',
133
- upstreamFetchedAt: Date.now(),
778
+ upstreamFetchedAt: fetchedAt,
779
+ didFetch: true,
134
780
  };
135
781
  } catch (error) {
136
782
  return {
@@ -167,6 +813,7 @@ function formatGitError(error: unknown): string {
167
813
  export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
168
814
  const parsed: ParsedPorcelainStatus = {
169
815
  branch: null,
816
+ headOid: null,
170
817
  upstream: null,
171
818
  ahead: 0,
172
819
  behind: 0,
@@ -181,6 +828,13 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
181
828
  for (const line of output.split('\n')) {
182
829
  if (!line) continue;
183
830
 
831
+ if (line.startsWith('# branch.oid ')) {
832
+ const oid = line.slice('# branch.oid '.length).trim();
833
+ // `(initial)` is git's sentinel for an unborn HEAD — not a real object id.
834
+ parsed.headOid = oid && oid !== '(initial)' && /^[0-9a-f]{7,64}$/.test(oid) ? oid : null;
835
+ continue;
836
+ }
837
+
184
838
  if (line.startsWith('# branch.head ')) {
185
839
  const branch = line.slice('# branch.head '.length).trim();
186
840
  parsed.branch = branch && branch !== '(detached)' ? branch : null;
@@ -285,6 +939,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
285
939
  untracked: 0,
286
940
  deleted: 0,
287
941
  renamed: 0,
942
+ dirty: false,
288
943
  hasConflicts: false,
289
944
  conflictFiles: [],
290
945
  stashCount: 0,
@@ -296,51 +951,170 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
296
951
 
297
952
  // ─── Submodule Status ───────────────────────────
298
953
 
954
+ interface SubmoduleStatusResult {
955
+ submodules: GitSubmoduleStatus[];
956
+ /** Actual checked-out HEAD oid per submodule path (for the C3 ancestry cache key). */
957
+ headOidByPath: Map<string, string>;
958
+ }
959
+
299
960
  async function getSubmoduleStatuses(
300
961
  repo: ResolvedGitRepo,
301
962
  options: GitStatusOptions,
302
- ): Promise<GitSubmoduleStatus[]> {
303
- if (!repo.repoRoot) return [];
963
+ ): Promise<SubmoduleStatusResult> {
964
+ if (!repo.repoRoot) return { submodules: [], headOidByPath: new Map() };
304
965
 
305
966
  try {
306
- const result = await runGit(repo, ['submodule', 'status', '--recursive'], options);
307
- return parseSubmoduleStatusOutput(result.stdout, repo.repoRoot, options.submoduleIgnorePaths);
967
+ // Do NOT shell out to `git submodule status`. That porcelain wrapper is a
968
+ // shell script (`git-submodule`) that, per submodule, spawns several child
969
+ // `git` processes; on Windows the wrapper + per-spawn cost alone measured
970
+ // 6.9–62.4s under AV, which dominated the whole collectGitRepoStatus budget
971
+ // and stalled the mesh graph cold-open. The information it gives us — the
972
+ // gitlink sync state (path / recorded SHA / +/-/U prefix) — is fully
973
+ // derivable from plumbing commands that don't go through the shell wrapper:
974
+ // • paths ← `.gitmodules` (git config --file, plumbing)
975
+ // • expected SHA ← `git ls-tree HEAD <path>` (the gitlink the super-
976
+ // project's HEAD tree records)
977
+ // • actual SHA ← `git -C <sub> rev-parse HEAD` (already paid below by
978
+ // enrichSubmoduleWorktreeStatus for the dirty check)
979
+ // Comparing expected vs actual reproduces `+` (out of sync); a checked-out
980
+ // submodule whose worktree is absent/uninitialized reproduces `-`. The `U`
981
+ // (conflict) prefix is surfaced separately via the superproject porcelain
982
+ // status that the caller already parses, and a conflicted submodule's own
983
+ // status read here also flags it dirty — so no row is lost.
984
+ const { submodules, headOidByPath } = await deriveSubmoduleGitlinkStatuses(repo, options);
985
+ await Promise.all(submodules.map(submodule => enrichSubmoduleWorktreeStatus(repo, submodule, options)));
986
+ return { submodules, headOidByPath };
308
987
  } catch {
988
+ return { submodules: [], headOidByPath: new Map() };
989
+ }
990
+ }
991
+
992
+ /**
993
+ * Enumerate the superproject's submodules and their gitlink sync state without the
994
+ * slow `git submodule status` shell wrapper. Pure plumbing: read paths from
995
+ * `.gitmodules`, the expected (recorded) gitlink SHA from `ls-tree HEAD`, and the
996
+ * actual checked-out SHA from the submodule's own `rev-parse HEAD`.
997
+ */
998
+ async function deriveSubmoduleGitlinkStatuses(
999
+ repo: ResolvedGitRepo,
1000
+ options: GitStatusOptions,
1001
+ ): Promise<SubmoduleStatusResult> {
1002
+ if (!repo.repoRoot) return { submodules: [], headOidByPath: new Map() };
1003
+ const paths = await readSubmodulePaths(repo, options);
1004
+ const ignoreSet = new Set(options.submoduleIgnorePaths || []);
1005
+ const lastCheckedAt = Date.now();
1006
+ const headOidByPath = new Map<string, string>();
1007
+
1008
+ const entries = await Promise.all(
1009
+ paths
1010
+ .filter(path => !ignoreSet.has(path))
1011
+ .map(async (path): Promise<GitSubmoduleStatus> => {
1012
+ const repoPath = repo.repoRoot + '/' + path;
1013
+ const expected = await readGitlinkExpectedSha(repo, path, options);
1014
+ const actual = await readSubmoduleHeadSha(repo, repoPath, options);
1015
+ // Reuse the actual checked-out HEAD oid for the C3 build-behind ancestry cache
1016
+ // key — it's the submodule HEAD the daemon build commit is tested against, and
1017
+ // we already paid this rev-parse for the outOfSync check, so no extra spawn.
1018
+ if (actual) headOidByPath.set(path, actual);
1019
+ // Uninitialized / no checked-out HEAD reproduces `git submodule status`'s
1020
+ // `-` prefix; a present-but-divergent HEAD reproduces the `+` prefix.
1021
+ const outOfSync = actual === null
1022
+ ? true
1023
+ : expected !== null && expected !== actual;
1024
+ return {
1025
+ path,
1026
+ // Prefer the recorded gitlink SHA (matches the legacy column); fall back
1027
+ // to the checked-out SHA so the field is never empty when both are known.
1028
+ commit: expected ?? actual ?? '',
1029
+ repoPath,
1030
+ dirty: false,
1031
+ outOfSync,
1032
+ lastCheckedAt,
1033
+ };
1034
+ }),
1035
+ );
1036
+ return { submodules: entries, headOidByPath };
1037
+ }
1038
+
1039
+ /** Read submodule paths from `.gitmodules` via plumbing (no shell wrapper). */
1040
+ async function readSubmodulePaths(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<string[]> {
1041
+ if (!repo.repoRoot) return [];
1042
+ const gitmodulesPath = repo.repoRoot + '/.gitmodules';
1043
+ try {
1044
+ const result = await runGit(
1045
+ repo,
1046
+ ['config', '--file', gitmodulesPath, '--get-regexp', '^submodule\\..*\\.path$'],
1047
+ options,
1048
+ );
1049
+ const paths: string[] = [];
1050
+ for (const line of result.stdout.split('\n')) {
1051
+ // Each line: `submodule.<name>.path <path>`
1052
+ const spaceIdx = line.indexOf(' ');
1053
+ if (spaceIdx < 0) continue;
1054
+ const value = line.slice(spaceIdx + 1).trim();
1055
+ if (value) paths.push(value);
1056
+ }
1057
+ return paths;
1058
+ } catch {
1059
+ // No .gitmodules (not a superproject) or unreadable → no submodules.
309
1060
  return [];
310
1061
  }
311
1062
  }
312
1063
 
313
- function parseSubmoduleStatusOutput(
314
- output: string,
315
- repoRoot: string,
316
- ignorePaths?: string[],
317
- ): GitSubmoduleStatus[] {
318
- const submodules: GitSubmoduleStatus[] = [];
319
- const ignoreSet = new Set(ignorePaths || []);
1064
+ /** Expected gitlink SHA recorded in the superproject HEAD tree for this submodule path. */
1065
+ async function readGitlinkExpectedSha(
1066
+ repo: ResolvedGitRepo,
1067
+ submodulePath: string,
1068
+ options: GitStatusOptions,
1069
+ ): Promise<string | null> {
1070
+ try {
1071
+ // `ls-tree HEAD <path>` prints: `<mode> commit <sha>\t<path>` for a gitlink.
1072
+ const result = await runGit(repo, ['ls-tree', 'HEAD', submodulePath], options);
1073
+ const line = result.stdout.split('\n').find(l => l.trim().length > 0);
1074
+ if (!line) return null;
1075
+ const match = line.match(/^\s*\d+\s+commit\s+([0-9a-f]{40})\b/);
1076
+ return match ? match[1] : null;
1077
+ } catch {
1078
+ return null;
1079
+ }
1080
+ }
1081
+
1082
+ /** Actual checked-out HEAD SHA of a submodule, or null if uninitialized/unreadable. */
1083
+ async function readSubmoduleHeadSha(
1084
+ repo: ResolvedGitRepo,
1085
+ repoPath: string,
1086
+ options: GitStatusOptions,
1087
+ ): Promise<string | null> {
1088
+ try {
1089
+ // Run in the submodule worktree via cwd (inside the superproject root, so the
1090
+ // executor's path-inside-repo guard is satisfied) rather than resolving the
1091
+ // submodule as a fresh repo — that would cost an extra `rev-parse --show-toplevel`
1092
+ // spawn per submodule, which is exactly the Windows spawn cost this fix removes.
1093
+ const result = await runGit(repo, ['rev-parse', 'HEAD'], { ...options, cwd: repoPath });
1094
+ const sha = result.stdout.trim();
1095
+ return /^[0-9a-f]{40}$/.test(sha) ? sha : null;
1096
+ } catch {
1097
+ return null;
1098
+ }
1099
+ }
320
1100
 
321
- for (const line of output.split('\n')) {
322
- if (!line.trim()) continue;
323
-
324
- // Format: [+- ]<commit> <path> (<branch>)
325
- // - = out of sync, + = dirty, ' ' = clean
326
- const match = line.match(/^([\-+\s])([0-9a-f]{40})\s+(\S+)(?:\s+\(([^)]+)\))?/);
327
- if (!match) continue;
328
-
329
- const prefix = match[1];
330
- const commit = match[2];
331
- const path = match[3];
332
-
333
- if (ignoreSet.has(path)) continue;
334
-
335
- submodules.push({
336
- path,
337
- commit,
338
- repoPath: repoRoot + '/' + path,
339
- dirty: prefix === '+',
340
- outOfSync: prefix === '-',
341
- lastCheckedAt: Date.now(),
1101
+ async function enrichSubmoduleWorktreeStatus(
1102
+ repo: ResolvedGitRepo,
1103
+ submodule: GitSubmoduleStatus,
1104
+ options: GitStatusOptions,
1105
+ ): Promise<void> {
1106
+ try {
1107
+ const result = await runGit(repo, ['status', '--porcelain=v2', '--branch'], {
1108
+ ...options,
1109
+ cwd: submodule.repoPath,
342
1110
  });
1111
+ const parsed = parsePorcelainV2Status(result.stdout);
1112
+ const dirty = parsed.staged + parsed.modified + parsed.untracked + parsed.deleted + parsed.renamed > 0
1113
+ || parsed.conflictFiles.length > 0;
1114
+ submodule.dirty = submodule.dirty || dirty;
1115
+ } catch (error) {
1116
+ submodule.dirty = true;
1117
+ submodule.error = formatGitError(error);
343
1118
  }
344
-
345
- return submodules;
346
1119
  }
1120
+