@starlove/orbit 1.4.0 → 2.0.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 (329) hide show
  1. package/Dockerfile +10 -8
  2. package/README.md +146 -164
  3. package/dist/full/csp.txt +1 -0
  4. package/dist/full/fonts/JetBrains_Mono/JetBrainsMono-subset.woff2 +0 -0
  5. package/dist/full/fonts/JetBrains_Mono/README.txt +5 -0
  6. package/dist/full/fonts/Mona_Sans/MonaSans-subset.woff2 +0 -0
  7. package/{public/fonts/Open_Sans → dist/full/fonts/Mona_Sans}/OFL.txt +93 -93
  8. package/dist/full/fonts/Mona_Sans/README.txt +10 -0
  9. package/{public/fonts/JetBrains_Mono → dist/full/fonts/Newsreader}/OFL.txt +93 -93
  10. package/dist/full/fonts/Newsreader/README.txt +8 -0
  11. package/dist/full/fonts/Newsreader/newsreader.woff2 +0 -0
  12. package/dist/full/index.html +36 -653
  13. package/dist/full/js/app.BmuponPJ.js +32 -0
  14. package/dist/full/js/app.BmuponPJ.js.map +1 -0
  15. package/dist/full/js/chunk.1fJGQGbW.js +2 -0
  16. package/dist/full/js/chunk.1fJGQGbW.js.map +1 -0
  17. package/dist/full/js/chunk.5D7JO2zp.js +2 -0
  18. package/dist/full/js/chunk.5D7JO2zp.js.map +1 -0
  19. package/dist/full/js/chunk.BrNn_D79.js +2 -0
  20. package/dist/full/js/chunk.BrNn_D79.js.map +1 -0
  21. package/dist/full/js/chunk.CokJXIqM.js +2 -0
  22. package/dist/full/js/chunk.CokJXIqM.js.map +1 -0
  23. package/dist/full/js/chunk.D_Haqy6R.js +2 -0
  24. package/dist/full/js/chunk.D_Haqy6R.js.map +1 -0
  25. package/dist/full/js/chunk.RO0jsH6I.js +15 -0
  26. package/dist/full/js/chunk.RO0jsH6I.js.map +1 -0
  27. package/dist/full/js/chunk.dqWOEtBM.js +2 -0
  28. package/dist/full/js/chunk.dqWOEtBM.js.map +1 -0
  29. package/dist/full/js/warm.f260bbbc.js +15 -0
  30. package/dist/full/orbits-favicon.png +0 -0
  31. package/dist/full/orbits-logo-bg-wide.png +0 -0
  32. package/dist/full/orbits-logo-bg.png +0 -0
  33. package/dist/full/orbits-logo.png +0 -0
  34. package/dist/full/styles.2b154ffe.css +1 -0
  35. package/dist/full/vendor/starlove-ui/theme-init.js +66 -0
  36. package/dist/full/version.json +2 -2
  37. package/docs/AGENT_GUIDE.md +114 -0
  38. package/docs/UPDATING.md +249 -64
  39. package/lib/adapters/indexeddb/blobs.d.ts +3 -0
  40. package/lib/adapters/indexeddb/blobs.js +54 -0
  41. package/lib/adapters/indexeddb/database.d.ts +60 -0
  42. package/lib/adapters/indexeddb/database.js +126 -0
  43. package/lib/adapters/indexeddb/index.d.ts +5 -0
  44. package/lib/adapters/indexeddb/index.js +3 -0
  45. package/lib/adapters/indexeddb/locks.d.ts +14 -0
  46. package/lib/adapters/indexeddb/locks.js +40 -0
  47. package/lib/adapters/indexeddb/store.d.ts +40 -0
  48. package/lib/adapters/indexeddb/store.js +424 -0
  49. package/lib/adapters/indexeddb/tables.d.ts +43 -0
  50. package/lib/adapters/indexeddb/tables.js +127 -0
  51. package/lib/adapters/memory/blobs.d.ts +3 -0
  52. package/lib/adapters/memory/blobs.js +44 -0
  53. package/lib/adapters/memory/board.d.ts +41 -0
  54. package/lib/adapters/memory/board.js +521 -0
  55. package/lib/adapters/memory/index.d.ts +2 -0
  56. package/lib/adapters/memory/index.js +2 -0
  57. package/lib/adapters/memory/search.d.ts +6 -0
  58. package/lib/adapters/memory/search.js +30 -0
  59. package/lib/adapters/memory/store.d.ts +34 -0
  60. package/lib/adapters/memory/store.js +266 -0
  61. package/lib/adapters/sqlite/backups.d.ts +53 -0
  62. package/lib/adapters/sqlite/backups.js +232 -0
  63. package/lib/adapters/sqlite/blobs.d.ts +14 -0
  64. package/lib/adapters/sqlite/blobs.js +109 -0
  65. package/lib/adapters/sqlite/board.d.ts +77 -0
  66. package/lib/adapters/sqlite/board.js +328 -0
  67. package/lib/adapters/sqlite/database.d.ts +67 -0
  68. package/lib/adapters/sqlite/database.js +285 -0
  69. package/lib/adapters/sqlite/index.d.ts +3 -0
  70. package/lib/adapters/sqlite/index.js +2 -0
  71. package/lib/adapters/sqlite/lock.d.ts +11 -0
  72. package/lib/adapters/sqlite/lock.js +34 -0
  73. package/lib/adapters/sqlite/records.d.ts +54 -0
  74. package/lib/adapters/sqlite/records.js +546 -0
  75. package/lib/adapters/sqlite/schema.d.ts +54 -0
  76. package/lib/adapters/sqlite/schema.js +276 -0
  77. package/lib/adapters/sqlite/store.d.ts +39 -0
  78. package/lib/adapters/sqlite/store.js +385 -0
  79. package/lib/browser/in-page.d.ts +31 -0
  80. package/lib/browser/in-page.js +46 -0
  81. package/lib/browser/old-preview.d.ts +13 -0
  82. package/lib/browser/old-preview.js +80 -0
  83. package/lib/browser/preview-board.d.ts +8 -0
  84. package/lib/browser/preview-board.js +69 -0
  85. package/lib/cli/main.d.ts +7 -0
  86. package/lib/cli/main.js +343 -0
  87. package/lib/cli/orbit.d.ts +8 -0
  88. package/lib/cli/orbit.js +25 -0
  89. package/lib/client/http.d.ts +38 -0
  90. package/lib/client/http.js +334 -0
  91. package/lib/client/local.d.ts +26 -0
  92. package/lib/client/local.js +190 -0
  93. package/lib/client/object-url.d.ts +8 -0
  94. package/lib/client/object-url.js +21 -0
  95. package/lib/client/types.d.ts +268 -0
  96. package/lib/client/types.js +8 -0
  97. package/lib/commands/agent-tools.d.ts +31 -0
  98. package/lib/commands/agent-tools.js +100 -0
  99. package/lib/commands/index.d.ts +56 -0
  100. package/lib/commands/index.js +298 -0
  101. package/lib/commands/parse.d.ts +73 -0
  102. package/lib/commands/parse.js +231 -0
  103. package/lib/commands/present.d.ts +134 -0
  104. package/lib/commands/present.js +198 -0
  105. package/lib/commands/session.d.ts +85 -0
  106. package/lib/commands/session.js +138 -0
  107. package/lib/commands/verbs.d.ts +35 -0
  108. package/lib/commands/verbs.js +842 -0
  109. package/lib/core/actors.d.ts +18 -0
  110. package/lib/core/actors.js +45 -0
  111. package/lib/core/agent.d.ts +128 -0
  112. package/lib/core/agent.js +323 -0
  113. package/lib/core/boards.d.ts +113 -0
  114. package/lib/core/boards.js +288 -0
  115. package/lib/core/bootstrap.d.ts +47 -0
  116. package/lib/core/bootstrap.js +100 -0
  117. package/lib/core/bytes.d.ts +10 -0
  118. package/lib/core/bytes.js +52 -0
  119. package/lib/core/card-paths.d.ts +25 -0
  120. package/lib/core/card-paths.js +44 -0
  121. package/lib/core/context.d.ts +60 -0
  122. package/lib/core/context.js +42 -0
  123. package/lib/core/cross-board.d.ts +81 -0
  124. package/lib/core/cross-board.js +228 -0
  125. package/lib/core/describe.d.ts +21 -0
  126. package/lib/core/describe.js +81 -0
  127. package/lib/core/errors.d.ts +159 -0
  128. package/lib/core/errors.js +160 -0
  129. package/lib/core/files.d.ts +79 -0
  130. package/lib/core/files.js +262 -0
  131. package/lib/core/history.d.ts +20 -0
  132. package/lib/core/history.js +53 -0
  133. package/lib/core/image-types.d.ts +16 -0
  134. package/lib/core/image-types.js +36 -0
  135. package/lib/core/model.d.ts +263 -0
  136. package/lib/core/model.js +34 -0
  137. package/lib/core/nebula-link.d.ts +14 -0
  138. package/lib/core/nebula-link.js +39 -0
  139. package/lib/core/relations.d.ts +58 -0
  140. package/lib/core/relations.js +112 -0
  141. package/lib/core/search.d.ts +38 -0
  142. package/lib/core/search.js +92 -0
  143. package/lib/core/sessions.d.ts +44 -0
  144. package/lib/core/sessions.js +158 -0
  145. package/lib/core/snapshot-schema.d.ts +295 -0
  146. package/lib/core/snapshot-schema.js +82 -0
  147. package/lib/core/snapshots.d.ts +75 -0
  148. package/lib/core/snapshots.js +409 -0
  149. package/lib/core/states.d.ts +25 -0
  150. package/lib/core/states.js +126 -0
  151. package/lib/core/text.d.ts +40 -0
  152. package/lib/core/text.js +113 -0
  153. package/lib/core/tickets.d.ts +69 -0
  154. package/lib/core/tickets.js +311 -0
  155. package/lib/core/trello.d.ts +17 -0
  156. package/lib/core/trello.js +269 -0
  157. package/lib/core/verdicts.d.ts +28 -0
  158. package/lib/core/verdicts.js +92 -0
  159. package/lib/core/views.d.ts +95 -0
  160. package/lib/core/views.js +186 -0
  161. package/lib/http/app.d.ts +46 -0
  162. package/lib/http/app.js +491 -0
  163. package/lib/mcp-server.d.ts +10 -0
  164. package/lib/mcp-server.js +120 -0
  165. package/lib/node/assets.d.ts +7 -0
  166. package/lib/node/assets.js +68 -0
  167. package/lib/node/data-folder.d.ts +21 -0
  168. package/lib/node/data-folder.js +34 -0
  169. package/lib/node/env.d.ts +38 -0
  170. package/lib/node/env.js +49 -0
  171. package/lib/node/host-check.d.ts +24 -0
  172. package/lib/node/host-check.js +53 -0
  173. package/lib/node/host-services.d.ts +9 -0
  174. package/lib/node/host-services.js +150 -0
  175. package/lib/node/local.d.ts +22 -0
  176. package/lib/node/local.js +61 -0
  177. package/lib/node/migrate.d.ts +53 -0
  178. package/lib/node/migrate.js +635 -0
  179. package/lib/node/package.d.ts +7 -0
  180. package/lib/node/package.js +14 -0
  181. package/lib/node/repo-board.d.ts +49 -0
  182. package/lib/node/repo-board.js +245 -0
  183. package/lib/node/serve.d.ts +8 -0
  184. package/lib/node/serve.js +106 -0
  185. package/lib/orbit.d.ts +47 -0
  186. package/lib/orbit.js +44 -0
  187. package/lib/quiet-sqlite-warning.d.ts +1 -0
  188. package/lib/quiet-sqlite-warning.js +18 -0
  189. package/lib/store/blob-path.d.ts +6 -0
  190. package/lib/store/blob-path.js +12 -0
  191. package/lib/store/conformance.d.ts +52 -0
  192. package/lib/store/conformance.js +1449 -0
  193. package/lib/store/search-words.d.ts +10 -0
  194. package/lib/store/search-words.js +19 -0
  195. package/lib/store/types.d.ts +452 -0
  196. package/lib/store/types.js +34 -0
  197. package/llms.txt +63 -0
  198. package/package.json +109 -25
  199. package/src/cli/orbit.js +11 -658
  200. package/src/mcp-server.js +16 -876
  201. package/SKILL-ORBIT.md +0 -151
  202. package/build.mjs +0 -210
  203. package/dist/full/js/app.62905fcb.js +0 -952
  204. package/dist/full/js/app.62905fcb.js.map +0 -7
  205. package/dist/full/styles.4e60d824.css +0 -1
  206. package/docs/AGENT_PROTOCOL.md +0 -424
  207. package/docs/MCP_SETUP.md +0 -54
  208. package/docs/ORBIT_DISPATCH.md +0 -280
  209. package/docs/SS-Orbit-Dark.png +0 -0
  210. package/docs/SS-Orbit-Light.png +0 -0
  211. package/docs/mcp-client.example.json +0 -13
  212. package/docs/setup/orbit-init-wizard.md +0 -174
  213. package/public/apple-touch-icon.png +0 -0
  214. package/public/cursors/live_arrow.cur +0 -0
  215. package/public/cursors/live_link.cur +0 -0
  216. package/public/cursors/live_move.cur +0 -0
  217. package/public/cursors/live_select.cur +0 -0
  218. package/public/cursors/live_unavail.cur +0 -0
  219. package/public/favicon-32x32.png +0 -0
  220. package/public/fonts/Chakra_Petch/ChakraPetch-Bold.ttf +0 -0
  221. package/public/fonts/Chakra_Petch/ChakraPetch-BoldItalic.ttf +0 -0
  222. package/public/fonts/Chakra_Petch/ChakraPetch-Italic.ttf +0 -0
  223. package/public/fonts/Chakra_Petch/ChakraPetch-Light.ttf +0 -0
  224. package/public/fonts/Chakra_Petch/ChakraPetch-LightItalic.ttf +0 -0
  225. package/public/fonts/Chakra_Petch/ChakraPetch-Medium.ttf +0 -0
  226. package/public/fonts/Chakra_Petch/ChakraPetch-MediumItalic.ttf +0 -0
  227. package/public/fonts/Chakra_Petch/ChakraPetch-Regular.ttf +0 -0
  228. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBold.ttf +0 -0
  229. package/public/fonts/Chakra_Petch/ChakraPetch-SemiBoldItalic.ttf +0 -0
  230. package/public/fonts/Chakra_Petch/OFL.txt +0 -93
  231. package/public/fonts/Inter/Inter-Italic-VariableFont_opsz,wght.ttf +0 -0
  232. package/public/fonts/Inter/Inter-VariableFont_opsz,wght.ttf +0 -0
  233. package/public/fonts/Inter/OFL.txt +0 -93
  234. package/public/fonts/Inter/README.txt +0 -118
  235. package/public/fonts/JetBrains_Mono/JetBrainsMono-Italic-VariableFont_wght.ttf +0 -0
  236. package/public/fonts/JetBrains_Mono/JetBrainsMono-VariableFont_wght.ttf +0 -0
  237. package/public/fonts/JetBrains_Mono/README.txt +0 -79
  238. package/public/fonts/OpenRing-Regular.ttf +0 -0
  239. package/public/fonts/Open_Sans/OpenSans-Italic-VariableFont_wdth,wght.ttf +0 -0
  240. package/public/fonts/Open_Sans/OpenSans-VariableFont_wdth,wght.ttf +0 -0
  241. package/public/fonts/Open_Sans/README.txt +0 -100
  242. package/public/fonts/Orbitron/OFL.txt +0 -93
  243. package/public/fonts/Orbitron/Orbitron-VariableFont_wght.ttf +0 -0
  244. package/public/fonts/Orbitron/README.txt +0 -68
  245. package/public/fonts/Rajdhani/OFL.txt +0 -93
  246. package/public/fonts/Rajdhani/Rajdhani-Bold.ttf +0 -0
  247. package/public/fonts/Rajdhani/Rajdhani-Light.ttf +0 -0
  248. package/public/fonts/Rajdhani/Rajdhani-Medium.ttf +0 -0
  249. package/public/fonts/Rajdhani/Rajdhani-Regular.ttf +0 -0
  250. package/public/fonts/Rajdhani/Rajdhani-SemiBold.ttf +0 -0
  251. package/public/fonts/runescape-uf.ttf +0 -0
  252. package/public/index.html +0 -769
  253. package/public/js/actor-labels.js +0 -65
  254. package/public/js/api.js +0 -39
  255. package/public/js/app.js +0 -45
  256. package/public/js/artifact-drawer.js +0 -79
  257. package/public/js/board-export.js +0 -161
  258. package/public/js/board-menu.js +0 -374
  259. package/public/js/card-actions.js +0 -545
  260. package/public/js/config.js +0 -20
  261. package/public/js/create-card.js +0 -190
  262. package/public/js/dom.js +0 -19
  263. package/public/js/drawer.js +0 -273
  264. package/public/js/font-preference.js +0 -229
  265. package/public/js/format.js +0 -433
  266. package/public/js/help-menu.js +0 -31
  267. package/public/js/icons.js +0 -16
  268. package/public/js/kanban.js +0 -694
  269. package/public/js/local-backend.js +0 -1015
  270. package/public/js/main.js +0 -161
  271. package/public/js/modal.js +0 -33
  272. package/public/js/motion-preference.js +0 -64
  273. package/public/js/panel.js +0 -58
  274. package/public/js/router.js +0 -134
  275. package/public/js/search.js +0 -56
  276. package/public/js/settings-tabs.js +0 -7
  277. package/public/js/settings.js +0 -1634
  278. package/public/js/sse.js +0 -159
  279. package/public/js/starscape.js +0 -332
  280. package/public/js/state.js +0 -129
  281. package/public/js/text-editing.js +0 -128
  282. package/public/js/theme-preference.js +0 -95
  283. package/public/js/ticket-detail.js +0 -1516
  284. package/public/js/ticket-typeahead.js +0 -49
  285. package/public/js/toast.js +0 -89
  286. package/public/js/topbar-notes.js +0 -155
  287. package/public/js/transport.js +0 -22
  288. package/public/js/typeahead.js +0 -206
  289. package/public/js/unread.js +0 -121
  290. package/public/js/update-check.js +0 -58
  291. package/public/js/url-routes.js +0 -94
  292. package/public/orbits-favicon.png +0 -0
  293. package/public/orbits-logo-bg-wide.png +0 -0
  294. package/public/orbits-logo-bg.png +0 -0
  295. package/public/orbits-logo.png +0 -0
  296. package/public/styles.css +0 -7709
  297. package/src/core/agent.js +0 -387
  298. package/src/core/agents-md.js +0 -109
  299. package/src/core/artifacts.js +0 -149
  300. package/src/core/attachments.js +0 -238
  301. package/src/core/auth.js +0 -85
  302. package/src/core/backups.js +0 -142
  303. package/src/core/board.js +0 -148
  304. package/src/core/boards.js +0 -409
  305. package/src/core/bootstrap.js +0 -170
  306. package/src/core/css-bundle.js +0 -30
  307. package/src/core/db.js +0 -315
  308. package/src/core/delete-board.js +0 -163
  309. package/src/core/dispatch.js +0 -1007
  310. package/src/core/events.js +0 -314
  311. package/src/core/http.js +0 -115
  312. package/src/core/migrate.js +0 -198
  313. package/src/core/paths.js +0 -55
  314. package/src/core/provision-repo-board.js +0 -119
  315. package/src/core/queries.js +0 -246
  316. package/src/core/registry.js +0 -211
  317. package/src/core/relations.js +0 -163
  318. package/src/core/review-verdicts.js +0 -138
  319. package/src/core/router.js +0 -556
  320. package/src/core/search.js +0 -183
  321. package/src/core/seed.js +0 -234
  322. package/src/core/snapshots.js +0 -692
  323. package/src/core/states.js +0 -167
  324. package/src/core/system-picker.js +0 -109
  325. package/src/core/tickets.js +0 -400
  326. package/src/core/util.js +0 -106
  327. package/src/core/version-check.js +0 -154
  328. package/src/mcp/orbit-client.js +0 -287
  329. package/src/server.js +0 -115
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Trello's board export, read as an Orbit export: lists become lanes (roles
3
+ * guessed from their names), cards become cards with their checklists,
4
+ * links and due dates in the description, labels and comments come along.
5
+ * Pure: it only reshapes the data it is given.
6
+ */
7
+ import { slugify } from "./text.js";
8
+ const isRaw = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
9
+ const rawList = (value) => (Array.isArray(value) ? value.filter(isRaw) : []);
10
+ const text = (value) => (typeof value === "string" || typeof value === "number" ? String(value) : "").replace(/\r\n/g, "\n").trim();
11
+ export function looksLikeTrello(value) {
12
+ return isRaw(value) && Array.isArray(value.cards) && Array.isArray(value.lists) && (typeof value.id === "string" || typeof value.name === "string");
13
+ }
14
+ const LABEL_COLORS = {
15
+ green: "#61bd4f",
16
+ yellow: "#f2d600",
17
+ orange: "#ff9f1a",
18
+ red: "#eb5a46",
19
+ purple: "#c377e0",
20
+ blue: "#0079bf",
21
+ sky: "#00c2e0",
22
+ lime: "#51e898",
23
+ pink: "#ff78cb",
24
+ black: "#344563"
25
+ };
26
+ function byPosition(a, b) {
27
+ const pa = Number(a.pos ?? Number.MAX_SAFE_INTEGER);
28
+ const pb = Number(b.pos ?? Number.MAX_SAFE_INTEGER);
29
+ if (pa !== pb)
30
+ return pa - pb;
31
+ return text(a.id).localeCompare(text(b.id));
32
+ }
33
+ function dateFromObjectId(value) {
34
+ const id = text(value);
35
+ if (!/^[0-9a-fA-F]{8}/.test(id))
36
+ return null;
37
+ const seconds = Number.parseInt(id.slice(0, 8), 16);
38
+ return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : null;
39
+ }
40
+ function scopedId(ids, prefix, value) {
41
+ return `trello-${prefix}-${(text(value) || ids.next()).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
42
+ }
43
+ function inferRole(name) {
44
+ const normalized = text(name).toLowerCase();
45
+ if (!normalized)
46
+ return null;
47
+ if (/^ai ready$|ai[- ]ready/.test(normalized))
48
+ return "ai_ready";
49
+ if (/review|qa|verify|validation/.test(normalized))
50
+ return "review";
51
+ if (/doing|in progress|active|working|wip/.test(normalized))
52
+ return "in_progress";
53
+ if (/done|complete|completed|shipped|closed/.test(normalized))
54
+ return "done";
55
+ return null;
56
+ }
57
+ function labelName(label, names) {
58
+ const explicit = text(label.name);
59
+ if (explicit)
60
+ return explicit;
61
+ const color = text(label.color);
62
+ const named = color && isRaw(names) ? text(names[color]) : "";
63
+ if (named)
64
+ return named;
65
+ return color ? `Trello ${color}` : "";
66
+ }
67
+ function priorityOf(card) {
68
+ const names = rawList(card.labels).map((label) => text(label.name || label.color).toLowerCase()).join(" ");
69
+ if (/urgent|critical|blocker|high|red/.test(names))
70
+ return 4;
71
+ if (/low|someday|backlog/.test(names))
72
+ return 1;
73
+ return 2;
74
+ }
75
+ function checklistsText(checklists) {
76
+ return checklists
77
+ .map((checklist) => {
78
+ const items = rawList(checklist.checkItems).sort(byPosition);
79
+ if (!items.length)
80
+ return "";
81
+ return [`Checklist: ${text(checklist.name) || "Untitled"}`, ...items.map((item) => `- [${item.state === "complete" ? "x" : " "}] ${text(item.name) || "Untitled item"}`)].join("\n");
82
+ })
83
+ .filter(Boolean)
84
+ .join("\n\n");
85
+ }
86
+ function attachmentsText(attachments) {
87
+ const lines = attachments.map((attachment) => {
88
+ const name = text(attachment.name) || text(attachment.fileName) || "Attachment";
89
+ const url = text(attachment.url);
90
+ return url ? `- ${name}: ${url}` : `- ${name}`;
91
+ });
92
+ return lines.length ? ["Trello attachments:", ...lines].join("\n") : "";
93
+ }
94
+ function description(card, listName, checklists) {
95
+ const parts = [];
96
+ const desc = text(card.desc);
97
+ if (desc)
98
+ parts.push(desc);
99
+ const meta = [];
100
+ if (card.idShort)
101
+ meta.push(`Trello card #${text(card.idShort)}`);
102
+ if (listName)
103
+ meta.push(`List: ${listName}`);
104
+ if (card.shortUrl || card.url)
105
+ meta.push(`URL: ${text(card.shortUrl || card.url)}`);
106
+ if (card.due)
107
+ meta.push(`Due: ${text(card.due)}${card.dueComplete ? " (complete)" : ""}`);
108
+ if (card.closed)
109
+ meta.push("Archived in Trello: yes");
110
+ if (meta.length)
111
+ parts.push(["Imported from Trello:", ...meta.map((line) => `- ${line}`)].join("\n"));
112
+ const checks = checklistsText(checklists);
113
+ if (checks)
114
+ parts.push(checks);
115
+ const files = attachmentsText(rawList(card.attachments));
116
+ if (files)
117
+ parts.push(files);
118
+ return parts.join("\n\n");
119
+ }
120
+ /** A Trello export as an Orbit export, for the board `target` names (or one named from Trello's). */
121
+ export function trelloToSnapshot(trello, target, clock, ids) {
122
+ const time = clock.now();
123
+ const boardName = text(trello.name) || "Imported Trello Board";
124
+ const boardSlug = target.slug || slugify(boardName) || `trello-${ids.next().slice(0, 8)}`;
125
+ const labels = new Map();
126
+ const addLabel = (raw) => {
127
+ const key = text(raw.id) || `${text(raw.color) || "label"}-${text(raw.name)}`;
128
+ const name = labelName(raw, trello.labelNames);
129
+ if (!name || labels.has(key))
130
+ return;
131
+ labels.set(key, { id: scopedId(ids, "label", key), name, color: LABEL_COLORS[text(raw.color).toLowerCase()] ?? "#64748b", created_at: dateFromObjectId(raw.id) ?? time });
132
+ };
133
+ for (const label of rawList(trello.labels))
134
+ addLabel(label);
135
+ for (const card of rawList(trello.cards))
136
+ for (const label of rawList(card.labels))
137
+ addLabel(label);
138
+ const checklists = new Map();
139
+ const addChecklist = (cardId, checklist) => {
140
+ if (!cardId)
141
+ return;
142
+ checklists.set(cardId, [...(checklists.get(cardId) ?? []), checklist]);
143
+ };
144
+ for (const checklist of rawList(trello.checklists))
145
+ addChecklist(text(checklist.idCard), checklist);
146
+ for (const card of rawList(trello.cards))
147
+ for (const checklist of rawList(card.checklists))
148
+ addChecklist(text(card.id), checklist);
149
+ const commentsByCard = new Map();
150
+ for (const action of rawList(trello.actions)) {
151
+ if (action.type !== "commentCard")
152
+ continue;
153
+ const data = isRaw(action.data) ? action.data : {};
154
+ const cardId = isRaw(data.card) ? text(data.card.id) : "";
155
+ if (!cardId)
156
+ continue;
157
+ commentsByCard.set(cardId, [...(commentsByCard.get(cardId) ?? []), action]);
158
+ }
159
+ for (const actions of commentsByCard.values())
160
+ actions.sort((a, b) => text(a.date).localeCompare(text(b.date)));
161
+ const lists = rawList(trello.lists).sort(byPosition);
162
+ const rolesSeen = new Set();
163
+ const firstOpen = lists.findIndex((list) => !list.closed);
164
+ const defaultIndex = firstOpen >= 0 ? firstOpen : 0;
165
+ const states = lists.map((list, index) => {
166
+ let role = inferRole(list.name);
167
+ if (role && rolesSeen.has(role))
168
+ role = null;
169
+ if (role)
170
+ rolesSeen.add(role);
171
+ return {
172
+ id: scopedId(ids, "list", list.id || `${index + 1}`),
173
+ name: text(list.name) || `Trello List ${index + 1}`,
174
+ position: index,
175
+ is_default: index === defaultIndex ? 1 : 0,
176
+ role,
177
+ created_at: dateFromObjectId(list.id) ?? time
178
+ };
179
+ });
180
+ const fallbackState = states[0]?.id;
181
+ const stateOfList = new Map(lists.map((list, index) => [text(list.id), states[index]?.id ?? fallbackState]));
182
+ const tickets = [];
183
+ const comments = [];
184
+ rawList(trello.cards)
185
+ .sort(byPosition)
186
+ .forEach((card, index) => {
187
+ const cardId = text(card.id);
188
+ const ticketId = scopedId(ids, "card", cardId || `${index + 1}`);
189
+ const stateId = stateOfList.get(text(card.idList)) ?? fallbackState;
190
+ if (!stateId)
191
+ return;
192
+ const updatedAt = text(card.dateLastActivity) || time;
193
+ const cardLabels = [];
194
+ const seen = new Set();
195
+ const addById = (rawId) => {
196
+ const label = labels.get(rawId);
197
+ if (label && !seen.has(label.id)) {
198
+ seen.add(label.id);
199
+ cardLabels.push(label);
200
+ }
201
+ };
202
+ for (const label of rawList(card.labels))
203
+ addById(text(label.id) || `${text(label.color) || "label"}-${text(label.name)}`);
204
+ for (const labelId of Array.isArray(card.idLabels) ? card.idLabels : [])
205
+ addById(text(labelId));
206
+ const listName = text(lists.find((list) => text(list.id) === text(card.idList))?.name);
207
+ tickets.push({
208
+ id: ticketId,
209
+ number: index + 1,
210
+ title: text(card.name) || `Untitled Trello card ${index + 1}`,
211
+ description: description(card, listName, checklists.get(cardId) ?? []),
212
+ type: "task",
213
+ parent_ticket_id: null,
214
+ ai_plan: "",
215
+ implementation_summary: "",
216
+ implementation_updates: "",
217
+ nebula_url: "",
218
+ state_id: stateId,
219
+ priority: priorityOf(card),
220
+ created_by: "trello-import",
221
+ created_at: dateFromObjectId(card.id) ?? updatedAt,
222
+ updated_at: updatedAt,
223
+ archived_at: card.closed ? updatedAt : null,
224
+ labels: cardLabels
225
+ });
226
+ (commentsByCard.get(cardId) ?? []).forEach((action, i) => {
227
+ const member = isRaw(action.memberCreator) ? action.memberCreator : {};
228
+ const data = isRaw(action.data) ? action.data : {};
229
+ comments.push({
230
+ id: scopedId(ids, "comment", action.id || `${cardId}-${i + 1}`),
231
+ ticket_id: ticketId,
232
+ author: text(member.fullName) || text(member.username) || "Trello",
233
+ kind: "human_comment",
234
+ body: text(data.text),
235
+ omitted_for_ai: 0,
236
+ created_at: text(action.date) || updatedAt
237
+ });
238
+ });
239
+ });
240
+ return {
241
+ format: "orbit-board-export",
242
+ source_format: "trello-board-export",
243
+ version: 9,
244
+ exported_at: time,
245
+ include_attachments: false,
246
+ imported_counts: { states: states.length, tickets: tickets.length, comments: comments.length, labels: labels.size },
247
+ board: {
248
+ id: target.id ?? scopedId(ids, "board", trello.id || ids.next()),
249
+ slug: boardSlug,
250
+ name: boardName,
251
+ repo_url: target.repo_url ?? "",
252
+ default_branch: target.default_branch ?? "main",
253
+ project_notes: text(trello.desc) ? `Imported from Trello.\n\n${text(trello.desc)}` : "Imported from Trello.",
254
+ agent_instructions: "",
255
+ ai_enabled: 1,
256
+ created_at: text(trello.dateLastActivity) || time,
257
+ updated_at: time
258
+ },
259
+ states,
260
+ labels: [...labels.values()],
261
+ tickets,
262
+ comments: comments.filter((comment) => comment.body),
263
+ review_verdicts: [],
264
+ relations: [],
265
+ attachments: [],
266
+ board_entries: [],
267
+ events: []
268
+ };
269
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Review verdicts: a reviewer's PASS, BLOCK or QUESTION on a card, with its
3
+ * findings and the commands it ran as structured lists, so tools read them
4
+ * instead of parsing comments.
5
+ */
6
+ import type { BoardCtx } from "./context.ts";
7
+ import type { Id, ReviewVerdict } from "./model.ts";
8
+ declare const LISTS: readonly ["blocking_findings", "optional_findings", "evidence_commands"];
9
+ /** A verdict as answers show it: its lists parsed, under their names without `_json`. */
10
+ export type VerdictView = Omit<ReviewVerdict, "blocking_findings_json" | "optional_findings_json" | "evidence_commands_json"> & Record<(typeof LISTS)[number], unknown[]>;
11
+ export interface NewVerdictInput {
12
+ verdict?: unknown;
13
+ blocking_findings?: unknown;
14
+ optional_findings?: unknown;
15
+ evidence_commands?: unknown;
16
+ reviewer_profile?: unknown;
17
+ reviewer_session_id?: unknown;
18
+ reviewed_commit_sha?: unknown;
19
+ dispatch_run_id?: unknown;
20
+ supersedes_prior_review_id?: unknown;
21
+ }
22
+ export declare function createVerdict(c: BoardCtx, ticketId: Id, input: NewVerdictInput): Promise<VerdictView>;
23
+ export declare function getVerdict(c: BoardCtx, reviewId: Id): Promise<VerdictView>;
24
+ /** A card's verdicts, newest first; 50 unless `limit` says (at most 200). */
25
+ export declare function listVerdicts(c: BoardCtx, ticketId: Id, options?: {
26
+ limit?: unknown;
27
+ }): Promise<VerdictView[]>;
28
+ export {};
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Review verdicts: a reviewer's PASS, BLOCK or QUESTION on a card, with its
3
+ * findings and the commands it ran as structured lists, so tools read them
4
+ * instead of parsing comments.
5
+ */
6
+ import { OrbitError } from "./errors.js";
7
+ import { record, touchTicket } from "./history.js";
8
+ import { optionalString, requiredString } from "./text.js";
9
+ import { ticketOn } from "./tickets.js";
10
+ const VERDICTS = ["PASS", "BLOCK", "QUESTION"];
11
+ const LISTS = ["blocking_findings", "optional_findings", "evidence_commands"];
12
+ function viewOf(row) {
13
+ const { blocking_findings_json, optional_findings_json, evidence_commands_json, ...rest } = row;
14
+ const parse = (json) => JSON.parse(json || "[]");
15
+ return {
16
+ ...rest,
17
+ blocking_findings: parse(blocking_findings_json),
18
+ optional_findings: parse(optional_findings_json),
19
+ evidence_commands: parse(evidence_commands_json),
20
+ supersedes_prior_review_id: rest.supersedes_prior_review_id || null
21
+ };
22
+ }
23
+ function list(value, field) {
24
+ if (value === undefined || value === null)
25
+ return [];
26
+ if (!Array.isArray(value))
27
+ throw new OrbitError(`invalid_${field}`);
28
+ return value;
29
+ }
30
+ export async function createVerdict(c, ticketId, input) {
31
+ const made = await c.data.write(async (w) => {
32
+ const ticket = await ticketOn(w, ticketId);
33
+ const verdict = requiredString(input.verdict, "verdict").toUpperCase();
34
+ if (!VERDICTS.includes(verdict))
35
+ throw new OrbitError("invalid_review_verdict");
36
+ const [blocking, optional, evidence] = LISTS.map((field) => list(input[field], field));
37
+ const supersedes = optionalString(input.supersedes_prior_review_id) || null;
38
+ if (supersedes) {
39
+ const prior = await w.verdicts.get(supersedes);
40
+ if (!prior)
41
+ throw new OrbitError("invalid_supersedes_prior_review_id");
42
+ if (prior.ticket_id !== ticket.id)
43
+ throw new OrbitError("superseded_review_ticket_mismatch");
44
+ }
45
+ const at = c.clock.now();
46
+ const row = {
47
+ id: c.ids.next(),
48
+ ticket_id: ticket.id,
49
+ verdict: verdict,
50
+ blocking_findings_json: JSON.stringify(blocking),
51
+ optional_findings_json: JSON.stringify(optional),
52
+ evidence_commands_json: JSON.stringify(evidence),
53
+ reviewer_profile: optionalString(input.reviewer_profile),
54
+ reviewer_session_id: optionalString(input.reviewer_session_id),
55
+ reviewed_commit_sha: optionalString(input.reviewed_commit_sha),
56
+ dispatch_run_id: optionalString(input.dispatch_run_id),
57
+ supersedes_prior_review_id: supersedes,
58
+ created_by: c.actor.name,
59
+ created_at: at
60
+ };
61
+ await w.verdicts.insert(row);
62
+ await w.tickets.update(ticket.id, { updated_at: at });
63
+ await record(w, c, "review_verdict_created", ticket.id, {
64
+ review_id: row.id,
65
+ verdict,
66
+ supersedes_prior_review_id: supersedes,
67
+ reviewed_commit_sha: row.reviewed_commit_sha,
68
+ dispatch_run_id: row.dispatch_run_id,
69
+ actor_type: c.actor.type,
70
+ actor_id: c.actor.id
71
+ });
72
+ await touchTicket(w, ticket.parent_ticket_id, at);
73
+ return row;
74
+ });
75
+ c.changed(c.board.id);
76
+ return viewOf(made);
77
+ }
78
+ export async function getVerdict(c, reviewId) {
79
+ const row = await c.data.read((r) => r.verdicts.get(reviewId));
80
+ if (!row)
81
+ throw new OrbitError("review_verdict_not_found");
82
+ return viewOf(row);
83
+ }
84
+ /** A card's verdicts, newest first; 50 unless `limit` says (at most 200). */
85
+ export async function listVerdicts(c, ticketId, options = {}) {
86
+ const n = Number(options.limit ?? 50);
87
+ const limit = !Number.isInteger(n) || n <= 0 ? 50 : Math.min(n, 200);
88
+ return c.data.read(async (r) => {
89
+ await ticketOn(r, ticketId);
90
+ return (await r.verdicts.list(ticketId)).reverse().slice(0, limit).map(viewOf);
91
+ });
92
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The shapes answers carry, built from records: a card with its lane's name
3
+ * and its board (`TicketView`), a short card, a card's blockers and who holds
4
+ * it. Each helper reads what it needs as sets, never one query per card.
5
+ */
6
+ import type { Board, Id, Label, LaneRole, OrbitEvent, RegisteredBoard, Relation, State, Ticket } from "./model.ts";
7
+ import type { BoardReader } from "../store/types.ts";
8
+ /** A card as answers show it: its record, its lane's name and role, and its board. */
9
+ export interface TicketView extends Ticket {
10
+ state_name: string;
11
+ state_role: LaneRole | null;
12
+ board_slug: string;
13
+ board_name: string;
14
+ }
15
+ /** A card with its lane, as blockers list them. */
16
+ export interface LanedTicket extends Ticket {
17
+ state_name: string;
18
+ state_role: LaneRole | null;
19
+ /** Set when the card blocks because it is an open sub-card of a blocking epic. */
20
+ via_epic_id?: Id;
21
+ }
22
+ /** A card in a few words, for lists inside answers. */
23
+ export interface CompactTicket {
24
+ id: Id;
25
+ board_id: Id;
26
+ board_slug: string | undefined;
27
+ number: number;
28
+ title: string;
29
+ type: Ticket["type"];
30
+ state_name: string;
31
+ state_role: LaneRole | null;
32
+ priority: Ticket["priority"];
33
+ }
34
+ /** The board as answers name it: its slug and name. */
35
+ export type BoardName = Pick<RegisteredBoard | Board, "slug" | "name">;
36
+ /** A board's lanes, by id. */
37
+ export declare class Lanes {
38
+ readonly list: State[];
39
+ private readonly byId;
40
+ constructor(list: State[]);
41
+ static of(r: BoardReader): Promise<Lanes>;
42
+ get(id: Id): State | undefined;
43
+ /** The first lane, by position, with this role. */
44
+ byRole(role: LaneRole): State | undefined;
45
+ byName(name: string): State | undefined;
46
+ /** The lane holding a role, or by its usual name on a board that never set one. */
47
+ forRole(role: LaneRole, name: string): State | undefined;
48
+ nameOf(ticket: Ticket): string;
49
+ roleOf(ticket: Ticket): LaneRole | null;
50
+ }
51
+ export declare function ticketView(ticket: Ticket, lanes: Lanes, board: BoardName): TicketView;
52
+ export declare function laned(ticket: Ticket, lanes: Lanes): LanedTicket;
53
+ export declare function compactTicket(ticket: LanedTicket & {
54
+ board_slug?: string;
55
+ }): CompactTicket;
56
+ /** Each card's labels, by name, for these cards (all when absent). */
57
+ export declare function labelsByTicket(r: BoardReader, ticketIds?: readonly Id[]): Promise<Map<Id, Label[]>>;
58
+ export declare function byCodePoint(a: string, b: string): number;
59
+ /** The ids of the cards blocking `ticketId` directly: "this blocked_by X" on the card, or "X blocks this" on the blocker. */
60
+ export declare function blockerIds(relations: readonly Relation[], ticketId: Id): Id[];
61
+ /**
62
+ * What blocks a card: its open blockers by number, where an epic stands for
63
+ * its open sub-cards not in a done lane (each with `via_epic_id`). The
64
+ * relation is the record: finishing a blocker deletes it.
65
+ */
66
+ export declare function unresolvedBlockers(r: BoardReader, ticketId: Id, lanes: Lanes): Promise<LanedTicket[]>;
67
+ /**
68
+ * Blockers for many cards at once, from the board's relations and open
69
+ * cards already read: what `view` shows for every open card.
70
+ */
71
+ export declare function blockersFromSets(ticketId: Id, relations: readonly Relation[], open: ReadonlyMap<Id, Ticket>, childrenOf: ReadonlyMap<Id, Ticket[]>, lanes: Lanes): Ticket[];
72
+ /** The events that start or end an agent's hold on a card. */
73
+ export declare const WORK_EVENTS: readonly ["agent_claimed", "state_changed", "agent_completed", "checkpoint_requested"];
74
+ export interface Holder {
75
+ name: string;
76
+ id: string | null;
77
+ since: string;
78
+ }
79
+ /** Who holds a card: the agent whose claim is the card's latest work event, from that event. */
80
+ export declare function holderFrom(event: OrbitEvent | undefined): Holder | null;
81
+ export declare function currentHolder(r: BoardReader, ticketId: Id): Promise<Holder | null>;
82
+ /** Whether a holder is this actor: by id when the claim recorded one, else by name. */
83
+ export declare function isHolder(holder: Holder | null, actor: {
84
+ id: string;
85
+ name: string;
86
+ }): boolean;
87
+ /** A lane that closes work: a done lane, or one named Cancelled or Won't. */
88
+ export declare function isClosedLane(state: Pick<State, "name" | "role">): boolean;
89
+ /** The cards on either side of a card's relations, both open, oldest relation first. */
90
+ export declare function relationRows(r: BoardReader, ticketId: Id, lanes: Lanes, board: BoardName): Promise<{
91
+ id: Id;
92
+ type: string;
93
+ direction: "outgoing" | "incoming";
94
+ other_ticket: TicketView;
95
+ }[]>;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The shapes answers carry, built from records: a card with its lane's name
3
+ * and its board (`TicketView`), a short card, a card's blockers and who holds
4
+ * it. Each helper reads what it needs as sets, never one query per card.
5
+ */
6
+ /** A board's lanes, by id. */
7
+ export class Lanes {
8
+ list;
9
+ byId;
10
+ constructor(list) {
11
+ this.list = list;
12
+ this.byId = new Map(list.map((state) => [state.id, state]));
13
+ }
14
+ static async of(r) {
15
+ return new Lanes(await r.states.list());
16
+ }
17
+ get(id) {
18
+ return this.byId.get(id);
19
+ }
20
+ /** The first lane, by position, with this role. */
21
+ byRole(role) {
22
+ return this.list.find((state) => state.role === role);
23
+ }
24
+ byName(name) {
25
+ return this.list.find((state) => state.name === name);
26
+ }
27
+ /** The lane holding a role, or by its usual name on a board that never set one. */
28
+ forRole(role, name) {
29
+ return this.byRole(role) ?? this.byName(name);
30
+ }
31
+ nameOf(ticket) {
32
+ return this.byId.get(ticket.state_id)?.name ?? "";
33
+ }
34
+ roleOf(ticket) {
35
+ return this.byId.get(ticket.state_id)?.role ?? null;
36
+ }
37
+ }
38
+ export function ticketView(ticket, lanes, board) {
39
+ return { ...ticket, state_name: lanes.nameOf(ticket), state_role: lanes.roleOf(ticket), board_slug: board.slug, board_name: board.name };
40
+ }
41
+ export function laned(ticket, lanes) {
42
+ return { ...ticket, state_name: lanes.nameOf(ticket), state_role: lanes.roleOf(ticket) };
43
+ }
44
+ export function compactTicket(ticket) {
45
+ return {
46
+ id: ticket.id,
47
+ board_id: ticket.board_id,
48
+ board_slug: ticket.board_slug,
49
+ number: ticket.number,
50
+ title: ticket.title,
51
+ type: ticket.type,
52
+ state_name: ticket.state_name,
53
+ state_role: ticket.state_role,
54
+ priority: ticket.priority
55
+ };
56
+ }
57
+ /** Each card's labels, by name, for these cards (all when absent). */
58
+ export async function labelsByTicket(r, ticketIds) {
59
+ const [labels, links] = await Promise.all([r.labels.list(), r.ticketLabels.list(ticketIds)]);
60
+ const byId = new Map(labels.map((label) => [label.id, label]));
61
+ const out = new Map();
62
+ for (const link of links) {
63
+ const label = byId.get(link.label_id);
64
+ if (!label)
65
+ continue;
66
+ const list = out.get(link.ticket_id) ?? [];
67
+ list.push(label);
68
+ out.set(link.ticket_id, list);
69
+ }
70
+ for (const list of out.values())
71
+ list.sort((a, b) => byCodePoint(a.name, b.name));
72
+ return out;
73
+ }
74
+ export function byCodePoint(a, b) {
75
+ return a < b ? -1 : a > b ? 1 : 0;
76
+ }
77
+ /** The ids of the cards blocking `ticketId` directly: "this blocked_by X" on the card, or "X blocks this" on the blocker. */
78
+ export function blockerIds(relations, ticketId) {
79
+ const ids = [];
80
+ for (const relation of relations) {
81
+ if (relation.source_ticket_id === ticketId && relation.type === "blocked_by")
82
+ ids.push(relation.target_ticket_id);
83
+ else if (relation.target_ticket_id === ticketId && relation.type === "blocks")
84
+ ids.push(relation.source_ticket_id);
85
+ }
86
+ return ids;
87
+ }
88
+ /**
89
+ * What blocks a card: its open blockers by number, where an epic stands for
90
+ * its open sub-cards not in a done lane (each with `via_epic_id`). The
91
+ * relation is the record: finishing a blocker deletes it.
92
+ */
93
+ export async function unresolvedBlockers(r, ticketId, lanes) {
94
+ const ids = blockerIds(await r.relations.list(ticketId), ticketId);
95
+ if (!ids.length)
96
+ return [];
97
+ const direct = (await r.tickets.list({ ids, archived: false })).sort((a, b) => a.number - b.number);
98
+ const seen = new Set();
99
+ const out = [];
100
+ for (const blocker of direct) {
101
+ if (blocker.type === "epic") {
102
+ for (const child of await r.tickets.list({ parent: blocker.id, archived: false })) {
103
+ if (lanes.roleOf(child) === "done" || seen.has(child.id))
104
+ continue;
105
+ seen.add(child.id);
106
+ out.push({ ...laned(child, lanes), via_epic_id: blocker.id });
107
+ }
108
+ continue;
109
+ }
110
+ if (seen.has(blocker.id))
111
+ continue;
112
+ seen.add(blocker.id);
113
+ out.push(laned(blocker, lanes));
114
+ }
115
+ return out;
116
+ }
117
+ /**
118
+ * Blockers for many cards at once, from the board's relations and open
119
+ * cards already read: what `view` shows for every open card.
120
+ */
121
+ export function blockersFromSets(ticketId, relations, open, childrenOf, lanes) {
122
+ const direct = blockerIds(relations, ticketId)
123
+ .map((id) => open.get(id))
124
+ .filter((ticket) => ticket !== undefined)
125
+ .sort((a, b) => a.number - b.number);
126
+ const seen = new Set();
127
+ const out = [];
128
+ for (const blocker of direct) {
129
+ const list = blocker.type === "epic" ? (childrenOf.get(blocker.id) ?? []).filter((child) => lanes.roleOf(child) !== "done") : [blocker];
130
+ for (const ticket of list) {
131
+ if (seen.has(ticket.id))
132
+ continue;
133
+ seen.add(ticket.id);
134
+ out.push(ticket);
135
+ }
136
+ }
137
+ return out;
138
+ }
139
+ /** The events that start or end an agent's hold on a card. */
140
+ export const WORK_EVENTS = ["agent_claimed", "state_changed", "agent_completed", "checkpoint_requested"];
141
+ /** Who holds a card: the agent whose claim is the card's latest work event, from that event. */
142
+ export function holderFrom(event) {
143
+ if (event?.type !== "agent_claimed")
144
+ return null;
145
+ let id = null;
146
+ try {
147
+ const body = JSON.parse(event.body_json || "{}");
148
+ id = typeof body.agent_id === "string" && body.agent_id ? body.agent_id : null;
149
+ }
150
+ catch {
151
+ id = null;
152
+ }
153
+ return { name: event.actor, id, since: event.created_at };
154
+ }
155
+ export async function currentHolder(r, ticketId) {
156
+ const [event] = await r.events.latest([ticketId], WORK_EVENTS);
157
+ return holderFrom(event);
158
+ }
159
+ /** Whether a holder is this actor: by id when the claim recorded one, else by name. */
160
+ export function isHolder(holder, actor) {
161
+ if (!holder)
162
+ return false;
163
+ return holder.id ? holder.id === actor.id : holder.name === actor.name;
164
+ }
165
+ /** A lane that closes work: a done lane, or one named Cancelled or Won't. */
166
+ export function isClosedLane(state) {
167
+ return state.role === "done" || /^(cancel|won['\u2019]?t)/i.test(state.name);
168
+ }
169
+ /** The cards on either side of a card's relations, both open, oldest relation first. */
170
+ export async function relationRows(r, ticketId, lanes, board) {
171
+ const relations = await r.relations.list(ticketId);
172
+ if (!relations.length)
173
+ return [];
174
+ const ids = [...new Set(relations.flatMap((relation) => [relation.source_ticket_id, relation.target_ticket_id]))];
175
+ const open = new Map((await r.tickets.list({ ids, archived: false })).map((ticket) => [ticket.id, ticket]));
176
+ const out = [];
177
+ for (const relation of relations) {
178
+ const source = open.get(relation.source_ticket_id);
179
+ const target = open.get(relation.target_ticket_id);
180
+ if (!source || !target)
181
+ continue;
182
+ const outgoing = relation.source_ticket_id === ticketId;
183
+ out.push({ id: relation.id, type: relation.type, direction: outgoing ? "outgoing" : "incoming", other_ticket: ticketView(outgoing ? target : source, lanes, board) });
184
+ }
185
+ return out;
186
+ }