limn-review 0.1.123

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 (474) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +133 -0
  3. package/dist/ai/runtime.js +535 -0
  4. package/dist/api/plugins/auth.js +169 -0
  5. package/dist/api/plugins/error-handler.js +114 -0
  6. package/dist/api/plugins/rate-limit.js +855 -0
  7. package/dist/api/plugins/security.js +321 -0
  8. package/dist/api/routes/activity.js +103 -0
  9. package/dist/api/routes/ai-runtime.js +67 -0
  10. package/dist/api/routes/auth.js +194 -0
  11. package/dist/api/routes/billing.js +209 -0
  12. package/dist/api/routes/bot-triage.js +1289 -0
  13. package/dist/api/routes/conflicts.js +730 -0
  14. package/dist/api/routes/contact.js +318 -0
  15. package/dist/api/routes/daily-brief.js +58 -0
  16. package/dist/api/routes/flow.js +71 -0
  17. package/dist/api/routes/health.js +7 -0
  18. package/dist/api/routes/insights.js +200 -0
  19. package/dist/api/routes/me.js +458 -0
  20. package/dist/api/routes/mentions.js +22 -0
  21. package/dist/api/routes/mergers.js +8 -0
  22. package/dist/api/routes/ml-labels.js +109 -0
  23. package/dist/api/routes/open-prs.js +35 -0
  24. package/dist/api/routes/prs.js +1633 -0
  25. package/dist/api/routes/reactions.js +217 -0
  26. package/dist/api/routes/repos.js +563 -0
  27. package/dist/api/routes/search.js +41 -0
  28. package/dist/api/routes/threads.js +118 -0
  29. package/dist/api/routes/timeline.js +150 -0
  30. package/dist/api/routes/users.js +67 -0
  31. package/dist/api/routes/webhooks.js +181 -0
  32. package/dist/api/routes/workspaces.js +322 -0
  33. package/dist/app.js +274 -0
  34. package/dist/ascii.js +19 -0
  35. package/dist/auth/account.js +482 -0
  36. package/dist/auth/crypto.js +46 -0
  37. package/dist/bot-triage/resolve.js +59 -0
  38. package/dist/cli.js +388 -0
  39. package/dist/coding/activity.js +80 -0
  40. package/dist/coding/agent.js +288 -0
  41. package/dist/coding/ai-fix/auto-fix.js +212 -0
  42. package/dist/coding/ai-fix/manager.js +370 -0
  43. package/dist/coding/ai-fix/persist.js +213 -0
  44. package/dist/coding/ai-fix/pr-context.js +58 -0
  45. package/dist/coding/ai-fix/prompts.js +138 -0
  46. package/dist/coding/ai-fix/review-seed.js +357 -0
  47. package/dist/coding/ai-fix/routes.js +425 -0
  48. package/dist/coding/git-ops.js +321 -0
  49. package/dist/coding/git.js +184 -0
  50. package/dist/coding/merge.js +262 -0
  51. package/dist/coding/safe-path.js +75 -0
  52. package/dist/coding/schema.js +43 -0
  53. package/dist/config.js +648 -0
  54. package/dist/conflict/diff.js +304 -0
  55. package/dist/conflict/git.js +118 -0
  56. package/dist/conflict/hash.js +73 -0
  57. package/dist/conflict/janitor.js +218 -0
  58. package/dist/conflict/land.js +675 -0
  59. package/dist/conflict/model-types.js +2 -0
  60. package/dist/conflict/model.js +1081 -0
  61. package/dist/conflict/parse.js +142 -0
  62. package/dist/conflict/seam.js +136 -0
  63. package/dist/conflict/session.js +532 -0
  64. package/dist/conflict/suggestion.js +398 -0
  65. package/dist/conflict/tokens.js +72 -0
  66. package/dist/data-dir.js +112 -0
  67. package/dist/db/actor-lanes.js +213 -0
  68. package/dist/db/automation-ids.js +97 -0
  69. package/dist/db/automation-output.js +349 -0
  70. package/dist/db/blast-radius-query.js +36 -0
  71. package/dist/db/blast-radius.js +226 -0
  72. package/dist/db/bot-dormancy.js +115 -0
  73. package/dist/db/bot-overlap.js +579 -0
  74. package/dist/db/bot-volume.js +722 -0
  75. package/dist/db/bot-window.js +35 -0
  76. package/dist/db/branch-queries.js +310 -0
  77. package/dist/db/change-shape.js +243 -0
  78. package/dist/db/changepoint.js +75 -0
  79. package/dist/db/cleanup.js +36 -0
  80. package/dist/db/client.js +102 -0
  81. package/dist/db/code-loc.js +388 -0
  82. package/dist/db/credits.js +79 -0
  83. package/dist/db/daily-brief.js +406 -0
  84. package/dist/db/dependency-cards.js +145 -0
  85. package/dist/db/erase-account.js +255 -0
  86. package/dist/db/export-account.js +189 -0
  87. package/dist/db/failing-checks.js +124 -0
  88. package/dist/db/file-coupling.js +197 -0
  89. package/dist/db/flow-detail.js +661 -0
  90. package/dist/db/flow-pointers.js +197 -0
  91. package/dist/db/flow-settings.js +67 -0
  92. package/dist/db/forecast.js +114 -0
  93. package/dist/db/line-overlap.js +81 -0
  94. package/dist/db/merge-queue.js +189 -0
  95. package/dist/db/migrate.js +17 -0
  96. package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
  97. package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
  98. package/dist/db/migrations/0002_famous_sersi.sql +9 -0
  99. package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
  100. package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
  101. package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
  102. package/dist/db/migrations/0006_cute_violations.sql +45 -0
  103. package/dist/db/migrations/0007_dark_anthem.sql +2 -0
  104. package/dist/db/migrations/0008_multitenant_accounts.sql +44 -0
  105. package/dist/db/migrations/0009_lean_bodies.sql +13 -0
  106. package/dist/db/migrations/0010_lean_bodies_nullable.sql +56 -0
  107. package/dist/db/migrations/0011_pr_diff_size.sql +10 -0
  108. package/dist/db/migrations/0012_findings_included_default.sql +7 -0
  109. package/dist/db/migrations/0013_review_routing.sql +9 -0
  110. package/dist/db/migrations/0014_account_last_active.sql +6 -0
  111. package/dist/db/migrations/0015_repos_inbox_watch.sql +9 -0
  112. package/dist/db/migrations/0016_repo_viewer_permission.sql +7 -0
  113. package/dist/db/migrations/0017_account_display_name.sql +7 -0
  114. package/dist/db/migrations/0018_finding_comment_kind.sql +7 -0
  115. package/dist/db/migrations/0019_finding_file_in_diff.sql +7 -0
  116. package/dist/db/migrations/0020_claude_review_cost_telemetry.sql +11 -0
  117. package/dist/db/migrations/0021_account_feed_last_seen.sql +7 -0
  118. package/dist/db/migrations/0022_ci_status_events.sql +21 -0
  119. package/dist/db/migrations/0023_ai_usage.sql +21 -0
  120. package/dist/db/migrations/0024_pr_head_ref_name.sql +6 -0
  121. package/dist/db/migrations/0025_account_billing.sql +7 -0
  122. package/dist/db/migrations/0026_ai_credit_allowance.sql +5 -0
  123. package/dist/db/migrations/0027_user_github_type.sql +6 -0
  124. package/dist/db/migrations/0028_bot_review_classification.sql +21 -0
  125. package/dist/db/migrations/0029_bot_mute_rules.sql +19 -0
  126. package/dist/db/migrations/0030_teams.sql +32 -0
  127. package/dist/db/migrations/0031_events_pr_idx.sql +7 -0
  128. package/dist/db/migrations/0032_benchmark_contributions.sql +29 -0
  129. package/dist/db/migrations/0033_thread_addressed_confidence.sql +9 -0
  130. package/dist/db/migrations/0034_thread_resolved_at.sql +6 -0
  131. package/dist/db/migrations/0035_pr_first_review_requested_at.sql +5 -0
  132. package/dist/db/migrations/0036_search_index.sql +25 -0
  133. package/dist/db/migrations/0037_author_indexes.sql +15 -0
  134. package/dist/db/migrations/0038_auto_merge_requests.sql +37 -0
  135. package/dist/db/migrations/0039_branch_status.sql +44 -0
  136. package/dist/db/migrations/0040_pr_review_decision.sql +10 -0
  137. package/dist/db/migrations/0041_branch_commit_detail.sql +38 -0
  138. package/dist/db/migrations/0042_repo_reviewers.sql +176 -0
  139. package/dist/db/migrations/0043_account_reviewers.sql +131 -0
  140. package/dist/db/migrations/0044_workspaces.sql +138 -0
  141. package/dist/db/migrations/0045_workspace_reviewers.sql +180 -0
  142. package/dist/db/migrations/0046_drop_inbox_watch.sql +25 -0
  143. package/dist/db/migrations/0047_ml_comment_labels.sql +40 -0
  144. package/dist/db/migrations/0048_ml_vendor_severity.sql +31 -0
  145. package/dist/db/migrations/0049_seat_pricing_repo_description.sql +21 -0
  146. package/dist/db/migrations/0050_users_app_slug.sql +12 -0
  147. package/dist/db/migrations/0051_auto_merge_queue.sql +17 -0
  148. package/dist/db/migrations/0052_trunk_ci_status_events.sql +43 -0
  149. package/dist/db/migrations/0053_automation_roles.sql +79 -0
  150. package/dist/db/migrations/0054_automation_vendor_kinds.sql +514 -0
  151. package/dist/db/migrations/0055_auto_merge_phase.sql +12 -0
  152. package/dist/db/migrations/0056_pr_mentions.sql +22 -0
  153. package/dist/db/migrations/0057_large_pr_threshold.sql +14 -0
  154. package/dist/db/migrations/0058_pending_mute.sql +53 -0
  155. package/dist/db/migrations/0059_merge_queue_state.sql +33 -0
  156. package/dist/db/migrations/0060_drop_my_turn_dismissals.sql +33 -0
  157. package/dist/db/migrations/0061_auto_merge_durable_anchors.sql +27 -0
  158. package/dist/db/migrations/0062_blast_radius_config.sql +21 -0
  159. package/dist/db/migrations/0063_repo_file_coupling.sql +33 -0
  160. package/dist/db/migrations/0064_pr_content_kind.sql +21 -0
  161. package/dist/db/migrations/0065_workspace_flow_settings.sql +13 -0
  162. package/dist/db/migrations/0066_review_request_events.sql +31 -0
  163. package/dist/db/migrations/0067_pr_security_signals.sql +86 -0
  164. package/dist/db/migrations/0068_my_turn_settings.sql +18 -0
  165. package/dist/db/migrations/0069_my_turn_dismissals.sql +41 -0
  166. package/dist/db/migrations/0070_claude_review_follow_up_ticket.sql +22 -0
  167. package/dist/db/migrations/0071_claude_review_trigger.sql +11 -0
  168. package/dist/db/migrations/0072_commit_message_headline.sql +13 -0
  169. package/dist/db/migrations/0073_claude_review_chat.sql +31 -0
  170. package/dist/db/migrations/0074_adopt_agentic_tables.sql +98 -0
  171. package/dist/db/migrations/0075_review_lens_drop_learnings.sql +15 -0
  172. package/dist/db/migrations/0076_claude_review_threads.sql +17 -0
  173. package/dist/db/migrations/0077_claude_review_ci_failures.sql +10 -0
  174. package/dist/db/migrations/0078_ai_fix_change_report.sql +17 -0
  175. package/dist/db/migrations/0079_claude_finding_story.sql +16 -0
  176. package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
  177. package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
  178. package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
  179. package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
  180. package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
  181. package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
  182. package/dist/db/migrations/meta/0006_snapshot.json +1745 -0
  183. package/dist/db/migrations/meta/0007_snapshot.json +1759 -0
  184. package/dist/db/migrations/meta/_journal.json +566 -0
  185. package/dist/db/migrations-pg/0000_tired_alex_power.sql +268 -0
  186. package/dist/db/migrations-pg/0001_dizzy_mockingbird.sql +3 -0
  187. package/dist/db/migrations-pg/0002_glorious_ikaris.sql +4 -0
  188. package/dist/db/migrations-pg/0003_melted_secret_warriors.sql +2 -0
  189. package/dist/db/migrations-pg/0004_numerous_triton.sql +1 -0
  190. package/dist/db/migrations-pg/0005_thick_blockbuster.sql +2 -0
  191. package/dist/db/migrations-pg/0006_majestic_dracula.sql +1 -0
  192. package/dist/db/migrations-pg/0007_same_human_torch.sql +2 -0
  193. package/dist/db/migrations-pg/0008_slimy_toxin.sql +1 -0
  194. package/dist/db/migrations-pg/0009_fuzzy_jubilee.sql +4 -0
  195. package/dist/db/migrations-pg/0010_late_hammerhead.sql +1 -0
  196. package/dist/db/migrations-pg/0011_happy_leech.sql +16 -0
  197. package/dist/db/migrations-pg/0012_woozy_captain_midlands.sql +16 -0
  198. package/dist/db/migrations-pg/0013_yielding_nicolaos.sql +1 -0
  199. package/dist/db/migrations-pg/0014_wakeful_bill_hollister.sql +2 -0
  200. package/dist/db/migrations-pg/0015_aspiring_black_tom.sql +1 -0
  201. package/dist/db/migrations-pg/0016_cute_ghost_rider.sql +30 -0
  202. package/dist/db/migrations-pg/0017_far_changeling.sql +24 -0
  203. package/dist/db/migrations-pg/0018_fancy_miss_america.sql +1 -0
  204. package/dist/db/migrations-pg/0019_benchmark_contributions.sql +22 -0
  205. package/dist/db/migrations-pg/0020_majestic_edwin_jarvis.sql +3 -0
  206. package/dist/db/migrations-pg/0021_green_salo.sql +1 -0
  207. package/dist/db/migrations-pg/0022_wise_baron_strucker.sql +1 -0
  208. package/dist/db/migrations-pg/0023_search_index.sql +19 -0
  209. package/dist/db/migrations-pg/0024_author_indexes.sql +9 -0
  210. package/dist/db/migrations-pg/0025_auto_merge_requests.sql +26 -0
  211. package/dist/db/migrations-pg/0026_branch_status.sql +32 -0
  212. package/dist/db/migrations-pg/0027_pr_review_decision.sql +5 -0
  213. package/dist/db/migrations-pg/0028_branch_commit_detail.sql +9 -0
  214. package/dist/db/migrations-pg/0029_repo_reviewers.sql +127 -0
  215. package/dist/db/migrations-pg/0030_account_reviewers.sql +77 -0
  216. package/dist/db/migrations-pg/0031_workspaces.sql +131 -0
  217. package/dist/db/migrations-pg/0032_workspace_reviewers.sql +165 -0
  218. package/dist/db/migrations-pg/0033_drop_inbox_watch.sql +14 -0
  219. package/dist/db/migrations-pg/0034_ml_comment_labels.sql +33 -0
  220. package/dist/db/migrations-pg/0035_ml_vendor_severity.sql +16 -0
  221. package/dist/db/migrations-pg/0036_seat_pricing_repo_description.sql +12 -0
  222. package/dist/db/migrations-pg/0037_users_app_slug.sql +9 -0
  223. package/dist/db/migrations-pg/0038_auto_merge_queue.sql +10 -0
  224. package/dist/db/migrations-pg/0039_trunk_ci_status_events.sql +27 -0
  225. package/dist/db/migrations-pg/0040_automation_roles.sql +63 -0
  226. package/dist/db/migrations-pg/0041_automation_vendor_kinds.sql +488 -0
  227. package/dist/db/migrations-pg/0042_auto_merge_phase.sql +8 -0
  228. package/dist/db/migrations-pg/0043_pr_mentions.sql +19 -0
  229. package/dist/db/migrations-pg/0044_large_pr_threshold.sql +6 -0
  230. package/dist/db/migrations-pg/0045_pending_mute.sql +26 -0
  231. package/dist/db/migrations-pg/0046_merge_queue_state.sql +18 -0
  232. package/dist/db/migrations-pg/0047_drop_my_turn_dismissals.sql +21 -0
  233. package/dist/db/migrations-pg/0048_auto_merge_durable_anchors.sql +27 -0
  234. package/dist/db/migrations-pg/0049_blast_radius_config.sql +8 -0
  235. package/dist/db/migrations-pg/0050_repo_file_coupling.sql +13 -0
  236. package/dist/db/migrations-pg/0051_pr_content_kind.sql +6 -0
  237. package/dist/db/migrations-pg/0052_workspace_flow_settings.sql +4 -0
  238. package/dist/db/migrations-pg/0053_review_request_events.sql +18 -0
  239. package/dist/db/migrations-pg/0054_pr_security_signals.sql +59 -0
  240. package/dist/db/migrations-pg/0055_my_turn_settings.sql +6 -0
  241. package/dist/db/migrations-pg/0056_my_turn_dismissals.sql +21 -0
  242. package/dist/db/migrations-pg/0057_claude_review_follow_up_ticket.sql +8 -0
  243. package/dist/db/migrations-pg/0058_claude_review_trigger.sql +4 -0
  244. package/dist/db/migrations-pg/0059_commit_message_headline.sql +3 -0
  245. package/dist/db/migrations-pg/0060_claude_review_chat.sql +24 -0
  246. package/dist/db/migrations-pg/0061_adopt_agentic_tables.sql +79 -0
  247. package/dist/db/migrations-pg/0062_review_lens_drop_learnings.sql +5 -0
  248. package/dist/db/migrations-pg/0063_claude_review_threads.sql +7 -0
  249. package/dist/db/migrations-pg/0064_claude_review_ci_failures.sql +4 -0
  250. package/dist/db/migrations-pg/0065_ai_fix_change_report.sql +9 -0
  251. package/dist/db/migrations-pg/0066_claude_finding_story.sql +7 -0
  252. package/dist/db/migrations-pg/meta/0000_snapshot.json +2137 -0
  253. package/dist/db/migrations-pg/meta/0001_snapshot.json +2143 -0
  254. package/dist/db/migrations-pg/meta/0002_snapshot.json +2170 -0
  255. package/dist/db/migrations-pg/meta/0003_snapshot.json +2182 -0
  256. package/dist/db/migrations-pg/meta/0004_snapshot.json +2188 -0
  257. package/dist/db/migrations-pg/meta/0005_snapshot.json +2201 -0
  258. package/dist/db/migrations-pg/meta/0006_snapshot.json +2207 -0
  259. package/dist/db/migrations-pg/meta/0007_snapshot.json +2219 -0
  260. package/dist/db/migrations-pg/meta/0008_snapshot.json +2226 -0
  261. package/dist/db/migrations-pg/meta/0009_snapshot.json +2250 -0
  262. package/dist/db/migrations-pg/meta/0010_snapshot.json +2256 -0
  263. package/dist/db/migrations-pg/meta/0011_snapshot.json +2412 -0
  264. package/dist/db/migrations-pg/meta/0012_snapshot.json +2528 -0
  265. package/dist/db/migrations-pg/meta/0013_snapshot.json +2534 -0
  266. package/dist/db/migrations-pg/meta/0014_snapshot.json +2547 -0
  267. package/dist/db/migrations-pg/meta/0015_snapshot.json +2553 -0
  268. package/dist/db/migrations-pg/meta/0016_snapshot.json +2774 -0
  269. package/dist/db/migrations-pg/meta/0017_snapshot.json +2999 -0
  270. package/dist/db/migrations-pg/meta/0018_snapshot.json +3020 -0
  271. package/dist/db/migrations-pg/meta/0020_snapshot.json +3228 -0
  272. package/dist/db/migrations-pg/meta/0021_snapshot.json +3234 -0
  273. package/dist/db/migrations-pg/meta/0022_snapshot.json +3240 -0
  274. package/dist/db/migrations-pg/meta/_journal.json +475 -0
  275. package/dist/db/ml-labels.js +1666 -0
  276. package/dist/db/my-turn-dismissals.js +343 -0
  277. package/dist/db/my-turn-settings.js +34 -0
  278. package/dist/db/pending-classify.js +46 -0
  279. package/dist/db/pending-mute.js +171 -0
  280. package/dist/db/pending-tabs.js +238 -0
  281. package/dist/db/period-metrics.js +820 -0
  282. package/dist/db/person-period.js +763 -0
  283. package/dist/db/pr-intervals.js +930 -0
  284. package/dist/db/pr-liveness.js +240 -0
  285. package/dist/db/pr-mentions.js +279 -0
  286. package/dist/db/pr-merge-queue-stamp.js +91 -0
  287. package/dist/db/queries.js +14631 -0
  288. package/dist/db/reaction-targets.js +94 -0
  289. package/dist/db/repo-activity.js +171 -0
  290. package/dist/db/retention.js +138 -0
  291. package/dist/db/review-threads-for-review.js +240 -0
  292. package/dist/db/run-migrations.js +24 -0
  293. package/dist/db/schema.pg.js +1220 -0
  294. package/dist/db/schema.sqlite.js +1801 -0
  295. package/dist/db/search.js +317 -0
  296. package/dist/db/security-alerts.js +218 -0
  297. package/dist/db/synthesis-input.js +565 -0
  298. package/dist/db/triage.js +320 -0
  299. package/dist/db/usage.js +63 -0
  300. package/dist/db/work-plan.js +1086 -0
  301. package/dist/db/working-hours.js +161 -0
  302. package/dist/feed/my-turn.js +122 -0
  303. package/dist/github/actions-logs.js +397 -0
  304. package/dist/github/auth.js +86 -0
  305. package/dist/github/branch-queries.js +229 -0
  306. package/dist/github/client.js +454 -0
  307. package/dist/github/codeowners.js +221 -0
  308. package/dist/github/commit-checks.js +105 -0
  309. package/dist/github/compare.js +110 -0
  310. package/dist/github/diff-anchor.js +227 -0
  311. package/dist/github/mutations.js +706 -0
  312. package/dist/github/pr-file-content.js +254 -0
  313. package/dist/github/pr-liveness.js +161 -0
  314. package/dist/github/queries.js +875 -0
  315. package/dist/github/rate-budget.js +129 -0
  316. package/dist/github/reactions.js +161 -0
  317. package/dist/github/reviewer-suggest.js +104 -0
  318. package/dist/github/team-reviewers.js +96 -0
  319. package/dist/index.js +184 -0
  320. package/dist/merge/auto-merge-runner.js +960 -0
  321. package/dist/ml/severity-client.js +218 -0
  322. package/dist/pr/detail-enricher.js +26 -0
  323. package/dist/pro/bind.js +302 -0
  324. package/dist/pro/contract.js +37 -0
  325. package/dist/pro/migrate.js +64 -0
  326. package/dist/review/agent-context.js +127 -0
  327. package/dist/review/agent.js +385 -0
  328. package/dist/review/agentic.js +59 -0
  329. package/dist/review/auth.js +94 -0
  330. package/dist/review/chat-agent.js +179 -0
  331. package/dist/review/claude-review/ai-ready.js +16 -0
  332. package/dist/review/claude-review/auto-settings.js +139 -0
  333. package/dist/review/claude-review/auto.js +244 -0
  334. package/dist/review/claude-review/chat.js +489 -0
  335. package/dist/review/claude-review/ci-failures.js +350 -0
  336. package/dist/review/claude-review/follow-up.js +310 -0
  337. package/dist/review/claude-review/manager.js +686 -0
  338. package/dist/review/claude-review/persist.js +996 -0
  339. package/dist/review/claude-review/prompts.js +357 -0
  340. package/dist/review/claude-review/routes.js +518 -0
  341. package/dist/review/claude-review/routing.js +61 -0
  342. package/dist/review/claude-review/specialists.js +190 -0
  343. package/dist/review/claude-review/threads.js +265 -0
  344. package/dist/review/claude-review/ticket.js +282 -0
  345. package/dist/review/clone-hygiene.js +380 -0
  346. package/dist/review/clone-manager.js +367 -0
  347. package/dist/review/llm.js +113 -0
  348. package/dist/review/model-options.js +48 -0
  349. package/dist/review/plugin-providers.js +12 -0
  350. package/dist/review/post-review.js +216 -0
  351. package/dist/review/post-seam.js +161 -0
  352. package/dist/review/prepare.js +168 -0
  353. package/dist/review/pricing.js +17 -0
  354. package/dist/review/schema.js +156 -0
  355. package/dist/review/submit-map.js +41 -0
  356. package/dist/review/usage.js +90 -0
  357. package/dist/shared/claude-review.js +335 -0
  358. package/dist/shared/conflict-fold.js +151 -0
  359. package/dist/shared/conflicts.js +30 -0
  360. package/dist/shared/flow-settings.js +191 -0
  361. package/dist/shared/index.js +8 -0
  362. package/dist/shared/my-turn-settings.js +238 -0
  363. package/dist/shared/pending-rules.js +281 -0
  364. package/dist/shared/product.js +54 -0
  365. package/dist/shared/types.js +896 -0
  366. package/dist/status.js +386 -0
  367. package/dist/sync/adaptive.js +185 -0
  368. package/dist/sync/app-attribution.js +66 -0
  369. package/dist/sync/auth-notices.js +36 -0
  370. package/dist/sync/backfill-ci-history.js +315 -0
  371. package/dist/sync/backfill-commit-headlines.js +136 -0
  372. package/dist/sync/backfill-pr-security.js +175 -0
  373. package/dist/sync/backfill-review-requests.js +97 -0
  374. package/dist/sync/benchmark-rollup.js +50 -0
  375. package/dist/sync/bot-detection.js +309 -0
  376. package/dist/sync/bot-resolution-markers.js +30 -0
  377. package/dist/sync/branch-status.js +634 -0
  378. package/dist/sync/classify-change-shape.js +150 -0
  379. package/dist/sync/commit-files.js +74 -0
  380. package/dist/sync/derive-thread-state.js +99 -0
  381. package/dist/sync/drain-review-threads.js +212 -0
  382. package/dist/sync/hydrate-detail.js +482 -0
  383. package/dist/sync/mention-scan.js +89 -0
  384. package/dist/sync/ml-enrichment.js +478 -0
  385. package/dist/sync/pr-change-signal.js +96 -0
  386. package/dist/sync/pr-liveness-sweep.js +139 -0
  387. package/dist/sync/pr-settle.js +381 -0
  388. package/dist/sync/refresh-pr.js +163 -0
  389. package/dist/sync/reparse-vendor-badges.js +495 -0
  390. package/dist/sync/resync-after-write.js +280 -0
  391. package/dist/sync/review-fingerprint.js +117 -0
  392. package/dist/sync/reviewer-behavior.js +103 -0
  393. package/dist/sync/reviewer-classify.js +503 -0
  394. package/dist/sync/routing-files.js +97 -0
  395. package/dist/sync/scheduled-jobs.js +12 -0
  396. package/dist/sync/scheduler.js +159 -0
  397. package/dist/sync/security-detect.js +521 -0
  398. package/dist/sync/sync-manager.js +750 -0
  399. package/dist/sync/sync-one-pr.js +205 -0
  400. package/dist/sync/sync-repo.js +400 -0
  401. package/dist/sync/unsettled-prs.js +506 -0
  402. package/dist/sync/upsert.js +1190 -0
  403. package/package.json +56 -0
  404. package/public/apple-touch-icon.png +0 -0
  405. package/public/assets/AiFixTab-CQP6PT5u.js +2 -0
  406. package/public/assets/ClaudeReviewTab-DQJ-58ZA.js +8 -0
  407. package/public/assets/PrBotBehaviourTab-CCnFrWbC.js +1 -0
  408. package/public/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
  409. package/public/assets/highlight-BEHUn5zE.css +10 -0
  410. package/public/assets/highlight-CIIZLa6Z.js +12 -0
  411. package/public/assets/index-BDnYs6zg.js +73 -0
  412. package/public/assets/index-mCuI--RC.css +1 -0
  413. package/public/assets/markdown-I_JLLVAZ.js +32 -0
  414. package/public/assets/perfProbe-Da13EhTE.js +1 -0
  415. package/public/assets/tanstack-5qsxxaLe.js +17 -0
  416. package/public/assets/vis-C3BCkP5U.js +1280 -0
  417. package/public/assets/vis-ziCVehMy.css +1 -0
  418. package/public/favicon-16.png +0 -0
  419. package/public/favicon-32.png +0 -0
  420. package/public/favicon-48.png +0 -0
  421. package/public/favicon.svg +5 -0
  422. package/public/index.html +41 -0
  423. package/public-landing/apple-touch-icon.png +0 -0
  424. package/public-landing/assets/archivo-latin-500-BWmvx2Mg.woff2 +0 -0
  425. package/public-landing/assets/archivo-latin-600-BN-JkNrl.woff2 +0 -0
  426. package/public-landing/assets/index-CA3eEGXO.js +40 -0
  427. package/public-landing/assets/index-naQrQ6Fe.css +1 -0
  428. package/public-landing/assets/jetbrains-mono-latin-400-RruCX7RW.woff2 +0 -0
  429. package/public-landing/assets/newsreader-latin-400-DExrRRrN.woff2 +0 -0
  430. package/public-landing/assets/newsreader-latin-400-italic-b0I-AwbV.woff2 +0 -0
  431. package/public-landing/bots/index.html +156 -0
  432. package/public-landing/contact/index.html +156 -0
  433. package/public-landing/cookies/index.html +156 -0
  434. package/public-landing/demo/limn-walkthrough-poster.jpg +0 -0
  435. package/public-landing/demo/limn-walkthrough.cues.json +44 -0
  436. package/public-landing/demo/limn-walkthrough.mp4 +0 -0
  437. package/public-landing/favicon-16.png +0 -0
  438. package/public-landing/favicon-32.png +0 -0
  439. package/public-landing/favicon-48.png +0 -0
  440. package/public-landing/favicon.svg +5 -0
  441. package/public-landing/features/index.html +156 -0
  442. package/public-landing/for-developers/index.html +156 -0
  443. package/public-landing/for-managers/index.html +156 -0
  444. package/public-landing/how-it-works/index.html +156 -0
  445. package/public-landing/how-we-measure/index.html +156 -0
  446. package/public-landing/icon-192.png +0 -0
  447. package/public-landing/icon-512.png +0 -0
  448. package/public-landing/index.html +156 -0
  449. package/public-landing/insights/index.html +156 -0
  450. package/public-landing/og-image.png +0 -0
  451. package/public-landing/pricing/index.html +156 -0
  452. package/public-landing/privacy/index.html +156 -0
  453. package/public-landing/pro/index.html +156 -0
  454. package/public-landing/reviews/index.html +156 -0
  455. package/public-landing/robots.txt +8 -0
  456. package/public-landing/shots/benchmark.png +0 -0
  457. package/public-landing/shots/bot-roi.png +0 -0
  458. package/public-landing/shots/bot-settings.png +0 -0
  459. package/public-landing/shots/chronology.png +0 -0
  460. package/public-landing/shots/feed.png +0 -0
  461. package/public-landing/shots/flow-metrics.png +0 -0
  462. package/public-landing/shots/free-reports.png +0 -0
  463. package/public-landing/shots/pending-board.png +0 -0
  464. package/public-landing/shots/pending-card.png +0 -0
  465. package/public-landing/shots/period-report.png +0 -0
  466. package/public-landing/shots/pr-detail.png +0 -0
  467. package/public-landing/shots/pr-threads.png +0 -0
  468. package/public-landing/shots/reach.png +0 -0
  469. package/public-landing/shots/repo-rows.png +0 -0
  470. package/public-landing/shots/severity-findings.png +0 -0
  471. package/public-landing/shots/severity-strip.png +0 -0
  472. package/public-landing/site.webmanifest +14 -0
  473. package/public-landing/sitemap.xml +51 -0
  474. package/public-landing/terms/index.html +156 -0
@@ -0,0 +1,1086 @@
1
+ // ── The work plan's CODE-DERIVED EVIDENCE (CORE, deterministic, no AI) ───────────────────────
2
+ //
3
+ // "What should I work on today", for ONE workspace, as a ranked worklist. The Pro plugin narrates
4
+ // it; NOTHING here is model-touched. Every figure, id, link and rank in `WorkPlanEvidence` is
5
+ // computed by this file, and the model may only foreground, order and annotate what it is handed.
6
+ //
7
+ // ── WHY THIS FILE CALLS `getWorkspaceInsights` INSTEAD OF DERIVING ANYTHING ──────────────────
8
+ // THE BRIEF SAYS HOW MUCH. THE PLAN SAYS IN WHAT ORDER. THEY ARE ONE POPULATION.
9
+ //
10
+ // The daily-brief strip sits directly above this panel and counts the cards of ONE fold —
11
+ // `getWorkspaceInsights(accountId, undefined, scope)`, which is also what `GET /api/attention`
12
+ // serves. So ALL SEVEN of this plan's signals are read off THOSE CARDS, never re-derived:
13
+ // re-deriving "an untouched thread" here would be a second predicate, and the day it disagreed
14
+ // with the strip the user would be looking at two numbers for one population. `counts` below
15
+ // travels on the wire precisely so that agreement is ASSERTABLE (work-plan.test.ts asserts it
16
+ // field-by-field against `computeBriefCounts`) rather than hoped for.
17
+ //
18
+ // ⚠ THE COUNT LOOP AT THE BOTTOM OF `foldCounts` IS A SECOND SPELLING OF db/daily-brief.ts's.
19
+ // It is deliberately IDENTICAL, predicate for predicate — `personal` for the personal subset, NOT
20
+ // `relevance`, exactly as the brief does it. If you change one, change the other, and the
21
+ // alignment test is what makes forgetting fail rather than ship.
22
+ //
23
+ // ── ALL SEVEN SIGNALS ARE NOW CARD-DERIVED ──────────────────────────────────────────────────
24
+ // "This can land right now" and "GitHub is refusing until you update the branch" USED to come from
25
+ // a standalone open-PR query in this file — which made the ranked list a SECOND POPULATION beside
26
+ // the board it sits on. They are now `merge` / `update_branch` INSIGHT CARDS emitted by
27
+ // `getWorkspaceInsights` (see the emitter there for the predicates and the no-author-filter rule),
28
+ // so this file re-orders one population and derives none of it.
29
+ //
30
+ // ⚠ INHERITED NARROWINGS, BOTH DELIBERATE AND BOTH REAL. The card fold applies an ULTRA-STALE
31
+ // GATE (no activity event in `INSIGHT_MAX_STALE_DAYS` = 90 ⇒ no card), and a row is drawn only
32
+ // from a card the Pending board LISTS (the board's uncapped fold, then `boardListCap` per list
33
+ // group — `listedCardIds`). A long-dormant-but-mergeable PR therefore does not appear here — and
34
+ // that is the point: the plan's `why` is shown on the row's card, so a row with no listed card
35
+ // behind it would be a sentence with nowhere to go.
36
+ //
37
+ // ── THE RANK IS THE CODE'S, NOT THE MODEL'S ─────────────────────────────────────────────────
38
+ // `score = w.proximity·proximity + w.stall·stallRisk + w.relevance·relevanceWeight`, with the
39
+ // READER's weights (Settings → My Turn; 0.50 / 0.30 / 0.20 until they choose), sorted descending
40
+ // with a TOTAL tie-break chain, so two ticks over unchanged data produce byte-identical order. A
41
+ // sort that could flip under the reader between polls is a defect in a panel people read top-down.
42
+ // The weights are not hashed: changing them can change WHICH rows the capped plan holds (the plan
43
+ // then reads `stale` once), never a hashed field of a row.
44
+ //
45
+ // ⚠ `ageHours`, `stallRisk` and `score` are DERIVED FROM `now`. They must never enter the
46
+ // plugin's payload hash, or a dormant workspace re-bills on a timer. Everything else on an item —
47
+ // id, kind, ids, url, relevance, `reason`, `proximity` — is stable across ticks for unchanged
48
+ // data, and `reason` is deliberately written WITHOUT relative-time phrasing ("3d ago") so that it
49
+ // stays hashable.
50
+ import { and, count, eq, inArray } from 'drizzle-orm';
51
+ import { DO_NEXT_RULES, PENDING_LIMITS } from '../shared/index.js';
52
+ import { db, schema } from './client.js';
53
+ import { getWorkspaceInsights, mergeCardDetail } from './queries.js';
54
+ import { dependencyProximityBase } from './dependency-cards.js';
55
+ import { getMyTurnSettings } from './my-turn-settings.js';
56
+ import { listedCardIds } from './pending-tabs.js';
57
+ import { approvalInfoFromStandings, computeReviewStandingsByPr } from './triage.js';
58
+ const { pullRequests, reviewThreads } = schema;
59
+ // ⚠ EVERY NUMBER BELOW IS READ FROM `DO_NEXT_RULES` (packages/shared/src/pending-rules.ts), which
60
+ // the Pending board's info popovers also print. Retune the ranker THERE, never with a local
61
+ // literal, or the app will explain an order it no longer produces.
62
+ /** How many rows the panel paints. `totals` carries the UNCAPPED population per kind, so a capped
63
+ * list can always disclose what it left out — a truncation nobody is told about is a lie about
64
+ * the size of the day. */
65
+ export const WORK_PLAN_ITEM_CAP = DO_NEXT_RULES.cap;
66
+ /**
67
+ * The plan's ranked rows as `InsightCard` ids, in rank order — the join the SPA makes from a
68
+ * narration step to the card it describes (`WorkPlanItem.cardId`).
69
+ */
70
+ export function doNextCardIds(evidence) {
71
+ return evidence.items.map((i) => i.cardId).filter((id) => id != null);
72
+ }
73
+ const HOUR_MS = 3_600_000;
74
+ // ── PROXIMITY: how few steps from landing, 0..1 ─────────────────────────────────────────────
75
+ // Base by the row's next step (`DO_NEXT_RULES.proximity`). A merge is one click; a nudge is a
76
+ // message to someone else, whose reply is then the actual step. Everything in between is ordered by
77
+ // how much work stands between the item and a merged PR.
78
+ //
79
+ // A red TRUNK is not a normal `unblock_ci`: it invalidates every open PR in the repo at once, so
80
+ // its base (`red_trunk`) sits above the per-PR arm rather than beside it.
81
+ //
82
+ // ── MERGE PROXIMITY IS APPROVAL-CONDITIONAL, AND THE ORDERING IS THE POINT ──────────────────
83
+ // A `clean` PR that NOBODY HAS REVIEWED is ready for GitHub, not ready for a human. Scoring it
84
+ // like an approved one had two visible consequences, both measured on real data:
85
+ // 1. the ranked head's top instruction became "merge this" for unreviewed code — and on this
86
+ // account, most merge-ready PRs are bot-authored (9 of 11 and 7 of 8 on two workspaces), so
87
+ // the head filled with unreviewed Dependabot;
88
+ // 2. because the per-PR dedup survivor is chosen by PROXIMITY, the merge row also BEAT that same
89
+ // PR's own `review` row — so the head said "nothing is blocking this" while the board below
90
+ // said "your turn" about one pull request.
91
+ // Dropping the unapproved case (`merge_unapproved`) BELOW `review` and `reply` fixes both at once:
92
+ // the review claim wins the dedup, and the merge row only leads once someone has actually approved.
93
+ // ⚠ On repos WITH required-review protection this changes almost nothing — those PRs are
94
+ // `blocked`, not `clean`. It bites exactly the unprotected repos, which is where it should.
95
+ //
96
+ // The three ADJUSTMENTS: conflicts are further out than the merge state alone makes it look; a
97
+ // wall of unanswered feedback is not one step from landing; a small change lands fast.
98
+ //
99
+ // ── STALL RISK: how likely this sits untouched, 0..1 ────────────────────────────────────────
100
+ // ⚠ MEASURED AGAINST THE ITEM'S OWN CLOCK (`facts.clock`), never a blanket "since opened". The
101
+ // seven signals age against different instants and saying WHICH is the difference between a fact
102
+ // and a plausible number. Fresh, or no clock at all, is `stallBase`: an unknown age is NOT urgent.
103
+ //
104
+ // ── RELEVANCE: the same three tiers My Turn and the attention board use ─────────────────────
105
+ /** Trim float noise so the wire (and any hash over it) is byte-stable for unchanged inputs. */
106
+ function round4(n) {
107
+ return Math.round(n * 10_000) / 10_000;
108
+ }
109
+ const clamp01 = (n) => (n < 0 ? 0 : n > 1 ? 1 : n);
110
+ /**
111
+ * THE ONE PROXIMITY RESOLVER. `baseOverride` replaces the per-kind default (the trunk CI arm).
112
+ *
113
+ * It returns its WORKING, not just the number, because the Pending board explains each card's
114
+ * score to the reader — and a second function re-deriving "which adjustments applied" for the
115
+ * explanation would be a second answer to a question this one already answered.
116
+ */
117
+ function proximityWorking(kind, facts, baseOverride) {
118
+ // The merge base is CONDITIONAL, not a bonus on top of a high base — see the note above.
119
+ const base = baseOverride ??
120
+ (kind === 'merge'
121
+ ? (facts.approvals ?? 0) > 0
122
+ ? 'merge_approved'
123
+ : 'merge_unapproved'
124
+ : kind);
125
+ const adjustments = [];
126
+ // The `conflicts` BASE already is the conflict; penalising it again would count it twice.
127
+ if (facts.mergeStateStatus === 'dirty' && base !== 'conflicts')
128
+ adjustments.push('conflicts');
129
+ if ((facts.untouchedThreads ?? 0) >= DO_NEXT_RULES.manyUntouchedThreadsMin) {
130
+ adjustments.push('many_untouched_threads');
131
+ }
132
+ if (facts.changedFiles != null && facts.changedFiles <= DO_NEXT_RULES.smallChangeMaxFiles) {
133
+ adjustments.push('small_change');
134
+ }
135
+ // Each adjustment is applied ONCE, in this fixed order, then the sum is clamped — not clamped
136
+ // between steps, which would make the order of the three matter.
137
+ let p = DO_NEXT_RULES.proximity[base];
138
+ for (const a of adjustments)
139
+ p += DO_NEXT_RULES.adjustments[a];
140
+ return { base, adjustments, value: round4(clamp01(p)) };
141
+ }
142
+ /** THE ONE STALL RESOLVER. A null/absent age is the base risk, never the top bucket. */
143
+ function stallRiskFor(ageHours) {
144
+ if (ageHours == null || !Number.isFinite(ageHours))
145
+ return DO_NEXT_RULES.stallBase;
146
+ for (const b of DO_NEXT_RULES.stallBuckets)
147
+ if (ageHours >= b.minHours)
148
+ return b.risk;
149
+ return DO_NEXT_RULES.stallBase;
150
+ }
151
+ /** THE SCORE. `w` is the READER's resolved weights (Settings → My Turn; `DO_NEXT_PRESETS.balanced`
152
+ * until they choose), never `DO_NEXT_RULES.weights` read directly — that field is the product
153
+ * default and the resolver's input, and a scorer reading it would rank by numbers the board's
154
+ * explanation no longer prints. */
155
+ function scoreFor(proximity, stallRisk, relevance, w) {
156
+ return round4(w.proximity * proximity +
157
+ w.stall * stallRisk +
158
+ w.relevance * DO_NEXT_RULES.relevanceWeight[relevance]);
159
+ }
160
+ /** A classic my_turn card's `reason` → the plan's kind. Somebody answered, named or wrote after
161
+ * you — a reply is owed. Everything else lands on the PR itself, which is review work. (The five
162
+ * promotions never reach here: each scores as its HOME kind did — see the my_turn arm.) */
163
+ function kindForMyTurn(reason) {
164
+ return reason === 'thread' ||
165
+ reason === 'thread_reply' ||
166
+ reason === 'comment_reply' ||
167
+ reason === 'mention'
168
+ ? 'reply'
169
+ : 'review';
170
+ }
171
+ /** DEDUP PRIORITY, and it is deliberately TIME-FREE. Several my_turn reasons collapse onto one
172
+ * `wp:review:<prId>` id (a PR can be both "review requested" and "approved, waiting on you"), and
173
+ * the id is the model's join key so it must be unique. Picking the winner by SCORE would make the
174
+ * choice depend on `ageHours`, which would in turn make `reason` — a hashed field — drift on a
175
+ * timer and re-bill a dormant workspace. So the winner is fixed by severity, then by this rank. */
176
+ ///
177
+ /// Every reason, in the product's DEFAULT type order (`MY_TURN_DEFAULT_ORDER`) — fixed, never the
178
+ /// reader's order, because a hashed `reason` must not change with a display preference. The five
179
+ /// promotions are never read here (they rank as their home kinds do), but the `Record` forces an
180
+ /// entry. ⚠ The tie rank is `SEVERITY_RANK × 100 + REASON_RANK`: with fifteen reasons a × 10 would
181
+ /// let a reason bleed across a severity band.
182
+ const REASON_RANK = {
183
+ review_request: 0,
184
+ mention: 1,
185
+ thread: 2,
186
+ thread_reply: 3,
187
+ comment_reply: 4,
188
+ pushed_since: 5,
189
+ own_ci_red: 6,
190
+ own_conflicts: 7,
191
+ trunk_red: 8,
192
+ pr_approved: 9,
193
+ own_ready: 10,
194
+ your_pr: 11,
195
+ own_thread: 12,
196
+ claude_review: 13,
197
+ watched_repo_pr: 14,
198
+ };
199
+ const SEVERITY_RANK = { high: 0, warn: 1, info: 2 };
200
+ function hoursSince(iso, now) {
201
+ if (iso == null)
202
+ return undefined;
203
+ const t = Date.parse(iso);
204
+ if (!Number.isFinite(t))
205
+ return undefined;
206
+ return Math.max(0, (now - t) / HOUR_MS);
207
+ }
208
+ const plural = (n, one, many = `${one}s`) => (n === 1 ? one : many);
209
+ /** The counts half of the evidence — a SECOND SPELLING of db/daily-brief.ts's `computeBriefCounts`
210
+ * loop, restricted to the six fields `WorkPlanEvidence.counts` carries.
211
+ *
212
+ * ⚠ PREDICATE FOR PREDICATE IDENTICAL, and `myTurnPersonal` uses `c.personal` for exactly the
213
+ * reason the brief does: `personal` is the boolean the notification surfaces count, and re-reading
214
+ * it off `relevance` here would be a third derivation of the same fact. (The ITEM's `relevance`
215
+ * below deliberately treats an ABSENT field as 'none' instead — a missing field may never invent
216
+ * an ownership claim in card copy. The two rules answer different questions and both are right.) */
217
+ function foldCounts(insights) {
218
+ const cards = insights.cards;
219
+ let myTurn = 0;
220
+ let myTurnPersonal = 0;
221
+ let ciFailing = 0;
222
+ let stalled = 0;
223
+ let untouchedThreads = 0;
224
+ let needsReviewer = 0;
225
+ for (const c of cards) {
226
+ if (c.kind === 'ci_failing')
227
+ ciFailing += 1;
228
+ else if (c.kind === 'my_turn') {
229
+ myTurn += 1;
230
+ if (c.personal)
231
+ myTurnPersonal += 1;
232
+ }
233
+ else if (c.kind === 'stalled_review')
234
+ stalled += 1;
235
+ else if (c.kind === 'untouched_thread')
236
+ untouchedThreads += 1;
237
+ else if (c.kind === 'reviewer_routing')
238
+ needsReviewer += 1;
239
+ }
240
+ // The three survey counts are the UNCAPPED populations, exactly as the brief now reports them —
241
+ // see `computeBriefCounts`. (The Pro plan hash leaves `counts` out, so this moves no stored plan.)
242
+ const t = insights.kindTotals;
243
+ // …and the my_turn / ci figures are what the Pending board LISTS, min(total, boardListCap) — the
244
+ // brief's rule too (see `computeBriefCounts`), so the two stay equal field for field.
245
+ const listed = (total, fallback) => total == null ? fallback : Math.min(total, PENDING_LIMITS.boardListCap);
246
+ return {
247
+ myTurn: listed(insights.myTurnTotal, myTurn),
248
+ myTurnPersonal: listed(insights.myTurnPersonalTotal, myTurnPersonal),
249
+ ciFailing: listed(insights.ciFailingTotal, ciFailing),
250
+ stalled: t?.stalled_review ?? stalled,
251
+ untouchedThreads: t?.untouched_thread ?? untouchedThreads,
252
+ needsReviewer: t?.reviewer_routing ?? needsReviewer,
253
+ };
254
+ }
255
+ function emptyEvidence(workspaceId, generatedAt) {
256
+ return {
257
+ workspaceId,
258
+ generatedAt: generatedAt.toISOString(),
259
+ items: [],
260
+ totals: {},
261
+ counts: {
262
+ myTurn: 0,
263
+ myTurnPersonal: 0,
264
+ ciFailing: 0,
265
+ stalled: 0,
266
+ untouchedThreads: 0,
267
+ needsReviewer: 0,
268
+ },
269
+ };
270
+ }
271
+ /**
272
+ * THE WORK PLAN'S EVIDENCE — ranked, capped, and entirely code-derived.
273
+ *
274
+ * Takes an ALREADY-RESOLVED `BotScope` (the caller owns `resolveWorkspaceScope`, exactly like
275
+ * every other scoped reader).
276
+ *
277
+ * ⚠ `scope.repoIds.length === 0` IS A REAL ANSWER — "this workspace is empty" — not a licence to
278
+ * widen to the account. It short-circuits to empty evidence (which also dodges the
279
+ * `inArray(col, [])` pitfall), and its all-zero `counts` still agree with the brief, whose fold
280
+ * returns the same zeros for the same workspace.
281
+ */
282
+ export async function getWorkPlan(accountId, scope) {
283
+ // ⚠ THE SAME CALL `GET /api/attention` MAKES — same fold, same scope, same DEFAULT window
284
+ // (`undefined`), and UNCAPPED like the board's. A different window here would silently give the
285
+ // plan a different population from the strip and the board; the capped fold (15 per kind in
286
+ // severity / newest order, 50 my_turn cards) would hand the ranker cards the board does not
287
+ // list, and their `why` would have no card to sit on. `counts` reads `kindTotals` and the
288
+ // my_turn / ci totals, which are the same number in both folds, so the brief still agrees.
289
+ //
290
+ // ⚠ THIS SIGNATURE IS LOAD-BEARING ACROSS TWO REPOSITORIES. It is `ProHostQueries.getWorkPlan`,
291
+ // and changing its shape is the one thing here that would force an `apiVersion` bump in four
292
+ // literals spanning the host and the plugin submodule. The split below moves the BODY out; the
293
+ // signature does not move.
294
+ const insights = await getWorkspaceInsights(accountId, undefined, scope, { uncapped: true });
295
+ return rankWorkPlan(accountId, scope, insights);
296
+ }
297
+ /**
298
+ * THE RANK ITSELF, over an ALREADY-FOLDED `getWorkspaceInsights` result.
299
+ *
300
+ * Split out of `getWorkPlan` so that `GET /api/attention` — which has just run that exact fold to
301
+ * build the board — can rank the same cards without folding them a second time. Two folds in one
302
+ * request would be two populations one refresh apart, which is the whole class of bug the Pending
303
+ * consolidation exists to remove.
304
+ *
305
+ * ⚠ THE EMPTY-SCOPE SHORT-CIRCUIT LIVES HERE, not in the wrapper, so BOTH callers inherit it.
306
+ */
307
+ export async function rankWorkPlan(accountId, scope, insights) {
308
+ const now = Date.now();
309
+ const generatedAt = new Date(now);
310
+ if (scope.repoIds.length === 0)
311
+ return emptyEvidence(scope.workspaceId, generatedAt);
312
+ const counts = foldCounts(insights);
313
+ // The READER's weights, the same ones the Pending board ranks by — so the plan and the board
314
+ // below it order one population one way.
315
+ const settings = await getMyTurnSettings(accountId);
316
+ const { candidates, scored } = await scoreCards(accountId, insights.cards, now, settings.weights);
317
+ // ⚠ A ROW MUST NAME A CARD THE BOARD LISTS. The plan's `why` is shown on its card; a row whose
318
+ // card sits below a tab's `boardListCap` has nowhere to go, and the kind / repo spread in
319
+ // `capWithKindCoverage` seats exactly such low-scoring rows. The board's own listing rule decides.
320
+ const listed = listedCardIds(insights.cards, scored, settings.order);
321
+ return rankCandidates(scope, generatedAt, counts, candidates, listed);
322
+ }
323
+ /**
324
+ * EVERY CARD'S DO NEXT SCORE — the one scorer behind both the Pro work plan's ranked rows and the
325
+ * Pending board's tabs, so the two can never score one card two ways.
326
+ *
327
+ * Returns the work-plan `candidates` (the seven signals, with ids / reasons / dedup ranks) and
328
+ * `scored` — the same score for EVERY scorable card, plus the BOARD-ONLY kinds (`conflicts`,
329
+ * `security`, `dependency_bump`), which the board ranks and the work plan never names. Review load
330
+ * and the bot cards get no score.
331
+ */
332
+ export async function scoreCards(accountId, cards, now,
333
+ // The reader's resolved weights (`getMyTurnSettings(accountId).weights`). REQUIRED, so no caller
334
+ // can forget it and quietly rank by the product default while the board prints the reader's.
335
+ weights) {
336
+ // Cards the Pending board ranks and the work plan never names — see the note at the loop's end.
337
+ const boardOnly = [];
338
+ // ── the shared per-PR facts every PR-grained row wants ────────────────────────────────────
339
+ // Batched over the UNION of PR ids the plan could name, so every row gets the same
340
+ // merge/approval/thread facts for free.
341
+ const cardPrIds = new Set();
342
+ for (const c of cards) {
343
+ // A red-trunk my_turn card adds nothing: its `prId` is only the landing PR of the branch's
344
+ // head, and the trunk arm takes no PR facts (see the ci_failing arm below).
345
+ if (c.kind === 'my_turn') {
346
+ if (c.reason !== 'trunk_red')
347
+ cardPrIds.add(c.prId);
348
+ }
349
+ else if (c.kind === 'stalled_review' ||
350
+ c.kind === 'untouched_thread' ||
351
+ c.kind === 'reviewer_routing' ||
352
+ c.kind === 'merge' ||
353
+ c.kind === 'update_branch' ||
354
+ c.kind === 'conflicts' ||
355
+ c.kind === 'security' ||
356
+ c.kind === 'dependency_bump') {
357
+ cardPrIds.add(c.prId);
358
+ }
359
+ else if (c.kind === 'ci_failing' && c.prId != null)
360
+ cardPrIds.add(c.prId);
361
+ }
362
+ const factPrIds = [...cardPrIds];
363
+ // ⚠ NARROW, AND GUARDED FOR EMPTY. Three fields, keyed on the ids the cards already named —
364
+ // this replaced an account-wide open-PR select that is now the card emitter's job.
365
+ // • `changedFiles` MUST stay in this select. Two readers depend on it — `sharedFacts` and the
366
+ // ci_failing 'your_pr' arm, which has no other source — and it is HASHED (`cf:`) as well as
367
+ // driving SMALL_DIFF_BONUS, the time-free key the per-PR dedup survivor is chosen by.
368
+ // Dropping it would flip every stored plan on an affected workspace permanently `stale`.
369
+ // • `mergeStateStatus` MUST stay: `sharedFacts` feeds it to EVERY PR-grained row, and it is
370
+ // hashed as `ms:`.
371
+ // • THE EMPTY GUARD IS NOT OPTIONAL. `factPrIds` legitimately empties on a workspace with no
372
+ // PR-grained cards, and `inArray(col, [])` is the pitfall this file already names.
373
+ const openRows = factPrIds.length > 0
374
+ ? await db
375
+ .select({
376
+ id: pullRequests.id,
377
+ mergeStateStatus: pullRequests.mergeStateStatus,
378
+ changedFiles: pullRequests.changedFiles,
379
+ })
380
+ .from(pullRequests)
381
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.id, factPrIds)))
382
+ .execute()
383
+ : [];
384
+ const openById = new Map(openRows.map((r) => [r.id, r]));
385
+ // ⚠ THE SAME FOLD THE PENDING CARD'S REVIEWER CHIPS COME FROM, so the plan's `approvals` and
386
+ // the card's cannot disagree — the two used to be `computeApprovalInfoByPr` and nothing, and a
387
+ // second fold over `reviews` is exactly how they would drift.
388
+ //
389
+ // ⚠ `approvals` IS HASHED (`payloadHashFor`), and this rewrite deliberately does not move it.
390
+ // `approvalInfoFromStandings` is the projection `computeApprovalInfoByPr` already was: the two
391
+ // differ only in EMISSION — the old map had no entry for a PR whose reviews are all comments,
392
+ // where this one does, with `approvals: 0`. `sharedFacts` reads `?.approvals ?? 0`, so the value
393
+ // is identical either way. Nothing else from the standings enters `WorkPlanFacts`: a reviewer's
394
+ // `standingAt` is a Date that moves without the work moving, and a hashed field that drifts on
395
+ // its own re-bills every stored plan on the workspace.
396
+ const approvalByPr = new Map([...(await computeReviewStandingsByPr(factPrIds))].map(([prId, st]) => [
397
+ prId,
398
+ approvalInfoFromStandings(st),
399
+ ]));
400
+ const untouchedByPr = new Map();
401
+ if (factPrIds.length > 0) {
402
+ const threadRows = await db
403
+ .select({ prId: reviewThreads.prId, c: count() })
404
+ .from(reviewThreads)
405
+ .where(and(inArray(reviewThreads.prId, factPrIds), eq(reviewThreads.derivedState, 'untouched')))
406
+ .groupBy(reviewThreads.prId)
407
+ .execute();
408
+ for (const r of threadRows)
409
+ untouchedByPr.set(r.prId, r.c);
410
+ }
411
+ /** The per-PR facts that do not depend on which SIGNAL produced the row. A PR outside the
412
+ * open/non-draft population (a my_turn thread on a draft, say) simply carries fewer facts —
413
+ * `undefined` means "not known here", never "zero". */
414
+ const sharedFacts = (prId) => {
415
+ const row = openById.get(prId);
416
+ const untouched = untouchedByPr.get(prId);
417
+ return {
418
+ approvals: approvalByPr.get(prId)?.approvals ?? 0,
419
+ ...(row?.mergeStateStatus != null
420
+ ? { mergeStateStatus: row.mergeStateStatus }
421
+ : {}),
422
+ ...(untouched != null ? { untouchedThreads: untouched } : {}),
423
+ };
424
+ };
425
+ const candidates = [];
426
+ const push = (kind, id, tieRank, parts) => {
427
+ const working = proximityWorking(kind, parts.facts, parts.proximityBase);
428
+ const proximity = working.value;
429
+ const stallRisk = stallRiskFor(parts.facts.ageHours);
430
+ // THE ID ENCODES THE GRAIN, so the flag is read back off it rather than passed: a PR-grained
431
+ // row is ADDRESSED by its PR (`wp:merge:<prId>`), while a repo- or thread-grained one is
432
+ // addressed by something else (`wp:unblock_ci:trunk:<repoId>`, `wp:thread:<threadId>`) and
433
+ // merely happens to carry a prId for linking. Deriving it means a new row kind cannot get the
434
+ // two out of step by forgetting an argument.
435
+ const prGrained = parts.prId != null && id === `wp:${kind}:${parts.prId}`;
436
+ candidates.push({
437
+ tieRank,
438
+ prGrained,
439
+ working,
440
+ item: {
441
+ id,
442
+ kind,
443
+ subject: parts.subject ?? 'pr',
444
+ prId: parts.prId,
445
+ repoId: parts.repoId,
446
+ repoFullName: parts.repoFullName,
447
+ prNumber: parts.prNumber,
448
+ prTitle: parts.prTitle,
449
+ ...(parts.threadId !== undefined ? { threadId: parts.threadId } : {}),
450
+ githubUrl: parts.githubUrl,
451
+ relevance: parts.relevance,
452
+ facts: parts.facts,
453
+ proximity,
454
+ stallRisk,
455
+ score: scoreFor(proximity, stallRisk, parts.relevance, weights),
456
+ reason: parts.reason,
457
+ cardId: parts.cardId,
458
+ },
459
+ });
460
+ };
461
+ // ── the seven card-derived signals ────────────────────────────────────────────────────────
462
+ for (const card of cards) {
463
+ // ── merge + update_branch ───────────────────────────────────────────────────────────────
464
+ // Folded off the cards like everything else. The card already carries relevance, the repo
465
+ // name and the url; this arm adds only the facts the ranker scores on.
466
+ if (card.kind === 'merge' || card.kind === 'update_branch') {
467
+ const c = card;
468
+ // The honest clock: the head commit is the code that is ready to land. `clock` says which
469
+ // instant was actually used, so a row falling back to `openedAt` does not claim otherwise.
470
+ const facts = {
471
+ ...sharedFacts(c.prId),
472
+ ciStatus: c.ciStatus,
473
+ changedFiles: c.changedFiles,
474
+ ageHours: hoursSince(c.lastCommitAt ?? c.openedAt, now),
475
+ clock: c.lastCommitAt != null ? 'last_commit' : 'opened',
476
+ };
477
+ push(c.kind, `wp:${c.kind}:${c.prId}`, 0, {
478
+ cardId: c.id,
479
+ prId: c.prId,
480
+ repoId: c.repoId,
481
+ repoFullName: c.repoFullName,
482
+ prNumber: c.prNumber,
483
+ prTitle: c.prTitle,
484
+ githubUrl: c.githubUrl,
485
+ // ⚠ VERBATIM off the card, exactly like the my_turn arm below. The card's fold is the ONE
486
+ // place "a repo you maintain" is decided.
487
+ relevance: c.relevance,
488
+ facts,
489
+ // The SAME builder the card used — but with the real untouched-thread count, which only
490
+ // this fold has. One function, so the card's `detail` and the row's `reason` cannot drift
491
+ // into two different sentences about one PR.
492
+ reason: mergeCardDetail(c.kind, c.mergeStateStatus, facts.untouchedThreads ?? 0, c.inMergeQueue),
493
+ });
494
+ continue;
495
+ }
496
+ if (card.kind === 'my_turn') {
497
+ // ⚠ BRANCH ON THE TRUNK CARD FIRST. `kind === 'my_turn'` narrows to a union whose `prId` is
498
+ // nullable, and a cast to `MyTurnCard` here would hide exactly the repo-grained card the
499
+ // compiler exists to make every consumer handle.
500
+ if (card.reason === 'trunk_red') {
501
+ const c = card;
502
+ // A PROMOTED red trunk scores EXACTLY as the ci_failing trunk arm below does — same kind,
503
+ // same id, same repo-only facts — so the plan's evidence is the same whether or not the
504
+ // reader promoted it. Only `relevance` (theirs now) and `cardId` differ.
505
+ push('unblock_ci', `wp:unblock_ci:trunk:${c.repoId}`, SEVERITY_RANK[c.severity], {
506
+ cardId: c.id,
507
+ prId: c.prId,
508
+ repoId: c.repoId,
509
+ repoFullName: c.repoFullName,
510
+ prNumber: c.prNumber,
511
+ prTitle: c.prTitle,
512
+ githubUrl: c.githubUrl,
513
+ subject: 'repo',
514
+ relevance: c.relevance ?? 'none',
515
+ facts: { ciStatus: c.ciStatus, ageHours: hoursSince(c.observedAt, now), clock: 'observed' },
516
+ reason: c.maintained
517
+ ? 'Trunk is red in a repo you maintain — every open PR here builds on it'
518
+ : 'Trunk is red — every open PR here builds on it',
519
+ proximityBase: 'red_trunk',
520
+ });
521
+ continue;
522
+ }
523
+ const c = card;
524
+ // THE FOUR PR-GRAINED PROMOTIONS score EXACTLY as their home cards did — the same
525
+ // `WorkPlanKind`, the same id, the same facts, the same `reason` sentence — so the Pro plan's
526
+ // evidence vocabulary and ids are identical whether or not the reader promoted anything.
527
+ // Only `relevance` (read off the card) and `cardId` may differ.
528
+ const own = c.own;
529
+ if (c.reason === 'own_ci_red' && own?.kind === 'ci_red') {
530
+ push('unblock_ci', `wp:unblock_ci:${c.prId}`, SEVERITY_RANK[c.severity], {
531
+ cardId: c.id,
532
+ prId: c.prId,
533
+ repoId: c.repoId,
534
+ repoFullName: c.repoFullName,
535
+ prNumber: c.prNumber,
536
+ prTitle: c.prTitle,
537
+ githubUrl: c.githubUrl,
538
+ relevance: c.relevance ?? 'none',
539
+ facts: {
540
+ ...sharedFacts(c.prId),
541
+ ciStatus: c.ciStatus,
542
+ changedFiles: c.changedFiles,
543
+ ageHours: hoursSince(own.lastCommitAt ?? c.openedAt, now),
544
+ clock: 'last_commit',
545
+ },
546
+ reason: 'Your open PR — its head commit is red',
547
+ });
548
+ continue;
549
+ }
550
+ if (c.reason === 'own_ready' && own?.kind === 'ready') {
551
+ const facts = {
552
+ ...sharedFacts(c.prId),
553
+ ciStatus: c.ciStatus,
554
+ changedFiles: c.changedFiles,
555
+ ageHours: hoursSince(own.lastCommitAt ?? c.openedAt, now),
556
+ clock: own.lastCommitAt != null ? 'last_commit' : 'opened',
557
+ };
558
+ push(own.forward, `wp:${own.forward}:${c.prId}`, 0, {
559
+ cardId: c.id,
560
+ prId: c.prId,
561
+ repoId: c.repoId,
562
+ repoFullName: c.repoFullName,
563
+ prNumber: c.prNumber,
564
+ prTitle: c.prTitle,
565
+ githubUrl: c.githubUrl,
566
+ relevance: c.relevance ?? 'none',
567
+ facts,
568
+ // The card's own `inMergeQueue` (prRef), so a queued PR's row says what its card says.
569
+ reason: mergeCardDetail(own.forward, own.mergeStateStatus, facts.untouchedThreads ?? 0, c.inMergeQueue),
570
+ });
571
+ continue;
572
+ }
573
+ if (c.reason === 'own_conflicts') {
574
+ // Board-only, like its home `conflicts` card (see the conflicts arm below for why).
575
+ const facts = {
576
+ ...sharedFacts(c.prId),
577
+ ciStatus: c.ciStatus,
578
+ changedFiles: c.changedFiles,
579
+ ageHours: hoursSince(c.openedAt, now),
580
+ clock: 'opened',
581
+ };
582
+ const working = proximityWorking('merge', facts, 'conflicts');
583
+ const stallRisk = stallRiskFor(facts.ageHours);
584
+ const relevance = c.relevance ?? 'none';
585
+ boardOnly.push({
586
+ cardId: c.id,
587
+ prId: c.prId,
588
+ score: scoreFor(working.value, stallRisk, relevance, weights),
589
+ proximity: working.value,
590
+ proximityBase: working.base,
591
+ adjustments: working.adjustments,
592
+ stallRisk,
593
+ relevance,
594
+ ageHours: roundAge(facts.ageHours),
595
+ clock: facts.clock ?? null,
596
+ });
597
+ continue;
598
+ }
599
+ if (c.reason === 'own_thread' && own?.kind === 'thread' && c.threadId != null) {
600
+ // The Unanswered threads card ages in WHOLE hours; so does this, so one thread scores the
601
+ // same on either tab.
602
+ const age = hoursSince(c.since, now);
603
+ push('thread', `wp:thread:${c.prId}:${c.threadId}`, 0, {
604
+ cardId: c.id,
605
+ prId: c.prId,
606
+ repoId: c.repoId,
607
+ repoFullName: c.repoFullName,
608
+ prNumber: c.prNumber,
609
+ prTitle: c.prTitle,
610
+ threadId: c.threadId,
611
+ githubUrl: c.githubUrl,
612
+ relevance: c.relevance ?? 'none',
613
+ facts: {
614
+ ...sharedFacts(c.prId),
615
+ ciStatus: c.ciStatus,
616
+ changedFiles: c.changedFiles,
617
+ ageHours: age != null ? Math.round(age) : undefined,
618
+ clock: 'thread_created',
619
+ },
620
+ reason: `Review thread on ${own.path} — no reply and no follow-up commit`,
621
+ });
622
+ continue;
623
+ }
624
+ if (c.reason === 'own_ci_red' || c.reason === 'own_ready' || c.reason === 'own_thread') {
625
+ // A promotion whose home facts are missing (`own` absent on the wire) has nothing to score
626
+ // as — never invent them.
627
+ continue;
628
+ }
629
+ const reason = c.reason;
630
+ const kind = kindForMyTurn(reason);
631
+ const facts = {
632
+ ...sharedFacts(c.prId),
633
+ ciStatus: c.ciStatus,
634
+ changedFiles: c.changedFiles,
635
+ ageHours: hoursSince(c.since, now),
636
+ // `since` is the instant the thing that needs you happened. Only the review-request
637
+ // section's is one of the four NAMED clocks; the rest are activity events we observed,
638
+ // and 'observed' is the honest label rather than a nearby-sounding lie.
639
+ clock: reason === 'review_request' ? 'requested' : 'observed',
640
+ };
641
+ push(kind,
642
+ // A thread-grained reply (`thread`, `thread_reply`) keys on its thread; `mention` and
643
+ // `comment_reply` are PR-grained — at most one PR-grained summons exists per PR (the fixed
644
+ // precedence inside `getMyTurn`).
645
+ kind === 'reply' && c.threadId != null
646
+ ? `wp:reply:${c.prId}:${c.threadId}`
647
+ : `wp:${kind}:${c.prId}`, SEVERITY_RANK[c.severity] * 100 + REASON_RANK[reason], {
648
+ cardId: c.id,
649
+ prId: c.prId,
650
+ repoId: c.repoId,
651
+ repoFullName: c.repoFullName,
652
+ prNumber: c.prNumber,
653
+ prTitle: c.prTitle,
654
+ ...(c.threadId != null ? { threadId: c.threadId } : {}),
655
+ githubUrl: c.githubUrl,
656
+ // ⚠ CARRIED THROUGH VERBATIM, and an ABSENT field is 'none'. The card's copy rule
657
+ // exactly: a missing field may never invent an ownership claim on screen.
658
+ relevance: c.relevance ?? 'none',
659
+ facts,
660
+ reason: myTurnReason(reason),
661
+ });
662
+ continue;
663
+ }
664
+ if (card.kind === 'ci_failing') {
665
+ const c = card;
666
+ const trunk = c.arm === 'trunk';
667
+ // ⚠ THE TRUNK ARM TAKES NO PR-DERIVED FACTS, even though it resolves a `prId`. That prId is
668
+ // the pull request that LANDED the branch's current head — a link target, nothing more — and
669
+ // its approvals, diff size and merge state are facts about THAT PR, not about the branch.
670
+ // Rendered on a row whose subject is "acme/api default branch", "one approval" is not merely
671
+ // irrelevant, it is a claim about the wrong object; it shipped that way for one generation
672
+ // and read as though the branch itself had been approved. The branch's own facts are its CI
673
+ // status and when we last looked.
674
+ const facts = trunk
675
+ ? { ciStatus: c.ciStatus, ageHours: hoursSince(c.observedAt, now), clock: 'observed' }
676
+ : {
677
+ // The 'your_pr' arm is BUILT from the viewer's own open PR, so prId is never null here
678
+ // — but the wire type permits it (the trunk arm above is why), so this degrades to the
679
+ // CI status alone rather than asserting.
680
+ ...(c.prId != null ? sharedFacts(c.prId) : {}),
681
+ ciStatus: c.ciStatus,
682
+ ...(c.prId != null && openById.get(c.prId) != null
683
+ ? { changedFiles: openById.get(c.prId).changedFiles }
684
+ : {}),
685
+ ageHours: hoursSince(c.observedAt, now),
686
+ // 'your_pr' dates off the HEAD COMMIT — the code the verdict is about. (The trunk arm
687
+ // above dates off OUR branch-snapshot refresh instead: two different facts, and the
688
+ // card already says which.)
689
+ clock: 'last_commit',
690
+ };
691
+ push('unblock_ci',
692
+ // ⚠ A repo-grained row cannot share the PR-grained id space: `pull_requests.id` and
693
+ // `repos.id` are separate sequences that both start at 1, so `wp:unblock_ci:3` would be
694
+ // ambiguous. The trunk arm gets its own segment. (Documented deviation from the wire
695
+ // comment's `wp:<kind>:<prId|repoId>` shorthand; the field is a free-form string and a
696
+ // collision here would silently join a model step to the wrong row.)
697
+ trunk ? `wp:unblock_ci:trunk:${c.repoId}` : `wp:unblock_ci:${c.prId}`, SEVERITY_RANK[c.severity], {
698
+ cardId: c.id,
699
+ prId: c.prId,
700
+ repoId: c.repoId,
701
+ repoFullName: c.repoFullName,
702
+ prNumber: c.prNumber,
703
+ prTitle: c.prTitle,
704
+ // The COMMIT page on the trunk arm — a trunk run's checks live on the commit — which the
705
+ // card already resolved. Passed through, never rebuilt.
706
+ githubUrl: c.githubUrl,
707
+ // THE ONLY 'repo' ROW IN THE PRODUCT. Everything else — including a thread, which is a
708
+ // conversation ON a pull request — is about a PR.
709
+ subject: trunk ? 'repo' : 'pr',
710
+ relevance: trunk ? 'maintained' : 'direct',
711
+ facts,
712
+ reason: trunk
713
+ ? 'Trunk is red in a repo you maintain — every open PR here builds on it'
714
+ : 'Your open PR — its head commit is red',
715
+ // A red trunk invalidates every open PR in the repo at once, so it outranks the per-PR
716
+ // arm rather than sitting beside it.
717
+ proximityBase: trunk ? 'red_trunk' : undefined,
718
+ });
719
+ continue;
720
+ }
721
+ if (card.kind === 'stalled_review') {
722
+ const c = card;
723
+ // Reviewers still on the hook: users AND GitHub TEAMS (a team request carries no user id,
724
+ // and it is still somebody being waited on).
725
+ const pending = c.requestedReviewerIds.length + c.requestedTeamNames.length;
726
+ const facts = {
727
+ ...sharedFacts(c.prId),
728
+ ciStatus: c.ciStatus,
729
+ changedFiles: c.changedFiles,
730
+ pendingReviewers: pending,
731
+ ageHours: c.ageHours,
732
+ clock: 'opened',
733
+ };
734
+ push('nudge', `wp:nudge:${c.prId}`, 0, {
735
+ cardId: c.id,
736
+ prId: c.prId,
737
+ repoId: c.repoId,
738
+ repoFullName: c.repoFullName,
739
+ prNumber: c.prNumber,
740
+ prTitle: c.prTitle,
741
+ githubUrl: c.githubUrl,
742
+ relevance: 'none',
743
+ facts,
744
+ // ⚠ A DIFFERENT SENTENCE FROM reviewer_routing's. Someone WAS asked here and has not
745
+ // moved; there, nobody has been asked at all. Two situations, two reasons.
746
+ reason: pending > 0
747
+ ? `Review requested from ${pending} ${plural(pending, 'reviewer')} — nobody has moved`
748
+ : 'Requested review has gone unanswered',
749
+ });
750
+ continue;
751
+ }
752
+ if (card.kind === 'untouched_thread') {
753
+ const c = card;
754
+ const facts = {
755
+ ...sharedFacts(c.prId),
756
+ ciStatus: c.ciStatus,
757
+ changedFiles: c.changedFiles,
758
+ ageHours: c.ageHours,
759
+ clock: 'thread_created',
760
+ };
761
+ push('thread', `wp:thread:${c.prId}:${c.threadId}`, 0, {
762
+ cardId: c.id,
763
+ prId: c.prId,
764
+ repoId: c.repoId,
765
+ repoFullName: c.repoFullName,
766
+ prNumber: c.prNumber,
767
+ prTitle: c.prTitle,
768
+ threadId: c.threadId,
769
+ githubUrl: c.githubUrl,
770
+ relevance: 'none',
771
+ facts,
772
+ reason: `Review thread on ${c.path} — no reply and no follow-up commit`,
773
+ });
774
+ continue;
775
+ }
776
+ if (card.kind === 'reviewer_routing') {
777
+ const c = card;
778
+ const facts = {
779
+ ...sharedFacts(c.prId),
780
+ ciStatus: c.ciStatus,
781
+ changedFiles: c.changedFiles,
782
+ // Nobody is on the hook — that is the whole card. Stated as 0, not omitted: here it is a
783
+ // measured fact rather than "not known".
784
+ pendingReviewers: 0,
785
+ ageHours: hoursSince(c.openedAt, now),
786
+ clock: 'opened',
787
+ };
788
+ push('nudge', `wp:nudge:${c.prId}`, 1, {
789
+ cardId: c.id,
790
+ prId: c.prId,
791
+ repoId: c.repoId,
792
+ repoFullName: c.repoFullName,
793
+ prNumber: c.prNumber,
794
+ prTitle: c.prTitle,
795
+ githubUrl: c.githubUrl,
796
+ relevance: 'none',
797
+ facts,
798
+ reason: 'Nobody has been asked to review this yet',
799
+ });
800
+ continue;
801
+ }
802
+ // ── security + dependency_bump: SCORED FOR THE BOARD, NEVER A PLAN ROW (the conflicts precedent)
803
+ // WorkPlanKind is a two-repository contract (the plugin's prompt enumerates seven kinds as a
804
+ // string); these rank only inside the Dependencies tab, whose strict group already puts security
805
+ // first — so no security bonus. A dependency PR starts from the base its STATE names (a ready
806
+ // one splits on approval like a `merge` card); a person's PR a tool flagged, from
807
+ // `security_alert`.
808
+ if (card.kind === 'security' || card.kind === 'dependency_bump') {
809
+ const c = card;
810
+ const facts = {
811
+ ...sharedFacts(c.prId),
812
+ ciStatus: c.ciStatus,
813
+ changedFiles: c.changedFiles,
814
+ // The head commit is the code that would land — the merge cards' clock.
815
+ ageHours: hoursSince(c.lastCommitAt ?? c.openedAt, now),
816
+ clock: c.lastCommitAt != null ? 'last_commit' : 'opened',
817
+ };
818
+ const working = proximityWorking('merge', facts, dependencyProximityBase(c.depState, facts.approvals ?? 0));
819
+ const stallRisk = stallRiskFor(facts.ageHours);
820
+ boardOnly.push({
821
+ cardId: c.id,
822
+ prId: c.prId,
823
+ score: scoreFor(working.value, stallRisk, c.relevance, weights),
824
+ proximity: working.value,
825
+ proximityBase: working.base,
826
+ adjustments: working.adjustments,
827
+ stallRisk,
828
+ relevance: c.relevance,
829
+ ageHours: roundAge(facts.ageHours),
830
+ clock: facts.clock ?? null,
831
+ });
832
+ continue;
833
+ }
834
+ // ── conflicts: SCORED FOR THE BOARD, NEVER A PLAN ROW ──────────────────────────────────
835
+ // A conflicts card competes inside the Pending board's "Needs fixing" tab, so it needs a score
836
+ // on the same scale as the red builds beside it. It still never becomes a work-plan ROW — see
837
+ // the note below — so it is scored straight into `boardOnly` and never pushed.
838
+ if (card.kind === 'conflicts') {
839
+ const c = card;
840
+ const facts = {
841
+ ...sharedFacts(c.prId),
842
+ ciStatus: c.ciStatus,
843
+ changedFiles: c.changedFiles,
844
+ // The card carries no head-commit time, so it ages from when the PR was opened — and says so.
845
+ ageHours: hoursSince(c.openedAt, now),
846
+ clock: 'opened',
847
+ };
848
+ const working = proximityWorking('merge', facts, 'conflicts');
849
+ const stallRisk = stallRiskFor(facts.ageHours);
850
+ boardOnly.push({
851
+ cardId: c.id,
852
+ prId: c.prId,
853
+ score: scoreFor(working.value, stallRisk, c.relevance, weights),
854
+ proximity: working.value,
855
+ proximityBase: working.base,
856
+ adjustments: working.adjustments,
857
+ stallRisk,
858
+ relevance: c.relevance,
859
+ ageHours: roundAge(facts.ageHours),
860
+ clock: facts.clock ?? null,
861
+ });
862
+ continue;
863
+ }
864
+ // reviewer_load / bot_signal / bot_only_review are SURVEYS of the workspace, not things one
865
+ // person does today. They are deliberately not worklist rows.
866
+ //
867
+ // ⚠ AND NEITHER ARE `conflicts`, `security` OR `dependency_bump`, WHICH ARE NOT SURVEYS — so
868
+ // their absence needs its own argument (written for conflicts; the Dependencies kinds follow it
869
+ // for reason (2), and because their tab's strict group is its own order).
870
+ // Two reasons, and the second is why it stays out today. (1) It is the one job on this board
871
+ // with no in-app step to rank: the plan orders what you could do NEXT, and "go resolve a merge
872
+ // conflict in a checkout" has no next. (2) The kind vocabulary is a TWO-REPOSITORY contract —
873
+ // `WorkPlanKind` + BASE_PROXIMITY here, and the plugin's prompt, which enumerates the seven
874
+ // kinds as a STRING that nothing type-checks — so adding a member host-side alone hands the
875
+ // model a kind it was never told about. The head is an ORDERING, not a filter, so an un-named
876
+ // card renders in the tail and no cap disclosure is harmed. Including it later is a deliberate
877
+ // change in BOTH repositories, not a one-line addition here.
878
+ }
879
+ const scored = [
880
+ ...candidates.map((c) => ({
881
+ cardId: c.item.cardId,
882
+ prId: c.item.prId,
883
+ score: c.item.score,
884
+ proximity: c.item.proximity,
885
+ proximityBase: c.working.base,
886
+ adjustments: c.working.adjustments,
887
+ stallRisk: c.item.stallRisk,
888
+ relevance: c.item.relevance,
889
+ ageHours: roundAge(c.item.facts.ageHours),
890
+ clock: c.item.facts.clock ?? null,
891
+ })),
892
+ ...boardOnly,
893
+ ];
894
+ return { candidates, scored };
895
+ }
896
+ /** One decimal is plenty for "3 days" and keeps float noise off the wire. */
897
+ function roundAge(hours) {
898
+ return hours == null ? null : Math.round(hours * 10) / 10;
899
+ }
900
+ /** The work plan's dedup → rank → cap over already-scored candidates. `listed` is the board's
901
+ * listed card ids: rows are drawn only from candidates whose card is listed, while `totals` still
902
+ * counts every row, so the cap disclosure covers what the board left out too. */
903
+ function rankCandidates(scope, generatedAt, counts, candidates, listed) {
904
+ const ranked = dedupAndRank(candidates);
905
+ // The UNCAPPED population per kind, folded off the pre-cap array — the disclosure that keeps
906
+ // WORK_PLAN_ITEM_CAP from reading as "that is everything".
907
+ //
908
+ // ⚠ IT COUNTS ITEMS, NOT CARDS, and is therefore NOT comparable to `counts` above: `counts` is
909
+ // the brief-aligned CARD population, `totals` is the post-dedup ROW population the capped list
910
+ // is drawn from. Two different questions; conflating them would make one of the two wrong.
911
+ const totals = {};
912
+ for (const item of ranked)
913
+ totals[item.kind] = (totals[item.kind] ?? 0) + 1;
914
+ // ⚠ FILTER BEFORE THE DEDUP, NOT AFTER. A PR's listed `merge` row and its unlisted `review` row
915
+ // collapse onto one survivor; deduping first could pick the unlisted one and then drop the PR.
916
+ // Which cards are listed is score-ordered, so on a list group over the cap this is the same
917
+ // bucket-crossing clock dependence the plugin's hash already accepts for the 12-row cap.
918
+ const onBoard = dedupAndRank(candidates.filter((c) => c.item.cardId != null && listed.has(c.item.cardId)));
919
+ return {
920
+ workspaceId: scope.workspaceId,
921
+ generatedAt: generatedAt.toISOString(),
922
+ items: capWithKindCoverage(onBoard),
923
+ totals,
924
+ counts,
925
+ };
926
+ }
927
+ /** Dedup by id, then by PR, then rank — the rows the cap selects from. */
928
+ function dedupAndRank(candidates) {
929
+ // ── dedup, rank ───────────────────────────────────────────────────────────────────────────
930
+ // ⚠ THE ID IS THE MODEL'S JOIN KEY, so it must be unique. Several signals collapse onto one id
931
+ // (a PR that is both "review requested" and "approved, waiting on you"); the survivor is chosen
932
+ // by the TIME-FREE `tieRank`, never by score — see REASON_RANK.
933
+ const byId = new Map();
934
+ for (const c of candidates) {
935
+ const prev = byId.get(c.item.id);
936
+ if (prev == null || c.tieRank < prev.tieRank)
937
+ byId.set(c.item.id, c);
938
+ }
939
+ // ⚠ AND THEN AGAIN BY PR, BECAUSE ONE PR IS ONE JOB. Deduping on the id alone is not enough:
940
+ // the my_turn fold and the merge fold reach the same PR under DIFFERENT ids, so an approved,
941
+ // mergeable PR of yours arrived twice — `wp:merge:<id>` saying "Mergeable now · 1 approval ·
942
+ // checks green" and `wp:review:<id>` saying "Your PR is approved and waiting on you". That is
943
+ // one instruction printed twice, and on a real workspace it burned two of the twelve slots and
944
+ // pushed genuine work off the bottom. Observed on live data, invisible to every unit test,
945
+ // because each fold is individually correct.
946
+ //
947
+ // The survivor is the HIGHEST-PROXIMITY row: proximity is the "how few steps from landing"
948
+ // axis, so the row that survives is the one naming the action furthest along — "merge it"
949
+ // beats "it is approved", which is the same sentence with less information.
950
+ //
951
+ // ⚠ PROXIMITY, NOT SCORE. Score folds `stallRisk`, which is derived from `now`, so choosing by
952
+ // score would let the survivor — and therefore its `reason`, a HASHED field — change on a
953
+ // timer and re-bill a dormant workspace. Proximity is time-free. `tieRank` breaks a tie, also
954
+ // time-free.
955
+ //
956
+ // ⚠ ONLY PR-GRAINED ROWS. Two untouched threads on one PR are two distinct jobs, and a red
957
+ // TRUNK is repo-grained — its `prId` is merely the landing PR of the current head, which the
958
+ // card claims nothing about. Collapsing either into a sibling would delete real work.
959
+ const byPr = new Map();
960
+ const kept = [];
961
+ for (const c of byId.values()) {
962
+ if (!c.prGrained || c.item.prId == null) {
963
+ kept.push(c);
964
+ continue;
965
+ }
966
+ const prev = byPr.get(c.item.prId);
967
+ if (prev == null ||
968
+ c.item.proximity > prev.item.proximity ||
969
+ (c.item.proximity === prev.item.proximity && c.tieRank < prev.tieRank)) {
970
+ byPr.set(c.item.prId, c);
971
+ }
972
+ }
973
+ const ranked = [...kept, ...byPr.values()].map((c) => c.item);
974
+ ranked.sort((a, b) => b.score - a.score ||
975
+ // Tie-breaks, in order, and the chain is TOTAL: two ticks over unchanged data must produce
976
+ // byte-identical order or rows flip under the reader between polls.
977
+ (b.facts.ageHours ?? -1) - (a.facts.ageHours ?? -1) ||
978
+ (a.prId ?? Number.MAX_SAFE_INTEGER) - (b.prId ?? Number.MAX_SAFE_INTEGER) ||
979
+ // The last resort: ids are unique by construction above, so this can never tie.
980
+ a.id.localeCompare(b.id));
981
+ return ranked;
982
+ }
983
+ /**
984
+ * Cap the ranked list at {@link WORK_PLAN_ITEM_CAP}, but SEAT THE BEST ROW OF EVERY KIND FIRST.
985
+ *
986
+ * ⚠ A PLAIN `.slice(0, CAP)` WOULD FILL THE WHOLE HEAD WITH ONE KIND. The old argument for this
987
+ * pass — that a kind deleted by the cap reads as a fact about the workspace ("nothing is behind
988
+ * trunk") — NO LONGER HOLDS: the head sits on top of the full board, and every displaced row is
989
+ * still on screen, below the divider, in the tail. Keep the pass anyway, for the reason that
990
+ * survives: the head PROMISES a spread of the day's work, and twelve rows of one kind above the
991
+ * fold is the same failure moved one screen higher. The scoring weights `relevance`, so a
992
+ * workspace with a dozen PRs naming the viewer directly out-scores every shared-work row.
993
+ *
994
+ * So: one slot per non-empty kind (highest-scoring member), then fill the remainder strictly by
995
+ * score, then restore score order over the selection. `ranked` is already sorted by the total
996
+ * tie-break chain above, so "first seen" IS "highest scoring", and the whole pass is stable.
997
+ */
998
+ function capWithKindCoverage(ranked) {
999
+ if (ranked.length <= WORK_PLAN_ITEM_CAP)
1000
+ return ranked;
1001
+ const chosen = new Set();
1002
+ const seenKind = new Set();
1003
+ for (const item of ranked) {
1004
+ if (seenKind.has(item.kind))
1005
+ continue;
1006
+ seenKind.add(item.kind);
1007
+ chosen.add(item.id);
1008
+ // Defensive: WorkPlanKind has seven members and the cap is twelve, so this cannot fire today.
1009
+ // It is here so that shrinking the cap below the number of kinds degrades to "the top N kinds"
1010
+ // rather than silently dropping the fill pass altogether.
1011
+ if (chosen.size === WORK_PLAN_ITEM_CAP)
1012
+ break;
1013
+ }
1014
+ // ⚠ THE FILL PASS IS LEVELLED BY KIND *AND* BY REPO, and a plain "fill by score" is what it
1015
+ // replaced. Seating one row per kind stops a kind being ABSENT; it does nothing to stop one
1016
+ // kind DOMINATING, and on real data that is the failure that actually shipped:
1017
+ //
1018
+ // 0.725 update_branch none drizzle-team/drizzle-orm #5929
1019
+ // 0.725 update_branch none drizzle-team/drizzle-orm #5938
1020
+ // …six more, byte-identical scores, same kind, same repo…
1021
+ //
1022
+ // EIGHT of twelve slots, one repo, one instruction — a "plan for the day" that is really one
1023
+ // sentence copied eight times. It happens because the score DEGENERATES on a backlog: every row
1024
+ // older than the top stall bucket carries `stallRisk === 1`, so that term is a constant, and
1025
+ // when a workspace has no `direct` work the relevance term is constant too. What is left is
1026
+ // `proximity`, which is per-KIND — so the ranking collapses into "sort by kind" and the fill
1027
+ // pass takes a run.
1028
+ //
1029
+ // The rule: fill in LEVELS. On level N take the highest-scoring remaining rows whose kind has
1030
+ // fewer than N seats AND whose repo has fewer than N seats. Both counters rise together, so the
1031
+ // head spreads across kinds and repos before it doubles up on either, and it degrades cleanly —
1032
+ // a workspace with one kind in one repo simply reaches its level and fills in score order.
1033
+ // Deterministic, because `ranked` is already totally ordered.
1034
+ const byKind = new Map();
1035
+ const byRepo = new Map();
1036
+ for (const item of ranked) {
1037
+ if (!chosen.has(item.id))
1038
+ continue;
1039
+ byKind.set(item.kind, (byKind.get(item.kind) ?? 0) + 1);
1040
+ byRepo.set(item.repoId, (byRepo.get(item.repoId) ?? 0) + 1);
1041
+ }
1042
+ for (let level = 2; chosen.size < WORK_PLAN_ITEM_CAP && level <= WORK_PLAN_ITEM_CAP + 1; level++) {
1043
+ for (const item of ranked) {
1044
+ if (chosen.size >= WORK_PLAN_ITEM_CAP)
1045
+ break;
1046
+ if (chosen.has(item.id))
1047
+ continue;
1048
+ if ((byKind.get(item.kind) ?? 0) >= level)
1049
+ continue;
1050
+ if ((byRepo.get(item.repoId) ?? 0) >= level)
1051
+ continue;
1052
+ chosen.add(item.id);
1053
+ byKind.set(item.kind, (byKind.get(item.kind) ?? 0) + 1);
1054
+ byRepo.set(item.repoId, (byRepo.get(item.repoId) ?? 0) + 1);
1055
+ }
1056
+ }
1057
+ return ranked.filter((i) => chosen.has(i.id));
1058
+ }
1059
+ /** CODE-WRITTEN blockers for the my_turn reasons. ⚠ Deliberately free of relative-time phrasing
1060
+ * (the card's own `detail` says "3d ago"): `reason` is a stable, hashable field and a clock
1061
+ * inside it would re-bill a dormant workspace on a timer. */
1062
+ function myTurnReason(reason) {
1063
+ switch (reason) {
1064
+ case 'review_request':
1065
+ return 'Review requested from you';
1066
+ case 'mention':
1067
+ return 'You were mentioned on this PR';
1068
+ case 'thread':
1069
+ return 'A reply is waiting on you in this thread';
1070
+ case 'thread_reply':
1071
+ return 'Someone replied to your comment in this thread';
1072
+ case 'comment_reply':
1073
+ return 'Someone commented after your comment';
1074
+ case 'pushed_since':
1075
+ return 'New commits since your last review or comment';
1076
+ case 'pr_approved':
1077
+ return 'Your PR is approved and waiting on you';
1078
+ case 'your_pr':
1079
+ return 'Your PR has new activity since you last opened it';
1080
+ case 'watched_repo_pr':
1081
+ return 'New PR you have not reviewed or commented on';
1082
+ case 'claude_review':
1083
+ return 'A Claude review has finished and needs actioning';
1084
+ }
1085
+ }
1086
+ //# sourceMappingURL=work-plan.js.map