agentbox-app 0.1.13 → 0.1.16

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 (278) hide show
  1. package/README.md +24 -9
  2. package/bin/agentbox.mjs +23 -1
  3. package/briefs/message-rules.md +36 -8
  4. package/briefs/worker.md +46 -23
  5. package/main/account-discovery.mjs +122 -30
  6. package/main/account-tooling.mjs +29 -1
  7. package/main/account-usage.mjs +46 -0
  8. package/main/agent-sessions.mjs +282 -268
  9. package/main/agents.mjs +179 -466
  10. package/main/analytics.mjs +12 -2
  11. package/main/answer-modes.mjs +103 -37
  12. package/main/approval-prompt-core.mjs +197 -0
  13. package/main/approval-prompt-server.mjs +4 -138
  14. package/main/approvals.mjs +101 -297
  15. package/main/artifact-path.mjs +47 -18
  16. package/main/claude-input.mjs +48 -4
  17. package/main/claude-plan.mjs +48 -0
  18. package/main/claude-session-open.mjs +28 -0
  19. package/main/claude-usage-process.mjs +40 -0
  20. package/main/claude-usage.mjs +18 -40
  21. package/main/cloud/account.mjs +179 -0
  22. package/main/cloud/aws.mjs +127 -0
  23. package/main/cloud/browser-return.mjs +58 -0
  24. package/main/cloud/clients.mjs +15 -0
  25. package/main/cloud/codex-cloud.mjs +53 -0
  26. package/main/cloud/config.mjs +107 -0
  27. package/main/cloud/docs.mjs +219 -0
  28. package/main/cloud/exec.mjs +27 -0
  29. package/main/cloud/host.mjs +165 -0
  30. package/main/cloud/kit.mjs +80 -0
  31. package/main/cloud/logins.mjs +543 -0
  32. package/main/cloud/managed.mjs +115 -0
  33. package/main/cloud/marker.mjs +104 -0
  34. package/main/cloud/org.mjs +53 -0
  35. package/main/cloud/reuse.mjs +208 -0
  36. package/main/cloud/run-cli.mjs +177 -0
  37. package/main/cloud/run.mjs +1934 -0
  38. package/main/cloud/usage.mjs +76 -0
  39. package/main/cloud-permission.mjs +51 -0
  40. package/main/codex-account.mjs +78 -0
  41. package/main/codex-app-server.mjs +276 -409
  42. package/main/codex-approvals.mjs +148 -433
  43. package/main/codex-models.mjs +20 -2
  44. package/main/codex-session.mjs +212 -507
  45. package/main/codex-threads-worker.mjs +12 -0
  46. package/main/codex-threads.mjs +71 -3
  47. package/main/codex-usage-file.mjs +5 -3
  48. package/main/codex-usage.mjs +56 -7
  49. package/main/codex-watch.mjs +17 -3
  50. package/main/codex.mjs +95 -363
  51. package/main/config.mjs +11 -1
  52. package/main/corner-tag.mjs +340 -19
  53. package/main/dev-window.mjs +1 -1
  54. package/main/engine-bin.mjs +232 -282
  55. package/main/engine-setup.mjs +21 -9
  56. package/main/feedback.mjs +3 -25
  57. package/main/folder-pictures.mjs +2 -2
  58. package/main/freeze-recorder-thread.mjs +86 -0
  59. package/main/freeze-recorder.mjs +146 -0
  60. package/main/fresh-user.mjs +20 -9
  61. package/main/git-change-offthread.mjs +17 -3
  62. package/main/git-change-thread.mjs +5 -2
  63. package/main/harnesses/claude.mjs +32 -11
  64. package/main/harnesses/codex.mjs +9 -2
  65. package/main/importable-threads.mjs +64 -6
  66. package/main/ipc.mjs +362 -111
  67. package/main/leftovers.mjs +117 -25
  68. package/main/liquid-glass.mjs +26 -0
  69. package/main/live-replies.mjs +10 -4
  70. package/main/main-went-red.mjs +330 -0
  71. package/main/main.mjs +199 -84
  72. package/main/memory-gate-history.mjs +36 -10
  73. package/main/memory-gate-server.mjs +62 -0
  74. package/main/memory-gate.mjs +14 -1
  75. package/main/menu.mjs +4 -2
  76. package/main/message-priority.mjs +2 -2
  77. package/main/mic.mjs +38 -0
  78. package/main/native/liquid-glass/build/liquid-glass.node +0 -0
  79. package/main/new-chats-watch.mjs +110 -0
  80. package/main/new-user-window.mjs +25 -0
  81. package/main/notify.mjs +24 -8
  82. package/main/phone-alerts.mjs +117 -0
  83. package/main/phone-inbox.mjs +625 -0
  84. package/main/phone-link.mjs +29 -9
  85. package/main/phone-uploads.mjs +476 -0
  86. package/main/picture-shape.mjs +114 -0
  87. package/main/quit-sequence.mjs +143 -0
  88. package/main/refresh-agent-models.mjs +15 -2
  89. package/main/relay-client.mjs +69 -1
  90. package/main/repeats.mjs +10 -4
  91. package/main/row-label.mjs +12 -41
  92. package/main/run-change.mjs +13 -0
  93. package/main/run-ledger.mjs +135 -0
  94. package/main/save-attachments.mjs +41 -0
  95. package/main/serve.mjs +45 -3
  96. package/main/settings.mjs +220 -44
  97. package/main/shell-path.mjs +185 -37
  98. package/main/ship-conflict-brief.mjs +119 -0
  99. package/main/ship-queue.mjs +115 -19
  100. package/main/source-updater.mjs +18 -10
  101. package/main/stage-attachment.mjs +68 -0
  102. package/main/stall-watch.mjs +50 -0
  103. package/main/store/home.mjs +87 -232
  104. package/main/store/paths.mjs +11 -0
  105. package/main/store/teammates-files.mjs +99 -0
  106. package/main/store/work-items.mjs +231 -13
  107. package/main/store.mjs +226 -52
  108. package/main/supervisor.mjs +3103 -341
  109. package/main/task-folders.mjs +430 -30
  110. package/main/task-terminals.mjs +110 -34
  111. package/main/team/index.mjs +114 -10
  112. package/main/team/memory-cloud.mjs +183 -1
  113. package/main/team/room-hours.mjs +48 -0
  114. package/main/team/room-setup.mjs +31 -0
  115. package/main/team/rooms-memory.mjs +454 -0
  116. package/main/team/rooms-shape.mjs +42 -0
  117. package/main/team/rooms-supabase.mjs +324 -0
  118. package/main/team/rooms.mjs +575 -0
  119. package/main/team/session.mjs +96 -26
  120. package/main/team/supabase-backend.mjs +180 -0
  121. package/main/team/sync.mjs +109 -1
  122. package/main/teammates.mjs +580 -0
  123. package/main/terminal-host.mjs +143 -0
  124. package/main/terminal-hosts.mjs +261 -0
  125. package/main/transcript-search.mjs +80 -0
  126. package/main/webview-guard.mjs +45 -0
  127. package/main/worktree-include.mjs +90 -219
  128. package/mcp/core/documents.mjs +3 -1
  129. package/mcp/core/repeats.mjs +88 -0
  130. package/mcp/core/teammates.mjs +222 -0
  131. package/mcp/core/work.mjs +76 -4
  132. package/mcp/stdio.mjs +24 -11
  133. package/mcp/tools.mjs +116 -8
  134. package/package.json +21 -5
  135. package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
  136. package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
  137. package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
  138. package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
  139. package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
  140. package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
  141. package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
  142. package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
  143. package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
  144. package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
  145. package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
  146. package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
  147. package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
  148. package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
  149. package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
  150. package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
  151. package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
  152. package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
  153. package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
  154. package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
  155. package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
  156. package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
  157. package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
  158. package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
  159. package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
  160. package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
  161. package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
  162. package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
  163. package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
  164. package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
  165. package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
  166. package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
  167. package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
  168. package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
  169. package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
  170. package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
  171. package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
  172. package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
  173. package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
  174. package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
  175. package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
  176. package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
  177. package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
  178. package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
  179. package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
  180. package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
  181. package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
  182. package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
  183. package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
  184. package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
  185. package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
  186. package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
  187. package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
  188. package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
  189. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
  190. package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
  191. package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
  192. package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
  193. package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
  194. package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
  195. package/renderer/dist/corner-tag.html +3 -3
  196. package/renderer/dist/index.html +3 -3
  197. package/scripts/after-install.mjs +30 -8
  198. package/scripts/cloud-shim.pl +57 -0
  199. package/shared/account-names.d.mts +6 -0
  200. package/shared/account-names.mjs +15 -0
  201. package/shared/agent-hours.mjs +136 -0
  202. package/shared/agent-import.mjs +60 -34
  203. package/shared/agents.mjs +126 -522
  204. package/shared/analytics-events.mjs +12 -0
  205. package/shared/approval-card.mjs +36 -0
  206. package/shared/bridge-map.mjs +27 -1
  207. package/shared/claude-code-shape.mjs +6 -0
  208. package/shared/claude-usage.d.mts +1 -0
  209. package/shared/claude-usage.mjs +42 -1
  210. package/shared/cloud-hand-back.d.mts +7 -0
  211. package/shared/cloud-hand-back.mjs +20 -0
  212. package/shared/cloud-kit.d.mts +219 -0
  213. package/shared/cloud-kit.mjs +341 -0
  214. package/shared/cloud-login-name.mjs +4 -0
  215. package/shared/cloud-start.mjs +17 -0
  216. package/shared/cloud-versions.mjs +23 -0
  217. package/shared/codex-import.mjs +2 -2
  218. package/shared/codex-modes.d.mts +22 -9
  219. package/shared/codex-modes.mjs +67 -11
  220. package/shared/codex-usage.mjs +108 -2
  221. package/shared/corner-tag.d.mts +23 -1
  222. package/shared/corner-tag.mjs +284 -29
  223. package/shared/dead-run-trace.mjs +19 -2
  224. package/shared/engines.mjs +70 -309
  225. package/shared/feedback.d.mts +7 -0
  226. package/shared/feedback.mjs +25 -0
  227. package/shared/first-run-practice.mjs +17 -24
  228. package/shared/harness-definitions.mjs +8 -0
  229. package/shared/instruction-defaults.json +2 -2
  230. package/shared/key-caps.d.mts +14 -0
  231. package/shared/key-caps.mjs +68 -0
  232. package/shared/message-edits.d.mts +7 -0
  233. package/shared/message-edits.mjs +35 -0
  234. package/shared/move-thread.d.mts +24 -0
  235. package/shared/move-thread.mjs +112 -0
  236. package/shared/named-threads.mjs +72 -0
  237. package/shared/not-shipped.d.mts +22 -0
  238. package/shared/not-shipped.mjs +89 -0
  239. package/shared/notify-rules.d.mts +6 -0
  240. package/shared/notify-rules.mjs +36 -8
  241. package/shared/phone-files.mjs +28 -0
  242. package/shared/phone-settings.mjs +118 -0
  243. package/shared/phone-snapshot.d.mts +16 -0
  244. package/shared/phone-snapshot.mjs +348 -0
  245. package/shared/picture-formats.d.mts +1 -0
  246. package/shared/picture-formats.mjs +13 -0
  247. package/shared/rank.d.mts +2 -1
  248. package/shared/rank.mjs +32 -19
  249. package/shared/repeats.d.mts +8 -0
  250. package/shared/repeats.mjs +33 -2
  251. package/shared/room-cards.mjs +167 -0
  252. package/shared/room-links.d.mts +4 -0
  253. package/shared/room-links.mjs +21 -0
  254. package/shared/runs-on.d.mts +3 -3
  255. package/shared/runs-on.mjs +8 -5
  256. package/shared/shipped-note.d.mts +4 -0
  257. package/shared/shipped-note.mjs +6 -0
  258. package/shared/shortcuts.mjs +14 -6
  259. package/shared/spawn-trouble.d.mts +4 -0
  260. package/shared/spawn-trouble.mjs +443 -40
  261. package/shared/team-rules.mjs +9 -2
  262. package/shared/teammates.d.mts +19 -0
  263. package/shared/teammates.mjs +756 -0
  264. package/shared/terminal-focus.d.mts +1 -0
  265. package/shared/terminal-focus.mjs +13 -0
  266. package/shared/thread-cards.mjs +4 -1
  267. package/shared/usage.d.mts +9 -0
  268. package/shared/usage.mjs +49 -3
  269. package/shared/websites.d.mts +15 -0
  270. package/shared/websites.mjs +61 -0
  271. package/shared/work-items.mjs +127 -30
  272. package/shared/work-lines.mjs +92 -244
  273. package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
  274. package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
  275. package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
  276. package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
  277. package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
  278. package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
@@ -0,0 +1,756 @@
1
+ // Teammates: the pure rules, shared by the main process, the store tools and
2
+ // the renderer, the way shared/repeats.mjs is.
3
+ //
4
+ // A teammate is a standing job, not a thread. Each time it wakes it works on an
5
+ // ordinary work item labelled `teammate:<id>`, so every rule about runs (held
6
+ // starts, Needs you, deadlines, the clean marker) applies to it unchanged.
7
+ // What is new lives in two append-only files per account: the teammates
8
+ // themselves, and one team chat that you and every teammate write to.
9
+ import { periodKey, isRepeatRule, servedOf, dateKey, clockLabel, DAY_NAMES } from './repeats.mjs';
10
+
11
+ export const TEAMMATE_LABEL_PREFIX = 'teammate:';
12
+ export const FROM_LABEL_PREFIX = 'from:';
13
+ export const YOU = 'you';
14
+ export const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
15
+ export const DEFAULT_LIMIT = 20;
16
+ export const CHAT_TAIL = 30;
17
+ // Teammates waking each other with no person or schedule in between stop after
18
+ // this many hops, so two of them cannot keep each other running.
19
+ export const MAX_HOPS = 8;
20
+
21
+ // Teammates are an alpha feature (Settings, Alpha features): off unless switched on.
22
+ export function teammatesOn(config) {
23
+ return config?.alphaTeammates === true;
24
+ }
25
+ export const HOP_LABEL_PREFIX = 'hop:';
26
+
27
+ export function hopOf(item) {
28
+ const found = (item?.labels ?? []).find((l) => typeof l === 'string' && l.startsWith(HOP_LABEL_PREFIX));
29
+ const n = found ? Number(found.slice(HOP_LABEL_PREFIX.length)) : 0;
30
+ return Number.isInteger(n) && n >= 0 ? n : 0;
31
+ }
32
+
33
+ export function teammateLabel(id) {
34
+ return `${TEAMMATE_LABEL_PREFIX}${id}`;
35
+ }
36
+
37
+ export function teammateIdOf(item) {
38
+ const found = (item?.labels ?? []).find((l) => typeof l === 'string' && l.startsWith(TEAMMATE_LABEL_PREFIX));
39
+ return found ? found.slice(TEAMMATE_LABEL_PREFIX.length) : null;
40
+ }
41
+
42
+ export function passedFromOf(item) {
43
+ const found = (item?.labels ?? []).find((l) => typeof l === 'string' && l.startsWith(FROM_LABEL_PREFIX));
44
+ return found ? found.slice(FROM_LABEL_PREFIX.length) : null;
45
+ }
46
+
47
+ const ID_RE = /^t-[0-9a-f]{6,}$/;
48
+ export function isTeammateId(id) {
49
+ return typeof id === 'string' && ID_RE.test(id);
50
+ }
51
+
52
+ // ---------------------------------------------------------------- teammates
53
+
54
+ // A field written and missing from here is folded away at the next start, the
55
+ // way `on` once vanished from every weekly repeating task.
56
+ const FIELDS = [
57
+ 'name', 'letter', 'role', 'job', 'goal', 'counted', 'works', 'asksFirst',
58
+ 'product', 'engine', 'model', 'effort', 'limit', 'paused', 'status',
59
+ 'endedAt', 'served', 'starter', 'createdAt', 'duties', 'dutyServed',
60
+ ];
61
+
62
+ export const STATUSES = ['draft', 'active'];
63
+
64
+ export function isWorks(works) {
65
+ return isRepeatRule(works);
66
+ }
67
+
68
+ // ---------------------------------------------------------------- duties
69
+ // What a teammate does on its own, each with its own times or "after a ship"
70
+ // in its project. A teammate with only the older single `works` schedule has
71
+ // none, and its round runs as before.
72
+ const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
73
+ const EVERY_DUTY = ['day', 'weekday', 'week'];
74
+
75
+ export function cleanDuty(d) {
76
+ if (!d || typeof d !== 'object') return null;
77
+ const id = typeof d.id === 'string' && /^d-[\w-]{1,40}$/.test(d.id) ? d.id : null;
78
+ const what = cleanText(d.what, 300);
79
+ if (!id || !what) return null;
80
+ const tell = cleanText(d.tell, 300);
81
+ if (d.after === 'ship') {
82
+ const most = Number.isInteger(d.most) && d.most >= 1 && d.most <= 14 ? d.most : null;
83
+ return { id, what, after: 'ship', ...(most ? { most } : {}), ...(tell ? { tell } : {}) };
84
+ }
85
+ const at = (Array.isArray(d.at) ? d.at : [d.at]).filter((x) => typeof x === 'string' && TIME.test(x));
86
+ if (!EVERY_DUTY.includes(d.every) || !at.length || at.length !== (Array.isArray(d.at) ? d.at : [d.at]).length) return null;
87
+ if (d.every === 'week' && !(Number.isInteger(d.on) && d.on >= 0 && d.on <= 6)) return null;
88
+ return { id, what, every: d.every, at: [...new Set(at)].sort().slice(0, 6), ...(d.every === 'week' ? { on: d.on } : {}), ...(tell ? { tell } : {}) };
89
+ }
90
+
91
+ const TIMES_WORD = ['', 'once', 'twice', 'three times', 'four times', 'five times'];
92
+
93
+ /** When a duty runs, in plain words: "Daily at 9:00am and 3:00pm", "After a ship, at most twice a week". */
94
+ export function dutyWhen(d) {
95
+ if (d?.after === 'ship') return d.most ? `After a ship, at most ${TIMES_WORD[d.most] ?? `${d.most} times`} a week` : 'After a ship';
96
+ const at = (Array.isArray(d?.at) ? d.at : []).map((x) => clockLabel(x));
97
+ if (!at.length) return '';
98
+ const times = at.length === 1 ? at[0] : `${at.slice(0, -1).join(', ')} and ${at.at(-1)}`;
99
+ if (d.every === 'day') return `Daily at ${times}`;
100
+ if (d.every === 'weekday') return `Weekdays at ${times}`;
101
+ return `${DAY_NAMES[d.on]}s at ${times}`;
102
+ }
103
+
104
+ /** The latest time slot of a timed duty that has come, as "YYYY-MM-DDTHH:MM", or null before the first. */
105
+ export function dutySlot(d, now = Date.now()) {
106
+ let best = null;
107
+ for (const at of d.at ?? []) {
108
+ const key = periodKey({ every: d.every, at, ...(d.every === 'week' ? { on: d.on } : {}) }, now);
109
+ if (!key) continue;
110
+ const slot = `${key}T${at}`;
111
+ if (!best || slot > best) best = slot;
112
+ }
113
+ return best;
114
+ }
115
+
116
+ /** What a duty has seen so far when it is set, so a time or a ship already past fires nothing. */
117
+ export function dutyStart(d, now = Date.now()) {
118
+ return d.after === 'ship' ? `ship@${now}` : dutySlot(d, now) ?? '';
119
+ }
120
+
121
+ export const DUTY_LABEL_PREFIX = 'duty:';
122
+
123
+ // What needs the person's yes first, as a rule. A question saved there is not
124
+ // one (setup refuses it now; older teammates may still carry one).
125
+ export function yesFirst(asksFirst) {
126
+ const rule = String(asksFirst ?? '').trim();
127
+ return rule && !rule.endsWith('?') ? rule : null;
128
+ }
129
+ // A run your own words in a teammate's chat woke. You are in that chat
130
+ // waiting for its answer, so it is next for a free agent.
131
+ export const CHAT_LABEL = 'from-chat';
132
+ export const dutyOf = (item) => (item?.labels ?? []).find((l) => typeof l === 'string' && l.startsWith(DUTY_LABEL_PREFIX))?.slice(DUTY_LABEL_PREFIX.length) ?? null;
133
+
134
+ function cleanText(v, max = 2000) {
135
+ if (typeof v !== 'string') return null;
136
+ const t = v.trim();
137
+ return t ? t.slice(0, max) : null;
138
+ }
139
+
140
+ function coerce(field, value) {
141
+ switch (field) {
142
+ case 'name': return cleanText(value, 40);
143
+ case 'role': return cleanText(value, 60);
144
+ case 'letter': {
145
+ const t = cleanText(value, 2);
146
+ return t ? t.toUpperCase() : null;
147
+ }
148
+ case 'job': case 'goal': case 'counted': case 'asksFirst': return cleanText(value, 2000);
149
+ case 'product': case 'engine': case 'model': case 'effort': case 'starter': return cleanText(value, 120);
150
+ case 'works': return value === null ? null : isWorks(value) ? { every: value.every, at: value.at, ...(value.every === 'week' ? { on: value.on } : {}) } : undefined;
151
+ case 'limit': return Number.isInteger(value) && value >= 1 && value <= 500 ? value : undefined;
152
+ case 'paused': return !!value;
153
+ case 'status': return STATUSES.includes(value) ? value : undefined;
154
+ case 'endedAt': case 'createdAt': return Number.isFinite(value) ? value : value === null ? null : undefined;
155
+ case 'served': return typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value) ? value : undefined;
156
+ case 'duties': return Array.isArray(value) ? value.map(cleanDuty).filter(Boolean).slice(0, 12) : value === null ? [] : undefined;
157
+ case 'dutyServed': {
158
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
159
+ const out = {};
160
+ for (const [k, v] of Object.entries(value)) if (/^d-[\w-]{1,40}$/.test(k) && typeof v === 'string' && v.length <= 40) out[k] = v;
161
+ return out;
162
+ }
163
+ default: return undefined;
164
+ }
165
+ }
166
+
167
+ // Newest wins per field. Memory is a list, written as `remember`/`forget` lines
168
+ // so two runs learning at once both keep what they learned.
169
+ export function foldTeammates(lines, now = Date.now()) {
170
+ const today = dateKey(now);
171
+ const byId = new Map();
172
+ for (const line of lines ?? []) {
173
+ if (!line || typeof line !== 'object') continue;
174
+ const { id, ts, patch } = line;
175
+ if (!isTeammateId(id) || !Number.isFinite(ts) || !patch || typeof patch !== 'object') continue;
176
+ let t = byId.get(id);
177
+ if (!t) {
178
+ t = { id, createdAt: ts, updatedAt: ts, status: 'active', paused: false, served: '', memory: [], limit: DEFAULT_LIMIT, duties: [], dutyServed: {} };
179
+ byId.set(id, t);
180
+ }
181
+ t.updatedAt = Math.max(t.updatedAt, ts);
182
+ for (const field of FIELDS) {
183
+ if (!(field in patch)) continue;
184
+ const v = coerce(field, patch[field]);
185
+ if (v === undefined) continue;
186
+ if (field === 'served') {
187
+ if (v <= today && v > t.served) t.served = v;
188
+ continue;
189
+ }
190
+ // What each duty has served is kept per duty, so two runs marking two
191
+ // duties at once both land.
192
+ if (field === 'dutyServed') {
193
+ t.dutyServed = { ...(t.dutyServed ?? {}), ...v };
194
+ continue;
195
+ }
196
+ t[field] = v;
197
+ }
198
+ const r = patch.remember;
199
+ if (r && typeof r === 'object' && typeof r.id === 'string' && cleanText(r.text, 300) && !t.memory.some((m) => m.id === r.id)) {
200
+ t.memory.push({ id: r.id, text: cleanText(r.text, 300), from: cleanText(r.from, 300), at: Number.isFinite(r.at) ? r.at : ts, by: typeof r.by === 'string' ? r.by : null });
201
+ }
202
+ if (typeof patch.forget === 'string') t.memory = t.memory.filter((m) => m.id !== patch.forget);
203
+ }
204
+ return [...byId.values()]
205
+ .filter((t) => !t.endedAt && t.name)
206
+ .sort((a, b) => a.createdAt - b.createdAt);
207
+ }
208
+
209
+ // A free letter for a new teammate: its own initial when nobody has it, else
210
+ // the next letter of its name, else the first free one.
211
+ export function letterFor(name, teammates = []) {
212
+ const taken = new Set(teammates.map((t) => t.letter).filter(Boolean));
213
+ const letters = String(name ?? '').toUpperCase().replace(/[^A-Z]/g, '');
214
+ for (const ch of letters) if (!taken.has(ch)) return ch;
215
+ for (const ch of 'ABCDEFGHIJKLMNOPQRSTUVWXYZ') if (!taken.has(ch)) return ch;
216
+ return letters[0] ?? '?';
217
+ }
218
+
219
+ // ---------------------------------------------------------------- playbooks
220
+ // What the person taught their teammates about one kind of work, by topic, so
221
+ // every teammate doing that work reads the same lessons. Lines: `book` (made),
222
+ // `lesson` (added, by a teammate, from a thread), `reader` (a teammate reads
223
+ // it), `forget` (a lesson taken out).
224
+ export function foldPlaybooks(lines) {
225
+ const books = new Map();
226
+ const lessonBook = new Map();
227
+ for (const line of lines ?? []) {
228
+ if (!line || typeof line !== 'object' || !Number.isFinite(line.ts)) continue;
229
+ if (line.kind === 'book' && typeof line.id === 'string' && cleanText(line.title, 120)) {
230
+ const had = books.get(line.id);
231
+ if (had) { had.title = cleanText(line.title, 120); had.updatedAt = Math.max(had.updatedAt, line.ts); continue; }
232
+ books.set(line.id, { id: line.id, title: cleanText(line.title, 120), readers: [], lessons: [], updatedAt: line.ts });
233
+ continue;
234
+ }
235
+ const book = books.get(line.book);
236
+ if (line.kind === 'lesson' && book && typeof line.id === 'string' && cleanText(line.text, 400) && !book.lessons.some((l) => l.id === line.id)) {
237
+ const from = line.from && typeof line.from === 'object' && typeof line.from.id === 'string'
238
+ ? { product: String(line.from.product ?? ''), id: line.from.id, title: cleanText(line.from.title, 200) ?? '' } : null;
239
+ book.lessons.push({ id: line.id, text: cleanText(line.text, 400), ts: line.ts, by: typeof line.by === 'string' ? line.by : null, from });
240
+ lessonBook.set(line.id, book);
241
+ if (isTeammateId(line.by) && !book.readers.includes(line.by)) book.readers.push(line.by);
242
+ book.updatedAt = Math.max(book.updatedAt, line.ts);
243
+ } else if (line.kind === 'reader' && book && isTeammateId(line.teammate) && !book.readers.includes(line.teammate)) {
244
+ book.readers.push(line.teammate);
245
+ book.updatedAt = Math.max(book.updatedAt, line.ts);
246
+ } else if (line.kind === 'forget' && lessonBook.has(line.id)) {
247
+ const b = lessonBook.get(line.id);
248
+ b.lessons = b.lessons.filter((l) => l.id !== line.id);
249
+ b.updatedAt = Math.max(b.updatedAt, line.ts);
250
+ }
251
+ }
252
+ return [...books.values()];
253
+ }
254
+
255
+ // ---------------------------------------------------------------- the chat
256
+
257
+ export const CHAT_KINDS = ['msg', 'pass', 'seen', 'act'];
258
+
259
+ export function foldChat(lines) {
260
+ const messages = [];
261
+ const seen = new Set();
262
+ const ids = new Set();
263
+ for (const line of lines ?? []) {
264
+ if (!line || typeof line !== 'object' || !CHAT_KINDS.includes(line.kind)) continue;
265
+ if (!Number.isFinite(line.ts)) continue;
266
+ if (line.kind === 'seen') {
267
+ if (typeof line.ref === 'string' && typeof line.for === 'string') seen.add(seenKey(line.ref, line.for));
268
+ continue;
269
+ }
270
+ if (typeof line.id !== 'string' || ids.has(line.id)) continue;
271
+ ids.add(line.id);
272
+ messages.push(line);
273
+ }
274
+ messages.sort((a, b) => a.ts - b.ts);
275
+ return { messages, seen };
276
+ }
277
+
278
+ export function seenKey(msgId, teammateId) {
279
+ return `${msgId}>${teammateId}`;
280
+ }
281
+
282
+ // Who a message is addressed to: the ones it names with @, matched on name.
283
+ export function mentionsIn(text, teammates = []) {
284
+ const out = [];
285
+ const lower = String(text ?? '').toLowerCase();
286
+ for (const t of teammates) {
287
+ if (!t.name) continue;
288
+ const re = new RegExp(`(^|[^\\w])@${t.name.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
289
+ if (re.test(lower)) out.push(t.id);
290
+ }
291
+ return out;
292
+ }
293
+
294
+ // ---------------------------------------------------------------- runs
295
+
296
+ export function runsOf(teammateId, items) {
297
+ return (items ?? []).filter((i) => teammateIdOf(i) === teammateId);
298
+ }
299
+
300
+ // A finished word from the agent that the person has not answered: the run is
301
+ // waiting on them, and so in Needs you, not running.
302
+ export function waitingOnYou(item) {
303
+ if (!item || item.status === 'done' || item.status === 'claimed') return false;
304
+ // Queued behind another run, or closed quietly: neither is in your inbox.
305
+ if (item.start === 'later' || (item.labels ?? []).includes('clean')) return false;
306
+ if (item.status === 'blocked') return true;
307
+ const r = item.wrote?.result;
308
+ if (!r || r.source !== 'agent') return false;
309
+ const a = item.wrote?.answer?.ts ?? 0;
310
+ return r.ts > a;
311
+ }
312
+
313
+ export function isQueued(item) {
314
+ return item?.status === 'open' && item?.start === 'later';
315
+ }
316
+
317
+ // Running or about to: one at a time per teammate, so this is what the queue
318
+ // waits behind.
319
+ export function isActiveRun(item) {
320
+ if (!item || item.status === 'done') return false;
321
+ if (isQueued(item)) return false;
322
+ return !waitingOnYou(item);
323
+ }
324
+
325
+ export function activeRunOf(teammateId, items) {
326
+ return runsOf(teammateId, items).find(isActiveRun) ?? null;
327
+ }
328
+
329
+ export function runsThisWeek(teammateId, items, now = Date.now()) {
330
+ return runsOf(teammateId, items).filter((i) => (i.createdAt ?? 0) > now - WEEK_MS).length;
331
+ }
332
+
333
+ // Deterministic, so a tick that dies after creating the run and before marking
334
+ // the message seen finds the same row next time rather than making a second.
335
+ export function runIdFor(seed) {
336
+ let h1 = 0x811c9dc5;
337
+ let h2 = 0x1b873593;
338
+ const s = String(seed);
339
+ for (let i = 0; i < s.length; i++) {
340
+ const c = s.charCodeAt(i);
341
+ h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
342
+ h2 = Math.imul(h2 ^ c, 0x5bd1e995) >>> 0;
343
+ }
344
+ return `w-${h1.toString(16).padStart(8, '0')}${(h2 >>> 16).toString(16).padStart(4, '0')}`;
345
+ }
346
+
347
+ function clip(text, max) {
348
+ const t = String(text ?? '').replace(/\s+/g, ' ').trim();
349
+ return t.length > max ? `${t.slice(0, max - 1).trimEnd()}…` : t;
350
+ }
351
+
352
+ export function runTitle(trigger, teammate) {
353
+ if (trigger.kind === 'schedule') return trigger.label === 'week' ? "This week's round" : "Today's round";
354
+ if (trigger.kind === 'duty') return clip(trigger.what, 120);
355
+ if (trigger.kind === 'pass') return clip(trigger.title || 'Work passed on', 120);
356
+ // Inbox rows lead with the teammate's name, so a title never repeats it.
357
+ if (trigger.opening === 'setup') return 'Set up its job';
358
+ if (trigger.opening === 'start') return 'Introduce itself and begin';
359
+ const said = partFor(trigger.text, teammate.name) || firstLine(trigger.text) || 'A message';
360
+ return clip(trigger.fromName ? `From ${trigger.fromName}: ${said}` : said, 120);
361
+ }
362
+
363
+ const firstLine = (text) => String(text ?? '').trim().split('\n')[0].trim();
364
+
365
+ // What a message says to one teammate it names: from its @Name to the end of
366
+ // that sentence, when there is anything there.
367
+ function partFor(text, name) {
368
+ const words = String(text ?? '');
369
+ const at = name ? words.search(new RegExp(`@${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`, 'i')) : -1;
370
+ if (at < 0) return null;
371
+ const rest = words.slice(at + name.length + 1).replace(/^[\s,:;–—-]+/, '');
372
+ const part = rest.split(/\n|\s@\w/)[0].replace(/[\s.,;:]+$/, '').trim();
373
+ return part ? part[0].toUpperCase() + part.slice(1) : null;
374
+ }
375
+
376
+ // What the tick should do now, decided without touching anything, so it can be
377
+ // tested on plain data and the main process only carries it out.
378
+ //
379
+ // - A message or pass addressed to a teammate wakes it. Your words go into the
380
+ // run already waiting on you or already running, since they are most likely
381
+ // the answer; anything else becomes a run of its own.
382
+ // - A schedule that is owed becomes a run.
383
+ // - One run at a time: a new run behind an active one is made queued, and a
384
+ // teammate with nothing active gets its oldest queued run released.
385
+ // - A teammate past its weekly runs takes nothing new except your own words.
386
+ export function planWakes({ teammates = [], chat = { messages: [], seen: new Set() }, items = [], now = Date.now(), shipped = {} } = {}) {
387
+ const actions = [];
388
+ const live = teammates.filter((t) => !t.endedAt);
389
+ const byId = new Map(live.map((t) => [t.id, t]));
390
+ const pendingNew = new Map();
391
+ const startsNow = new Map();
392
+ const limited = new Set();
393
+
394
+ const active = (t) => !!activeRunOf(t.id, items) || startsNow.get(t.id);
395
+ const overLimit = (t) => runsThisWeek(t.id, items, now) + (pendingNew.get(t.id) ?? 0) >= (t.limit ?? DEFAULT_LIMIT);
396
+
397
+ const newRun = (t, trigger, seed, { fromYou = false } = {}) => {
398
+ if (!fromYou && overLimit(t)) {
399
+ limited.add(t.id);
400
+ return null;
401
+ }
402
+ const later = active(t);
403
+ const id = runIdFor(seed);
404
+ actions.push({ type: 'run', teammate: t.id, id, trigger, title: runTitle(trigger, t), start: later ? 'later' : 'now' });
405
+ pendingNew.set(t.id, (pendingNew.get(t.id) ?? 0) + 1);
406
+ if (!later) startsNow.set(t.id, true);
407
+ return id;
408
+ };
409
+
410
+ for (const m of chat.messages ?? []) {
411
+ if (m.kind !== 'msg' && m.kind !== 'pass') continue;
412
+ const targets = m.kind === 'pass' ? [m.to] : (Array.isArray(m.to) ? m.to : []);
413
+ for (const tid of targets) {
414
+ const t = byId.get(tid);
415
+ if (!t || chat.seen.has(seenKey(m.id, tid))) continue;
416
+ if (m.from === tid) { actions.push({ type: 'seen', msg: m.id, teammate: tid }); continue; }
417
+ if (t.paused && m.from !== YOU) continue;
418
+ const fromYou = m.from === YOU;
419
+ const hop = fromYou ? 0 : (Number.isInteger(m.hop) ? m.hop : 0) + 1;
420
+ if (hop > MAX_HOPS) {
421
+ actions.push({ type: 'seen', msg: m.id, teammate: tid });
422
+ actions.push({ type: 'hops', teammate: tid, msg: m.id, from: m.from });
423
+ continue;
424
+ }
425
+ // A new thread to a teammate is new work, never an answer to its last question.
426
+ if (fromYou && m.kind === 'msg' && !m.fresh) {
427
+ const runs = runsOf(tid, items);
428
+ const waiting = runs.filter(waitingOnYou).sort((a, b) => (b.wrote?.result?.ts ?? 0) - (a.wrote?.result?.ts ?? 0))[0];
429
+ // A running run hears your words only if your words started it;
430
+ // anything else is new work, queued behind it.
431
+ const yours = new Set((chat.messages ?? []).filter((x) => x.kind === 'msg' && x.from === YOU && x.id !== m.id).map((x) => runIdFor(`${x.id}>${tid}`)));
432
+ const running = runs.find((i) => isActiveRun(i) && i.status === 'claimed' && yours.has(i.id));
433
+ const into = waiting ?? running;
434
+ if (into) {
435
+ actions.push({ type: 'answer', teammate: tid, item: into.id, product: into.product, text: m.text, msg: m.id });
436
+ actions.push({ type: 'seen', msg: m.id, teammate: tid, run: into.id });
437
+ continue;
438
+ }
439
+ }
440
+ // Where it came from travels with it: the project it was passed from, and
441
+ // the thread that spoke for you, when one did.
442
+ const origin = { ...(m.product ? { product: m.product } : {}), ...(m.via ? { via: m.via, viaProduct: m.viaProduct ?? null } : {}) };
443
+ const trigger = m.kind === 'pass'
444
+ ? { kind: 'pass', from: m.from, title: m.title, details: m.details, msg: m.id, hop, ...origin }
445
+ : { kind: 'message', from: m.from, fromName: fromYou ? null : byId.get(m.from)?.name ?? null, text: m.text, msg: m.id, hop, ...origin, ...(fromYou && m.start ? { opening: 'start' } : fromYou && m.setup ? { opening: 'setup' } : {}) };
446
+ const id = newRun(t, trigger, `${m.id}>${tid}`, { fromYou });
447
+ if (id) actions.push({ type: 'seen', msg: m.id, teammate: tid, run: id });
448
+ }
449
+ }
450
+
451
+ for (const t of live) {
452
+ if (t.paused || t.status !== 'active' || !isWorks(t.works)) continue;
453
+ const key = periodKey(t.works, now);
454
+ if (!key || key <= servedOf(t)) continue;
455
+ const id = newRun(t, { kind: 'schedule', key, label: t.works.every === 'week' ? 'week' : 'round' }, `${t.id}@${key}`);
456
+ if (id) actions.push({ type: 'served', teammate: t.id, key });
457
+ }
458
+
459
+ for (const t of live) {
460
+ if (t.paused || t.status !== 'active') continue;
461
+ for (const d of t.duties ?? []) {
462
+ const served = t.dutyServed?.[d.id] ?? '';
463
+ let key = null;
464
+ if (d.after === 'ship') {
465
+ const at = shipped?.[t.product];
466
+ const seenAt = Number(String(served).replace(/^ship@/, '')) || 0;
467
+ if (!Number.isFinite(at) || at <= seenAt) continue;
468
+ const week = runsOf(t.id, items).filter((i) => dutyOf(i) === d.id && now - (i.createdAt ?? 0) < WEEK_MS).length;
469
+ if (d.most && week >= d.most) continue;
470
+ key = `ship@${at}`;
471
+ } else {
472
+ const slot = dutySlot(d, now);
473
+ if (!slot || slot <= served) continue;
474
+ key = slot;
475
+ }
476
+ const id = newRun(t, { kind: 'duty', duty: d.id, what: d.what, ...(d.tell ? { tell: d.tell } : {}), key }, `${t.id}#${d.id}@${key}`);
477
+ if (id) actions.push({ type: 'dutyServed', teammate: t.id, duty: d.id, key });
478
+ }
479
+ }
480
+
481
+ for (const t of live) {
482
+ if (t.paused || active(t)) continue;
483
+ const next = runsOf(t.id, items).filter(isQueued).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0))[0];
484
+ if (next) actions.push({ type: 'release', teammate: t.id, item: next.id, product: next.product });
485
+ }
486
+
487
+ for (const tid of limited) actions.push({ type: 'limit', teammate: tid });
488
+ return actions;
489
+ }
490
+
491
+ // ---------------------------------------------------------------- what you see
492
+
493
+ function verbFor(item, teammates) {
494
+ const from = passedFromOf(item);
495
+ if (from) {
496
+ const who = teammates.find((t) => t.id === from);
497
+ return { verb: 'passed', by: from, byName: who?.name ?? 'A teammate' };
498
+ }
499
+ return { verb: 'picked' };
500
+ }
501
+
502
+ // The lines a run leaves in the chat, read straight off the ledger so they can
503
+ // never disagree with it: picked up (or passed on), sent you, finished.
504
+ export function runLines(items, teammates = []) {
505
+ const out = [];
506
+ for (const item of items ?? []) {
507
+ const tid = teammateIdOf(item);
508
+ if (!tid) continue;
509
+ const base = { teammate: tid, item: item.id, product: item.product, title: item.label || item.title };
510
+ if (isQueued(item)) {
511
+ out.push({ ...base, id: `${item.id}:queued`, kind: 'run', verb: 'queued', ts: item.createdAt ?? 0 });
512
+ continue;
513
+ }
514
+ const v = verbFor(item, teammates);
515
+ out.push({ ...base, id: `${item.id}:start`, kind: 'run', verb: v.verb, by: v.by, byName: v.byName, ts: item.wrote?.start?.ts ?? item.createdAt ?? 0 });
516
+ const a = item.wrote?.answer;
517
+ const said = typeof item.answer === 'string' ? item.answer.trim() : '';
518
+ if (a && a.source === 'founder' && said && said !== '(withdrawn)') {
519
+ out.push({ ...base, id: `${item.id}:answered`, kind: 'run', verb: 'answered', text: clip(said, 400), ts: a.ts });
520
+ }
521
+ const r = item.wrote?.result;
522
+ if (r && r.source === 'agent' && !(item.labels ?? []).includes('clean')) {
523
+ const answered = (item.wrote?.answer?.ts ?? 0) > r.ts;
524
+ const said = String(item.result ?? '').split('\n').map((l) => l.replace(/\*\*|__|`|^#+\s*/g, '').trim()).find(Boolean) ?? '';
525
+ out.push({ ...base, id: `${item.id}:sent`, kind: 'run', verb: 'sent', said: clip(said, 200), waiting: waitingOnYou(item), answered, ts: r.ts });
526
+ }
527
+ if (item.status === 'done') {
528
+ out.push({ ...base, id: `${item.id}:done`, kind: 'run', verb: 'finished', ts: item.wrote?.status?.ts ?? item.updatedAt ?? 0 });
529
+ }
530
+ }
531
+ return out.sort((a, b) => a.ts - b.ts);
532
+ }
533
+
534
+ function startOfDay(now) {
535
+ const d = new Date(now);
536
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
537
+ }
538
+
539
+ // The panel beside a teammate: what is waiting on you, what it is on now, and
540
+ // what it finished today.
541
+ export function statusOf(teammate, items, now = Date.now()) {
542
+ const runs = runsOf(teammate.id, items);
543
+ const waiting = runs.filter(waitingOnYou).map((i) => ({ id: i.id, product: i.product, title: i.label || i.title }));
544
+ const running = runs.find((i) => isActiveRun(i));
545
+ const next = runs.filter(isQueued).sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0)).map((i) => ({ id: i.id, product: i.product, title: i.label || i.title }));
546
+ const queued = next.length;
547
+ const today = startOfDay(now);
548
+ const done = runs
549
+ .filter((i) => i.status === 'done' && (i.wrote?.status?.ts ?? i.updatedAt ?? 0) >= today)
550
+ .map((i) => ({ id: i.id, product: i.product, title: i.label || i.title }));
551
+ let now_ = null;
552
+ if (running) now_ = running.label || running.title;
553
+ else if (waiting.length) now_ = 'Waiting on you';
554
+ else if (teammate.paused) now_ = 'Paused';
555
+ return { waiting, now: now_, running: running ? { id: running.id, product: running.product } : null, queued, next, done, runsThisWeek: runsThisWeek(teammate.id, items, now) };
556
+ }
557
+
558
+ // ---------------------------------------------------------------- starters
559
+
560
+ // The six a new person can start from. Copy is plain and specific about what
561
+ // each one brings you, because that is what has to jump out.
562
+ /** What a new teammate says first, with the starters as answers to tap. */
563
+ export const GREETING = 'Hi. What should I take off your plate? Pick one, or tell me in your own words.';
564
+
565
+ /** The starters a new teammate offers: those whose job or name nobody on the team has yet. */
566
+ export function starterAsks(team = []) {
567
+ return STARTERS.filter((s) => !team.some((t) => t.starter === s.starter || t.name === s.name))
568
+ .map((s) => ({ starter: s.starter, ask: s.ask, name: s.name, role: s.role }));
569
+ }
570
+
571
+ // Above the app's default, Medium: a teammate works with nobody watching.
572
+ export const TEAMMATE_EFFORT = 'high';
573
+
574
+ export const STARTERS = [
575
+ {
576
+ starter: 'quality', name: 'Quality lead', role: 'Quality lead',
577
+ ask: 'Find bugs before your users do',
578
+ outcome: 'Finds bugs before your users do',
579
+ sends: 'A bug, with the steps to make it happen',
580
+ job: 'Use the project the way a new user would, every weekday morning. Try the main flows on a phone-sized window and a slow connection. File anything broken with the exact steps to make it happen, and pass fixable bugs to whoever fixes things on the team.',
581
+ goal: 'Nothing broken reaches a user',
582
+ counted: 'Bugs users report that you did not find first',
583
+ asksFirst: 'Before paying with a real card or signing in as a real person',
584
+ works: { every: 'weekday', at: '09:00' },
585
+ },
586
+ {
587
+ starter: 'maintainer', name: 'Maintainer', role: 'Maintainer',
588
+ ask: 'Fix what gets filed',
589
+ outcome: 'Fixes what gets filed, and asks you before shipping',
590
+ sends: 'A fix that is ready to ship',
591
+ job: 'Fix the bugs your teammates pass you and keep the project healthy: one branch per fix, with a test that fails before the fix and passes after. Never ship or merge without asking the person first.',
592
+ goal: 'Every bug fixed within two days',
593
+ counted: 'Days from a bug being filed to its fix being ready',
594
+ asksFirst: 'Before shipping, merging, or deleting anything',
595
+ works: null,
596
+ },
597
+ {
598
+ starter: 'marketing', name: 'Marketing lead', role: 'Marketing lead',
599
+ ask: 'Post about what we build',
600
+ outcome: 'Brings you posts and clips about what you build, then makes the ones you pick',
601
+ sends: 'Posts and clips ready for your yes',
602
+ job: 'Keep people hearing about the project without the person having to think about it. When something ships, decide whether it is worth a post. Bring whole posts, each with a short clip, written the way the person writes. Nothing is posted without their yes.',
603
+ goal: 'Something worth sharing goes out every week',
604
+ counted: 'Posts the person approved each week',
605
+ asksFirst: 'Before anything is posted or sent in their name',
606
+ works: { every: 'week', on: 1, at: '09:00' },
607
+ },
608
+ {
609
+ starter: 'support', name: 'Support lead', role: 'Support lead',
610
+ ask: 'Answer user emails',
611
+ outcome: 'Answers user emails, and asks you before any refund',
612
+ sends: 'A refund or a tricky reply to approve',
613
+ job: 'Read what users ask, draft clear answers, and keep a short list of what people get stuck on. Ask the person before any refund, promise or reply that commits them to something.',
614
+ goal: 'Every user question answered the same day',
615
+ counted: 'Questions still unanswered at the end of the day',
616
+ asksFirst: 'Before any refund, promise, or reply that commits you',
617
+ works: { every: 'weekday', at: '10:00' },
618
+ },
619
+ {
620
+ starter: 'growth', name: 'Growth lead', role: 'Growth lead',
621
+ ask: 'Tell me what moved signups',
622
+ outcome: 'Tells you what moved signups, and what to try next',
623
+ sends: 'What moved signups this week',
624
+ job: 'Every Friday, look at what changed in signups and usage this week, explain what moved it in plain words, and suggest one thing to try next week.',
625
+ goal: 'One clear next step for growth every week',
626
+ counted: 'Experiments tried each month',
627
+ asksFirst: 'Before changing anything users can see',
628
+ works: { every: 'week', on: 5, at: '15:00' },
629
+ },
630
+ {
631
+ starter: 'fundraising', name: 'Fundraiser', role: 'Fundraiser',
632
+ ask: 'Find investors who fit',
633
+ outcome: 'Finds investors who fit, and drafts intros from you',
634
+ sends: 'Intro emails ready for your yes',
635
+ job: 'Build and keep a short list of investors who fit the project, with why each one fits. Draft intro emails for the person to send. Never send anything yourself.',
636
+ goal: 'First meetings with investors who fit',
637
+ counted: 'First meetings booked',
638
+ asksFirst: 'Before anything is sent in their name',
639
+ works: { every: 'week', on: 2, at: '09:00' },
640
+ },
641
+ ];
642
+
643
+ // ---------------------------------------------------------------- the offer
644
+ // When you keep asking for the same kind of thing, the app offers a teammate
645
+ // for it: one inbox row, which no agent ever runs, answered by a tap.
646
+ export const OFFER_LABEL = 'teammate-offer';
647
+ export const OFFER_MAKE = 'Make a teammate for it';
648
+ export const OFFER_NOT_NOW = 'Not now';
649
+ const OFFER_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
650
+ const OFFER_AT_LEAST = 3;
651
+ const STOP = new Set(('the a an and or but for to of in on at by with from into onto about as is are was were be been it its this that these those '
652
+ + 'my our your their me we you i us can could would should will please pls just also some any all new more make do does did get got let lets '
653
+ + 'need want like have has had what which who how why when where there here then than them they not no yes ok okay one two three today tomorrow week').split(' '));
654
+
655
+ function wordsOf(title) {
656
+ return new Set(String(title ?? '').toLowerCase().replace(/[’']/g, '').split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)));
657
+ }
658
+
659
+ function alike(a, b) {
660
+ let shared = 0;
661
+ for (const w of a) if (b.has(w)) shared += 1;
662
+ return shared >= 2 && shared / Math.min(a.size, b.size) >= 0.6;
663
+ }
664
+
665
+ // Threads you wrote yourself in the last month: not a teammate's or a repeat's
666
+ // runs, not imported, not an offer.
667
+ function yourAsks(items, now) {
668
+ return (items ?? []).filter((i) => {
669
+ const labels = i.labels ?? [];
670
+ if (!labels.includes('founder') || i.wrote?.labels?.source === 'agent') return false;
671
+ if (labels.some((l) => /^(teammate|repeat|hop|from):/.test(l) || l === OFFER_LABEL || l === 'thread-import' || l === 'codex-import' || l === 'codex')) return false;
672
+ return now - (i.createdAt ?? 0) <= OFFER_WINDOW_MS && wordsOf(i.title).size >= 2;
673
+ });
674
+ }
675
+
676
+ /**
677
+ * The offer to make now, if any: three or more of your own asks this month
678
+ * alike enough to be one job, under a key no offer has used. One open offer at
679
+ * a time, and one a day at most.
680
+ */
681
+ function askedOnce(group) {
682
+ const seen = new Map();
683
+ for (const i of group) {
684
+ const title = String(i.label || i.title).trim();
685
+ const had = seen.get(title.toLowerCase());
686
+ if (had) had.n += 1; else seen.set(title.toLowerCase(), { title, n: 1 });
687
+ }
688
+ return [...seen.values()];
689
+ }
690
+
691
+ export function offerFrom({ items = [], offered = new Set(), now = Date.now() } = {}) {
692
+ const open = (items ?? []).filter((i) => (i.labels ?? []).includes(OFFER_LABEL));
693
+ if (open.some((i) => i.status !== 'done' || now - (i.createdAt ?? 0) < 24 * 60 * 60 * 1000)) return null;
694
+ const asks = yourAsks(items, now).sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0));
695
+ const bags = new Map(asks.map((i) => [i.id, wordsOf(i.title)]));
696
+ for (const lead of asks) {
697
+ const group = asks.filter((i) => i === lead || alike(bags.get(lead.id), bags.get(i.id)));
698
+ if (group.length < OFFER_AT_LEAST) continue;
699
+ const counts = new Map();
700
+ for (const i of group) for (const w of bags.get(i.id)) counts.set(w, (counts.get(w) ?? 0) + 1);
701
+ const common = [...counts].filter(([, n]) => n >= Math.ceil(group.length / 2)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3).map(([w]) => w);
702
+ const key = common.sort().join('-');
703
+ if (!key || offered.has(key)) continue;
704
+ const byProduct = new Map();
705
+ for (const i of group) byProduct.set(i.product, (byProduct.get(i.product) ?? 0) + 1);
706
+ const product = [...byProduct].sort((a, b) => b[1] - a[1])[0]?.[0] ?? lead.product;
707
+ return {
708
+ key, product, count: group.length,
709
+ // The same words asked again are one line, not five.
710
+ titles: [...new Map(group.map((i) => [String(i.label || i.title).trim().toLowerCase(), String(i.label || i.title).trim()])).values()].slice(0, 5),
711
+ asked: askedOnce(group).slice(0, 5),
712
+ threads: group.slice(0, OFFER_THREADS).map((i) => ({ product: i.product, id: i.id, title: String(i.label || i.title).trim() })),
713
+ title: `Want a teammate for things like “${clip(lead.label || lead.title, 60)}”?`,
714
+ said: `You've asked for this ${group.length} times this month.`,
715
+ };
716
+ }
717
+ return null;
718
+ }
719
+
720
+ const OFFER_THREADS = 12;
721
+
722
+ /**
723
+ * What was said in the threads a teammate is made from, for its setup brief:
724
+ * your words whole, the agent's cut short, the app's notes left out, all of
725
+ * it inside `cap` characters. `read(product, id)` gives a thread's turns
726
+ * ({ source, field, text }, oldest first).
727
+ */
728
+ export function threadsDigest(threads, read, { cap = 40_000 } = {}) {
729
+ if (!threads?.length) return '';
730
+ const per = Math.floor(cap / threads.length);
731
+ const cut = (text, most) => (text.length > most ? `${text.slice(0, most).trimEnd()}…` : text);
732
+ const one = (t, agentMost, youMost) => {
733
+ let turns = [];
734
+ try { turns = read(t.product, t.id) ?? []; } catch { turns = []; }
735
+ const said = turns.filter((x) => x.source === 'founder' || x.source === 'agent').map((x) => (x.source === 'founder'
736
+ ? `You: ${cut(x.text.trim(), youMost)}`
737
+ : `Agent: ${cut(x.text.trim().replace(/\s*\n\s*/g, ' '), agentMost)}`));
738
+ return [`### ${t.title} (${t.product}/${t.id})`, ...(said.length ? said : ['(nothing could be read from this thread)'])].join('\n');
739
+ };
740
+ return threads.map((t) => {
741
+ for (const [agentMost, youMost] of [[1200, 4000], [300, 1500], [120, 600]]) {
742
+ const text = one(t, agentMost, youMost);
743
+ if (text.length <= per) return text;
744
+ }
745
+ return cut(one(t, 120, 600), per - 1);
746
+ }).join('\n\n').slice(0, cap);
747
+ }
748
+
749
+ /** What a tap on the offer meant: 'make', 'dismiss', or your own words to make one from. */
750
+ export function offerAnswer(answer) {
751
+ const said = String(answer ?? '').trim();
752
+ if (!said || said === '(withdrawn)') return null;
753
+ if (/not now|^no\b|^2\.?$/i.test(said)) return { kind: 'dismiss' };
754
+ if (said.toLowerCase().includes(OFFER_MAKE.toLowerCase()) || /^1\.?$/.test(said) || /^(yes|yep|sure|ok|okay)\b/i.test(said)) return { kind: 'make' };
755
+ return { kind: 'make', words: said };
756
+ }