@crouton-kit/crouter 0.3.27 → 0.3.29

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 (343) hide show
  1. package/dist/build-root.js +4 -2
  2. package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
  3. package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
  4. package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
  5. package/dist/builtin-memory/crouter-development/personas.md +1 -1
  6. package/dist/builtin-memory/crouter-development/plugins.md +20 -18
  7. package/dist/builtin-memory/design.md +1 -1
  8. package/dist/builtin-memory/development.md +1 -1
  9. package/dist/builtin-memory/internal/INDEX.md +1 -1
  10. package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
  11. package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
  12. package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
  13. package/dist/builtin-memory/internal/storage-tiers.md +1 -1
  14. package/dist/builtin-memory/planning.md +1 -1
  15. package/dist/builtin-memory/spec.md +6 -4
  16. package/dist/builtin-personas/orchestration-kernel.md +4 -5
  17. package/dist/builtin-personas/runtime-base.md +8 -4
  18. package/dist/builtin-personas/spec/PERSONA.md +8 -4
  19. package/dist/builtin-personas/spec/orchestrator.md +5 -3
  20. package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
  21. package/dist/builtin-views/canvas/core.mjs +586 -0
  22. package/dist/builtin-views/canvas/text.mjs +58 -0
  23. package/dist/builtin-views/canvas/tui.mjs +165 -0
  24. package/dist/builtin-views/canvas/web.jsx +120 -0
  25. package/dist/builtin-views/git-pr/core.mjs +673 -0
  26. package/dist/builtin-views/git-pr/text.mjs +84 -0
  27. package/dist/builtin-views/git-pr/tui.mjs +302 -0
  28. package/dist/builtin-views/git-pr/web.jsx +216 -0
  29. package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
  30. package/dist/builtin-views/inbox/core.mjs +1273 -0
  31. package/dist/builtin-views/inbox/text.mjs +73 -0
  32. package/dist/builtin-views/inbox/tui.mjs +312 -0
  33. package/dist/builtin-views/inbox/web.jsx +188 -0
  34. package/dist/builtin-views/linkedin/core.mjs +906 -0
  35. package/dist/builtin-views/linkedin/text.mjs +69 -0
  36. package/dist/builtin-views/linkedin/tui.mjs +427 -0
  37. package/dist/builtin-views/linkedin/web.jsx +206 -0
  38. package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
  39. package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
  40. package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
  41. package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
  42. package/dist/cli.js +3 -3
  43. package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
  44. package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
  45. package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
  46. package/dist/clients/attach/__tests__/git-info.test.js +31 -0
  47. package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
  48. package/dist/clients/attach/__tests__/reconnect-giveup.test.js +30 -0
  49. package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
  50. package/dist/clients/attach/__tests__/titled-editor.test.d.ts +1 -0
  51. package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
  52. package/dist/clients/attach/attach-cmd.js +445 -41
  53. package/dist/clients/attach/auth-pickers.d.ts +9 -0
  54. package/dist/clients/attach/auth-pickers.js +194 -0
  55. package/dist/clients/attach/canvas-panels.d.ts +12 -0
  56. package/dist/clients/attach/canvas-panels.js +78 -0
  57. package/dist/clients/attach/chat-view.d.ts +27 -2
  58. package/dist/clients/attach/chat-view.js +98 -9
  59. package/dist/clients/attach/clipboard-image.d.ts +47 -8
  60. package/dist/clients/attach/clipboard-image.js +150 -26
  61. package/dist/clients/attach/clipboard-text.d.ts +3 -0
  62. package/dist/clients/attach/clipboard-text.js +39 -0
  63. package/dist/clients/attach/config-load.d.ts +54 -7
  64. package/dist/clients/attach/config-load.js +99 -3
  65. package/dist/clients/attach/context-message.d.ts +12 -0
  66. package/dist/clients/attach/context-message.js +72 -0
  67. package/dist/clients/attach/git-info.d.ts +20 -0
  68. package/dist/clients/attach/git-info.js +44 -0
  69. package/dist/clients/attach/graph-overlay.d.ts +43 -0
  70. package/dist/clients/attach/graph-overlay.js +306 -0
  71. package/dist/clients/attach/input-controller.d.ts +81 -13
  72. package/dist/clients/attach/input-controller.js +307 -67
  73. package/dist/clients/attach/pickers.d.ts +58 -0
  74. package/dist/clients/attach/pickers.js +171 -0
  75. package/dist/clients/attach/slash-commands.d.ts +44 -1
  76. package/dist/clients/attach/slash-commands.js +250 -27
  77. package/dist/clients/attach/titled-editor.d.ts +33 -0
  78. package/dist/clients/attach/titled-editor.js +84 -0
  79. package/dist/clients/attach/view-socket.d.ts +37 -2
  80. package/dist/clients/attach/view-socket.js +131 -7
  81. package/dist/clients/web/dev-server.d.ts +7 -0
  82. package/dist/clients/web/dev-server.js +59 -0
  83. package/dist/clients/web/events.d.ts +14 -0
  84. package/dist/clients/web/events.js +151 -0
  85. package/dist/clients/web/server.d.ts +18 -0
  86. package/dist/clients/web/server.js +450 -0
  87. package/dist/clients/web/web-cmd.d.ts +2 -0
  88. package/dist/clients/web/web-cmd.js +120 -0
  89. package/dist/commands/canvas.js +1 -2
  90. package/dist/commands/chord.js +1 -1
  91. package/dist/commands/dashboard.js +5 -1
  92. package/dist/commands/human/prompts.js +3 -3
  93. package/dist/commands/human/queue.d.ts +17 -0
  94. package/dist/commands/human/queue.js +111 -4
  95. package/dist/commands/memory/__tests__/lint-schema.test.js +27 -4
  96. package/dist/commands/memory/lint.d.ts +5 -4
  97. package/dist/commands/memory/lint.js +23 -7
  98. package/dist/commands/memory/read.js +2 -2
  99. package/dist/commands/memory/shared.d.ts +7 -1
  100. package/dist/commands/memory/shared.js +18 -3
  101. package/dist/commands/memory/write.js +20 -7
  102. package/dist/commands/memory.js +4 -4
  103. package/dist/commands/node.d.ts +1 -1
  104. package/dist/commands/node.js +64 -65
  105. package/dist/commands/pkg.js +1 -2
  106. package/dist/commands/revive.js +11 -3
  107. package/dist/commands/sys/feedback.d.ts +1 -0
  108. package/dist/commands/sys/feedback.js +163 -0
  109. package/dist/commands/sys/sync.d.ts +1 -0
  110. package/dist/commands/sys/sync.js +187 -0
  111. package/dist/commands/sys.js +4 -2
  112. package/dist/commands/view-cycle.js +2 -2
  113. package/dist/commands/view-list.js +8 -8
  114. package/dist/commands/view-new.js +21 -17
  115. package/dist/commands/view-pick.js +1 -1
  116. package/dist/commands/view-run.js +35 -14
  117. package/dist/commands/view.js +8 -6
  118. package/dist/commands/workspace.d.ts +2 -0
  119. package/dist/commands/workspace.js +161 -0
  120. package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
  121. package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
  122. package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
  123. package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
  124. package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
  125. package/dist/core/__tests__/broker-snapshot-history.test.d.ts +1 -0
  126. package/dist/core/__tests__/broker-snapshot-history.test.js +105 -0
  127. package/dist/core/__tests__/child-death-wake.test.js +56 -111
  128. package/dist/core/__tests__/close.test.js +14 -20
  129. package/dist/core/__tests__/context-intro.test.js +19 -19
  130. package/dist/core/__tests__/daemon-boot.test.js +30 -23
  131. package/dist/core/__tests__/draw-style.test.js +1 -1
  132. package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
  133. package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
  134. package/dist/core/__tests__/fixtures/fake-engine.d.ts +24 -1
  135. package/dist/core/__tests__/fixtures/fake-engine.js +32 -2
  136. package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
  137. package/dist/core/__tests__/focuses.test.js +25 -13
  138. package/dist/core/__tests__/fork.test.js +22 -5
  139. package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
  140. package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
  141. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
  142. package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
  143. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
  144. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
  145. package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
  146. package/dist/core/__tests__/full/cascade-close.test.js +22 -8
  147. package/dist/core/__tests__/full/detach-focus.test.js +66 -169
  148. package/dist/core/__tests__/helpers/harness.d.ts +6 -9
  149. package/dist/core/__tests__/helpers/harness.js +19 -36
  150. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
  151. package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
  152. package/dist/core/__tests__/human-stranded-deliver.test.d.ts +1 -0
  153. package/dist/core/__tests__/human-stranded-deliver.test.js +108 -0
  154. package/dist/core/__tests__/lifecycle.test.js +1 -19
  155. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
  156. package/dist/core/__tests__/memory-resolver.test.js +1 -1
  157. package/dist/core/__tests__/on-read-dedup-resume.test.d.ts +1 -0
  158. package/dist/core/__tests__/on-read-dedup-resume.test.js +81 -0
  159. package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
  160. package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
  161. package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
  162. package/dist/core/__tests__/relaunch-root.test.js +114 -0
  163. package/dist/core/__tests__/reset.test.js +19 -79
  164. package/dist/core/__tests__/steer-note.test.js +9 -7
  165. package/dist/core/__tests__/tmux-surface.test.js +13 -7
  166. package/dist/core/bootstrap.d.ts +0 -3
  167. package/dist/core/bootstrap.js +1 -143
  168. package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
  169. package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
  170. package/dist/core/canvas/browse/app.js +131 -8
  171. package/dist/core/canvas/browse/model.d.ts +23 -5
  172. package/dist/core/canvas/browse/model.js +68 -13
  173. package/dist/core/canvas/browse/render.d.ts +7 -1
  174. package/dist/core/canvas/browse/render.js +178 -47
  175. package/dist/core/canvas/canvas.js +1 -1
  176. package/dist/core/canvas/focuses.d.ts +4 -4
  177. package/dist/core/canvas/focuses.js +7 -7
  178. package/dist/core/canvas/nav-model.d.ts +162 -0
  179. package/dist/core/canvas/nav-model.js +487 -0
  180. package/dist/core/canvas/paths.d.ts +7 -0
  181. package/dist/core/canvas/paths.js +9 -0
  182. package/dist/core/canvas/render.d.ts +68 -9
  183. package/dist/core/canvas/render.js +195 -61
  184. package/dist/core/canvas/types.d.ts +16 -23
  185. package/dist/core/config.js +1 -4
  186. package/dist/core/memory-resolver.js +43 -5
  187. package/dist/core/runtime/bearings.d.ts +1 -1
  188. package/dist/core/runtime/bearings.js +8 -8
  189. package/dist/core/runtime/branded-host.d.ts +16 -0
  190. package/dist/core/runtime/branded-host.js +127 -0
  191. package/dist/core/runtime/broker-protocol.d.ts +212 -6
  192. package/dist/core/runtime/broker-sdk.d.ts +0 -12
  193. package/dist/core/runtime/broker-sdk.js +77 -6
  194. package/dist/core/runtime/broker.d.ts +5 -1
  195. package/dist/core/runtime/broker.js +520 -59
  196. package/dist/core/runtime/close.d.ts +12 -2
  197. package/dist/core/runtime/close.js +37 -16
  198. package/dist/core/runtime/front-door.js +14 -7
  199. package/dist/core/runtime/host.d.ts +14 -34
  200. package/dist/core/runtime/host.js +14 -50
  201. package/dist/core/runtime/launch.d.ts +12 -9
  202. package/dist/core/runtime/launch.js +27 -15
  203. package/dist/core/runtime/lifecycle.d.ts +1 -1
  204. package/dist/core/runtime/lifecycle.js +15 -19
  205. package/dist/core/runtime/nodes.d.ts +0 -55
  206. package/dist/core/runtime/nodes.js +6 -74
  207. package/dist/core/runtime/placement.d.ts +91 -334
  208. package/dist/core/runtime/placement.js +262 -828
  209. package/dist/core/runtime/promote.d.ts +2 -0
  210. package/dist/core/runtime/promote.js +24 -6
  211. package/dist/core/runtime/recap.d.ts +8 -0
  212. package/dist/core/runtime/recap.js +107 -0
  213. package/dist/core/runtime/recycle.js +25 -61
  214. package/dist/core/runtime/reset.d.ts +43 -37
  215. package/dist/core/runtime/reset.js +131 -218
  216. package/dist/core/runtime/revive.d.ts +9 -29
  217. package/dist/core/runtime/revive.js +45 -135
  218. package/dist/core/runtime/spawn.d.ts +15 -11
  219. package/dist/core/runtime/spawn.js +168 -136
  220. package/dist/core/runtime/surface-bg.d.ts +11 -0
  221. package/dist/core/runtime/surface-bg.js +68 -0
  222. package/dist/core/runtime/tmux.d.ts +15 -43
  223. package/dist/core/runtime/tmux.js +38 -80
  224. package/dist/core/scope.d.ts +1 -2
  225. package/dist/core/scope.js +1 -5
  226. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
  227. package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
  228. package/dist/core/skill-sync/builtins.d.ts +42 -0
  229. package/dist/core/skill-sync/builtins.js +112 -0
  230. package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
  231. package/dist/core/skill-sync/claude-plugins.js +71 -0
  232. package/dist/core/skill-sync/engine.d.ts +42 -0
  233. package/dist/core/skill-sync/engine.js +633 -0
  234. package/dist/core/skill-sync/export.d.ts +23 -0
  235. package/dist/core/skill-sync/export.js +86 -0
  236. package/dist/core/skill-sync/manifest.d.ts +64 -0
  237. package/dist/core/skill-sync/manifest.js +181 -0
  238. package/dist/core/skill-sync/profile.d.ts +76 -0
  239. package/dist/core/skill-sync/profile.js +173 -0
  240. package/dist/core/skill-sync/snapshot.d.ts +57 -0
  241. package/dist/core/skill-sync/snapshot.js +120 -0
  242. package/dist/core/spawn.d.ts +14 -0
  243. package/dist/core/spawn.js +29 -9
  244. package/dist/core/substrate/index.d.ts +2 -2
  245. package/dist/core/substrate/index.js +7 -7
  246. package/dist/core/substrate/injected-store.d.ts +10 -0
  247. package/dist/core/substrate/injected-store.js +55 -0
  248. package/dist/core/substrate/on-read.js +68 -6
  249. package/dist/core/substrate/render.d.ts +8 -11
  250. package/dist/core/substrate/render.js +29 -43
  251. package/dist/core/substrate/schema.d.ts +16 -11
  252. package/dist/core/substrate/schema.js +32 -31
  253. package/dist/core/tui/host.d.ts +12 -4
  254. package/dist/core/tui/host.js +280 -149
  255. package/dist/core/view/bridge.d.ts +10 -0
  256. package/dist/core/view/bridge.js +31 -0
  257. package/dist/core/view/chrome.d.ts +9 -0
  258. package/dist/core/view/chrome.js +22 -0
  259. package/dist/core/view/contract.d.ts +171 -0
  260. package/dist/core/view/contract.js +23 -0
  261. package/dist/core/view/loader.d.ts +31 -0
  262. package/dist/core/view/loader.js +188 -0
  263. package/dist/core/view/transport-local.d.ts +7 -0
  264. package/dist/core/view/transport-local.js +70 -0
  265. package/dist/core/view/transport.d.ts +4 -0
  266. package/dist/core/view/transport.js +15 -0
  267. package/dist/daemon/crtrd.d.ts +38 -27
  268. package/dist/daemon/crtrd.js +303 -376
  269. package/dist/daemon/manage.js +6 -1
  270. package/dist/index.js +1 -1
  271. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
  272. package/dist/pi-extensions/canvas-context-intro.js +4 -4
  273. package/dist/pi-extensions/canvas-doc-substrate.js +29 -20
  274. package/dist/pi-extensions/canvas-nav.js +35 -385
  275. package/dist/pi-extensions/canvas-recap.d.ts +37 -0
  276. package/dist/pi-extensions/canvas-recap.js +236 -0
  277. package/dist/pi-extensions/canvas-resume.js +2 -1
  278. package/dist/pi-extensions/canvas-stophook.d.ts +15 -13
  279. package/dist/pi-extensions/canvas-stophook.js +119 -103
  280. package/dist/pi-extensions/canvas-view.js +2 -1
  281. package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
  282. package/dist/pi-extensions/widget-order-bus.js +34 -0
  283. package/dist/prompts/view.d.ts +2 -2
  284. package/dist/prompts/view.js +145 -73
  285. package/dist/types.d.ts +1 -6
  286. package/dist/types.js +1 -3
  287. package/dist/web/ViewChrome.d.ts +7 -0
  288. package/dist/web/ViewChrome.js +28 -0
  289. package/dist/web/ViewPane.d.ts +39 -0
  290. package/dist/web/ViewPane.js +48 -0
  291. package/dist/web/index.d.ts +6 -0
  292. package/dist/web/index.js +16 -0
  293. package/dist/web/runtime.d.ts +39 -0
  294. package/dist/web/runtime.js +133 -0
  295. package/dist/web/states.d.ts +24 -0
  296. package/dist/web/states.js +24 -0
  297. package/dist/web/transport-http.d.ts +5 -0
  298. package/dist/web/transport-http.js +28 -0
  299. package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
  300. package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
  301. package/dist/web-client/index.html +13 -0
  302. package/package.json +20 -6
  303. package/dist/builtin-views/canvas/client.mjs +0 -303
  304. package/dist/builtin-views/canvas/view.mjs +0 -576
  305. package/dist/builtin-views/git-pr/client.mjs +0 -440
  306. package/dist/builtin-views/git-pr/view.mjs +0 -675
  307. package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
  308. package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
  309. package/dist/builtin-views/inbox/view.mjs +0 -889
  310. package/dist/builtin-views/linkedin/client.mjs +0 -610
  311. package/dist/builtin-views/linkedin/view.mjs +0 -1171
  312. package/dist/commands/pkg/bridge.d.ts +0 -1
  313. package/dist/commands/pkg/bridge.js +0 -137
  314. package/dist/commands/skill/author.d.ts +0 -3
  315. package/dist/commands/skill/author.js +0 -140
  316. package/dist/commands/skill/shared.d.ts +0 -3
  317. package/dist/commands/skill/shared.js +0 -19
  318. package/dist/commands/skill.d.ts +0 -2
  319. package/dist/commands/skill.js +0 -21
  320. package/dist/commands/tmux-spread.d.ts +0 -2
  321. package/dist/commands/tmux-spread.js +0 -144
  322. package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
  323. package/dist/core/__tests__/full/placement-focus.test.js +0 -309
  324. package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
  325. package/dist/core/__tests__/full/placement-revive.test.js +0 -238
  326. package/dist/core/__tests__/full/placement-teardown.test.js +0 -221
  327. package/dist/core/__tests__/home-session.test.js +0 -186
  328. package/dist/core/__tests__/relaunch.test.js +0 -335
  329. package/dist/core/bridge-map.d.ts +0 -19
  330. package/dist/core/bridge-map.js +0 -73
  331. package/dist/core/tui/contract.d.ts +0 -83
  332. package/dist/core/tui/contract.js +0 -8
  333. package/dist/core/tui/loader.d.ts +0 -16
  334. package/dist/core/tui/loader.js +0 -94
  335. package/dist/prompts/skill.d.ts +0 -2
  336. package/dist/prompts/skill.js +0 -650
  337. /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
  338. /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
  339. /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
  340. /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
  341. /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
  342. /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/reconnect-giveup.test.d.ts} +0 -0
  343. /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
@@ -0,0 +1,673 @@
1
+ // @ts-check
2
+ /**
3
+ * Git / PR board — the PORTABLE CORE of the crtr `git-pr` view (manifest · init ·
4
+ * sources · intents). The reference dual-target view: this one core renders in
5
+ * BOTH targets — the tmux TUI (`crtr view run git-pr`, via `tui.mjs`) and the
6
+ * React+Tailwind web page (`crtr view serve git-pr`, via `web.jsx`).
7
+ *
8
+ * Runs in BOTH Node and the browser, so it imports NOTHING — no `node:*`, no
9
+ * crtr. The data layer is expressed as transport-agnostic `Source` descriptors:
10
+ * the core describes WHAT to run (`request()` → a SourceRequest for `git`/`gh`),
11
+ * the host's Transport runs it (local `execFile` for the TUI, the HTTP bridge
12
+ * for web), and the pure `parse()` turns bytes → typed data. The porcelain/JSON
13
+ * parsing (parseStatus / rollupCi / relAge / classifyGh …) is pure string work
14
+ * that runs anywhere.
15
+ *
16
+ * NOTHING throws. Sources return a `Result<T>` (typed `SourceError` on failure);
17
+ * the `refresh` intent maps a blocking git error to a guided takeover and keeps
18
+ * the last-known board on a transient failure (graceful partial failure).
19
+ *
20
+ * @module git-pr/core
21
+ */
22
+
23
+ /**
24
+ * @typedef {import('../../core/view/contract.js').Source<any, any>} AnySource
25
+ * @typedef {import('../../core/view/contract.js').SourceError} SourceError
26
+ * @typedef {import('../../core/view/contract.js').RawResponse} RawResponse
27
+ * @typedef {import('../../core/view/contract.js').IntentCtx<GitPrState>} Ctx
28
+ */
29
+
30
+ /**
31
+ * One changed file, flattened from `git status --porcelain=v1 -z`.
32
+ * @typedef {Object} ChangedFile
33
+ * @property {string} path
34
+ * @property {string} xy
35
+ * @property {'staged'|'modified'|'untracked'|'conflict'} cls
36
+ * @property {number} add
37
+ * @property {number} del
38
+ */
39
+ /**
40
+ * Local git state. `lastCommit` / `upstream` are null on an empty repo / a branch
41
+ * with no upstream — degraded fields, not errors.
42
+ * @typedef {Object} GitState
43
+ * @property {string} branch
44
+ * @property {boolean} detached
45
+ * @property {string|null} upstream
46
+ * @property {number} ahead
47
+ * @property {number} behind
48
+ * @property {{sha:string, subject:string, when:string}|null} lastCommit
49
+ * @property {ChangedFile[]} files
50
+ * @property {{staged:number, modified:number, untracked:number, conflict:number}} counts
51
+ */
52
+ /**
53
+ * One open pull request, flattened from `gh pr list --json …`.
54
+ * @typedef {Object} Pr
55
+ * @property {number} number
56
+ * @property {string} title
57
+ * @property {string} headRefName
58
+ * @property {boolean} isDraft
59
+ * @property {boolean} current
60
+ * @property {'approved'|'changes'|'review'} review
61
+ * @property {'pass'|'fail'|'pending'|'none'} ci
62
+ * @property {string} updatedAt
63
+ */
64
+ /**
65
+ * One logical board row (built once per refresh; re-rendered on resize without a
66
+ * re-fetch). The cursor moves over all of them — label/spacer rows are inert.
67
+ * @typedef {{kind:'label', text:string}
68
+ * | {kind:'file', file:ChangedFile}
69
+ * | {kind:'clean'}
70
+ * | {kind:'spacer'}
71
+ * | {kind:'pr', pr:Pr}
72
+ * | {kind:'note', text:string}} BoardRow
73
+ */
74
+ /**
75
+ * The view's immutable state (the core owns it; intents replace it via ctx.set).
76
+ * @typedef {Object} GitPrState
77
+ * @property {GitState|null} git
78
+ * @property {SourceError|null} gitErr Typed git failure; presenters render its `display` VERBATIM.
79
+ * @property {Pr[]} prs
80
+ * @property {string|null} prNote
81
+ * @property {BoardRow[]} board
82
+ * @property {number} cursor
83
+ * @property {number} scroll
84
+ * @property {number} lastFetch
85
+ */
86
+
87
+ // ── Result helpers (inlined — the core imports nothing) ───────────────────────
88
+
89
+ /** @template T @param {T} data @returns {{ok:true, data:T}} */
90
+ function ok(data) {
91
+ return { ok: true, data };
92
+ }
93
+ /** @param {SourceError} error @returns {{ok:false, error:SourceError}} */
94
+ function fail(error) {
95
+ return { ok: false, error };
96
+ }
97
+
98
+ // ── Tiny pure utilities ───────────────────────────────────────────────────────
99
+
100
+ /** @param {string} s @returns {string} */
101
+ function firstLine(s) {
102
+ const lines = String(s || '')
103
+ .split(/\r?\n/)
104
+ .map((l) => l.trim())
105
+ .filter(Boolean);
106
+ return lines.length ? lines[0] : '';
107
+ }
108
+
109
+ /** @param {string|number} v @returns {number} */
110
+ function toInt(v) {
111
+ const n = parseInt(String(v), 10);
112
+ return Number.isFinite(n) ? n : 0;
113
+ }
114
+
115
+ /** @param {number} n @param {string} w @returns {string} */
116
+ export function plural(n, w) {
117
+ return `${n} ${w}${n === 1 ? '' : 's'}`;
118
+ }
119
+
120
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
121
+
122
+ /**
123
+ * Relative-age ladder (design §5): `now` (<60s), `{m}m` (<60m), `{h}h` (<24h),
124
+ * `{d}d` (<7d), else `Mon D` (`Mar 4`), prior-year `Mon ʼYY`. Max ~5 cols.
125
+ * Shared by the TUI render + the text dump (both import it from here).
126
+ * @param {string} iso @param {number} now @returns {string}
127
+ */
128
+ export function relAge(iso, now) {
129
+ const t = Date.parse(iso);
130
+ if (Number.isNaN(t)) return '';
131
+ const s = Math.max(0, Math.floor((now - t) / 1000));
132
+ if (s < 60) return 'now';
133
+ const m = Math.floor(s / 60);
134
+ if (m < 60) return `${m}m`;
135
+ const h = Math.floor(m / 60);
136
+ if (h < 24) return `${h}h`;
137
+ const d = Math.floor(h / 24);
138
+ if (d < 7) return `${d}d`;
139
+ const date = new Date(t);
140
+ const mon = MONTHS[date.getMonth()] || '?';
141
+ if (date.getFullYear() === new Date(now).getFullYear()) return `${mon} ${date.getDate()}`;
142
+ return `${mon} ʼ${String(date.getFullYear()).slice(-2)}`;
143
+ }
144
+
145
+ // ── git: status / churn parsing ──────────────────────────────────
146
+
147
+ /**
148
+ * Classify a porcelain XY pair into the file's primary class.
149
+ * @param {string} x @param {string} y
150
+ * @returns {'staged'|'modified'|'untracked'|'conflict'}
151
+ */
152
+ function classify(x, y) {
153
+ if (x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')) return 'conflict';
154
+ if (x === '?' || y === '?') return 'untracked';
155
+ if (y !== ' ' && y !== '') return 'modified';
156
+ if (x !== ' ' && x !== '') return 'staged';
157
+ return 'modified';
158
+ }
159
+
160
+ /**
161
+ * Parse `git status --porcelain=v1 -z -uall` into changed files + column tallies.
162
+ * @param {string} stdout
163
+ * @returns {{files: ChangedFile[], counts: GitState['counts']}}
164
+ */
165
+ function parseStatus(stdout) {
166
+ const parts = String(stdout || '').split('\0');
167
+ /** @type {ChangedFile[]} */
168
+ const files = [];
169
+ const counts = { staged: 0, modified: 0, untracked: 0, conflict: 0 };
170
+ for (let i = 0; i < parts.length; i++) {
171
+ const entry = parts[i];
172
+ if (!entry || entry.length < 3) continue;
173
+ const x = entry[0];
174
+ const y = entry[1];
175
+ const path = entry.slice(3);
176
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') i++;
177
+ const cls = classify(x, y);
178
+ if (cls === 'conflict') counts.conflict++;
179
+ else if (cls === 'untracked') counts.untracked++;
180
+ else {
181
+ if (x !== ' ' && x !== '?' && x !== '') counts.staged++;
182
+ if (y !== ' ' && y !== '?' && y !== '') counts.modified++;
183
+ }
184
+ files.push({ path, xy: `${x}${y}`, cls, add: 0, del: 0 });
185
+ }
186
+ return { files, counts };
187
+ }
188
+
189
+ /**
190
+ * Parse `git diff [--cached] --numstat` into a path→churn map. Binary files
191
+ * (`-\t-`) and renames (`old => new`) are skipped — churn is best-effort.
192
+ * @param {string} stdout
193
+ * @returns {Array<{path:string, add:number, del:number}>}
194
+ */
195
+ function parseNumstat(stdout) {
196
+ /** @type {Array<{path:string, add:number, del:number}>} */
197
+ const out = [];
198
+ for (const line of String(stdout || '').split(/\r?\n/)) {
199
+ if (!line.trim()) continue;
200
+ const m = line.split('\t');
201
+ if (m.length < 3) continue;
202
+ const add = m[0];
203
+ const del = m[1];
204
+ const path = m.slice(2).join('\t');
205
+ if (add === '-' || del === '-' || path.includes(' => ')) continue;
206
+ out.push({ path, add: toInt(add), del: toInt(del) });
207
+ }
208
+ return out;
209
+ }
210
+
211
+ const CLASS_RANK = { conflict: 0, staged: 1, modified: 2, untracked: 3 };
212
+
213
+ // ── gh: rollups + classification ────────────────────────────────
214
+
215
+ const CI_BAD = new Set(['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
216
+ const CI_GOOD = new Set(['SUCCESS', 'NEUTRAL', 'SKIPPED']);
217
+
218
+ /** @param {any[]|undefined} items @returns {'pass'|'fail'|'pending'|'none'} */
219
+ function rollupCi(items) {
220
+ if (!Array.isArray(items) || items.length === 0) return 'none';
221
+ let pending = false;
222
+ for (const it of items) {
223
+ const concl = String((it && it.conclusion) || '').toUpperCase();
224
+ const state = String((it && it.state) || '').toUpperCase();
225
+ const status = String((it && it.status) || '').toUpperCase();
226
+ if (CI_BAD.has(concl) || CI_BAD.has(state)) return 'fail';
227
+ if (status && status !== 'COMPLETED') pending = true;
228
+ else if (state && !CI_GOOD.has(state) && !CI_BAD.has(state)) pending = true;
229
+ else if (!concl && !state && !status) pending = true;
230
+ }
231
+ return pending ? 'pending' : 'pass';
232
+ }
233
+
234
+ /** @param {string} d @returns {'approved'|'changes'|'review'} */
235
+ function normReview(d) {
236
+ const s = String(d || '').toUpperCase();
237
+ if (s === 'APPROVED') return 'approved';
238
+ if (s === 'CHANGES_REQUESTED') return 'changes';
239
+ return 'review';
240
+ }
241
+
242
+ // ── Typed SourceError displays (the `display`/`kind` split; presenters render
243
+ // `display` verbatim and never branch on `kind`) ────────────────────────────
244
+
245
+ /** @type {SourceError} */
246
+ const GIT_MISSING = {
247
+ kind: 'git-missing',
248
+ display: {
249
+ headline: 'git not found',
250
+ explanation: 'crtr could not find the git binary on PATH.',
251
+ nextStep: 'Install git, then press g.',
252
+ level: 'error',
253
+ blocking: true,
254
+ },
255
+ };
256
+ /** @type {SourceError} */
257
+ const NOT_A_REPO = {
258
+ kind: 'not-a-repo',
259
+ display: {
260
+ headline: 'Not a git repository',
261
+ explanation: 'This view monitors a git repo, and the current directory is not one.',
262
+ nextStep: 'cd into a repository (or run `git init`), then press g.',
263
+ level: 'action',
264
+ blocking: true,
265
+ },
266
+ };
267
+ /** @param {string} msg @returns {SourceError} */
268
+ function gitFailed(msg) {
269
+ return {
270
+ kind: 'git-failed',
271
+ display: {
272
+ headline: 'Git unavailable',
273
+ explanation: msg || 'A git command failed.',
274
+ nextStep: 'Press g to retry.',
275
+ level: 'error',
276
+ blocking: false,
277
+ },
278
+ };
279
+ }
280
+ /** @param {string} kind @param {string} headline @returns {SourceError} */
281
+ function ghError(kind, headline) {
282
+ return { kind, display: { headline, explanation: '', nextStep: '', level: 'info', blocking: false } };
283
+ }
284
+ /**
285
+ * Classify a failed `gh` invocation into a guided PR-section note (auth first,
286
+ * then network, then a missing remote, else generic).
287
+ * @param {string} stderr @returns {SourceError}
288
+ */
289
+ function classifyGh(stderr) {
290
+ const s = String(stderr || '').toLowerCase();
291
+ if (/auth|logged in|gh auth login|authentication|not logged/.test(s)) {
292
+ return ghError('gh-unauthed', 'gh is not authenticated — run `gh auth login`.');
293
+ }
294
+ if (/could not resolve host|dial tcp|network is unreachable|no such host|timeout|temporary failure|connection refused/.test(s)) {
295
+ return ghError('gh-network', 'Cannot reach GitHub (offline?).');
296
+ }
297
+ if (/could not resolve to a repository|no git remote|none of the git remotes|no default remote|not a github|head branch could not/.test(s)) {
298
+ return ghError('gh-no-remote', 'No GitHub remote for this repository.');
299
+ }
300
+ return ghError('gh-failed', firstLine(stderr) || 'gh command failed.');
301
+ }
302
+
303
+ // ── Sources (reads): a request descriptor + a pure parse. The host's transport
304
+ // runs the request (local execFile for TUI, the HTTP bridge for web). ───────
305
+
306
+ /**
307
+ * PRIMARY git instrument — working-tree status. Its parse owns the three hard
308
+ * git states (missing binary / not-a-repo / a failing git command); empty-repo
309
+ * and no-upstream degrade to null fields on the secondary reads, not errors.
310
+ * @type {import('../../core/view/contract.js').Source<{files:ChangedFile[], counts:GitState['counts']}>}
311
+ */
312
+ export const gitStatusSource = {
313
+ id: 'git-status',
314
+ request: () => ({ kind: 'exec', bin: 'git', args: ['status', '--porcelain=v1', '-z', '--untracked-files=all'] }),
315
+ parse: (raw) => {
316
+ if (!raw.ok) return fail(GIT_MISSING);
317
+ if (raw.exitCode !== 0) {
318
+ const s = String(raw.stderr || '').toLowerCase();
319
+ if (/not a git repository/.test(s)) return fail(NOT_A_REPO);
320
+ return fail(gitFailed(firstLine(raw.stderr)));
321
+ }
322
+ return ok(parseStatus(raw.stdout));
323
+ },
324
+ };
325
+
326
+ /** Current branch name ('' ⇒ detached HEAD; resolve via gitHeadSource). */
327
+ export const gitBranchSource = {
328
+ id: 'git-branch',
329
+ request: () => ({ kind: 'exec', bin: 'git', args: ['branch', '--show-current'] }),
330
+ parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? raw.stdout.trim() : ''),
331
+ };
332
+
333
+ /** Short HEAD sha — used to label a detached HEAD. */
334
+ export const gitHeadSource = {
335
+ id: 'git-head',
336
+ request: () => ({ kind: 'exec', bin: 'git', args: ['rev-parse', '--short', 'HEAD'] }),
337
+ parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? raw.stdout.trim() : ''),
338
+ };
339
+
340
+ /** Tracking ref (e.g. "origin/main") or null when no upstream is set. */
341
+ export const gitUpstreamSource = {
342
+ id: 'git-upstream',
343
+ request: () => ({ kind: 'exec', bin: 'git', args: ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'] }),
344
+ parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? raw.stdout.trim() : null),
345
+ };
346
+
347
+ /** Ahead/behind vs upstream (only meaningful when an upstream exists). */
348
+ export const gitAheadBehindSource = {
349
+ id: 'git-ahead-behind',
350
+ request: () => ({ kind: 'exec', bin: 'git', args: ['rev-list', '--left-right', '--count', '@{upstream}...HEAD'] }),
351
+ parse: (raw) => {
352
+ if (!raw.ok || raw.exitCode !== 0) return ok({ ahead: 0, behind: 0 });
353
+ const nums = raw.stdout.trim().split(/\s+/);
354
+ return ok({ behind: toInt(nums[0]), ahead: toInt(nums[1]) });
355
+ },
356
+ };
357
+
358
+ /** Last commit (null on an empty repo). */
359
+ export const gitLogSource = {
360
+ id: 'git-log',
361
+ request: () => ({ kind: 'exec', bin: 'git', args: ['log', '-1', '--pretty=%h%x1f%s%x1f%cI'] }),
362
+ parse: (raw) => {
363
+ if (!raw.ok || raw.exitCode !== 0 || raw.stdout.trim() === '') return ok(null);
364
+ const [sha, subject, when] = raw.stdout.replace(/\n$/, '').split('\x1f');
365
+ return ok({ sha: sha || '', subject: subject || '', when: when || '' });
366
+ },
367
+ };
368
+
369
+ /** Unstaged churn (best-effort; never fatal). */
370
+ export const gitDiffSource = {
371
+ id: 'git-diff',
372
+ request: () => ({ kind: 'exec', bin: 'git', args: ['diff', '--numstat'] }),
373
+ parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? parseNumstat(raw.stdout) : []),
374
+ };
375
+
376
+ /** Staged churn (best-effort; never fatal). */
377
+ export const gitDiffCachedSource = {
378
+ id: 'git-diff-cached',
379
+ request: () => ({ kind: 'exec', bin: 'git', args: ['diff', '--cached', '--numstat'] }),
380
+ parse: (raw) => ok(raw.ok && raw.exitCode === 0 ? parseNumstat(raw.stdout) : []),
381
+ };
382
+
383
+ /**
384
+ * Open PRs for this repo. BEST-EFFORT: a typed error becomes the PR section's
385
+ * guided note (the git section is unaffected). `current`/sort-by-current is
386
+ * applied in `refresh` (parse has no access to the branch).
387
+ * @type {import('../../core/view/contract.js').Source<Pr[]>}
388
+ */
389
+ export const prsSource = {
390
+ id: 'prs',
391
+ request: () => ({
392
+ kind: 'exec', bin: 'gh',
393
+ args: ['pr', 'list', '--state', 'open', '--limit', '30', '--json',
394
+ 'number,title,headRefName,reviewDecision,statusCheckRollup,updatedAt,isDraft'],
395
+ }),
396
+ parse: (raw) => {
397
+ if (!raw.ok) return fail(ghError('gh-missing', 'gh (GitHub CLI) not found — install it to see PRs.'));
398
+ if (raw.exitCode !== 0) return fail(classifyGh(raw.stderr || raw.stdout));
399
+ let arr;
400
+ try {
401
+ arr = JSON.parse(raw.stdout.trim() || '[]');
402
+ } catch {
403
+ return fail(ghError('gh-failed', 'could not parse gh output as JSON.'));
404
+ }
405
+ if (!Array.isArray(arr)) arr = [];
406
+ /** @type {Pr[]} */
407
+ const prs = arr.map((p) => {
408
+ const o = p || {};
409
+ const head = String(o.headRefName || '');
410
+ return {
411
+ number: typeof o.number === 'number' ? o.number : toInt(o.number),
412
+ title: String(o.title || '(untitled)'),
413
+ headRefName: head,
414
+ isDraft: !!o.isDraft,
415
+ current: false,
416
+ review: normReview(o.reviewDecision),
417
+ ci: rollupCi(o.statusCheckRollup),
418
+ updatedAt: String(o.updatedAt || ''),
419
+ };
420
+ });
421
+ return ok(prs);
422
+ },
423
+ };
424
+
425
+ // ── Board model + chrome copy (shared by render + dump; pure) ──────────────────
426
+
427
+ /**
428
+ * Flatten git + PR state into the ordered board rows. Each section degrades
429
+ * inline (a clean reward row / a guided gh note / a "no open PRs" note).
430
+ * @param {GitPrState} state @returns {BoardRow[]}
431
+ */
432
+ export function buildBoard(state) {
433
+ /** @type {BoardRow[]} */
434
+ const rows = [];
435
+ const g = state.git;
436
+ rows.push({ kind: 'label', text: 'Working tree' });
437
+ if (!g || g.files.length === 0) rows.push({ kind: 'clean' });
438
+ else for (const f of g.files) rows.push({ kind: 'file', file: f });
439
+
440
+ rows.push({ kind: 'spacer' });
441
+ rows.push({ kind: 'label', text: 'Pull requests' });
442
+ if (state.prNote) rows.push({ kind: 'note', text: state.prNote });
443
+ else if (state.prs.length === 0) rows.push({ kind: 'note', text: 'No open pull requests.' });
444
+ else for (const pr of state.prs) rows.push({ kind: 'pr', pr });
445
+ return rows;
446
+ }
447
+
448
+ /** Working-tree state as one short phrase, for the subtitle + dump. @param {GitState} g */
449
+ export function treePhrase(g) {
450
+ const c = g.counts;
451
+ if (g.files.length === 0) return 'clean';
452
+ /** @type {string[]} */
453
+ const parts = [];
454
+ if (c.conflict) parts.push(`${c.conflict} conflict`);
455
+ if (c.staged) parts.push(`${c.staged} staged`);
456
+ if (c.modified) parts.push(`${c.modified} modified`);
457
+ if (c.untracked) parts.push(`${c.untracked} untracked`);
458
+ return parts.join(' · ') || plural(g.files.length, 'change');
459
+ }
460
+
461
+ /** Live title subtitle — branch + tracking delta + tree state. @param {{git:GitState|null}} state */
462
+ function subtitleFor(state) {
463
+ const g = state.git;
464
+ if (!g) return null;
465
+ let s = g.branch;
466
+ if (g.ahead) s += ` ↑${g.ahead}`;
467
+ if (g.behind) s += ` ↓${g.behind}`;
468
+ s += ` · ${treePhrase(g)}`;
469
+ return s;
470
+ }
471
+
472
+ /** Footer status (left, transient). @param {{git:GitState|null, prs:Pr[], prNote:string|null}} state */
473
+ function footerSummary(state) {
474
+ const g = state.git;
475
+ if (!g) return null;
476
+ const files = g.files.length === 0 ? 'clean' : plural(g.files.length, 'change');
477
+ const prs = state.prNote ? 'PRs n/a' : plural(state.prs.length, 'PR');
478
+ return `${files} · ${prs}`;
479
+ }
480
+
481
+ /** The one thing that wants a human's eyes → an ACTION banner. @param {{git:GitState|null, prs:Pr[], prNote:string|null}} state */
482
+ function attentionFor(state) {
483
+ const g = state.git;
484
+ /** @type {string[]} */
485
+ const parts = [];
486
+ if (g && g.counts.conflict) parts.push(`${plural(g.counts.conflict, 'conflict')}`);
487
+ if (!state.prNote) {
488
+ const failing = state.prs.filter((p) => p.ci === 'fail').length;
489
+ const changes = state.prs.filter((p) => p.review === 'changes').length;
490
+ if (failing) parts.push(`${failing} PR${failing === 1 ? '' : 's'} failing CI`);
491
+ if (changes) parts.push(`${changes} PR${changes === 1 ? '' : 's'} need changes`);
492
+ }
493
+ return parts.length ? `${parts.join(' · ')} — needs attention` : null;
494
+ }
495
+
496
+ // ── The portable core ──────────────────────────────────────────────────────────
497
+
498
+ /** @type {import('../../core/view/contract.js').ViewCore<GitPrState>} */
499
+ const core = {
500
+ manifest: {
501
+ id: 'git-pr',
502
+ title: 'Git / PR',
503
+ description: 'Local git state + GitHub PR/CI status for the repo at this cwd',
504
+ refreshMs: 5000,
505
+ },
506
+
507
+ /** Cheap + synchronous initial state — NO fetch (the host paints a loading
508
+ * frame, then dispatches the first 'refresh'). @returns {GitPrState} */
509
+ init() {
510
+ return {
511
+ git: null,
512
+ gitErr: null,
513
+ prs: [],
514
+ prNote: null,
515
+ board: [],
516
+ cursor: 0,
517
+ scroll: 0,
518
+ lastFetch: 0,
519
+ };
520
+ },
521
+
522
+ sources: {
523
+ gitStatusSource,
524
+ gitBranchSource,
525
+ gitHeadSource,
526
+ gitUpstreamSource,
527
+ gitAheadBehindSource,
528
+ gitLogSource,
529
+ gitDiffSource,
530
+ gitDiffCachedSource,
531
+ prsSource,
532
+ },
533
+
534
+ intents: {
535
+ /**
536
+ * Read git + PR state, rebuild the board. Runs in the host's single-flight
537
+ * lane. A BLOCKING git error (no binary / not a repo) drops to a guided
538
+ * takeover; a transient git-failed KEEPS the last-known board + a banner. PR
539
+ * failures degrade the PR section only (an inline note).
540
+ * @param {Ctx} ctx
541
+ */
542
+ async refresh(ctx) {
543
+ ctx.signal.setStatus('Reading git…');
544
+
545
+ const st = await ctx.resolve(gitStatusSource);
546
+ if (!st.ok) {
547
+ const err = st.error;
548
+ const hadGit = ctx.state.git != null;
549
+ // git-failed WITH a last-known board ⇒ keep it (transient). Hard cases
550
+ // (not-a-repo / git-missing) and a first-load git-failed ⇒ takeover.
551
+ const keep = !err.display.blocking && hadGit;
552
+ ctx.set((s) => {
553
+ /** @type {GitPrState} */
554
+ const next = { ...s, gitErr: err };
555
+ if (!keep) {
556
+ next.git = null;
557
+ next.prs = [];
558
+ next.prNote = null;
559
+ next.board = buildBoard(next);
560
+ }
561
+ next.lastFetch = Date.now();
562
+ return next;
563
+ });
564
+ // A takeover owns the whole rect and already names cause + next step —
565
+ // don't stack a banner under it. A kept board raises the cause instead.
566
+ if (keep) ctx.signal.setBanner(err.display.explanation, err.display.level);
567
+ else ctx.signal.clearBanner();
568
+ ctx.signal.setStatus(null);
569
+ ctx.signal.setSubtitle(keep ? subtitleFor(ctx.state) : null);
570
+ return;
571
+ }
572
+
573
+ // Secondary git reads — independent, best-effort, resolved concurrently.
574
+ const [brR, headR, upR, abR, logR, diffR, diffCR] = await Promise.all([
575
+ ctx.resolve(gitBranchSource),
576
+ ctx.resolve(gitHeadSource),
577
+ ctx.resolve(gitUpstreamSource),
578
+ ctx.resolve(gitAheadBehindSource),
579
+ ctx.resolve(gitLogSource),
580
+ ctx.resolve(gitDiffSource),
581
+ ctx.resolve(gitDiffCachedSource),
582
+ ]);
583
+
584
+ let branch = brR.ok ? brR.data : '';
585
+ let detached = false;
586
+ if (branch === '') {
587
+ detached = true;
588
+ branch = headR.ok && headR.data ? `(detached ${headR.data})` : '(no branch)';
589
+ }
590
+ const upstream = upR.ok ? upR.data : null;
591
+ let ahead = 0;
592
+ let behind = 0;
593
+ if (upstream && abR.ok) {
594
+ ahead = abR.data.ahead;
595
+ behind = abR.data.behind;
596
+ }
597
+ const lastCommit = logR.ok ? logR.data : null;
598
+
599
+ // Churn (best-effort) → apply to the status files, then problems-first sort.
600
+ /** @type {Map<string,{add:number,del:number}>} */
601
+ const churn = new Map();
602
+ const accChurn = (rows) => {
603
+ for (const c of rows) {
604
+ const prev = churn.get(c.path) || { add: 0, del: 0 };
605
+ prev.add += c.add;
606
+ prev.del += c.del;
607
+ churn.set(c.path, prev);
608
+ }
609
+ };
610
+ if (diffR.ok) accChurn(diffR.data);
611
+ if (diffCR.ok) accChurn(diffCR.data);
612
+
613
+ const { files, counts } = st.data;
614
+ for (const f of files) {
615
+ const c = churn.get(f.path);
616
+ if (c) {
617
+ f.add = c.add;
618
+ f.del = c.del;
619
+ }
620
+ }
621
+ files.sort((a, b) => {
622
+ const r = CLASS_RANK[a.cls] - CLASS_RANK[b.cls];
623
+ return r !== 0 ? r : a.path < b.path ? -1 : a.path > b.path ? 1 : 0;
624
+ });
625
+
626
+ /** @type {GitState} */
627
+ const git = { branch, detached, upstream, ahead, behind, lastCommit, files, counts };
628
+
629
+ // PRs — best-effort. Mark the current-branch PR + sort it first.
630
+ const p = await ctx.resolve(prsSource);
631
+ /** @type {Pr[]} */
632
+ let prs = [];
633
+ /** @type {string|null} */
634
+ let prNote = null;
635
+ if (p.ok) {
636
+ prs = p.data.map((pr) => ({ ...pr, current: pr.headRefName !== '' && pr.headRefName === branch }));
637
+ prs.sort((a, b) => {
638
+ if (a.current !== b.current) return a.current ? -1 : 1;
639
+ return a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0;
640
+ });
641
+ } else {
642
+ prNote = p.error.display.headline;
643
+ }
644
+
645
+ ctx.set((s) => {
646
+ /** @type {GitPrState} */
647
+ const next = { ...s, git, gitErr: null, prs, prNote };
648
+ next.board = buildBoard(next);
649
+ if (next.cursor >= next.board.length) next.cursor = Math.max(0, next.board.length - 1);
650
+ next.lastFetch = Date.now();
651
+ return next;
652
+ });
653
+
654
+ const live = { git, prs, prNote };
655
+ ctx.signal.setSubtitle(subtitleFor(live));
656
+ const attn = attentionFor(live);
657
+ if (attn) ctx.signal.setBanner(attn, 'action');
658
+ else ctx.signal.clearBanner();
659
+ ctx.signal.setStatus(footerSummary(live));
660
+ },
661
+
662
+ /** @param {Ctx} ctx */
663
+ cursorDown: (ctx) => ctx.set((s) => ({ ...s, cursor: s.board.length ? Math.min(s.board.length - 1, s.cursor + 1) : 0 })),
664
+ /** @param {Ctx} ctx */
665
+ cursorUp: (ctx) => ctx.set((s) => ({ ...s, cursor: Math.max(0, s.cursor - 1) })),
666
+ /** @param {Ctx} ctx @param {number} [i] */
667
+ select: (ctx, i) => ctx.set((s) => ({ ...s, cursor: typeof i === 'number' ? Math.max(0, Math.min(s.board.length - 1, i)) : s.cursor })),
668
+ /** @param {Ctx} ctx */
669
+ quit: (ctx) => ctx.signal.quit(),
670
+ },
671
+ };
672
+
673
+ export default core;