agentbox-app 0.1.6

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 (302) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +176 -0
  3. package/bin/agentbox.mjs +103 -0
  4. package/briefs/adhd-mode.md +5 -0
  5. package/briefs/message-rules.md +95 -0
  6. package/briefs/worker.md +230 -0
  7. package/briefs/writing-rules.md +336 -0
  8. package/main/account-discovery.mjs +145 -0
  9. package/main/account-tooling.mjs +261 -0
  10. package/main/agent-activity.mjs +56 -0
  11. package/main/agent-files.mjs +422 -0
  12. package/main/agent-schedule.mjs +254 -0
  13. package/main/agent-sessions.mjs +435 -0
  14. package/main/agents.mjs +1095 -0
  15. package/main/analytics.mjs +206 -0
  16. package/main/answer-modes.mjs +206 -0
  17. package/main/approval-prompt-server.mjs +160 -0
  18. package/main/approvals.mjs +487 -0
  19. package/main/artifact-path.mjs +144 -0
  20. package/main/claude-bin.mjs +311 -0
  21. package/main/claude-input.mjs +117 -0
  22. package/main/claude-models.mjs +244 -0
  23. package/main/claude-plan.mjs +148 -0
  24. package/main/claude-usage.mjs +149 -0
  25. package/main/code-change.mjs +383 -0
  26. package/main/code-file.mjs +116 -0
  27. package/main/codex-account.mjs +146 -0
  28. package/main/codex-app-server.mjs +796 -0
  29. package/main/codex-approvals.mjs +712 -0
  30. package/main/codex-bin.mjs +261 -0
  31. package/main/codex-compaction.mjs +49 -0
  32. package/main/codex-models.mjs +194 -0
  33. package/main/codex-session.mjs +797 -0
  34. package/main/codex-threads.mjs +268 -0
  35. package/main/codex-usage-file.mjs +192 -0
  36. package/main/codex-usage.mjs +149 -0
  37. package/main/codex-watch.mjs +35 -0
  38. package/main/codex.mjs +757 -0
  39. package/main/config.mjs +345 -0
  40. package/main/copy-selection.mjs +55 -0
  41. package/main/crash-report.mjs +158 -0
  42. package/main/demo.mjs +254 -0
  43. package/main/dev-window.mjs +103 -0
  44. package/main/doc-file.mjs +99 -0
  45. package/main/doc-scheme.mjs +126 -0
  46. package/main/first-run.mjs +190 -0
  47. package/main/folders.mjs +91 -0
  48. package/main/fresh-user.mjs +120 -0
  49. package/main/git-change.mjs +323 -0
  50. package/main/img-scheme.mjs +90 -0
  51. package/main/instruction-settings.mjs +205 -0
  52. package/main/ipc.mjs +1558 -0
  53. package/main/live-replies.mjs +77 -0
  54. package/main/machine.mjs +113 -0
  55. package/main/main.mjs +972 -0
  56. package/main/menu.mjs +177 -0
  57. package/main/notify.mjs +155 -0
  58. package/main/open-source-file.mjs +8 -0
  59. package/main/project-identity.mjs +149 -0
  60. package/main/provider-commands.mjs +32 -0
  61. package/main/rail-note.mjs +74 -0
  62. package/main/repeats.mjs +261 -0
  63. package/main/row-label.mjs +185 -0
  64. package/main/screen-detail.mjs +44 -0
  65. package/main/serve.mjs +262 -0
  66. package/main/settings.mjs +912 -0
  67. package/main/staleness.mjs +76 -0
  68. package/main/store/chats.mjs +188 -0
  69. package/main/store/home.mjs +383 -0
  70. package/main/store/migrate.mjs +40 -0
  71. package/main/store/paths.mjs +82 -0
  72. package/main/store/pending-writes.mjs +122 -0
  73. package/main/store/project-lock.mjs +200 -0
  74. package/main/store/project.mjs +523 -0
  75. package/main/store/queues.mjs +31 -0
  76. package/main/store/work-items.mjs +299 -0
  77. package/main/store/write-queue.mjs +14 -0
  78. package/main/store-modules.mjs +23 -0
  79. package/main/store.mjs +1269 -0
  80. package/main/supervisor.mjs +6875 -0
  81. package/main/task-commands.mjs +161 -0
  82. package/main/task-folders-offthread.mjs +84 -0
  83. package/main/task-folders-thread.mjs +13 -0
  84. package/main/task-folders.mjs +409 -0
  85. package/main/task-remote-control.mjs +65 -0
  86. package/main/task-terminals.mjs +169 -0
  87. package/main/terminal-access.mjs +6 -0
  88. package/main/updater.mjs +240 -0
  89. package/main/zoom-keys.mjs +54 -0
  90. package/main/zoom-readout.mjs +20 -0
  91. package/mcp/agentbox-mcp.sh +34 -0
  92. package/mcp/core/account.mjs +77 -0
  93. package/mcp/core/chat.mjs +61 -0
  94. package/mcp/core/documents.mjs +238 -0
  95. package/mcp/core/page.mjs +152 -0
  96. package/mcp/core/products.mjs +110 -0
  97. package/mcp/core/skills.mjs +126 -0
  98. package/mcp/core/work.mjs +377 -0
  99. package/mcp/stdio.mjs +70 -0
  100. package/mcp/tools.mjs +257 -0
  101. package/package.json +186 -0
  102. package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
  103. package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
  104. package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
  105. package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
  106. package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
  107. package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
  108. package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
  109. package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
  110. package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
  111. package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
  112. package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
  113. package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
  114. package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
  115. package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
  116. package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
  117. package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
  118. package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
  119. package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
  120. package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
  121. package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
  122. package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
  123. package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
  124. package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
  125. package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
  126. package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
  127. package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
  128. package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
  129. package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
  130. package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
  131. package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
  132. package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
  133. package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
  134. package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
  135. package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
  136. package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
  137. package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
  138. package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
  139. package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
  140. package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
  141. package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
  142. package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
  143. package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
  144. package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
  145. package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
  146. package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
  147. package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
  148. package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
  149. package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
  150. package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
  151. package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
  152. package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
  153. package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
  154. package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
  155. package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
  156. package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
  157. package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
  158. package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
  159. package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
  160. package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
  161. package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
  162. package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
  163. package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
  164. package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
  165. package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
  166. package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
  167. package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
  168. package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
  169. package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
  170. package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
  171. package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
  172. package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
  173. package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
  174. package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
  175. package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
  176. package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
  177. package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
  178. package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
  179. package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
  180. package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
  181. package/renderer/dist/emulator/audio-rules.mjs +74 -0
  182. package/renderer/dist/emulator/data/compression/README.md +11 -0
  183. package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
  184. package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
  185. package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
  186. package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
  187. package/renderer/dist/emulator/data/cores/README.md +22 -0
  188. package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
  189. package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
  190. package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
  191. package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
  192. package/renderer/dist/emulator/data/cores/package.json +65 -0
  193. package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
  194. package/renderer/dist/emulator/data/emulator.css +1614 -0
  195. package/renderer/dist/emulator/data/loader.js +170 -0
  196. package/renderer/dist/emulator/data/localization/README.md +63 -0
  197. package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
  198. package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
  199. package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
  200. package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
  201. package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
  202. package/renderer/dist/emulator/data/localization/en-US.json +339 -0
  203. package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
  204. package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
  205. package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
  206. package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
  207. package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
  208. package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
  209. package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
  210. package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
  211. package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
  212. package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
  213. package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
  214. package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
  215. package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
  216. package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
  217. package/renderer/dist/emulator/data/src/GameManager.js +464 -0
  218. package/renderer/dist/emulator/data/src/compression.js +142 -0
  219. package/renderer/dist/emulator/data/src/emulator.js +6158 -0
  220. package/renderer/dist/emulator/data/src/gamepad.js +167 -0
  221. package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
  222. package/renderer/dist/emulator/data/src/shaders.js +236 -0
  223. package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
  224. package/renderer/dist/emulator/data/src/storage.js +125 -0
  225. package/renderer/dist/emulator/data/version.json +5 -0
  226. package/renderer/dist/emulator/host.js +299 -0
  227. package/renderer/dist/emulator/index.html +28 -0
  228. package/renderer/dist/emulator/provenance.json +6 -0
  229. package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
  230. package/renderer/dist/index.html +13 -0
  231. package/scripts/after-install.mjs +59 -0
  232. package/scripts/approval-server.sh +43 -0
  233. package/scripts/before-publish.mjs +133 -0
  234. package/shared/agent-import.d.mts +13 -0
  235. package/shared/agent-import.mjs +252 -0
  236. package/shared/agents.d.mts +190 -0
  237. package/shared/agents.mjs +937 -0
  238. package/shared/analytics-events.mjs +63 -0
  239. package/shared/answers.d.mts +12 -0
  240. package/shared/answers.mjs +80 -0
  241. package/shared/artifact-keys.d.mts +28 -0
  242. package/shared/artifact-keys.mjs +140 -0
  243. package/shared/bridge-map.mjs +128 -0
  244. package/shared/claude-commands.d.mts +20 -0
  245. package/shared/claude-commands.generated.mjs +149 -0
  246. package/shared/claude-commands.mjs +131 -0
  247. package/shared/claude-models.generated.d.mts +15 -0
  248. package/shared/claude-models.generated.mjs +26 -0
  249. package/shared/claude-usage.d.mts +16 -0
  250. package/shared/claude-usage.mjs +172 -0
  251. package/shared/codex-commands.d.mts +4 -0
  252. package/shared/codex-commands.mjs +17 -0
  253. package/shared/codex-import.d.mts +38 -0
  254. package/shared/codex-import.mjs +232 -0
  255. package/shared/codex-modes.mjs +159 -0
  256. package/shared/codex-usage.mjs +180 -0
  257. package/shared/contracts.mjs +412 -0
  258. package/shared/crash-scrub.mjs +182 -0
  259. package/shared/dashboard.mjs +481 -0
  260. package/shared/dead-run-trace.mjs +127 -0
  261. package/shared/demo-world.mjs +415 -0
  262. package/shared/effort-levels.d.mts +15 -0
  263. package/shared/effort-levels.mjs +76 -0
  264. package/shared/engines.d.mts +62 -0
  265. package/shared/engines.mjs +389 -0
  266. package/shared/first-run-examples.mjs +60 -0
  267. package/shared/first-run-line.mjs +149 -0
  268. package/shared/first-run-practice.d.mts +32 -0
  269. package/shared/first-run-practice.mjs +254 -0
  270. package/shared/first-run-shape.mjs +100 -0
  271. package/shared/fresh-user-home.mjs +189 -0
  272. package/shared/hook-failure.mjs +22 -0
  273. package/shared/install-id.mjs +39 -0
  274. package/shared/instruction-defaults.json +5 -0
  275. package/shared/library-folder.mjs +28 -0
  276. package/shared/notify-rules.mjs +120 -0
  277. package/shared/product-name.d.mts +6 -0
  278. package/shared/product-name.mjs +144 -0
  279. package/shared/project-folder-check.d.mts +22 -0
  280. package/shared/project-folder-check.mjs +152 -0
  281. package/shared/provider-commands.d.mts +8 -0
  282. package/shared/provider-commands.mjs +58 -0
  283. package/shared/rank.d.mts +19 -0
  284. package/shared/rank.mjs +146 -0
  285. package/shared/recovery.mjs +26 -0
  286. package/shared/referenced-files.d.mts +11 -0
  287. package/shared/referenced-files.mjs +122 -0
  288. package/shared/repeats.d.mts +45 -0
  289. package/shared/repeats.mjs +193 -0
  290. package/shared/schemes.mjs +33 -0
  291. package/shared/settings-terminal.d.mts +1 -0
  292. package/shared/settings-terminal.mjs +14 -0
  293. package/shared/side-build.mjs +57 -0
  294. package/shared/spawn-trouble.d.mts +8 -0
  295. package/shared/spawn-trouble.mjs +599 -0
  296. package/shared/terminal-state.d.mts +1 -0
  297. package/shared/terminal-state.mjs +58 -0
  298. package/shared/usage.d.mts +53 -0
  299. package/shared/usage.mjs +319 -0
  300. package/shared/work-items.mjs +561 -0
  301. package/shared/work-lines.mjs +360 -0
  302. package/worker-permissions.json +6 -0
@@ -0,0 +1,561 @@
1
+ // Work items: the neutral substrate under a self-driving product.
2
+ //
3
+ // This module is the PURE core, zero-dep ESM, so every writer shares one set of
4
+ // rules: the MCP server (many processes at once), the app's main process, and a
5
+ // renderer that folds the same lines to draw the same list. No filesystem, no
6
+ // network, no Date.now except as an injected default.
7
+ //
8
+ // What lives here is MECHANISM ONLY: identity, lifecycle, claiming, leases,
9
+ // fencing. What a work item MEANS (a milestone, a question, a bug, a next move,
10
+ // whether a category has earned the right to act without asking) is deliberately
11
+ // NOT here. That judgment belongs to the surface above this one, which is going
12
+ // to iterate hard, and a substrate that encoded this week's theory of work would
13
+ // have to be rewritten every time the theory moved.
14
+ //
15
+ // The storage shape is the ledger the dashboard already uses: append-only jsonl,
16
+ // folded newest-wins per field. There is no read-modify-write, so a dozen server
17
+ // processes cannot lose each other's writes. Two rules make that safe rather
18
+ // than merely likely:
19
+ //
20
+ // FENCING Every claim takes a monotonically increasing epoch. Every later
21
+ // agent line carries the epoch it was written under, and the fold
22
+ // DISCARDS any agent line whose epoch is below the current one.
23
+ // Without it, lease expiry is a zombie-writer bug: worker A's lease
24
+ // expires mid-task, B claims, then A (alive, just slow) appends
25
+ // "done" and newest-wins believes A. With it, breaking a lease is
26
+ // safe rather than mostly safe.
27
+ //
28
+ // AUTHORITY The founder is not a worker, so a founder line carries NO epoch, is
29
+ // never discarded as stale, and outranks any agent line for the fields it sets.
30
+ // This is what lets the app write an answer while a worker still holds the
31
+ // claim: the answer lands, the worker's own status keeps flowing under its
32
+ // epoch, and neither erases the other. Same rule as beats in
33
+ // shared/dashboard.mjs.
34
+
35
+ /* ------------------------------- lifecycle ------------------------------- */
36
+ // The only states the claim machinery reasons about. Everything else about what
37
+ // a work item is (kind, labels) is free-form text the layer above interprets.
38
+ //
39
+ // blocked is not a synonym for "hard": it means this item cannot proceed until
40
+ // something outside it changes, which is the one distinction a puller has to
41
+ // make, since claiming a blocked item wastes a whole session.
42
+ export const WORK_ITEM_STATUSES = ['open', 'claimed', 'done', 'blocked'];
43
+
44
+ // Claimable states. done is terminal; claimed belongs to someone until its lease
45
+ // lapses; blocked is skipped by a filter-based pull but can still be claimed by
46
+ // id, because a worker sent specifically to unblock something should not be told
47
+ // no by its own queue.
48
+ const PULLABLE = ['open'];
49
+
50
+ export const WORK_ITEM_SOURCES = ['agent', 'founder', 'system'];
51
+
52
+ // CLAUDE CODE'S OWN SIX PERMISSION MODES, by its own values. Read off
53
+ // `claude --help` on 2.1.241 and off code.claude.com/docs/en/permission-modes,
54
+ // 2026-08-23, and every one of them handed to the real CLI to check it starts.
55
+ // The renderer, main/settings.mjs and main/answer-modes.mjs each have their own
56
+ // reason to know this list. IT IS NOT A LEDGER FIELD ANY MORE: what one reply is
57
+ // allowed to do moved out of this file entirely; the note further down says
58
+ // why. This list stays here because it is a plain browser-safe constant and
59
+ // everything that needs it already imports this module.
60
+ export const CLAUDE_PERMISSION_MODES = [
61
+ 'default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions',
62
+ ];
63
+
64
+ // THE ONE FIELD THAT WAS A GRANT RATHER THAN AN OPINION HAS LEFT THIS FILE.
65
+ //
66
+ // `answerMode` used to live on the ledger: what one reply is allowed to do on
67
+ // the founder's Mac, turned straight into `--permission-mode` on a real process
68
+ // by spawnPlan. A guard here stripped it off any line that did not claim to be
69
+ // hers, which closed the STRUCTURED path, the MCP and the app.
70
+ //
71
+ // IT COULD NOT CLOSE THE FILE, AND NOTHING WRITTEN HERE EVER COULD. `source` is
72
+ // a plain string in a line and nothing checks it. A worker with Bash could
73
+ // append one line saying `"source":"founder"` and grant its own next run
74
+ // bypassPermissions; the rule and the forgery lived in the same file. Proved on
75
+ // a scratch ledger, 2026-08-24.
76
+ //
77
+ // So the grant left rather than being guarded harder. It is kept in the app's
78
+ // own storage now, written by main and by nothing else (main/answer-modes.mjs),
79
+ // which is the convention Claude Code uses for its standing policy. Everything
80
+ // on the ledger today is a claim about the WORK, and a forged line can still
81
+ // fake her archive or her answer — true of any append-only log nobody signs, and
82
+ // filed for her rather than papered over here. What it can no longer do is hand
83
+ // anything a permission.
84
+ //
85
+ // If a future field is a grant rather than an opinion, the answer is the same
86
+ // one: it does not go on the ledger.
87
+
88
+ // Fields a patch may set. An unrecognized key is DROPPED rather than stored: the
89
+ // ledger is a contract, not a bag, and a typo'd field that silently becomes
90
+ // state is worse than one that visibly does nothing.
91
+ // `label` IS THE NAME ON THE ROW, AND IT EXISTS BECAUSE `title` CANNOT BE ONE.
92
+ //
93
+ // A message she dictates becomes an item whose title is its first line,
94
+ // verbatim (message-split.ts). Measured on her Agentbox store, 2026-09-03: 214
95
+ // items are hers, median title 84 characters, 145 of them over 60, 37 ending in
96
+ // an ellipsis mid sentence, and 76 opening with "I", "I've been", "Can you" or
97
+ // "We". Eleven of those in a column is eleven rows that look like one row.
98
+ //
99
+ // The obvious fix, having a session write a better title, does not work and
100
+ // never did: `beats` below makes her outrank an agent per field, so 208 of those
101
+ // 214 titles are hers and an agent's rewrite of one is accepted here and then
102
+ // dropped on the floor, silently. That is the whole reason this is a second
103
+ // field rather than a better-behaved writer.
104
+ //
105
+ // So `title` stays exactly what she typed and nothing about who owns it changes.
106
+ // `label` is the short written name a session puts beside it, and the LIST
107
+ // prefers it (`rowTitle`, list-rules.ts). Everything else still reads the title,
108
+ // so her own words are what she finds when she opens the row, and search matches
109
+ // either name.
110
+ export const WORK_ITEM_FIELDS = [
111
+ 'title', 'label', 'body', 'kind', 'labels', 'priority', 'parent',
112
+ 'status', 'result', 'note', 'answer', 'product', 'runAt', 'answeredThrough',
113
+ 'engine', 'model', 'effort',
114
+ ];
115
+
116
+ // How long a claim survives without a heartbeat. The MCP server heartbeats on a
117
+ // timer while it holds any claim, so this is a liveness signal about the PROCESS
118
+ // (session dies, child dies, heartbeats stop) rather than anything the model has
119
+ // to remember to do. Generous on purpose: a worker wrongly losing a claim
120
+ // mid-task is a worse failure than an abandoned item sitting idle a few minutes.
121
+ export const LEASE_MS = 5 * 60 * 1000;
122
+
123
+ /* --------------------------------- limits -------------------------------- */
124
+ // Every budget here is in BYTES, because the only ceiling that actually breaks
125
+ // is a byte one: the disk writer refuses a line over MAX_LINE_BYTES outright,
126
+ // and a refused write loses a whole report rather than its tail.
127
+ const MAX_TITLE = 200;
128
+ const MAX_SHORT = 200;
129
+ // ONE budget for every long text, because a result is the answer to a body and
130
+ // there is no reason an answer may be shorter than the question. It used to be
131
+ // a quarter of it, and on 2026-08-11 three of the founder's finished research
132
+ // answers reached her reading pane cut mid-sentence for no reason she could
133
+ // see. Four fields at this size still leave a line at half the append ceiling.
134
+ export const MAX_TEXT_BYTES = 32 * 1024;
135
+ const MAX_LABELS = 20;
136
+ const MAX_ID_CHARS = 64;
137
+ // A single line longer than this is treated as torn or hostile and skipped
138
+ // rather than parsed. Matches the ceiling records.mjs already uses.
139
+ export const MAX_LINE_BYTES = 256 * 1024;
140
+
141
+ const ID_RE = /^[a-zA-Z0-9_-]{1,64}$/;
142
+
143
+ export function isWorkItemId(id) {
144
+ return typeof id === 'string' && ID_RE.test(id);
145
+ }
146
+
147
+ /* --------------------------------- lines --------------------------------- */
148
+ // One appended line. Kept as a builder rather than an object literal at each
149
+ // call site so the MCP and the app cannot drift on shape, and so validation runs
150
+ // once, here, before anything reaches disk.
151
+ export function buildLine({ id, patch, source = 'agent', epoch = null, now = Date.now() }) {
152
+ if (!isWorkItemId(id)) throw new Error(`invalid work item id: ${id}`);
153
+ const src = WORK_ITEM_SOURCES.includes(source) ? source : 'agent';
154
+ const clean = pickFields(patch);
155
+ if (!clean || !Object.keys(clean).length) throw new Error('patch had no recognized work item fields');
156
+ const line = { id, ts: Number.isFinite(now) ? now : Date.now(), source: src, patch: clean };
157
+ // Only an agent is fenced. A founder line carrying an epoch would be claiming
158
+ // to be a worker, so the epoch is dropped rather than honored.
159
+ if (src === 'agent' && Number.isFinite(epoch)) line.epoch = epoch;
160
+ return line;
161
+ }
162
+
163
+ // A claim line is its own shape: it is the thing that MINTS an epoch rather than
164
+ // being fenced by one, so it never goes through buildLine.
165
+ export function buildClaimLine({ id, epoch, holder, now = Date.now(), leaseMs = LEASE_MS }) {
166
+ if (!isWorkItemId(id)) throw new Error(`invalid work item id: ${id}`);
167
+ if (!Number.isFinite(epoch) || epoch < 1) throw new Error(`invalid epoch: ${epoch}`);
168
+ const ts = Number.isFinite(now) ? now : Date.now();
169
+ return {
170
+ id, ts, source: 'agent', epoch, claim: {
171
+ holder: String(holder ?? 'unknown').slice(0, MAX_ID_CHARS),
172
+ leaseUntil: ts + (Number.isFinite(leaseMs) ? leaseMs : LEASE_MS),
173
+ },
174
+ patch: { status: 'claimed' },
175
+ };
176
+ }
177
+
178
+ // A heartbeat extends the lease and nothing else. Written by the server on a
179
+ // timer, never by a model.
180
+ //
181
+ // It carries NO patch, and that is the whole point: built from buildClaimLine it
182
+ // inherited `status: 'claimed'`, so every tick restated a status the worker may
183
+ // have moved on from, and one landed three seconds after the founder archived an
184
+ // item and pulled it back out of her archive. The fold ignores a heartbeat's
185
+ // patch too, for the lines already written.
186
+ export function buildHeartbeatLine({ id, epoch, holder, now = Date.now(), leaseMs = LEASE_MS }) {
187
+ const line = buildClaimLine({ id, epoch, holder, now, leaseMs });
188
+ line.heartbeat = true;
189
+ line.patch = {};
190
+ return line;
191
+ }
192
+
193
+ // Release: hand the item back without completing it. Fenced like any agent line,
194
+ // so a zombie cannot release its successor's claim.
195
+ //
196
+ // A RELEASE GIVES UP A CLAIM AND SAYS NOTHING ABOUT STATUS. It used to carry
197
+ // patch:{status:'open'}, which made a clean shutdown erase a status the worker
198
+ // had deliberately set: mark an item blocked, exit tidily, and the goodbye
199
+ // handler released the still-held claim and reopened it, so the queue spawned
200
+ // another worker onto work that could not proceed (four times on one item). The
201
+ // tidier the exit, the worse the outcome, since a worker killed outright kept
202
+ // its blocked status. Handing back an UNFINISHED claim still reads open,
203
+ // because the fold derives that from the claim being gone rather than from
204
+ // anyone writing it down.
205
+ export function buildReleaseLine({ id, epoch, now = Date.now() }) {
206
+ if (!isWorkItemId(id)) throw new Error(`invalid work item id: ${id}`);
207
+ return { id, ts: Number.isFinite(now) ? now : Date.now(), source: 'agent', epoch, release: true };
208
+ }
209
+
210
+ /* -------------------------------- reading -------------------------------- */
211
+ // One line, defensively read. Anything unparseable returns null so a corrupt
212
+ // ledger degrades to an older picture rather than a broken list.
213
+ export function normalizeLine(line) {
214
+ let raw = line;
215
+ if (typeof raw === 'string') {
216
+ const text = raw.trim();
217
+ if (!text) return null;
218
+ try { raw = JSON.parse(text); } catch { return null; }
219
+ }
220
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
221
+ if (!isWorkItemId(raw.id)) return null;
222
+ const patch = raw.patch;
223
+ if (patch != null && (typeof patch !== 'object' || Array.isArray(patch))) return null;
224
+ const source = WORK_ITEM_SOURCES.includes(raw.source) ? raw.source : 'agent';
225
+ // Validate on READ, not only on write. buildLine cleans what WE append, but
226
+ // the file is the contract and anything can be in it: a line from an older
227
+ // build, a hand edit, a future writer that learned a field we do not know.
228
+ // A garbled field must leave the older value standing rather than become
229
+ // state, so coercion happens here, where every reader passes.
230
+ // Stripped on READ as well as on write, because the file is the contract and
231
+ // anything can be in it: an older build, a hand edit, or a writer that is not
232
+ // us. A grant a non-founder line carries never becomes state.
233
+ const cleanPatch = pickFields(patch) ?? {};
234
+ return {
235
+ id: raw.id,
236
+ ts: Number.isFinite(raw.ts) ? raw.ts : 0,
237
+ source,
238
+ // A founder line is never fenced, so its epoch is ignored even if present.
239
+ epoch: source === 'agent' && Number.isFinite(raw.epoch) ? raw.epoch : null,
240
+ claim: raw.claim && typeof raw.claim === 'object' ? raw.claim : null,
241
+ heartbeat: raw.heartbeat === true,
242
+ release: raw.release === true,
243
+ patch: cleanPatch,
244
+ };
245
+ }
246
+
247
+ // Keep only recognized fields, coerced. Returns null when nothing survives, so a
248
+ // caller can refuse the write instead of appending a line that means nothing.
249
+ export function pickFields(patch) {
250
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return null;
251
+ const out = {};
252
+ for (const [field, value] of Object.entries(patch)) {
253
+ if (!WORK_ITEM_FIELDS.includes(field)) continue;
254
+ const clean = coerceField(field, value);
255
+ if (clean !== undefined) out[field] = clean;
256
+ }
257
+ return Object.keys(out).length ? out : null;
258
+ }
259
+
260
+ // Over-long text is CUT WITH A MARK, never silently. Five digest rows sit in
261
+ // the founder's ledger because workers appending to a nearly-full note could
262
+ // not tell whether the store would keep what they wrote, and correctly refused
263
+ // to risk dropping news she had not read; each opened a fresh row instead
264
+ // (2026-08-06). A writer that can see it lost 400 characters can summarise and
265
+ // try again. One that cannot has to guess, and guessing is what flooded her.
266
+ const encoder = new TextEncoder();
267
+ const byteLength = (text) => encoder.encode(text).length;
268
+
269
+ function str(value, max) {
270
+ if (typeof value !== 'string') return undefined;
271
+ const trimmed = value.trim();
272
+ if (!trimmed) return undefined;
273
+ if (byteLength(trimmed) <= max) return trimmed;
274
+ const mark = (n) => `\n\n[truncated: ${n} characters dropped by the store]`;
275
+ const fits = (keep) => byteLength(trimmed.slice(0, keep) + mark(trimmed.length - keep)) <= max;
276
+ // The longest prefix that still leaves room for the mark describing the cut,
277
+ // since the mark must not itself overflow the field it is describing. Cost
278
+ // rises with every character kept and the count it removes from the mark
279
+ // never adds a byte, so the edge is a clean one and a search finds it exactly.
280
+ let lo = 0;
281
+ let hi = trimmed.length;
282
+ while (lo < hi) {
283
+ const mid = Math.ceil((lo + hi) / 2);
284
+ if (fits(mid)) lo = mid; else hi = mid - 1;
285
+ }
286
+ // Never end on half of a surrogate pair: the budget is bytes, but the cut is
287
+ // made in code units, and an emoji split down the middle is a broken glyph in
288
+ // her reading pane on top of the loss the mark is already reporting.
289
+ const high = lo > 0 && lo < trimmed.length ? trimmed.charCodeAt(lo - 1) : 0;
290
+ if (high >= 0xd800 && high <= 0xdbff) lo -= 1;
291
+ return trimmed.slice(0, lo) + mark(trimmed.length - lo);
292
+ }
293
+
294
+ function coerceField(field, value) {
295
+ switch (field) {
296
+ case 'title': return str(value, MAX_TITLE);
297
+ // The written name the list draws. Bounded the same way the title is and no
298
+ // tighter: what a NAME may be (three to seven words, 64 characters) is a
299
+ // rule about how the app writes, and it lives with the writer in
300
+ // main/row-label.mjs. This module holds no taxonomy, only storage.
301
+ case 'label': return str(value, MAX_TITLE);
302
+ case 'body': case 'result': case 'note': case 'answer': return str(value, MAX_TEXT_BYTES);
303
+ // kind and product are free strings on purpose: the taxonomy of work is the
304
+ // caller's to invent and change without a migration here.
305
+ //
306
+ // `engine` (WHICH CODING AGENT PICKS THIS UP) and `model` join them for the
307
+ // same stated reason: the list of engines is the layer above's to grow, and
308
+ // this module holds no taxonomy. Nothing here has to validate them because
309
+ // nothing here reads them — `engineFor` in shared/engines.mjs falls back to
310
+ // Claude Code for any name it does not know, so an unrecognised value is a
311
+ // working session rather than a row that silently never runs. `effort` (HOW
312
+ // HARD IT THINKS) is the same kind of word: the five the CLI takes live in
313
+ // shared/effort-levels.mjs, and the spawn gates on them, so a value this
314
+ // module does not recognise is a run at Claude Code's own choice rather
315
+ // than a row that never runs.
316
+ case 'kind': case 'product': case 'parent': case 'engine': case 'model': case 'effort': return str(value, MAX_SHORT);
317
+ case 'status': return WORK_ITEM_STATUSES.includes(value) ? value : undefined;
318
+ case 'priority': return Number.isFinite(value) ? Math.trunc(value) : undefined;
319
+ // When this item may next be acted on, in epoch ms. 0 means unscheduled,
320
+ // and it is a REAL value rather than a missing one on purpose: a field
321
+ // that coerces to undefined is dropped by the fold, so without a falsy
322
+ // sentinel a schedule could be set and never cleared.
323
+ case 'runAt': return Number.isFinite(value) ? Math.trunc(value) : undefined;
324
+ // WHICH ANSWER A SESSION HAS ALREADY ACTED ON, as that answer's own ts.
325
+ //
326
+ // An answer had two states and the ledger recorded one. A worker finishes
327
+ // acting on the founder's words and leaves the row where it found it, open
328
+ // and still carrying them, because the thread is alive and closing it is
329
+ // the wrong move. Nothing on disk said the words had been DEALT WITH, so a
330
+ // finished thread and a worker that died mid-sentence were the same item,
331
+ // and every reader guessed: the app kept them out of her inbox, printed them
332
+ // red as "stopped", and respawned workers on them forever (one row took 23
333
+ // sessions on the same two-day-old word, every one a successful no-op).
334
+ //
335
+ // A ts rather than a flag, so her NEXT answer outdates it by carrying a
336
+ // later one, with nothing to clear and nobody who has to remember to.
337
+ case 'answeredThrough': return Number.isFinite(value) ? Math.trunc(value) : undefined;
338
+ case 'labels': {
339
+ if (!Array.isArray(value)) return undefined;
340
+ const labels = value.map((l) => str(l, MAX_SHORT)).filter(Boolean).slice(0, MAX_LABELS);
341
+ return labels.length ? labels : undefined;
342
+ }
343
+ default: return undefined;
344
+ }
345
+ }
346
+
347
+ /* --------------------------------- folding ------------------------------- */
348
+ // Fold every line into the current picture: a Map of id to work item.
349
+ //
350
+ // Each item carries `wrote`: per field, WHO set the value that survived and
351
+ // WHEN. It is the fold's own bookkeeping (it is how founder-outranks-agent is
352
+ // enforced) and it used to be deleted on the way out, on the grounds that no
353
+ // reader needed it. Two readers did, and both failures were silent:
354
+ //
355
+ // WHO An agent's `done` and the founder's archive are the same word for
356
+ // opposite events. Her own ask, ended by an agent, is an answer she has
357
+ // not received; ended by her, it is filed. the app's inbox could not tell
358
+ // them apart, so every answer to a question she asked went straight to
359
+ // the archive and she reasonably concluded the work never happened
360
+ // (2026-08-06: she asked the same question three times in six hours,
361
+ // and all three had been answered within twenty minutes).
362
+ // WHEN Two identical nudges are two asks. A delivery keyed on the words
363
+ // alone treats the second as already handled and swallows it.
364
+ //
365
+ // Order of business per line, and the order matters:
366
+ // 1. skip anything unparseable
367
+ // 2. skip an agent line fenced out by a newer epoch (the zombie writer)
368
+ // 3. apply the claim, if this line carries one
369
+ // 4. apply the patch field by field, founder outranking agent
370
+ export function foldWorkItems(lines, now = Date.now()) {
371
+ const items = new Map();
372
+
373
+ for (const raw of Array.isArray(lines) ? lines : []) {
374
+ const line = normalizeLine(raw);
375
+ if (!line) continue;
376
+
377
+ let item = items.get(line.id);
378
+ if (!item) {
379
+ item = {
380
+ id: line.id, status: 'open', title: '', kind: '', labels: [],
381
+ priority: 0, epoch: 0, claim: null, createdAt: line.ts, updatedAt: line.ts,
382
+ wrote: {},
383
+ };
384
+ items.set(line.id, item);
385
+ }
386
+
387
+ // Fencing. An agent line written under a superseded epoch is the whole
388
+ // reason this field exists: it is a live worker whose lease lapsed, and
389
+ // everything it has to say about this item is now someone else's business.
390
+ if (line.source === 'agent' && line.epoch != null && line.epoch < item.epoch) continue;
391
+
392
+ if (line.claim) {
393
+ // A claim at or above the current epoch takes the item. A heartbeat only
394
+ // extends a lease it still legitimately holds.
395
+ if (line.epoch >= item.epoch) {
396
+ item.epoch = line.epoch;
397
+ item.claim = {
398
+ holder: String(line.claim.holder ?? ''),
399
+ leaseUntil: Number.isFinite(line.claim.leaseUntil) ? line.claim.leaseUntil : line.ts,
400
+ };
401
+ }
402
+ }
403
+
404
+ if (line.release && line.epoch != null && line.epoch >= item.epoch) item.claim = null;
405
+
406
+ // A HEARTBEAT EXTENDS A LEASE AND NOTHING ELSE, which is what its own
407
+ // comment always said. It is built from buildClaimLine, so it carried that
408
+ // line's `patch: { status: 'claimed' }`, and a timer tick has therefore
409
+ // been quietly rewriting status: over an item a live worker had just set to
410
+ // 'blocked', and, three seconds after the founder archived one, over that.
411
+ // Enforced on READ as well as at the write, because the lines that did it
412
+ // are already in her ledger and will be there forever.
413
+ const patch = line.heartbeat ? {} : line.patch;
414
+
415
+ for (const [field, value] of Object.entries(patch)) {
416
+ const held = item.wrote[field];
417
+ // There used to be an exception here, for machinery spending a one-off
418
+ // permission mode over the founder's own grant. That field is not on the
419
+ // ledger any more (see the note beside WORK_ITEM_FIELDS), so
420
+ // the plain rule is the only rule: a weaker writer never overwrites a
421
+ // stronger one's value.
422
+ if (held && !beats(line, held)) continue;
423
+ item[field] = value;
424
+ item.wrote[field] = { ts: line.ts, source: authorityOf(line, field, value) };
425
+ }
426
+
427
+ item.updatedAt = Math.max(item.updatedAt, line.ts);
428
+ if (line.ts < item.createdAt) item.createdAt = line.ts;
429
+ }
430
+
431
+ // Lease expiry is computed at READ time, never written. A held claim whose
432
+ // lease has lapsed reads as open again, which is what makes an abandoned item
433
+ // recoverable without anyone having to notice it was abandoned.
434
+ //
435
+ // The same derivation covers a released claim, and that is the point: only an
436
+ // item still sitting at `claimed` with nobody holding it goes back to open.
437
+ // Any status a worker or the founder actually chose (done, blocked, or one we
438
+ // have not invented yet) is theirs, and nothing about giving a claim back
439
+ // overwrites it.
440
+ for (const item of items.values()) {
441
+ item.claimExpired = !!(item.claim && item.claim.leaseUntil < now);
442
+ if (item.status === 'claimed' && (!item.claim || item.claimExpired)) item.status = 'open';
443
+ }
444
+
445
+ return items;
446
+ }
447
+
448
+ /* -------------------------------- the thread ----------------------------- */
449
+ // EVERY TURN OF ONE ROW, OLDEST FIRST. The fold answers "what does this item
450
+ // say now"; this answers "what has been said here", which is a different
451
+ // question and nothing could ask it before.
452
+ //
453
+ // She was describing a real hole. Every reply she wrote spawned a fresh session
454
+ // holding one field, `answer`, with no sight of her earlier answers on the same
455
+ // row and no sight of the work it was replying about. One session built
456
+ // thirty-nine images off a bare line asking for more options, because cold that
457
+ // is what the line says. The whole exchange was on disk the entire time: the
458
+ // ledger is append-only, so every superseded answer, result, note and rewritten
459
+ // body is still a line in it. Only the fold was throwing them away.
460
+ //
461
+ // FOUR FIELDS ARE THE CONVERSATION and the rest are bookkeeping. Her answers,
462
+ // the results sessions wrote back, the checkpoints they left her, and the body
463
+ // each round rewrote to ask the next question. status, labels, priority and
464
+ // claims are how the row moves, not anything anyone said.
465
+ const THREAD_FIELDS = ['body', 'note', 'result', 'answer'];
466
+
467
+ export function threadOf(lines, id) {
468
+ const turns = [];
469
+ for (const raw of Array.isArray(lines) ? lines : []) {
470
+ const line = normalizeLine(raw);
471
+ if (!line || line.id !== id) continue;
472
+ // A heartbeat carries a patch it never meant (see the fold), so it would
473
+ // otherwise put a stale body into the transcript every five minutes.
474
+ if (line.heartbeat) continue;
475
+ for (const field of THREAD_FIELDS) {
476
+ const text = line.patch?.[field];
477
+ if (typeof text !== 'string' || !text.trim()) continue;
478
+ turns.push({ ts: line.ts, source: authorityOf(line, field, text), field, text });
479
+ }
480
+ }
481
+ // Stable by time. Within one millisecond THREAD_FIELDS order decides, so an
482
+ // append that sets a body and a result reads body-then-result rather than in
483
+ // whatever order Object.entries happened to hand them over.
484
+ turns.sort((a, b) => a.ts - b.ts);
485
+ // THE SAME TEXT TWICE IS ONE THING SAID ONCE. A patch that rewrites a title
486
+ // and re-sends an unchanged body is the ordinary case, and the second copy is
487
+ // not a turn: it is the store's shape leaking into what reads as a transcript.
488
+ const out = [];
489
+ const seen = new Set();
490
+ for (const turn of turns) {
491
+ const key = `${turn.field}:${turn.text}`;
492
+ if (seen.has(key)) continue;
493
+ seen.add(key);
494
+ out.push(turn);
495
+ }
496
+ return out;
497
+ }
498
+
499
+ // How much authority a write actually claims over the field it sets.
500
+ //
501
+ // The founder's word wins per field, and for a status that has to mean her
502
+ // terminal word: an item she archived is archived, whatever a worker still
503
+ // running on it writes next. But 'open' from her is not a verdict, it is a
504
+ // HANDBACK, and holding the field with it would freeze the very machinery
505
+ // she just handed it to: the claim, the blocked, the done that follow could
506
+ // never land. So a handback claims no authority, and the item moves again
507
+ // as it should.
508
+ function authorityOf(line, field, value) {
509
+ if (line.source === 'founder' && field === 'status' && value === 'open') return 'system';
510
+ return line.source;
511
+ }
512
+
513
+ // Does the incoming line outrank the one already holding this field? The founder
514
+ // wins over any agent regardless of time; between equals, later wins.
515
+ function beats(incoming, held) {
516
+ if (held.source === 'founder' && incoming.source !== 'founder') return false;
517
+ if (incoming.source === 'founder' && held.source !== 'founder') return true;
518
+ return incoming.ts >= held.ts;
519
+ }
520
+
521
+ /* --------------------------------- queries ------------------------------- */
522
+ // Can this item be pulled right now by a worker that holds nothing?
523
+ // Whether an item's scheduled moment has arrived. A `runAt` in the future means
524
+ // nothing happens with this item yet: it is not claimable, it does not spawn,
525
+ // and it is not in the founder's inbox.
526
+ //
527
+ // A MISSED MOMENT NEEDS NO CATCH-UP. Nothing fires, so nothing can be missed:
528
+ // once the moment passes this is true forever, and the first read after the
529
+ // machine wakes is as good as the one that would have happened at 6am. That is
530
+ // the whole reason this is a predicate over the clock rather than a timer, and
531
+ // it is what makes a schedule survive an app restart, a reboot, and a store
532
+ // restored onto another machine (the founder's requirement, 2026-08-06).
533
+ export function isDue(item, now = Date.now()) {
534
+ return !item?.runAt || item.runAt <= now;
535
+ }
536
+
537
+ export function isClaimable(item, now = Date.now()) {
538
+ if (!item) return false;
539
+ if (!PULLABLE.includes(item.status)) return false;
540
+ if (!isDue(item, now)) return false;
541
+ return !item.claim || item.claim.leaseUntil < now;
542
+ }
543
+
544
+ // The list, in the order a puller should consider it: highest priority first,
545
+ // then oldest, so nothing starves behind a stream of new arrivals.
546
+ export function sortForPull(items) {
547
+ return [...items].sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.createdAt - b.createdAt);
548
+ }
549
+
550
+ export function matchesFilter(item, filter = {}) {
551
+ if (!item) return false;
552
+ if (filter.status && item.status !== filter.status) return false;
553
+ if (filter.kind && item.kind !== filter.kind) return false;
554
+ if (filter.product && item.product !== filter.product) return false;
555
+ if (filter.parent && item.parent !== filter.parent) return false;
556
+ if (Array.isArray(filter.labels) && filter.labels.length) {
557
+ const has = new Set(item.labels ?? []);
558
+ if (!filter.labels.every((l) => has.has(l))) return false;
559
+ }
560
+ return true;
561
+ }