@penclipai/server 2026.426.0 → 2026.505.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (253) hide show
  1. package/dist/adapters/builtin-adapter-types.d.ts.map +1 -1
  2. package/dist/adapters/builtin-adapter-types.js +3 -0
  3. package/dist/adapters/builtin-adapter-types.js.map +1 -1
  4. package/dist/adapters/index.d.ts +2 -2
  5. package/dist/adapters/index.d.ts.map +1 -1
  6. package/dist/adapters/index.js +1 -1
  7. package/dist/adapters/index.js.map +1 -1
  8. package/dist/adapters/registry.d.ts +2 -1
  9. package/dist/adapters/registry.d.ts.map +1 -1
  10. package/dist/adapters/registry.js +76 -6
  11. package/dist/adapters/registry.js.map +1 -1
  12. package/dist/adapters/types.d.ts +1 -1
  13. package/dist/adapters/types.d.ts.map +1 -1
  14. package/dist/adapters/utils.d.ts.map +1 -1
  15. package/dist/adapters/utils.js +2 -1
  16. package/dist/adapters/utils.js.map +1 -1
  17. package/dist/attachment-types.d.ts +1 -16
  18. package/dist/attachment-types.d.ts.map +1 -1
  19. package/dist/attachment-types.js +7 -0
  20. package/dist/attachment-types.js.map +1 -1
  21. package/dist/auth/better-auth.d.ts +3 -1
  22. package/dist/auth/better-auth.d.ts.map +1 -1
  23. package/dist/auth/better-auth.js +8 -2
  24. package/dist/auth/better-auth.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +27 -13
  27. package/dist/index.js.map +1 -1
  28. package/dist/middleware/auth.d.ts.map +1 -1
  29. package/dist/middleware/auth.js +143 -2
  30. package/dist/middleware/auth.js.map +1 -1
  31. package/dist/onboarding-assets/ceo/AGENTS.md +1 -1
  32. package/dist/onboarding-assets/ceo/HEARTBEAT.md +5 -5
  33. package/dist/redaction.d.ts.map +1 -1
  34. package/dist/redaction.js +30 -12
  35. package/dist/redaction.js.map +1 -1
  36. package/dist/routes/access.d.ts.map +1 -1
  37. package/dist/routes/access.js +10 -0
  38. package/dist/routes/access.js.map +1 -1
  39. package/dist/routes/activity.d.ts.map +1 -1
  40. package/dist/routes/activity.js +4 -2
  41. package/dist/routes/activity.js.map +1 -1
  42. package/dist/routes/adapters.d.ts.map +1 -1
  43. package/dist/routes/adapters.js +1 -0
  44. package/dist/routes/adapters.js.map +1 -1
  45. package/dist/routes/agents.d.ts.map +1 -1
  46. package/dist/routes/agents.js +317 -56
  47. package/dist/routes/agents.js.map +1 -1
  48. package/dist/routes/costs.d.ts.map +1 -1
  49. package/dist/routes/costs.js +21 -2
  50. package/dist/routes/costs.js.map +1 -1
  51. package/dist/routes/instance-settings.d.ts.map +1 -1
  52. package/dist/routes/instance-settings.js +37 -2
  53. package/dist/routes/instance-settings.js.map +1 -1
  54. package/dist/routes/issue-tree-control.d.ts.map +1 -1
  55. package/dist/routes/issue-tree-control.js +3 -1
  56. package/dist/routes/issue-tree-control.js.map +1 -1
  57. package/dist/routes/issues.d.ts.map +1 -1
  58. package/dist/routes/issues.js +257 -32
  59. package/dist/routes/issues.js.map +1 -1
  60. package/dist/routes/projects.d.ts.map +1 -1
  61. package/dist/routes/projects.js +10 -3
  62. package/dist/routes/projects.js.map +1 -1
  63. package/dist/routes/routines.d.ts.map +1 -1
  64. package/dist/routes/routines.js +6 -1
  65. package/dist/routes/routines.js.map +1 -1
  66. package/dist/routes/workspace-command-authz.d.ts +1 -1
  67. package/dist/routes/workspace-command-authz.d.ts.map +1 -1
  68. package/dist/routes/workspace-command-authz.js +2 -2
  69. package/dist/routes/workspace-command-authz.js.map +1 -1
  70. package/dist/runtime-api.d.ts +4 -0
  71. package/dist/runtime-api.d.ts.map +1 -1
  72. package/dist/runtime-api.js +38 -10
  73. package/dist/runtime-api.js.map +1 -1
  74. package/dist/services/companies.d.ts +6 -0
  75. package/dist/services/companies.d.ts.map +1 -1
  76. package/dist/services/companies.js +1 -0
  77. package/dist/services/companies.js.map +1 -1
  78. package/dist/services/company-portability.d.ts.map +1 -1
  79. package/dist/services/company-portability.js +16 -15
  80. package/dist/services/company-portability.js.map +1 -1
  81. package/dist/services/costs.d.ts +9 -0
  82. package/dist/services/costs.d.ts.map +1 -1
  83. package/dist/services/costs.js +45 -1
  84. package/dist/services/costs.js.map +1 -1
  85. package/dist/services/environment-execution-target.d.ts.map +1 -1
  86. package/dist/services/environment-execution-target.js +7 -13
  87. package/dist/services/environment-execution-target.js.map +1 -1
  88. package/dist/services/environment-run-orchestrator.d.ts.map +1 -1
  89. package/dist/services/environment-run-orchestrator.js +56 -0
  90. package/dist/services/environment-run-orchestrator.js.map +1 -1
  91. package/dist/services/environment-runtime.d.ts +2 -0
  92. package/dist/services/environment-runtime.d.ts.map +1 -1
  93. package/dist/services/environment-runtime.js +80 -39
  94. package/dist/services/environment-runtime.js.map +1 -1
  95. package/dist/services/heartbeat-stop-metadata.d.ts +2 -1
  96. package/dist/services/heartbeat-stop-metadata.d.ts.map +1 -1
  97. package/dist/services/heartbeat-stop-metadata.js +10 -1
  98. package/dist/services/heartbeat-stop-metadata.js.map +1 -1
  99. package/dist/services/heartbeat-stop-metadata.test.js +24 -0
  100. package/dist/services/heartbeat-stop-metadata.test.js.map +1 -1
  101. package/dist/services/heartbeat.d.ts +156 -5
  102. package/dist/services/heartbeat.d.ts.map +1 -1
  103. package/dist/services/heartbeat.js +1384 -112
  104. package/dist/services/heartbeat.js.map +1 -1
  105. package/dist/services/index.d.ts +1 -0
  106. package/dist/services/index.d.ts.map +1 -1
  107. package/dist/services/index.js +1 -0
  108. package/dist/services/index.js.map +1 -1
  109. package/dist/services/instance-settings.d.ts.map +1 -1
  110. package/dist/services/instance-settings.js +4 -1
  111. package/dist/services/instance-settings.js.map +1 -1
  112. package/dist/services/issue-execution-policy.d.ts +56 -1
  113. package/dist/services/issue-execution-policy.d.ts.map +1 -1
  114. package/dist/services/issue-execution-policy.js +400 -2
  115. package/dist/services/issue-execution-policy.js.map +1 -1
  116. package/dist/services/issue-thread-interactions.d.ts +5 -1
  117. package/dist/services/issue-thread-interactions.d.ts.map +1 -1
  118. package/dist/services/issue-thread-interactions.js +44 -1
  119. package/dist/services/issue-thread-interactions.js.map +1 -1
  120. package/dist/services/issue-tree-control.d.ts +1 -0
  121. package/dist/services/issue-tree-control.d.ts.map +1 -1
  122. package/dist/services/issue-tree-control.js +84 -4
  123. package/dist/services/issue-tree-control.js.map +1 -1
  124. package/dist/services/issues.d.ts +10 -1
  125. package/dist/services/issues.d.ts.map +1 -1
  126. package/dist/services/issues.js +452 -48
  127. package/dist/services/issues.js.map +1 -1
  128. package/dist/services/plugin-environment-driver.d.ts +4 -0
  129. package/dist/services/plugin-environment-driver.d.ts.map +1 -1
  130. package/dist/services/plugin-environment-driver.js +18 -1
  131. package/dist/services/plugin-environment-driver.js.map +1 -1
  132. package/dist/services/productivity-review.d.ts +83 -0
  133. package/dist/services/productivity-review.d.ts.map +1 -0
  134. package/dist/services/productivity-review.js +650 -0
  135. package/dist/services/productivity-review.js.map +1 -0
  136. package/dist/services/recovery/index.d.ts +1 -1
  137. package/dist/services/recovery/index.d.ts.map +1 -1
  138. package/dist/services/recovery/index.js +1 -1
  139. package/dist/services/recovery/index.js.map +1 -1
  140. package/dist/services/recovery/issue-graph-liveness.d.ts +13 -1
  141. package/dist/services/recovery/issue-graph-liveness.d.ts.map +1 -1
  142. package/dist/services/recovery/issue-graph-liveness.js +212 -92
  143. package/dist/services/recovery/issue-graph-liveness.js.map +1 -1
  144. package/dist/services/recovery/origins.d.ts +2 -0
  145. package/dist/services/recovery/origins.d.ts.map +1 -1
  146. package/dist/services/recovery/origins.js +4 -0
  147. package/dist/services/recovery/origins.js.map +1 -1
  148. package/dist/services/recovery/run-liveness-continuations.d.ts.map +1 -1
  149. package/dist/services/recovery/run-liveness-continuations.js.map +1 -1
  150. package/dist/services/recovery/service.d.ts +20 -2
  151. package/dist/services/recovery/service.d.ts.map +1 -1
  152. package/dist/services/recovery/service.js +405 -63
  153. package/dist/services/recovery/service.js.map +1 -1
  154. package/dist/services/routines.d.ts +5 -2
  155. package/dist/services/routines.d.ts.map +1 -1
  156. package/dist/services/routines.js +47 -3
  157. package/dist/services/routines.js.map +1 -1
  158. package/dist/worktree-config.d.ts.map +1 -1
  159. package/dist/worktree-config.js +2 -5
  160. package/dist/worktree-config.js.map +1 -1
  161. package/package.json +16 -15
  162. package/skills/diagnose-why-work-stopped/SKILL.md +161 -0
  163. package/skills/paperclip/SKILL.md +37 -26
  164. package/skills/paperclip/references/api-reference.md +6 -2
  165. package/skills/paperclip-converting-plans-to-tasks/SKILL.md +42 -0
  166. package/skills/paperclip-create-agent/SKILL.md +3 -2
  167. package/skills/paperclip-create-agent/references/agent-instruction-templates.md +1 -1
  168. package/skills/paperclip-create-agent/references/api-reference.md +7 -2
  169. package/skills/paperclip-create-agent/references/baseline-role-guide.md +1 -1
  170. package/skills/paperclip-create-agent/references/draft-review-checklist.md +2 -2
  171. package/skills/paperclip-dev/SKILL.md +267 -0
  172. package/skills/terminal-bench-loop/SKILL.md +236 -0
  173. package/ui-dist/assets/{_basePickBy-BRqa7PJ5.js → _basePickBy-BS0Fg_DB.js} +1 -1
  174. package/ui-dist/assets/{_baseUniq-DhE2yrXC.js → _baseUniq-Dtnt_4SE.js} +1 -1
  175. package/ui-dist/assets/{arc-7qnikTQ3.js → arc-BCoOPxh5.js} +1 -1
  176. package/ui-dist/assets/{architectureDiagram-VXUJARFQ-CH0wVUOM.js → architectureDiagram-VXUJARFQ-C6eX2QUo.js} +1 -1
  177. package/ui-dist/assets/{blockDiagram-VD42YOAC-CeeRyJQX.js → blockDiagram-VD42YOAC-aUueUD4B.js} +1 -1
  178. package/ui-dist/assets/browser-ponyfill-BlAfsWm_.js +2 -0
  179. package/ui-dist/assets/{c4Diagram-YG6GDRKO-C_cV0CGo.js → c4Diagram-YG6GDRKO-CfPWRlOF.js} +1 -1
  180. package/ui-dist/assets/channel-ChNSCFJf.js +1 -0
  181. package/ui-dist/assets/{chunk-4BX2VUAB-DQ6pxPVT.js → chunk-4BX2VUAB-BTD1apA4.js} +1 -1
  182. package/ui-dist/assets/{chunk-55IACEB6-L8pS0IoX.js → chunk-55IACEB6-BXXF_ClN.js} +1 -1
  183. package/ui-dist/assets/{chunk-B4BG7PRW-BZKGE88E.js → chunk-B4BG7PRW-hAZeWGP8.js} +1 -1
  184. package/ui-dist/assets/{chunk-DI55MBZ5-CefSoZ_K.js → chunk-DI55MBZ5-cOH3UoEl.js} +1 -1
  185. package/ui-dist/assets/{chunk-FMBD7UC4-Bc3qTTHB.js → chunk-FMBD7UC4-Cu2yZOcl.js} +1 -1
  186. package/ui-dist/assets/{chunk-QN33PNHL-CjWBr5bI.js → chunk-QN33PNHL-0DNN5aRU.js} +1 -1
  187. package/ui-dist/assets/{chunk-QZHKN3VN-C0JUdmmz.js → chunk-QZHKN3VN-B9_bhK2n.js} +1 -1
  188. package/ui-dist/assets/{chunk-TZMSLE5B-D4d4I82z.js → chunk-TZMSLE5B-Cr5xwxio.js} +1 -1
  189. package/ui-dist/assets/classDiagram-2ON5EDUG-4aK1QZU3.js +1 -0
  190. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-4aK1QZU3.js +1 -0
  191. package/ui-dist/assets/clone-C8lk5Qbc.js +1 -0
  192. package/ui-dist/assets/{cose-bilkent-S5V4N54A-B09h9XGZ.js → cose-bilkent-S5V4N54A-6_Dw6gpQ.js} +1 -1
  193. package/ui-dist/assets/{dagre-6UL2VRFP-CA02PXuX.js → dagre-6UL2VRFP-CFBhlh5H.js} +1 -1
  194. package/ui-dist/assets/{diagram-PSM6KHXK-DaT9cnrY.js → diagram-PSM6KHXK-C88ftcah.js} +1 -1
  195. package/ui-dist/assets/{diagram-QEK2KX5R-Drwc3gBw.js → diagram-QEK2KX5R-9EUupcuH.js} +1 -1
  196. package/ui-dist/assets/{diagram-S2PKOQOG-CpsGCaT6.js → diagram-S2PKOQOG-Dsml0wWh.js} +1 -1
  197. package/ui-dist/assets/{erDiagram-Q2GNP2WA-CVkBh9TY.js → erDiagram-Q2GNP2WA-sM-XdfHS.js} +1 -1
  198. package/ui-dist/assets/{flowDiagram-NV44I4VS-De9sXvPR.js → flowDiagram-NV44I4VS-qll7oaoW.js} +1 -1
  199. package/ui-dist/assets/{ganttDiagram-JELNMOA3-CSFa0gXS.js → ganttDiagram-JELNMOA3-VWnJMcjC.js} +1 -1
  200. package/ui-dist/assets/{gitGraphDiagram-V2S2FVAM-DEJaChxa.js → gitGraphDiagram-V2S2FVAM-DFnocrfl.js} +1 -1
  201. package/ui-dist/assets/{graph-D2R4DCtu.js → graph-nq3Qye4Z.js} +1 -1
  202. package/ui-dist/assets/{index-DEG-9CFs.js → index-3Owzaheh.js} +1 -1
  203. package/ui-dist/assets/{index-DHnKx9xX.js → index-B2A-a635.js} +1 -1
  204. package/ui-dist/assets/{index-C1I0SGDm.js → index-BGFrRiqa.js} +1 -1
  205. package/ui-dist/assets/{index-B44EtLRv.js → index-BVC5UhRK.js} +1 -1
  206. package/ui-dist/assets/{index-C_dAXwxT.js → index-BrP1U_Hy.js} +1 -1
  207. package/ui-dist/assets/{index-flZjKn_n.js → index-CXXHGqM8.js} +1 -1
  208. package/ui-dist/assets/{index-ssM_UKPW.js → index-CgyPAauR.js} +1 -1
  209. package/ui-dist/assets/{index-Ct1AraKR.js → index-CksQ4Ytv.js} +1 -1
  210. package/ui-dist/assets/{index-DQ6I_vpd.js → index-CrNzj2vZ.js} +1 -1
  211. package/ui-dist/assets/{index-DzZID5RY.js → index-CxbZBH3M.js} +1 -1
  212. package/ui-dist/assets/{index-Cn6_RRY5.js → index-D-dSSrf-.js} +1 -1
  213. package/ui-dist/assets/{index-CVa2OHgx.js → index-D6uZ_7Vh.js} +1 -1
  214. package/ui-dist/assets/{index-BzjWQd50.js → index-D7JGmxas.js} +1 -1
  215. package/ui-dist/assets/{index-CnT1_9UF.js → index-DDqO9GAq.js} +1 -1
  216. package/ui-dist/assets/index-DEUtmlPm.js +513 -0
  217. package/ui-dist/assets/{index-D2fEhyQg.js → index-DF5RDSoK.js} +1 -1
  218. package/ui-dist/assets/{index-CZGNe8K3.js → index-DfI92epU.js} +1 -1
  219. package/ui-dist/assets/{index-ByamXtyB.js → index-Dukb9MDQ.js} +1 -1
  220. package/ui-dist/assets/index-HP73_6Vr.css +1 -0
  221. package/ui-dist/assets/{index-BJS4rvUh.js → index-NXDTW2n4.js} +1 -1
  222. package/ui-dist/assets/{index-Bad5Hy7e.js → index-SxPPG9ig.js} +1 -1
  223. package/ui-dist/assets/{index-CC51mhhA.js → index-lC4Yz3Gw.js} +1 -1
  224. package/ui-dist/assets/{index-BFzkl36p.js → index-q2RXGI2V.js} +1 -1
  225. package/ui-dist/assets/{index-40icqWwg.js → index-qjfdrS96.js} +1 -1
  226. package/ui-dist/assets/{infoDiagram-HS3SLOUP-CJcjzWkM.js → infoDiagram-HS3SLOUP-CTrK5xoS.js} +1 -1
  227. package/ui-dist/assets/{journeyDiagram-XKPGCS4Q-ByITI00s.js → journeyDiagram-XKPGCS4Q-YFC7FykG.js} +1 -1
  228. package/ui-dist/assets/{kanban-definition-3W4ZIXB7-DvEjKke-.js → kanban-definition-3W4ZIXB7-B3dlyva0.js} +1 -1
  229. package/ui-dist/assets/{layout-CZcd66hi.js → layout-DefunPTK.js} +1 -1
  230. package/ui-dist/assets/{linear-jTUy3iHu.js → linear-CIPvzeMv.js} +1 -1
  231. package/ui-dist/assets/{mermaid.core-DECSZPbJ.js → mermaid.core-zKYhmnnR.js} +4 -4
  232. package/ui-dist/assets/{mindmap-definition-VGOIOE7T-Twtu17_c.js → mindmap-definition-VGOIOE7T-BlU-ebRa.js} +1 -1
  233. package/ui-dist/assets/{pieDiagram-ADFJNKIX-DlbgZ010.js → pieDiagram-ADFJNKIX-Ceto4LXH.js} +1 -1
  234. package/ui-dist/assets/{quadrantDiagram-AYHSOK5B-CMAa3qAT.js → quadrantDiagram-AYHSOK5B-C6M6hkuE.js} +1 -1
  235. package/ui-dist/assets/{requirementDiagram-UZGBJVZJ-CXRTfJOe.js → requirementDiagram-UZGBJVZJ-B-bcG938.js} +1 -1
  236. package/ui-dist/assets/{sankeyDiagram-TZEHDZUN-DeyO4fer.js → sankeyDiagram-TZEHDZUN-CIqty6Qi.js} +1 -1
  237. package/ui-dist/assets/{sequenceDiagram-WL72ISMW-Ch8wlJIL.js → sequenceDiagram-WL72ISMW-CIt2R5tk.js} +1 -1
  238. package/ui-dist/assets/{stateDiagram-FKZM4ZOC-BgL_AAl9.js → stateDiagram-FKZM4ZOC-BC1RFlfg.js} +1 -1
  239. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-Iy6tYSSw.js +1 -0
  240. package/ui-dist/assets/{timeline-definition-IT6M3QCI-D1QWd7TQ.js → timeline-definition-IT6M3QCI-DZqvoU94.js} +1 -1
  241. package/ui-dist/assets/{treemap-GDKQZRPO-B5RkmUv8.js → treemap-GDKQZRPO-CSeKauwA.js} +1 -1
  242. package/ui-dist/assets/{xychartDiagram-PRI3JC2R-WtDhjZfk.js → xychartDiagram-PRI3JC2R-Ut3mCiEd.js} +1 -1
  243. package/ui-dist/index.html +2 -2
  244. package/ui-dist/locales/en/common.json +137 -1
  245. package/ui-dist/locales/zh-CN/common.json +111 -1
  246. package/ui-dist/assets/browser-ponyfill-Ct3hGqsr.js +0 -2
  247. package/ui-dist/assets/channel-pHFjGZL-.js +0 -1
  248. package/ui-dist/assets/classDiagram-2ON5EDUG-X4ZksqXl.js +0 -1
  249. package/ui-dist/assets/classDiagram-v2-WZHVMYZB-X4ZksqXl.js +0 -1
  250. package/ui-dist/assets/clone-DZzimpfG.js +0 -1
  251. package/ui-dist/assets/index-C1oE3J7o.css +0 -1
  252. package/ui-dist/assets/index-fSIlEIHr.js +0 -510
  253. package/ui-dist/assets/stateDiagram-v2-4FDKWEC3-gnLzrhSv.js +0 -1
@@ -0,0 +1,650 @@
1
+ import { and, asc, desc, eq, gt, inArray, isNull, notInArray, sql } from "drizzle-orm";
2
+ import { clampIssueRequestDepth } from "@penclipai/shared";
3
+ import { agents, companies, costEvents, heartbeatRuns, issueComments, issues, projects, } from "@penclipai/db";
4
+ import { logger } from "../middleware/logger.js";
5
+ import { logActivity } from "./activity-log.js";
6
+ import { budgetService } from "./budgets.js";
7
+ import { issueService } from "./issues.js";
8
+ import { RECOVERY_ORIGIN_KINDS } from "./recovery/origins.js";
9
+ export const PRODUCTIVITY_REVIEW_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.issueProductivityReview;
10
+ export const DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS = 10;
11
+ export const DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS = 6;
12
+ export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY = 10;
13
+ export const DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS = 30;
14
+ export const DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS = 6 * 60 * 60 * 1000;
15
+ export const DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
16
+ export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS = 3;
17
+ export const DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS = 24 * 60 * 60 * 1000;
18
+ export const DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW = 3;
19
+ const TERMINAL_RUN_STATUSES = ["succeeded", "failed", "cancelled", "timed_out"];
20
+ const ACTIVE_RUN_STATUSES = ["queued", "running", "scheduled_retry"];
21
+ const MAX_CANDIDATE_ISSUES = 250;
22
+ const MAX_RUNS_FOR_STREAK = 100;
23
+ const MAX_PARENT_WALK_DEPTH = 25;
24
+ export const PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX = "Productivity review evidence refreshed.";
25
+ function productivityReviewFingerprint(sourceIssueId) {
26
+ return `productivity-review:${sourceIssueId}`;
27
+ }
28
+ function issueRunScopeSql(issueId) {
29
+ return sql `(
30
+ ${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId}
31
+ or ${heartbeatRuns.contextSnapshot}->>'taskId' = ${issueId}
32
+ or ${heartbeatRuns.contextSnapshot}->>'taskKey' = ${issueId}
33
+ )`;
34
+ }
35
+ function msToHuman(ms) {
36
+ if (ms === null)
37
+ return "unknown";
38
+ const minutes = Math.floor(ms / 60_000);
39
+ if (minutes < 60)
40
+ return `${minutes}m`;
41
+ const hours = Math.floor(minutes / 60);
42
+ const days = Math.floor(hours / 24);
43
+ if (days > 0)
44
+ return `${days}d ${hours % 24}h`;
45
+ return `${hours}h ${minutes % 60}m`;
46
+ }
47
+ function issueUiLink(issue, prefix) {
48
+ const label = issue.identifier ?? issue.id;
49
+ return `[${label}](/${prefix}/issues/${label})`;
50
+ }
51
+ function runUiLink(run, prefix) {
52
+ return `[${run.id}](/${prefix}/agents/${run.agentId}/runs/${run.id})`;
53
+ }
54
+ function truncateInline(value, max = 260) {
55
+ if (!value)
56
+ return "";
57
+ const compact = value.replace(/\s+/g, " ").trim();
58
+ return compact.length <= max ? compact : `${compact.slice(0, max - 3)}...`;
59
+ }
60
+ function readPositiveInteger(value, fallback) {
61
+ return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
62
+ }
63
+ function coerceDate(value) {
64
+ if (!value)
65
+ return null;
66
+ return value instanceof Date ? value : new Date(value);
67
+ }
68
+ function buildThresholds(overrides) {
69
+ return {
70
+ noCommentStreakRuns: readPositiveInteger(overrides?.noCommentStreakRuns ?? DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS, DEFAULT_PRODUCTIVITY_REVIEW_NO_COMMENT_STREAK_RUNS),
71
+ longActiveMs: readPositiveInteger(overrides?.longActiveMs ?? DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000, DEFAULT_PRODUCTIVITY_REVIEW_LONG_ACTIVE_HOURS * 60 * 60 * 1000),
72
+ highChurnHourly: readPositiveInteger(overrides?.highChurnHourly ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY, DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_HOURLY),
73
+ highChurnSixHours: readPositiveInteger(overrides?.highChurnSixHours ?? DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS, DEFAULT_PRODUCTIVITY_REVIEW_HIGH_CHURN_SIX_HOURS),
74
+ resolvedSnoozeMs: readPositiveInteger(overrides?.resolvedSnoozeMs ?? DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS, DEFAULT_PRODUCTIVITY_REVIEW_RESOLVED_SNOOZE_MS),
75
+ refreshIntervalMs: readPositiveInteger(overrides?.refreshIntervalMs ?? DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS, DEFAULT_PRODUCTIVITY_REVIEW_REFRESH_INTERVAL_MS),
76
+ maxRefreshComments: readPositiveInteger(overrides?.maxRefreshComments ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS, DEFAULT_PRODUCTIVITY_REVIEW_MAX_REFRESH_COMMENTS),
77
+ creationWindowMs: readPositiveInteger(overrides?.creationWindowMs ?? DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS, DEFAULT_PRODUCTIVITY_REVIEW_CREATION_WINDOW_MS),
78
+ maxCreationsPerWindow: readPositiveInteger(overrides?.maxCreationsPerWindow ?? DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW, DEFAULT_PRODUCTIVITY_REVIEW_MAX_CREATIONS_PER_WINDOW),
79
+ };
80
+ }
81
+ function choosePrimaryTrigger(input) {
82
+ if (input.noComment)
83
+ return "no_comment_streak";
84
+ if (input.highChurn)
85
+ return "high_churn";
86
+ if (input.longActive)
87
+ return "long_active_duration";
88
+ return null;
89
+ }
90
+ function isSoftStopTrigger(trigger) {
91
+ return trigger === "no_comment_streak" || trigger === "high_churn";
92
+ }
93
+ function formatTrigger(trigger) {
94
+ if (trigger === "no_comment_streak")
95
+ return "No-comment streak";
96
+ if (trigger === "high_churn")
97
+ return "High churn";
98
+ return "Long active duration";
99
+ }
100
+ export function productivityReviewService(db, deps) {
101
+ const issuesSvc = issueService(db);
102
+ const budgets = budgetService(db);
103
+ async function getCompanyIssuePrefix(companyId) {
104
+ return db
105
+ .select({ issuePrefix: companies.issuePrefix })
106
+ .from(companies)
107
+ .where(eq(companies.id, companyId))
108
+ .then((rows) => rows[0]?.issuePrefix ?? "PAP");
109
+ }
110
+ async function getAgent(agentId) {
111
+ return db
112
+ .select()
113
+ .from(agents)
114
+ .where(eq(agents.id, agentId))
115
+ .then((rows) => rows[0] ?? null);
116
+ }
117
+ function isAgentInvokable(agent) {
118
+ return Boolean(agent && !["paused", "terminated", "pending_approval"].includes(agent.status));
119
+ }
120
+ async function isProductivityReviewDescendant(issue) {
121
+ let parentId = issue.parentId;
122
+ let depth = 0;
123
+ while (parentId && depth < MAX_PARENT_WALK_DEPTH) {
124
+ const parent = await db
125
+ .select({ id: issues.id, parentId: issues.parentId, originKind: issues.originKind })
126
+ .from(issues)
127
+ .where(and(eq(issues.companyId, issue.companyId), eq(issues.id, parentId)))
128
+ .then((rows) => rows[0] ?? null);
129
+ if (!parent)
130
+ return false;
131
+ if (parent.originKind === PRODUCTIVITY_REVIEW_ORIGIN_KIND)
132
+ return true;
133
+ parentId = parent.parentId;
134
+ depth += 1;
135
+ }
136
+ return false;
137
+ }
138
+ async function findOpenProductivityReview(companyId, sourceIssueId) {
139
+ return db
140
+ .select()
141
+ .from(issues)
142
+ .where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), eq(issues.originId, sourceIssueId), isNull(issues.hiddenAt), notInArray(issues.status, ["done", "cancelled"])))
143
+ .orderBy(desc(issues.updatedAt))
144
+ .limit(1)
145
+ .then((rows) => rows[0] ?? null);
146
+ }
147
+ async function findRecentResolvedProductivityReview(companyId, sourceIssueId, thresholds, now) {
148
+ const cutoff = new Date(now.getTime() - thresholds.resolvedSnoozeMs);
149
+ return db
150
+ .select({ id: issues.id, identifier: issues.identifier, status: issues.status, updatedAt: issues.updatedAt })
151
+ .from(issues)
152
+ .where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), eq(issues.originId, sourceIssueId), eq(issues.status, "done"), gt(issues.updatedAt, cutoff)))
153
+ .orderBy(desc(issues.updatedAt))
154
+ .limit(1)
155
+ .then((rows) => rows[0] ?? null);
156
+ }
157
+ async function countRecentProductivityReviews(companyId, sourceIssueId, thresholds, now) {
158
+ const cutoff = new Date(now.getTime() - thresholds.creationWindowMs);
159
+ return db
160
+ .select({ count: sql `count(*)::int` })
161
+ .from(issues)
162
+ .where(and(eq(issues.companyId, companyId), eq(issues.originKind, PRODUCTIVITY_REVIEW_ORIGIN_KIND), eq(issues.originId, sourceIssueId), isNull(issues.hiddenAt), sql `${issues.status} <> 'cancelled'`, sql `${issues.createdAt} >= ${cutoff.toISOString()}::timestamptz`))
163
+ .then((rows) => Number(rows[0]?.count ?? 0));
164
+ }
165
+ async function getRefreshCommentState(companyId, reviewIssueId) {
166
+ return db
167
+ .select({
168
+ count: sql `count(*)::int`,
169
+ latestCreatedAt: sql `max(${issueComments.createdAt})`,
170
+ })
171
+ .from(issueComments)
172
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, reviewIssueId), sql `${issueComments.body} like ${`${PRODUCTIVITY_REVIEW_REFRESH_COMMENT_PREFIX}%`}`))
173
+ .then((rows) => {
174
+ const row = rows[0];
175
+ return {
176
+ count: Number(row?.count ?? 0),
177
+ latestCreatedAt: coerceDate(row?.latestCreatedAt),
178
+ };
179
+ });
180
+ }
181
+ async function addRefreshComment(reviewIssueId, body, generatedAt) {
182
+ const comment = await issuesSvc.addComment(reviewIssueId, body, {});
183
+ await db
184
+ .update(issueComments)
185
+ .set({ createdAt: generatedAt, updatedAt: generatedAt })
186
+ .where(eq(issueComments.id, comment.id));
187
+ await db
188
+ .update(issues)
189
+ .set({ updatedAt: generatedAt })
190
+ .where(eq(issues.id, reviewIssueId));
191
+ return comment;
192
+ }
193
+ async function countIssueRunsSince(companyId, agentId, issueId, since) {
194
+ return db
195
+ .select({ count: sql `count(*)::int` })
196
+ .from(heartbeatRuns)
197
+ .where(and(eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId), issueRunScopeSql(issueId), sql `coalesce(${heartbeatRuns.startedAt}, ${heartbeatRuns.createdAt}) >= ${since.toISOString()}::timestamptz`))
198
+ .then((rows) => rows[0]?.count ?? 0);
199
+ }
200
+ async function countIssueCommentsSince(companyId, issueId, agentId, since) {
201
+ return db
202
+ .select({ count: sql `count(*)::int` })
203
+ .from(issueComments)
204
+ .innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
205
+ .where(and(eq(issueComments.companyId, companyId), eq(issueComments.issueId, issueId), eq(issueComments.authorAgentId, agentId), eq(heartbeatRuns.companyId, companyId), eq(heartbeatRuns.agentId, agentId), issueRunScopeSql(issueId), since ? sql `${issueComments.createdAt} >= ${since.toISOString()}::timestamptz` : undefined))
206
+ .then((rows) => rows[0]?.count ?? 0);
207
+ }
208
+ async function collectEvidence(sourceIssue, sourceAgent, thresholds, now) {
209
+ const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
210
+ const sixHoursAgo = new Date(now.getTime() - 6 * 60 * 60 * 1000);
211
+ const latestRuns = await db
212
+ .select()
213
+ .from(heartbeatRuns)
214
+ .where(and(eq(heartbeatRuns.companyId, sourceIssue.companyId), eq(heartbeatRuns.agentId, sourceAgent.id), issueRunScopeSql(sourceIssue.id)))
215
+ .orderBy(desc(heartbeatRuns.createdAt), desc(heartbeatRuns.id))
216
+ .limit(MAX_RUNS_FOR_STREAK);
217
+ const runIds = latestRuns.map((run) => run.id);
218
+ const commentRunIds = new Set();
219
+ if (runIds.length > 0) {
220
+ const commentRows = await db
221
+ .select({ createdByRunId: issueComments.createdByRunId })
222
+ .from(issueComments)
223
+ .where(and(eq(issueComments.companyId, sourceIssue.companyId), eq(issueComments.issueId, sourceIssue.id), inArray(issueComments.createdByRunId, runIds)));
224
+ for (const row of commentRows) {
225
+ if (row.createdByRunId)
226
+ commentRunIds.add(row.createdByRunId);
227
+ }
228
+ }
229
+ const terminalRuns = latestRuns.filter((run) => TERMINAL_RUN_STATUSES.includes(run.status));
230
+ let noCommentStreak = 0;
231
+ for (const run of terminalRuns) {
232
+ if (commentRunIds.has(run.id))
233
+ break;
234
+ noCommentStreak += 1;
235
+ }
236
+ const [runCountLastHour, runCountLastSixHours, assigneeRunCommentCount, assigneeRunCommentCountLastHour, assigneeRunCommentCountLastSixHours, latestComments, costRow,] = await Promise.all([
237
+ countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, oneHourAgo),
238
+ countIssueRunsSince(sourceIssue.companyId, sourceAgent.id, sourceIssue.id, sixHoursAgo),
239
+ countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id),
240
+ countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, oneHourAgo),
241
+ countIssueCommentsSince(sourceIssue.companyId, sourceIssue.id, sourceAgent.id, sixHoursAgo),
242
+ db
243
+ .select({ comment: issueComments })
244
+ .from(issueComments)
245
+ .innerJoin(heartbeatRuns, eq(heartbeatRuns.id, issueComments.createdByRunId))
246
+ .where(and(eq(issueComments.companyId, sourceIssue.companyId), eq(issueComments.issueId, sourceIssue.id), eq(issueComments.authorAgentId, sourceAgent.id), eq(heartbeatRuns.companyId, sourceIssue.companyId), eq(heartbeatRuns.agentId, sourceAgent.id), issueRunScopeSql(sourceIssue.id)))
247
+ .orderBy(desc(issueComments.createdAt), desc(issueComments.id))
248
+ .limit(5)
249
+ .then((rows) => rows.map((row) => row.comment)),
250
+ db
251
+ .select({ costCents: sql `coalesce(sum(${costEvents.costCents}), 0)::int` })
252
+ .from(costEvents)
253
+ .where(and(eq(costEvents.companyId, sourceIssue.companyId), eq(costEvents.issueId, sourceIssue.id)))
254
+ .then((rows) => rows[0] ?? { costCents: 0 }),
255
+ ]);
256
+ const activeRunCount = latestRuns.filter((run) => ACTIVE_RUN_STATUSES.includes(run.status)).length;
257
+ const activeStartedAt = sourceIssue.startedAt ?? sourceIssue.executionLockedAt ?? null;
258
+ const elapsedMs = sourceIssue.status === "in_progress" && activeStartedAt
259
+ ? Math.max(0, now.getTime() - activeStartedAt.getTime())
260
+ : null;
261
+ const noComment = noCommentStreak >= thresholds.noCommentStreakRuns;
262
+ const longActive = elapsedMs !== null && elapsedMs >= thresholds.longActiveMs;
263
+ const highChurn = runCountLastHour >= thresholds.highChurnHourly ||
264
+ assigneeRunCommentCountLastHour >= thresholds.highChurnHourly ||
265
+ runCountLastSixHours >= thresholds.highChurnSixHours ||
266
+ assigneeRunCommentCountLastSixHours >= thresholds.highChurnSixHours;
267
+ const trigger = choosePrimaryTrigger({ noComment, longActive, highChurn });
268
+ if (!trigger)
269
+ return null;
270
+ const triggerReasons = [];
271
+ if (noComment)
272
+ triggerReasons.push(`${noCommentStreak} consecutive completed issue-linked runs had no run-created issue comment`);
273
+ if (longActive)
274
+ triggerReasons.push(`current active episode has lasted ${msToHuman(elapsedMs)}`);
275
+ if (highChurn) {
276
+ triggerReasons.push(`${runCountLastHour} runs/${assigneeRunCommentCountLastHour} assignee-run comments in 1h; ${runCountLastSixHours} runs/${assigneeRunCommentCountLastSixHours} assignee-run comments in 6h`);
277
+ }
278
+ return {
279
+ trigger,
280
+ triggerReasons,
281
+ sourceIssue,
282
+ sourceAgent,
283
+ noCommentStreak,
284
+ totalRunCount: latestRuns.length,
285
+ terminalRunCount: terminalRuns.length,
286
+ activeRunCount,
287
+ runCountLastHour,
288
+ runCountLastSixHours,
289
+ commentCount: assigneeRunCommentCount,
290
+ commentCountLastHour: assigneeRunCommentCountLastHour,
291
+ commentCountLastSixHours: assigneeRunCommentCountLastSixHours,
292
+ elapsedMs,
293
+ latestRuns: latestRuns.slice(0, 5),
294
+ latestComments,
295
+ costCents: costRow.costCents,
296
+ usageSamples: latestRuns
297
+ .filter((run) => run.usageJson)
298
+ .slice(0, 3)
299
+ .map((run) => ({ runId: run.id, usageJson: run.usageJson ?? null })),
300
+ nextAction: latestRuns.find((run) => run.nextAction)?.nextAction ?? null,
301
+ thresholds,
302
+ generatedAt: now,
303
+ };
304
+ }
305
+ async function resolveReviewOwnerAgentId(sourceIssue, sourceAgent) {
306
+ const candidateIds = [];
307
+ if (sourceAgent.reportsTo)
308
+ candidateIds.push(sourceAgent.reportsTo);
309
+ if (sourceIssue.createdByAgentId)
310
+ candidateIds.push(sourceIssue.createdByAgentId);
311
+ if (sourceIssue.projectId) {
312
+ const project = await db
313
+ .select({ leadAgentId: projects.leadAgentId })
314
+ .from(projects)
315
+ .where(and(eq(projects.companyId, sourceIssue.companyId), eq(projects.id, sourceIssue.projectId)))
316
+ .then((rows) => rows[0] ?? null);
317
+ if (project?.leadAgentId)
318
+ candidateIds.push(project.leadAgentId);
319
+ }
320
+ const roleCandidates = await db
321
+ .select({ id: agents.id })
322
+ .from(agents)
323
+ .where(and(eq(agents.companyId, sourceIssue.companyId), inArray(agents.role, ["cto", "ceo"])))
324
+ .orderBy(sql `case when ${agents.role} = 'cto' then 0 else 1 end`, asc(agents.createdAt), asc(agents.id));
325
+ candidateIds.push(...roleCandidates.map((agent) => agent.id));
326
+ const seen = new Set();
327
+ for (const agentId of candidateIds) {
328
+ if (seen.has(agentId))
329
+ continue;
330
+ seen.add(agentId);
331
+ const candidate = await getAgent(agentId);
332
+ if (!candidate || candidate.companyId !== sourceIssue.companyId || !isAgentInvokable(candidate))
333
+ continue;
334
+ const budgetBlock = await budgets.getInvocationBlock(sourceIssue.companyId, candidate.id, {
335
+ issueId: sourceIssue.id,
336
+ projectId: sourceIssue.projectId ?? null,
337
+ });
338
+ if (!budgetBlock)
339
+ return candidate.id;
340
+ }
341
+ return null;
342
+ }
343
+ function buildReviewMarkdown(evidence, prefix) {
344
+ const latestRuns = evidence.latestRuns.length > 0
345
+ ? evidence.latestRuns.map((run) => `- ${runUiLink(run, prefix)} \`${run.status}\` liveness \`${run.livenessState ?? "unknown"}\`, created ${run.createdAt.toISOString()}${run.nextAction ? `, next action: ${truncateInline(run.nextAction, 160)}` : ""}`).join("\n")
346
+ : "- none";
347
+ const latestComments = evidence.latestComments.length > 0
348
+ ? evidence.latestComments.map((comment) => `- ${comment.createdAt.toISOString()}${comment.createdByRunId ? ` run \`${comment.createdByRunId}\`` : ""}: ${truncateInline(comment.body)}`).join("\n")
349
+ : "- none";
350
+ const usage = evidence.usageSamples.length > 0
351
+ ? evidence.usageSamples.map((sample) => `- \`${sample.runId}\`: \`${JSON.stringify(sample.usageJson).slice(0, 500)}\``).join("\n")
352
+ : "- no usage payloads on sampled runs";
353
+ return [
354
+ "Paperclip detected an unusual productivity/progression pattern on an assigned issue.",
355
+ "",
356
+ "## Source",
357
+ "",
358
+ `- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
359
+ `- Assigned agent: ${evidence.sourceAgent.name} (${evidence.sourceAgent.role})`,
360
+ `- Primary trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
361
+ `- Trigger reasons: ${evidence.triggerReasons.join("; ")}`,
362
+ `- Generated at: ${evidence.generatedAt.toISOString()}`,
363
+ "",
364
+ "## Evidence",
365
+ "",
366
+ `- Total sampled issue-linked runs: ${evidence.totalRunCount}`,
367
+ `- Terminal sampled runs: ${evidence.terminalRunCount}`,
368
+ `- Active queued/running/scheduled runs: ${evidence.activeRunCount}`,
369
+ `- No-comment completed-run streak: ${evidence.noCommentStreak}`,
370
+ `- Current active elapsed time: ${msToHuman(evidence.elapsedMs)}`,
371
+ `- Runs in rolling windows: ${evidence.runCountLastHour}/1h, ${evidence.runCountLastSixHours}/6h`,
372
+ `- Assignee run-linked comments total/window: ${evidence.commentCount} total, ${evidence.commentCountLastHour}/1h, ${evidence.commentCountLastSixHours}/6h`,
373
+ `- Cost events total: ${evidence.costCents} cents`,
374
+ `- Current next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 500) : "none recorded"}`,
375
+ "",
376
+ "## Thresholds",
377
+ "",
378
+ `- No-comment streak: ${evidence.thresholds.noCommentStreakRuns} completed runs`,
379
+ `- Long active duration: ${msToHuman(evidence.thresholds.longActiveMs)}`,
380
+ `- High churn: ${evidence.thresholds.highChurnHourly}/1h or ${evidence.thresholds.highChurnSixHours}/6h runs/assignee-run comments`,
381
+ `- Resolved-review snooze: ${msToHuman(evidence.thresholds.resolvedSnoozeMs)}`,
382
+ "",
383
+ "## Latest Runs",
384
+ "",
385
+ latestRuns,
386
+ "",
387
+ "## Latest Assignee Run Comments",
388
+ "",
389
+ latestComments,
390
+ "",
391
+ "## Usage Samples",
392
+ "",
393
+ usage,
394
+ "",
395
+ "## Manager Decision",
396
+ "",
397
+ "- Close as productive if this pattern is expected.",
398
+ "- Continue with a snooze window if the current work should keep running without repeat review spam.",
399
+ "- Request decomposition, reroute, block with an unblock owner, or stop/cancel the source work if the work is inefficient.",
400
+ ].join("\n");
401
+ }
402
+ function buildRefreshComment(evidence, prefix) {
403
+ return [
404
+ "Productivity review evidence refreshed.",
405
+ "",
406
+ `- Source issue: ${issueUiLink(evidence.sourceIssue, prefix)}`,
407
+ `- Trigger: \`${evidence.trigger}\` (${formatTrigger(evidence.trigger)})`,
408
+ `- Reasons: ${evidence.triggerReasons.join("; ")}`,
409
+ `- No-comment streak: ${evidence.noCommentStreak}`,
410
+ `- Runs/assignee comments: ${evidence.runCountLastHour}/${evidence.commentCountLastHour} in 1h, ${evidence.runCountLastSixHours}/${evidence.commentCountLastSixHours} in 6h`,
411
+ `- Next action: ${evidence.nextAction ? truncateInline(evidence.nextAction, 300) : "none recorded"}`,
412
+ ].join("\n");
413
+ }
414
+ async function createOrUpdateReview(evidence, opts) {
415
+ const existing = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
416
+ if (existing) {
417
+ const refreshState = await getRefreshCommentState(evidence.sourceIssue.companyId, existing.id);
418
+ const lastRefreshOrCreationAt = refreshState.latestCreatedAt ?? existing.createdAt;
419
+ if (refreshState.count >= opts.thresholds.maxRefreshComments ||
420
+ evidence.generatedAt.getTime() - lastRefreshOrCreationAt.getTime() < opts.thresholds.refreshIntervalMs) {
421
+ return { kind: "existing", reviewIssueId: existing.id };
422
+ }
423
+ await addRefreshComment(existing.id, buildRefreshComment(evidence, opts.prefix), evidence.generatedAt);
424
+ await logActivity(db, {
425
+ companyId: evidence.sourceIssue.companyId,
426
+ actorType: "system",
427
+ actorId: "system",
428
+ action: "issue.productivity_review_updated",
429
+ entityType: "issue",
430
+ entityId: existing.id,
431
+ agentId: existing.assigneeAgentId,
432
+ details: {
433
+ source: "productivity_review.reconcile",
434
+ sourceIssueId: evidence.sourceIssue.id,
435
+ trigger: evidence.trigger,
436
+ noCommentStreak: evidence.noCommentStreak,
437
+ runCountLastHour: evidence.runCountLastHour,
438
+ commentCountLastHour: evidence.commentCountLastHour,
439
+ },
440
+ });
441
+ return { kind: "updated", reviewIssueId: existing.id };
442
+ }
443
+ const recentCreationCount = await countRecentProductivityReviews(evidence.sourceIssue.companyId, evidence.sourceIssue.id, opts.thresholds, evidence.generatedAt);
444
+ if (recentCreationCount >= opts.thresholds.maxCreationsPerWindow) {
445
+ return { kind: "creation_capped", reviewIssueId: null };
446
+ }
447
+ const ownerAgentId = await resolveReviewOwnerAgentId(evidence.sourceIssue, evidence.sourceAgent);
448
+ let review;
449
+ try {
450
+ review = await issuesSvc.create(evidence.sourceIssue.companyId, {
451
+ title: `Review productivity for ${evidence.sourceIssue.identifier ?? evidence.sourceIssue.title}`,
452
+ description: buildReviewMarkdown(evidence, opts.prefix),
453
+ status: "todo",
454
+ priority: evidence.trigger === "long_active_duration" ? "medium" : "high",
455
+ parentId: evidence.sourceIssue.id,
456
+ projectId: evidence.sourceIssue.projectId,
457
+ goalId: evidence.sourceIssue.goalId,
458
+ billingCode: evidence.sourceIssue.billingCode,
459
+ assigneeAgentId: ownerAgentId,
460
+ originKind: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
461
+ originId: evidence.sourceIssue.id,
462
+ originFingerprint: productivityReviewFingerprint(evidence.sourceIssue.id),
463
+ requestDepth: clampIssueRequestDepth(evidence.sourceIssue.requestDepth + 1),
464
+ });
465
+ }
466
+ catch (error) {
467
+ const maybe = error;
468
+ const uniqueConflict = maybe.code === "23505" &&
469
+ (maybe.constraint === "issues_active_productivity_review_uq" ||
470
+ typeof maybe.message === "string" && maybe.message.includes("issues_active_productivity_review_uq"));
471
+ if (!uniqueConflict)
472
+ throw error;
473
+ const raced = await findOpenProductivityReview(evidence.sourceIssue.companyId, evidence.sourceIssue.id);
474
+ if (!raced)
475
+ throw error;
476
+ return { kind: "existing", reviewIssueId: raced.id };
477
+ }
478
+ await db
479
+ .update(issues)
480
+ .set({ createdAt: evidence.generatedAt, updatedAt: evidence.generatedAt })
481
+ .where(eq(issues.id, review.id));
482
+ await logActivity(db, {
483
+ companyId: evidence.sourceIssue.companyId,
484
+ actorType: "system",
485
+ actorId: "system",
486
+ action: "issue.productivity_review_created",
487
+ entityType: "issue",
488
+ entityId: review.id,
489
+ agentId: ownerAgentId,
490
+ details: {
491
+ source: "productivity_review.reconcile",
492
+ sourceIssueId: evidence.sourceIssue.id,
493
+ trigger: evidence.trigger,
494
+ noCommentStreak: evidence.noCommentStreak,
495
+ runCountLastHour: evidence.runCountLastHour,
496
+ commentCountLastHour: evidence.commentCountLastHour,
497
+ },
498
+ });
499
+ if (ownerAgentId && deps?.enqueueWakeup) {
500
+ await deps.enqueueWakeup(ownerAgentId, {
501
+ source: "assignment",
502
+ triggerDetail: "system",
503
+ reason: "issue_assigned",
504
+ payload: {
505
+ issueId: review.id,
506
+ sourceIssueId: evidence.sourceIssue.id,
507
+ trigger: evidence.trigger,
508
+ },
509
+ requestedByActorType: "system",
510
+ requestedByActorId: "productivity_review",
511
+ contextSnapshot: {
512
+ issueId: review.id,
513
+ taskId: review.id,
514
+ wakeReason: "issue_assigned",
515
+ source: PRODUCTIVITY_REVIEW_ORIGIN_KIND,
516
+ sourceIssueId: evidence.sourceIssue.id,
517
+ productivityReviewTrigger: evidence.trigger,
518
+ },
519
+ });
520
+ }
521
+ return { kind: "created", reviewIssueId: review.id };
522
+ }
523
+ async function reconcileProductivityReviews(opts) {
524
+ const now = opts?.now ?? new Date();
525
+ const thresholds = buildThresholds(opts?.thresholds);
526
+ const candidates = await db
527
+ .select()
528
+ .from(issues)
529
+ .where(and(opts?.companyId ? eq(issues.companyId, opts.companyId) : undefined, isNull(issues.hiddenAt), isNull(issues.assigneeUserId), inArray(issues.status, ["todo", "in_progress"]), sql `${issues.assigneeAgentId} is not null`, sql `${issues.originKind} <> ${PRODUCTIVITY_REVIEW_ORIGIN_KIND}`))
530
+ .orderBy(asc(issues.updatedAt), asc(issues.id))
531
+ .limit(MAX_CANDIDATE_ISSUES);
532
+ const result = {
533
+ scanned: candidates.length,
534
+ created: 0,
535
+ updated: 0,
536
+ existing: 0,
537
+ snoozed: 0,
538
+ creationCapped: 0,
539
+ skipped: 0,
540
+ failed: 0,
541
+ reviewIssueIds: [],
542
+ failedIssueIds: [],
543
+ };
544
+ const prefixCache = new Map();
545
+ for (const candidate of candidates) {
546
+ if (!candidate.assigneeAgentId) {
547
+ result.skipped += 1;
548
+ continue;
549
+ }
550
+ if (await isProductivityReviewDescendant(candidate)) {
551
+ result.skipped += 1;
552
+ continue;
553
+ }
554
+ if (await findRecentResolvedProductivityReview(candidate.companyId, candidate.id, thresholds, now)) {
555
+ result.snoozed += 1;
556
+ continue;
557
+ }
558
+ const sourceAgent = await getAgent(candidate.assigneeAgentId);
559
+ if (!sourceAgent || sourceAgent.companyId !== candidate.companyId) {
560
+ result.skipped += 1;
561
+ continue;
562
+ }
563
+ const evidence = await collectEvidence(candidate, sourceAgent, thresholds, now);
564
+ if (!evidence) {
565
+ result.skipped += 1;
566
+ continue;
567
+ }
568
+ let prefix = prefixCache.get(candidate.companyId);
569
+ if (!prefix) {
570
+ prefix = await getCompanyIssuePrefix(candidate.companyId);
571
+ prefixCache.set(candidate.companyId, prefix);
572
+ }
573
+ try {
574
+ const outcome = await createOrUpdateReview(evidence, { prefix, thresholds });
575
+ if (outcome.kind === "created")
576
+ result.created += 1;
577
+ else if (outcome.kind === "updated")
578
+ result.updated += 1;
579
+ else if (outcome.kind === "creation_capped")
580
+ result.creationCapped += 1;
581
+ else
582
+ result.existing += 1;
583
+ if (outcome.reviewIssueId)
584
+ result.reviewIssueIds.push(outcome.reviewIssueId);
585
+ }
586
+ catch (err) {
587
+ result.failed += 1;
588
+ result.failedIssueIds.push(candidate.id);
589
+ logger.warn({
590
+ err,
591
+ companyId: candidate.companyId,
592
+ issueId: candidate.id,
593
+ requestDepth: candidate.requestDepth,
594
+ }, "productivity review reconciliation skipped malformed candidate");
595
+ }
596
+ }
597
+ return result;
598
+ }
599
+ async function isProductivityReviewContinuationHoldActive(input) {
600
+ const now = input.now ?? new Date();
601
+ const thresholds = buildThresholds(input.thresholds);
602
+ const [sourceIssue, sourceAgent, openReview] = await Promise.all([
603
+ db
604
+ .select()
605
+ .from(issues)
606
+ .where(and(eq(issues.companyId, input.companyId), eq(issues.id, input.issueId)))
607
+ .then((rows) => rows[0] ?? null),
608
+ getAgent(input.agentId),
609
+ findOpenProductivityReview(input.companyId, input.issueId),
610
+ ]);
611
+ if (!sourceIssue || !sourceAgent || !openReview)
612
+ return { held: false };
613
+ if (sourceAgent.companyId !== input.companyId)
614
+ return { held: false };
615
+ const evidence = await collectEvidence(sourceIssue, sourceAgent, thresholds, now);
616
+ if (!evidence || !isSoftStopTrigger(evidence.trigger))
617
+ return { held: false };
618
+ return {
619
+ held: true,
620
+ reviewIssueId: openReview.id,
621
+ reviewIdentifier: openReview.identifier,
622
+ trigger: evidence.trigger,
623
+ reason: evidence.triggerReasons.join("; "),
624
+ };
625
+ }
626
+ async function recordContinuationHold(input) {
627
+ await logActivity(db, {
628
+ companyId: input.companyId,
629
+ actorType: "system",
630
+ actorId: "system",
631
+ agentId: input.agentId,
632
+ runId: input.runId,
633
+ action: "issue.productivity_review_continuation_held",
634
+ entityType: "issue",
635
+ entityId: input.issueId,
636
+ details: {
637
+ source: "productivity_review.continuation_hold",
638
+ reviewIssueId: input.reviewIssueId,
639
+ trigger: input.trigger,
640
+ reason: input.reason,
641
+ },
642
+ });
643
+ }
644
+ return {
645
+ reconcileProductivityReviews,
646
+ isProductivityReviewContinuationHoldActive,
647
+ recordContinuationHold,
648
+ };
649
+ }
650
+ //# sourceMappingURL=productivity-review.js.map