@cohortapp/agent-sdk 2.16.0 → 2.18.4

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 (529) hide show
  1. package/.claude/settings.json +18 -0
  2. package/.env.example +23 -7
  3. package/README.md +1 -0
  4. package/bin/maestro.mjs +62 -0
  5. package/docs/guides/billing-console-keys.md +60 -0
  6. package/docs/guides/front-door-session.md +54 -9
  7. package/docs/guides/mac-mini.md +20 -25
  8. package/docs/guides/poller-daemon-setup.md +4 -1
  9. package/docs/guides/setup-wizard.md +1 -1
  10. package/docs/runbooks/fleet-rollout.md +156 -0
  11. package/docs/runbooks/mac-mini-bootstrap.md +12 -14
  12. package/lib/action-executor.js +19 -3
  13. package/lib/budget-guard.mjs +279 -3
  14. package/lib/channels/base-adapter.mjs +3 -1
  15. package/lib/channels/contract.mjs +2 -1
  16. package/lib/channels/inbox-item.mjs +8 -0
  17. package/lib/claude-bin.mjs +5 -6
  18. package/lib/cli/doctor-checks.mjs +141 -10
  19. package/lib/cli/global-setup-extras.mjs +5 -1
  20. package/lib/cli/inbox.mjs +100 -15
  21. package/lib/cli/seat-auth.mjs +463 -0
  22. package/lib/cli/session.mjs +80 -12
  23. package/lib/collective/capture.mjs +8 -6
  24. package/lib/collective/global-config.mjs +63 -1
  25. package/lib/collective/presence.mjs +142 -5
  26. package/lib/comms/send-gate.mjs +559 -1
  27. package/lib/context/budget.mjs +327 -0
  28. package/lib/context/history-scope.mjs +138 -0
  29. package/lib/diagnostics/alerts.mjs +49 -0
  30. package/lib/diagnostics/cadence-output-freshness.mjs +288 -0
  31. package/lib/engine/agents/definitions.mjs +343 -0
  32. package/lib/engine/agents/persist.mjs +275 -0
  33. package/lib/engine/agents/runtime.mjs +748 -0
  34. package/lib/engine/agents/usage.mjs +95 -0
  35. package/lib/engine/auth-status.mjs +139 -0
  36. package/lib/engine/budget.mjs +194 -0
  37. package/lib/engine/cli.mjs +1204 -0
  38. package/lib/engine/commands/index.mjs +269 -0
  39. package/lib/engine/context/budget.mjs +219 -0
  40. package/lib/engine/context/cache.mjs +125 -0
  41. package/lib/engine/context/child-env.mjs +215 -0
  42. package/lib/engine/context/compaction.mjs +342 -0
  43. package/lib/engine/context/images.mjs +90 -0
  44. package/lib/engine/context/instructions.mjs +327 -0
  45. package/lib/engine/context/lazy-instructions.mjs +169 -0
  46. package/lib/engine/context/manager.mjs +182 -0
  47. package/lib/engine/context/real-path.mjs +91 -0
  48. package/lib/engine/context/secret-values.mjs +163 -0
  49. package/lib/engine/context/settings.mjs +274 -0
  50. package/lib/engine/context/stream-input.mjs +159 -0
  51. package/lib/engine/guard.mjs +152 -0
  52. package/lib/engine/hooks.mjs +713 -0
  53. package/lib/engine/loop.mjs +560 -0
  54. package/lib/engine/mcp/client.mjs +254 -0
  55. package/lib/engine/mcp/config.mjs +301 -0
  56. package/lib/engine/mcp/http.mjs +201 -0
  57. package/lib/engine/mcp/index.mjs +146 -0
  58. package/lib/engine/mcp/jsonrpc.mjs +147 -0
  59. package/lib/engine/mcp/naming.mjs +66 -0
  60. package/lib/engine/mcp/resources.mjs +89 -0
  61. package/lib/engine/mcp/results.mjs +133 -0
  62. package/lib/engine/mcp/stdio.mjs +137 -0
  63. package/lib/engine/mcp/supervisor.mjs +116 -0
  64. package/lib/engine/messages.mjs +104 -0
  65. package/lib/engine/output/json.mjs +164 -0
  66. package/lib/engine/output/stream-json.mjs +266 -0
  67. package/lib/engine/permissions.mjs +845 -0
  68. package/lib/engine/process-identity.mjs +164 -0
  69. package/lib/engine/process-tree.mjs +551 -0
  70. package/lib/engine/prompt.mjs +60 -0
  71. package/lib/engine/session/store.mjs +299 -0
  72. package/lib/engine/session-runtime/args.mjs +97 -0
  73. package/lib/engine/session-runtime/host.mjs +143 -0
  74. package/lib/engine/session-runtime/inbox.mjs +122 -0
  75. package/lib/engine/session-runtime/notifications.mjs +129 -0
  76. package/lib/engine/session-runtime/registry.mjs +328 -0
  77. package/lib/engine/session-runtime/runner.mjs +344 -0
  78. package/lib/engine/session-runtime/socket.mjs +212 -0
  79. package/lib/engine/session-runtime/wakeup.mjs +115 -0
  80. package/lib/engine/skills/index.mjs +321 -0
  81. package/lib/engine/tools/bash-background.mjs +533 -0
  82. package/lib/engine/tools/bash.mjs +216 -0
  83. package/lib/engine/tools/edit.mjs +97 -0
  84. package/lib/engine/tools/glob.mjs +81 -0
  85. package/lib/engine/tools/grep.mjs +224 -0
  86. package/lib/engine/tools/index.mjs +84 -0
  87. package/lib/engine/tools/list-agents.mjs +32 -0
  88. package/lib/engine/tools/ls.mjs +127 -0
  89. package/lib/engine/tools/monitor.mjs +82 -0
  90. package/lib/engine/tools/notebook-edit.mjs +218 -0
  91. package/lib/engine/tools/read.mjs +103 -0
  92. package/lib/engine/tools/schedule-wakeup.mjs +45 -0
  93. package/lib/engine/tools/schema.mjs +144 -0
  94. package/lib/engine/tools/send-message.mjs +77 -0
  95. package/lib/engine/tools/session.mjs +70 -0
  96. package/lib/engine/tools/todo.mjs +144 -0
  97. package/lib/engine/tools/toolsearch.mjs +217 -0
  98. package/lib/engine/tools/walk.mjs +193 -0
  99. package/lib/engine/tools/web-switch.mjs +31 -0
  100. package/lib/engine/tools/webfetch-html.mjs +387 -0
  101. package/lib/engine/tools/webfetch-net.mjs +340 -0
  102. package/lib/engine/tools/webfetch.mjs +198 -0
  103. package/lib/engine/tools/websearch.mjs +91 -0
  104. package/lib/engine/tools/workflow.mjs +95 -0
  105. package/lib/engine/tools/write.mjs +76 -0
  106. package/lib/engine/tui/line-editor.mjs +137 -0
  107. package/lib/engine/tui/render.mjs +86 -0
  108. package/lib/engine/tui/tui.mjs +274 -0
  109. package/lib/engine/wire/anthropic-messages.mjs +263 -0
  110. package/lib/engine/wire/effort.mjs +36 -0
  111. package/lib/engine/wire/errors.mjs +496 -0
  112. package/lib/engine/wire/http.mjs +441 -0
  113. package/lib/engine/wire/index.mjs +76 -0
  114. package/lib/engine/wire/openai-chat.mjs +332 -0
  115. package/lib/engine/wire/prompt-cache.mjs +79 -0
  116. package/lib/engine/wire/search.mjs +140 -0
  117. package/lib/engine/wire/sse.mjs +114 -0
  118. package/lib/engine/wire/stall.mjs +349 -0
  119. package/lib/engine/wire/token-provider.mjs +175 -0
  120. package/lib/engine/wire/usage.mjs +192 -0
  121. package/lib/engine/workflow/host.mjs +524 -0
  122. package/lib/engine/workflow/journal.mjs +188 -0
  123. package/lib/engine/workflow/json-schema.mjs +171 -0
  124. package/lib/engine/workflow/meta.mjs +329 -0
  125. package/lib/engine/workflow/notifications.mjs +52 -0
  126. package/lib/engine/workflow/runtime.mjs +447 -0
  127. package/lib/engine/workflow/sandbox.mjs +534 -0
  128. package/lib/engine/workflow/worker.mjs +141 -0
  129. package/lib/engine/workflow/worktree.mjs +74 -0
  130. package/lib/execution/disposition.mjs +1 -1
  131. package/lib/execution/intake.mjs +10 -0
  132. package/lib/execution/surface-policy.mjs +15 -0
  133. package/lib/learning/curator.mjs +8 -6
  134. package/lib/learning/reflect.mjs +8 -6
  135. package/lib/model-router/catalog/cohort.yaml +137 -0
  136. package/lib/model-router/catalog.mjs +118 -1
  137. package/lib/model-router/economics.mjs +9 -0
  138. package/lib/model-router/failover.mjs +67 -16
  139. package/lib/model-router/llm-task.mjs +39 -3
  140. package/lib/model-router/resolve.mjs +95 -3
  141. package/lib/model-router/spawn.mjs +46 -47
  142. package/lib/model-router/taxonomy.mjs +126 -4
  143. package/lib/org/cost-sync.mjs +141 -11
  144. package/lib/org/inbound/broadcast.mjs +289 -0
  145. package/lib/org/inbound/collective.mjs +375 -0
  146. package/lib/org/inbound/directedness.mjs +96 -8
  147. package/lib/org/inbound/facts.mjs +82 -4
  148. package/lib/org/inbound/hydrate.mjs +555 -51
  149. package/lib/org/inbound/project.mjs +22 -0
  150. package/lib/org/inbound/surfaces.mjs +14 -0
  151. package/lib/org/llm-token.mjs +879 -0
  152. package/lib/org/mesh.mjs +61 -0
  153. package/lib/org/messaging.mjs +3 -1
  154. package/lib/org/protocol.checksum +1 -1
  155. package/lib/org/protocol.mjs +15 -0
  156. package/lib/org/quota.mjs +520 -0
  157. package/lib/org/tool-surface.mjs +104 -16
  158. package/lib/org/ui-parity.mjs +16 -1
  159. package/lib/org/work-ledger.mjs +37 -6
  160. package/lib/rate-guard.mjs +114 -1
  161. package/lib/resource-governor.mjs +41 -6
  162. package/lib/runtime/adapter.mjs +823 -0
  163. package/lib/runtime/child-env.mjs +191 -0
  164. package/lib/runtime/legacy-shell-guard.mjs +97 -0
  165. package/lib/runtime/seat-engine.mjs +162 -0
  166. package/lib/session/ask-ledger.mjs +271 -0
  167. package/lib/session/current-work.mjs +676 -0
  168. package/lib/session/feed-core.mjs +40 -3
  169. package/lib/session/launch-args.mjs +56 -4
  170. package/lib/session/status-summary.mjs +26 -9
  171. package/lib/session/upgrade-notice.mjs +42 -0
  172. package/lib/setup/claude-probe.mjs +117 -13
  173. package/lib/setup/enrich.mjs +13 -10
  174. package/lib/setup/sections/model.mjs +39 -13
  175. package/lib/telemetry/collect.mjs +208 -9
  176. package/lib/upgrade/ignored-drift.mjs +105 -0
  177. package/lib/voice/post-call-brief.mjs +30 -17
  178. package/package.json +15 -3
  179. package/plugins/maestro-skills/skills/board-work.md +5 -0
  180. package/plugins/maestro-skills/skills/inbound-triage.md +56 -15
  181. package/plugins/maestro-skills/skills/main-session.md +18 -7
  182. package/scripts/ci/check-tarball-fidelity.mjs +126 -2
  183. package/scripts/ci/run-tests.mjs +47 -19
  184. package/scripts/cohort-llm/api-key-helper.mjs +92 -0
  185. package/scripts/collective/hook-runner.mjs +29 -2
  186. package/scripts/continuous-monitor.sh +13 -0
  187. package/scripts/cost/track-claude-usage.mjs +15 -0
  188. package/scripts/daemon/agent-daemon.mjs +408 -20
  189. package/scripts/daemon/assurance.mjs +48 -12
  190. package/scripts/daemon/cadence-consumer.mjs +218 -68
  191. package/scripts/daemon/cadence-handlers.mjs +73 -4
  192. package/scripts/daemon/classifier.mjs +75 -26
  193. package/scripts/daemon/context-compiler.mjs +104 -59
  194. package/scripts/daemon/deliver.mjs +30 -1
  195. package/scripts/daemon/dispatcher.mjs +804 -157
  196. package/scripts/daemon/health.mjs +14 -1
  197. package/scripts/daemon/lib/session-router.mjs +310 -42
  198. package/scripts/daemon/maestro-daemon.mjs +11 -0
  199. package/scripts/daemon/prompt-builder.mjs +121 -12
  200. package/scripts/daemon/responder.mjs +315 -146
  201. package/scripts/daemon/sdk-version.mjs +98 -16
  202. package/scripts/eval/probe-gateway.mjs +635 -0
  203. package/scripts/eval/replay/extract.mjs +270 -0
  204. package/scripts/eval/replay/grade.mjs +260 -0
  205. package/scripts/eval/replay/lib/config.mjs +50 -0
  206. package/scripts/eval/replay/lib/effects.mjs +65 -0
  207. package/scripts/eval/replay/lib/fixture.mjs +188 -0
  208. package/scripts/eval/replay/lib/judge.mjs +72 -0
  209. package/scripts/eval/replay/lib/redact.mjs +136 -0
  210. package/scripts/eval/replay/lib/sandbox.mjs +170 -0
  211. package/scripts/eval/replay/lib/schema-check.mjs +63 -0
  212. package/scripts/eval/replay/lib/transcript.mjs +76 -0
  213. package/scripts/eval/replay/mcp-replay-stub.mjs +101 -0
  214. package/scripts/eval/replay/report.mjs +185 -0
  215. package/scripts/eval/replay/run.mjs +404 -0
  216. package/scripts/fleet/rollout.mjs +1094 -0
  217. package/scripts/hooks/pre-send-audit.sh +36 -245
  218. package/scripts/hooks/pre-write-yaml-validate.mjs +275 -0
  219. package/scripts/hooks/validate-state-yaml.sh +190 -0
  220. package/scripts/huddle/huddle-llm.mjs +361 -0
  221. package/scripts/huddle/huddle-server.mjs +46 -121
  222. package/scripts/local-triggers/autoupdate.sh +448 -78
  223. package/scripts/local-triggers/run-trigger.sh +13 -0
  224. package/scripts/maintenance/pin-integrity.mjs +364 -0
  225. package/scripts/poll-slack-events.sh +41 -9
  226. package/scripts/poller/slack-socket-mode.mjs +28 -3
  227. package/scripts/session/supervisor.mjs +80 -13
  228. package/scripts/spawn-session.sh +13 -0
  229. package/bin/maestro.test.mjs +0 -1574
  230. package/lib/action-executor.test.mjs +0 -871
  231. package/lib/archetype.test.mjs +0 -132
  232. package/lib/assurance/plan-note.test.mjs +0 -234
  233. package/lib/assurance/room-budget.test.mjs +0 -486
  234. package/lib/assurance/tier.test.mjs +0 -174
  235. package/lib/autonomy.test.mjs +0 -66
  236. package/lib/backlog.test.mjs +0 -302
  237. package/lib/backup/policy.test.mjs +0 -305
  238. package/lib/budget-escalate.test.mjs +0 -232
  239. package/lib/budget-guard.envelope.test.mjs +0 -476
  240. package/lib/budget-guard.test.mjs +0 -427
  241. package/lib/cadence-bus-requeue.test.mjs +0 -83
  242. package/lib/cadence-bus-schedule.test.mjs +0 -194
  243. package/lib/cadence-bus.test.mjs +0 -720
  244. package/lib/cadences.test.mjs +0 -230
  245. package/lib/capability/inventory.test.mjs +0 -232
  246. package/lib/capability.test.mjs +0 -78
  247. package/lib/channels/base-adapter.test.mjs +0 -590
  248. package/lib/channels/channels.test.mjs +0 -371
  249. package/lib/channels/contract.test.mjs +0 -162
  250. package/lib/channels/inbox-item.test.mjs +0 -368
  251. package/lib/channels/orgmail/adapter.test.mjs +0 -448
  252. package/lib/channels/pairing.test.mjs +0 -270
  253. package/lib/channels/repeat-suppressor.test.mjs +0 -134
  254. package/lib/channels/slack-adapter.test.mjs +0 -212
  255. package/lib/channels/telegram-adapter.test.mjs +0 -306
  256. package/lib/channels/voice/adapter.test.mjs +0 -278
  257. package/lib/channels/whatsapp/adapter-baileys.test.mjs +0 -359
  258. package/lib/channels/whatsapp/baileys-typing.test.mjs +0 -154
  259. package/lib/charter.test.mjs +0 -89
  260. package/lib/claude-bin.test.mjs +0 -131
  261. package/lib/cli/board.test.mjs +0 -227
  262. package/lib/cli/design.test.mjs +0 -270
  263. package/lib/cli/doctor-checks.test.mjs +0 -336
  264. package/lib/cli/global-setup-extras.test.mjs +0 -462
  265. package/lib/cli/inbox.test.mjs +0 -230
  266. package/lib/cli/session-ack.test.mjs +0 -63
  267. package/lib/cli/session.test.mjs +0 -613
  268. package/lib/collective/capture.test.mjs +0 -121
  269. package/lib/collective/cards.test.mjs +0 -114
  270. package/lib/collective/config.test.mjs +0 -123
  271. package/lib/collective/global-config.test.mjs +0 -220
  272. package/lib/collective/global-skills.test.mjs +0 -126
  273. package/lib/collective/presence.test.mjs +0 -95
  274. package/lib/collective/recall.test.mjs +0 -116
  275. package/lib/collective/vendor-skills.test.mjs +0 -306
  276. package/lib/comms/send-gate.test.mjs +0 -770
  277. package/lib/comms.test.mjs +0 -41
  278. package/lib/cost/ledger-row.test.mjs +0 -183
  279. package/lib/design/design-md.test.mjs +0 -318
  280. package/lib/design/fixtures/DESIGN.golden.md +0 -238
  281. package/lib/design/fixtures/PRODUCT.golden.md +0 -67
  282. package/lib/design/fixtures/foundation.json +0 -133
  283. package/lib/design/refresh-gate.test.mjs +0 -144
  284. package/lib/design/write.test.mjs +0 -241
  285. package/lib/diagnostics/alerts.test.mjs +0 -318
  286. package/lib/diagnostics/backup-freshness.test.mjs +0 -185
  287. package/lib/diagnostics/counters.test.mjs +0 -206
  288. package/lib/diagnostics/events.test.mjs +0 -290
  289. package/lib/diagnostics/otel.test.mjs +0 -196
  290. package/lib/diagnostics/trace.test.mjs +0 -251
  291. package/lib/env-compat.test.mjs +0 -104
  292. package/lib/execution/disposition.test.mjs +0 -553
  293. package/lib/execution/drive.test.mjs +0 -270
  294. package/lib/execution/effects.test.mjs +0 -344
  295. package/lib/execution/intake.test.mjs +0 -389
  296. package/lib/execution/journal.test.mjs +0 -261
  297. package/lib/execution/match.test.mjs +0 -235
  298. package/lib/execution/pipeline.test.mjs +0 -392
  299. package/lib/execution/route.test.mjs +0 -186
  300. package/lib/execution/surface-policy.test.mjs +0 -162
  301. package/lib/fs-atomic.test.mjs +0 -72
  302. package/lib/fs-ownership.test.mjs +0 -158
  303. package/lib/goals/admission.test.mjs +0 -164
  304. package/lib/goals/classify.test.mjs +0 -167
  305. package/lib/goals/collaborate.test.mjs +0 -336
  306. package/lib/goals/gaps.test.mjs +0 -284
  307. package/lib/goals/loop.test.mjs +0 -845
  308. package/lib/hooks/bus.test.mjs +0 -387
  309. package/lib/identity/persona.test.mjs +0 -142
  310. package/lib/kpi-sensors.test.mjs +0 -278
  311. package/lib/kpi.test.mjs +0 -244
  312. package/lib/learning/config.test.mjs +0 -75
  313. package/lib/learning/counters.test.mjs +0 -69
  314. package/lib/learning/curator-consolidate.test.mjs +0 -238
  315. package/lib/learning/curator.test.mjs +0 -106
  316. package/lib/learning/reflect.test.mjs +0 -0
  317. package/lib/learning/session-index.test.mjs +0 -125
  318. package/lib/learning/skill-writer.test.mjs +0 -210
  319. package/lib/mandate/audit.test.mjs +0 -195
  320. package/lib/mandate/contract.test.mjs +0 -185
  321. package/lib/mandate/derive.test.mjs +0 -274
  322. package/lib/mandate/model.test.mjs +0 -164
  323. package/lib/mandate/refresh.test.mjs +0 -389
  324. package/lib/mcp/server.test.mjs +0 -426
  325. package/lib/model-router/auth-profiles.test.mjs +0 -580
  326. package/lib/model-router/catalog.test.mjs +0 -385
  327. package/lib/model-router/economics.test.mjs +0 -438
  328. package/lib/model-router/failover.test.mjs +0 -439
  329. package/lib/model-router/health.test.mjs +0 -338
  330. package/lib/model-router/integration-coverage.test.mjs +0 -831
  331. package/lib/model-router/integration.test.mjs +0 -564
  332. package/lib/model-router/ledger.test.mjs +0 -415
  333. package/lib/model-router/llm-task.test.mjs +0 -392
  334. package/lib/model-router/org-credentials.test.mjs +0 -265
  335. package/lib/model-router/pricing-refresh.test.mjs +0 -286
  336. package/lib/model-router/reconcile.test.mjs +0 -316
  337. package/lib/model-router/repair.test.mjs +0 -180
  338. package/lib/model-router/spawn.test.mjs +0 -446
  339. package/lib/model-router/taxonomy.test.mjs +0 -410
  340. package/lib/model-router.test.mjs +0 -1207
  341. package/lib/org/activity.test.mjs +0 -134
  342. package/lib/org/approvals.test.mjs +0 -216
  343. package/lib/org/awareness.test.mjs +0 -159
  344. package/lib/org/board-mine-cache.test.mjs +0 -53
  345. package/lib/org/board.test.mjs +0 -187
  346. package/lib/org/bootstrap-context.test.mjs +0 -153
  347. package/lib/org/client.test.mjs +0 -1206
  348. package/lib/org/cohort-client.test.mjs +0 -126
  349. package/lib/org/cost-sync.test.mjs +0 -153
  350. package/lib/org/doctor.test.mjs +0 -346
  351. package/lib/org/engagement-ledger.test.mjs +0 -112
  352. package/lib/org/engagement.test.mjs +0 -739
  353. package/lib/org/handoff.test.mjs +0 -269
  354. package/lib/org/inbound/directedness.test.mjs +0 -668
  355. package/lib/org/inbound/facts.test.mjs +0 -471
  356. package/lib/org/inbound/hydrate.test.mjs +0 -453
  357. package/lib/org/inbound/index.test.mjs +0 -429
  358. package/lib/org/inbound/project.test.mjs +0 -287
  359. package/lib/org/integration-tools.test.mjs +0 -160
  360. package/lib/org/keys.test.mjs +0 -92
  361. package/lib/org/knowledge.test.mjs +0 -326
  362. package/lib/org/leases.test.mjs +0 -235
  363. package/lib/org/mesh-directives.test.mjs +0 -110
  364. package/lib/org/mesh-integration.test.mjs +0 -127
  365. package/lib/org/mesh.test.mjs +0 -400
  366. package/lib/org/messaging.test.mjs +0 -471
  367. package/lib/org/param-contract.test.mjs +0 -477
  368. package/lib/org/policy.test.mjs +0 -237
  369. package/lib/org/protocol.checksum.test.mjs +0 -90
  370. package/lib/org/protocol.test.mjs +0 -323
  371. package/lib/org/push.test.mjs +0 -792
  372. package/lib/org/registry.test.mjs +0 -100
  373. package/lib/org/resource-tools.test.mjs +0 -361
  374. package/lib/org/tool-access.test.mjs +0 -144
  375. package/lib/org/tool-surface-integration.test.mjs +0 -120
  376. package/lib/org/tool-surface.test.mjs +0 -1268
  377. package/lib/org/typing.test.mjs +0 -291
  378. package/lib/org/ui-parity.test.mjs +0 -560
  379. package/lib/org/verify.test.mjs +0 -194
  380. package/lib/org/work-ledger.test.mjs +0 -273
  381. package/lib/plan/adoption-e2e.test.mjs +0 -366
  382. package/lib/plan/budget-enforcement.test.mjs +0 -400
  383. package/lib/plan/compile.test.mjs +0 -382
  384. package/lib/plan/emit.test.mjs +0 -269
  385. package/lib/plan/explain.test.mjs +0 -188
  386. package/lib/prompts/parallelism.test.mjs +0 -177
  387. package/lib/rag/rag.test.mjs +0 -505
  388. package/lib/rate-guard.test.mjs +0 -272
  389. package/lib/reactive-gate.test.mjs +0 -57
  390. package/lib/render.test.mjs +0 -68
  391. package/lib/resource-governor.test.mjs +0 -488
  392. package/lib/scheduling/dynamic-jobs.test.mjs +0 -344
  393. package/lib/scheduling/jitter.test.mjs +0 -140
  394. package/lib/secrets/broker.test.mjs +0 -280
  395. package/lib/secrets/providers.test.mjs +0 -274
  396. package/lib/security/audit-engine.test.mjs +0 -424
  397. package/lib/security/coerce-args.test.mjs +0 -281
  398. package/lib/security/dangerous-tools.test.mjs +0 -68
  399. package/lib/security/external-content.test.mjs +0 -84
  400. package/lib/security/redact.test.mjs +0 -441
  401. package/lib/security/secret-equal.test.mjs +0 -55
  402. package/lib/session/config.test.mjs +0 -92
  403. package/lib/session/feed-core.test.mjs +0 -198
  404. package/lib/session/first-run.test.mjs +0 -121
  405. package/lib/session/frontdoor.test.mjs +0 -205
  406. package/lib/session/handoffs.test.mjs +0 -183
  407. package/lib/session/identity.test.mjs +0 -180
  408. package/lib/session/inbox-claims.test.mjs +0 -286
  409. package/lib/session/launch-args.test.mjs +0 -157
  410. package/lib/session/liveness.test.mjs +0 -100
  411. package/lib/session/status-summary.test.mjs +0 -118
  412. package/lib/session-permissions.test.mjs +0 -120
  413. package/lib/setup/claude-probe.test.mjs +0 -187
  414. package/lib/setup/completeness.test.mjs +0 -110
  415. package/lib/setup/context-pack.test.mjs +0 -89
  416. package/lib/setup/enrich.test.mjs +0 -115
  417. package/lib/setup/enroll-from-cohort.test.mjs +0 -300
  418. package/lib/setup/integration.test.mjs +0 -162
  419. package/lib/setup/io.test.mjs +0 -77
  420. package/lib/setup/runner.test.mjs +0 -132
  421. package/lib/setup/sections/identity.test.mjs +0 -234
  422. package/lib/setup/sections/inventory.test.mjs +0 -198
  423. package/lib/setup/sections/learning.test.mjs +0 -81
  424. package/lib/setup/sections/mandate.test.mjs +0 -388
  425. package/lib/setup/sections/messaging.test.mjs +0 -127
  426. package/lib/setup/sections/model.test.mjs +0 -240
  427. package/lib/setup/sections/org.test.mjs +0 -346
  428. package/lib/setup/sections/orgmail.test.mjs +0 -118
  429. package/lib/setup/sections/recovery.test.mjs +0 -98
  430. package/lib/setup/sections/subagents.test.mjs +0 -429
  431. package/lib/setup/sections/verify.test.mjs +0 -175
  432. package/lib/setup/sot.test.mjs +0 -81
  433. package/lib/setup/state.test.mjs +0 -115
  434. package/lib/singleton.test.mjs +0 -151
  435. package/lib/subagents/cli.test.mjs +0 -389
  436. package/lib/subagents/client.test.mjs +0 -309
  437. package/lib/subagents/gap.test.mjs +0 -234
  438. package/lib/subagents/lock.test.mjs +0 -248
  439. package/lib/subagents/manifest.test.mjs +0 -175
  440. package/lib/subagents/refs.test.mjs +0 -204
  441. package/lib/subagents/resolve.test.mjs +0 -422
  442. package/lib/subagents/schema.test.mjs +0 -328
  443. package/lib/telemetry/alerts.test.mjs +0 -109
  444. package/lib/telemetry/collect.test.mjs +0 -1274
  445. package/lib/tool-definitions-integration.test.mjs +0 -83
  446. package/lib/tool-definitions.test.mjs +0 -437
  447. package/lib/upgrade/global-refresh.test.mjs +0 -65
  448. package/lib/upgrade/launchd-reconcile.test.mjs +0 -272
  449. package/lib/upgrade/post-steps.test.mjs +0 -200
  450. package/lib/upgrade/verify.test.mjs +0 -164
  451. package/lib/util/fetch-timeout.test.mjs +0 -202
  452. package/lib/util/reconnect.test.mjs +0 -369
  453. package/lib/util/unhandled.test.mjs +0 -216
  454. package/lib/voice/outbound.test.mjs +0 -69
  455. package/lib/voice/session-rotation.test.mjs +0 -114
  456. package/lib/voice/stt.test.mjs +0 -226
  457. package/lib/voice/voice.test.mjs +0 -990
  458. package/scripts/cadence/enqueue-cadence-tick.test.mjs +0 -187
  459. package/scripts/ci/check-docs-accuracy.test.mjs +0 -409
  460. package/scripts/ci/check-durable-write-seam.test.mjs +0 -90
  461. package/scripts/ci/check-no-build-artifacts.test.mjs +0 -71
  462. package/scripts/ci/check-no-residual-identity.test.mjs +0 -202
  463. package/scripts/ci/check-skill-packs.test.mjs +0 -495
  464. package/scripts/ci/check-subagent-frontmatter.test.mjs +0 -124
  465. package/scripts/ci/check.test.mjs +0 -194
  466. package/scripts/ci/conformance-org-api.test.mjs +0 -425
  467. package/scripts/cloud-relay/voice/relay-identity.test.mjs +0 -96
  468. package/scripts/collective/hook-runner.test.mjs +0 -173
  469. package/scripts/cost/fleet-digest.test.mjs +0 -207
  470. package/scripts/cost/track-claude-usage-pricing.test.mjs +0 -183
  471. package/scripts/cost/track-claude-usage.test.mjs +0 -148
  472. package/scripts/daemon/agent-daemon-board-mine.test.mjs +0 -96
  473. package/scripts/daemon/agent-daemon-design.test.mjs +0 -238
  474. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +0 -60
  475. package/scripts/daemon/agent-daemon.test.mjs +0 -995
  476. package/scripts/daemon/assurance-e2e.test.mjs +0 -613
  477. package/scripts/daemon/assurance.test.mjs +0 -1791
  478. package/scripts/daemon/board-mirror.test.mjs +0 -165
  479. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +0 -393
  480. package/scripts/daemon/cadence-consumer-governance.test.mjs +0 -276
  481. package/scripts/daemon/cadence-consumer.test.mjs +0 -776
  482. package/scripts/daemon/cadence-handlers.test.mjs +0 -837
  483. package/scripts/daemon/classifier-identity.test.mjs +0 -137
  484. package/scripts/daemon/classifier.test.mjs +0 -266
  485. package/scripts/daemon/classify-kind.test.mjs +0 -40
  486. package/scripts/daemon/context-compiler.test.mjs +0 -300
  487. package/scripts/daemon/deliver.test.mjs +0 -564
  488. package/scripts/daemon/dispatcher-cooldown.test.mjs +0 -122
  489. package/scripts/daemon/dispatcher-governance.test.mjs +0 -1013
  490. package/scripts/daemon/dispatcher-resume.test.mjs +0 -166
  491. package/scripts/daemon/execution-ladder.test.mjs +0 -470
  492. package/scripts/daemon/goal-steward-cadence.test.mjs +0 -312
  493. package/scripts/daemon/inbox-deferral-session.test.mjs +0 -49
  494. package/scripts/daemon/inbox-deferral.test.mjs +0 -336
  495. package/scripts/daemon/inbox-wake.test.mjs +0 -199
  496. package/scripts/daemon/integration.test.mjs +0 -149
  497. package/scripts/daemon/lib/self-echo.test.mjs +0 -153
  498. package/scripts/daemon/lib/session-router.test.mjs +0 -295
  499. package/scripts/daemon/prompt-builder-preamble.test.mjs +0 -210
  500. package/scripts/daemon/prompt-builder.test.mjs +0 -344
  501. package/scripts/daemon/responder-cost.test.mjs +0 -68
  502. package/scripts/daemon/responder-history.test.mjs +0 -185
  503. package/scripts/daemon/sdk-version.test.mjs +0 -31
  504. package/scripts/daemon/session-lock.test.mjs +0 -252
  505. package/scripts/daemon/session-outcomes.test.mjs +0 -533
  506. package/scripts/daemon/typing-registry.test.mjs +0 -102
  507. package/scripts/hooks/pre-send-audit.test.mjs +0 -354
  508. package/scripts/huddle/huddle-prompt.test.mjs +0 -176
  509. package/scripts/local-triggers/autoupdate.test.mjs +0 -518
  510. package/scripts/local-triggers/generate-plists.test.mjs +0 -456
  511. package/scripts/media-generation/brand-clause.test.mjs +0 -135
  512. package/scripts/org/send-orgmail.first-contact.test.mjs +0 -102
  513. package/scripts/poller/inbox-privilege-injection.test.mjs +0 -167
  514. package/scripts/poller/inbox-scan-poller.test.mjs +0 -295
  515. package/scripts/poller/lib/cloud-relay-dedup.test.mjs +0 -133
  516. package/scripts/poller/slack-socket-mode.test.mjs +0 -805
  517. package/scripts/poller-launchd/install.test.mjs +0 -243
  518. package/scripts/restore-from-backup.test.mjs +0 -181
  519. package/scripts/session/feed.test.mjs +0 -196
  520. package/scripts/session/supervisor-sh.test.mjs +0 -218
  521. package/scripts/session/supervisor.test.mjs +0 -482
  522. package/scripts/setup/configure-macos.test.mjs +0 -306
  523. package/scripts/setup/gen-subagent-manifest.test.mjs +0 -124
  524. package/scripts/setup/generate-agent-package-json.test.mjs +0 -143
  525. package/scripts/setup/generate-capability.test.mjs +0 -134
  526. package/scripts/setup/init-agent.test.mjs +0 -370
  527. package/scripts/setup/init-skill-marketplace.test.mjs +0 -193
  528. package/scripts/vendor/sync-skill-packs.test.mjs +0 -103
  529. package/scripts/watchdog/memory-watchdog.test.mjs +0 -64
@@ -7,19 +7,31 @@
7
7
  * classify and the agent "replies" to a blank message. This module re-joins the
8
8
  * content, and it does so through the agent's OWN entitled reads:
9
9
  *
10
- * | surface | read | ACL hq applies |
11
- * |----------------|-----------------------------------------|-----------------------------|
12
- * | dm / mention | messaging.history (already in facts) | assertChannelAccess |
13
- * | thread_reply | messaging.history (already in facts) | assertChannelAccess |
14
- * | call | (payload only — topic + callId) | — |
15
- * | task_* | board.taskComments | org-scoped task |
16
- * | file_comment | file.listComments | assertChannelVisible |
17
- * | doc_comment | files.comments | doc ACL (NOT_FOUND if none) |
18
- * | decision | decision.listComments | org-scoped |
19
- * | escalation | escalation.list (already in facts) | org-scoped |
20
- * | approval | approval.get (already in facts) | org-scoped |
21
- * | handoff | (payload only — intent + itemId) | — |
22
- * | email | email.thread / email.message | OWN MAILBOX ONLY |
10
+ * | surface | read | ACL hq applies |
11
+ * |----------------|--------------------------------------------|-----------------------------|
12
+ * | dm / mention | messaging.history (already in facts) | assertChannelAccess |
13
+ * | thread_reply | messaging.history (already in facts) | assertChannelAccess |
14
+ * | call | calling.getDetails / .getTranscript, | participation-gated |
15
+ * | | messaging.history for the room | assertChannelAccess |
16
+ * | task_* | board.taskComments | org-scoped task |
17
+ * | file_comment | file.listComments + file.listPinned | assertChannelVisible |
18
+ * | doc_comment | files.comments + files.docRead | doc ACL (NOT_FOUND if none) |
19
+ * | decision | decision.listComments | org-scoped |
20
+ * | escalation | escalation.list (facts) + board.taskComments| org-scoped |
21
+ * | approval | approval.get (facts) + the item's comments | org-scoped |
22
+ * | handoff | board.taskComments on the origin task | org-scoped task |
23
+ * | calendar | calendar.list (facts) + the room's history | lens floor / channel ACL |
24
+ * | email | email.thread / email.message | OWN MAILBOX ONLY |
25
+ *
26
+ * ── WHY FIVE SURFACES USED TO HAVE NO HISTORY AT ALL ──
27
+ * `call`, `approval`, `escalation`, `calendar` and `handoff` rendered the
28
+ * entity's own fields and stopped (design §3 R5). An approval names an action
29
+ * class and a requester; an escalation names a severity. Neither says what the
30
+ * work IS, so the agent answered the row rather than the situation. Each now
31
+ * reaches for the conversation the entity is anchored to, through the SAME
32
+ * `io.call` seam and the SAME `renderThread` budget as every other surface, and
33
+ * each probe is independently fail-open: a probe that fails leaves that one
34
+ * block absent and the item still hydrates.
23
35
  *
24
36
  * Because every one of those is the same read the agent could make by hand,
25
37
  * hydration can never widen what the agent may see — it only re-joins content
@@ -128,6 +140,220 @@ export function renderThread(comments, { limit = 8, max = 2000 } = {}) {
128
140
  return kept.join("\n") || null;
129
141
  }
130
142
 
143
+ /**
144
+ * Event kinds whose TRIGGER is the newest row of the comment thread the
145
+ * hydrator is about to read. hq's own handlers are the source: `board/comment`
146
+ * appends `board.commented`, `board/addTaskComment` appends `item.commented`,
147
+ * `file/addComment` appends `file.commented`, `files/commentAdd` appends
148
+ * `doc.comment`, and both `decision/comment` and `decision/requestAdjustment`
149
+ * write a `DecisionComment` row before appending theirs.
150
+ *
151
+ * Nothing else does. `item.assigned`, `item.moved`, `file.shared` and
152
+ * `decision.signed` are events ABOUT an entity that happens to have a comment
153
+ * thread — for those the newest comment is prior context, not the trigger.
154
+ */
155
+ export const COMMENT_KINDS = Object.freeze([
156
+ "item.commented",
157
+ "board.commented",
158
+ "file.commented",
159
+ "doc.comment",
160
+ "decision.comment",
161
+ "decision.adjustment_requested",
162
+ ]);
163
+
164
+ const COMMENT_KIND_SET = new Set(COMMENT_KINDS);
165
+
166
+ /** Is this event kind itself a comment? See {@link COMMENT_KINDS}. */
167
+ export function isCommentKind(kind) {
168
+ return COMMENT_KIND_SET.has(s(kind));
169
+ }
170
+
171
+ /**
172
+ * The comments that are CONTEXT for this event, i.e. everything except the one
173
+ * that triggered it.
174
+ *
175
+ * This used to be a flat `comments.slice(0, -1)` on every board, file, doc and
176
+ * decision hydrator. That is right for a comment event and wrong for every
177
+ * other kind: an `item.assigned` or a `decision.signed` has no comment of its
178
+ * own, so slicing the tail threw away the newest — and on a freshly assigned
179
+ * task with exactly one comment, that comment was the whole of the context and
180
+ * the agent saw none of it.
181
+ *
182
+ * Two guards, in order of precision:
183
+ * 1. the payload's own `commentId`, when the row is still on the page. Exact,
184
+ * and correct even when a newer comment landed between the event being
185
+ * appended and this read — the flat slice dropped the WRONG row there.
186
+ * 2. otherwise the event kind ({@link isCommentKind}).
187
+ *
188
+ * @param {object[]} comments the full thread, as read
189
+ * @param {{kind?:string, commentId?:string}} [ev]
190
+ * @returns {object[]} a new array — the caller's is never mutated
191
+ */
192
+ export function priorComments(comments, ev = {}) {
193
+ const rows = Array.isArray(comments) ? comments.slice() : [];
194
+ const id = s(ev && ev.commentId);
195
+ if (id) {
196
+ const idx = rows.findIndex((c) => s(c && c.id) === id);
197
+ if (idx !== -1) {
198
+ rows.splice(idx, 1);
199
+ return rows;
200
+ }
201
+ }
202
+ if (isCommentKind(ev && ev.kind)) return rows.slice(0, -1);
203
+ return rows;
204
+ }
205
+
206
+ /**
207
+ * How many of a room's most recent messages a non-messaging surface borrows.
208
+ * A call or a calendar event needs enough of the room to know what is being
209
+ * talked about; it is not the conversation itself, so it gets less than one.
210
+ */
211
+ const ROOM_HISTORY_TURNS = 8;
212
+
213
+ /** Messages fetched when a room has to be paged for one of those surfaces. */
214
+ const ROOM_HISTORY_LIMIT = 25;
215
+
216
+ /** Transcript lines requested / rendered for a call. */
217
+ const CALL_TRANSCRIPT_LIMIT = 40;
218
+ const CALL_TRANSCRIPT_TURNS = 12;
219
+
220
+ /** Prior occurrences of a recurring event rendered on a calendar surface. */
221
+ const CALENDAR_SERIES_TURNS = 3;
222
+
223
+ /** The artifact body budget. Same clamp `clip` already defaults to. */
224
+ const ARTIFACT_BODY_CHARS = 4000;
225
+
226
+ /** The triggering comment's own budget, alongside an artifact body. */
227
+ const COMMENT_CHARS = 1200;
228
+
229
+ /**
230
+ * The doc surface's own ceiling for `hydrated.text`.
231
+ *
232
+ * Every other hydrator returns `clip(...)` at {@link clip}'s 4000 default. A doc
233
+ * carries a body AND the comment being answered, and clipping the two together
234
+ * at 4000 would spend the whole allowance on the document and cut off the
235
+ * question. So the ceiling is stated, and it is the sum of the declared parts
236
+ * plus the heading — not "whatever the parts happen to add up to".
237
+ */
238
+ const DOC_TEXT_CHARS = 5600;
239
+
240
+ /**
241
+ * Pinned-file rows scanned for one comment. `file.listPinned` carries no limit
242
+ * on the wire, so the bound lives here.
243
+ */
244
+ const PINNED_FILES_MAX = 200;
245
+
246
+ /**
247
+ * The most doc HTML that is ever CONVERTED.
248
+ *
249
+ * `files.docRead` returns the whole document; the wire has no limit to ask for
250
+ * and inventing a parameter hq does not document would be a silent no-op. So
251
+ * the raw string is clamped the instant it lands, BEFORE `htmlToText` runs and
252
+ * before the per-pull cache holds it. Measured before this clamp: 8 MB of HTML
253
+ * converted to 7,435,349 characters to retain 4,000. Generous against
254
+ * {@link ARTIFACT_BODY_CHARS} because markup is most of the bytes — the body of
255
+ * a long doc still reaches its full text budget.
256
+ */
257
+ export const DOC_HTML_MAX_CHARS = 200_000;
258
+
259
+ /**
260
+ * The doc HTML this pull is willing to hold and convert. Exported so the bound
261
+ * is testable on its own — the clamp is invisible in the hydrated output (the
262
+ * 4000-char text clip hides it), which is exactly how it went missing.
263
+ */
264
+ export function clampDocHtml(html) {
265
+ const t = s(html);
266
+ return t.length > DOC_HTML_MAX_CHARS ? t.slice(0, DOC_HTML_MAX_CHARS) : t;
267
+ }
268
+
269
+ /**
270
+ * The room's recent conversation, for a surface anchored to a channel.
271
+ *
272
+ * THE ONE RULE `facts.mjs` enforces holds here too: a channel outside the
273
+ * visible roster is NEVER touched. `messaging.channels` returns a private room
274
+ * only to its members, so refusing to page anything outside that set means this
275
+ * cannot become the aperture the directedness probe declined to open. When the
276
+ * roster is unknown (`visibleChannelIds === null`) we also decline — unknown is
277
+ * not permission, and this is the one gate in the file that is NOT fail-open.
278
+ * `facts.mjs:314-321` already enforces exactly that (`if (!visible) { … return; }`);
279
+ * writing the guard as `visible instanceof Set && !visible.has(id)` let the null
280
+ * case through, so an unreadable roster plus a channel id supplied by the event
281
+ * payload paged a room this seat may never have been in. hq's
282
+ * `assertChannelAccess` still refused it, but the local gate the docstring
283
+ * claims has to exist or the claim is the thing that is wrong.
284
+ *
285
+ * Free when the facts pass already paged the channel; one capped
286
+ * `messaging.history` otherwise, memoised for the pull.
287
+ *
288
+ * @returns {Promise<string|null>} a rendered thread block, or null
289
+ */
290
+ async function roomHistory(channelId, facts, io, cache) {
291
+ const id = s(channelId);
292
+ if (!id || !io) return null;
293
+ const visible = facts && facts.visibleChannelIds;
294
+ if (!(visible instanceof Set) || !visible.has(id)) return null;
295
+
296
+ const page = facts && facts.channelPages instanceof Map ? facts.channelPages.get(id) : null;
297
+ const messages = page && Array.isArray(page.messages) && page.messages.length
298
+ ? page.messages
299
+ : await cached(cache, `roomHistory:${id}`, async () => {
300
+ const frame = await io.call("messaging.history", { channelId: id, limit: ROOM_HISTORY_LIMIT });
301
+ return frame && frame.ok === true ? arrayOf(resultOf(frame), "messages") : [];
302
+ });
303
+
304
+ return renderThread(
305
+ (Array.isArray(messages) ? messages : []).map((m) => ({
306
+ authorName: speakerFor(m, facts),
307
+ body: m.body ?? m.text,
308
+ createdAt: m.createdAt,
309
+ })),
310
+ { limit: ROOM_HISTORY_TURNS },
311
+ );
312
+ }
313
+
314
+ /**
315
+ * A board item's comment thread. Shared by the board, escalation, approval and
316
+ * handoff hydrators, which all anchor on a task id. Fail-open: an unreadable
317
+ * thread is an absent block, never a failed hydration.
318
+ */
319
+ async function taskComments(taskId, io, cache) {
320
+ const id = s(taskId);
321
+ if (!id || !io) return [];
322
+ return cached(cache, `taskComments:${id}`, async () => {
323
+ const frame = await io.call("board.taskComments", { taskId: id });
324
+ return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
325
+ });
326
+ }
327
+
328
+ /** Join the context blocks a surface gathered, dropping the ones that are empty. */
329
+ function joinContext(blocks) {
330
+ const kept = (Array.isArray(blocks) ? blocks : []).map((b) => s(b).trim()).filter(Boolean);
331
+ return kept.length ? kept.join("\n") : null;
332
+ }
333
+
334
+ /**
335
+ * HTML → the text a reader would see. `files.docRead` returns the doc's HEAD
336
+ * html; handing raw markup to the model spends the budget on tags and teaches
337
+ * it to answer in markup. Deliberately small: block tags become newlines, the
338
+ * five XML entities are decoded, everything else goes.
339
+ */
340
+ export function htmlToText(html) {
341
+ return s(html)
342
+ .replace(/<(script|style)[\s\S]*?<\/\1>/gi, " ")
343
+ .replace(/<\/(p|div|li|h[1-6]|tr|blockquote)>/gi, "\n")
344
+ .replace(/<br\s*\/?>/gi, "\n")
345
+ .replace(/<[^>]+>/g, "")
346
+ .replace(/&nbsp;/gi, " ")
347
+ .replace(/&lt;/gi, "<")
348
+ .replace(/&gt;/gi, ">")
349
+ .replace(/&quot;/gi, '"')
350
+ .replace(/&#39;/gi, "'")
351
+ .replace(/&amp;/gi, "&")
352
+ .replace(/[ \t]+/g, " ")
353
+ .replace(/\n{3,}/g, "\n\n")
354
+ .trim();
355
+ }
356
+
131
357
  /**
132
358
  * @typedef {Object} Hydrated
133
359
  * @property {boolean} ok
@@ -164,7 +390,7 @@ export async function hydrate(o = {}) {
164
390
  case "messaging":
165
391
  return hydrateMessage(c, facts);
166
392
  case "calling":
167
- return hydrateCall(c, facts);
393
+ return await hydrateCall(c, facts, io, cache);
168
394
  case "board":
169
395
  return await hydrateTask(c, verdict, facts, io, cache);
170
396
  case "file":
@@ -174,13 +400,13 @@ export async function hydrate(o = {}) {
174
400
  case "decision":
175
401
  return await hydrateDecision(c, facts, io, cache);
176
402
  case "escalation":
177
- return hydrateEscalation(c, facts);
403
+ return await hydrateEscalation(c, facts, io, cache);
178
404
  case "approval":
179
- return hydrateApproval(c, facts);
405
+ return await hydrateApproval(c, facts, io, cache);
180
406
  case "handoff":
181
- return hydrateHandoff(c, facts);
407
+ return await hydrateHandoff(c, facts, io, cache);
182
408
  case "calendar":
183
- return hydrateCalendar(c, facts);
409
+ return await hydrateCalendar(c, facts, io, cache);
184
410
  case "email":
185
411
  return await hydrateEmail(c, facts, io, cache);
186
412
  default:
@@ -242,15 +468,73 @@ function hydrateMessage(c, facts) {
242
468
  };
243
469
  }
244
470
 
245
- /** Calls: the invite topic is the whole content; there is no body to fetch. */
246
- function hydrateCall(c, facts) {
471
+ /**
472
+ * A call. The invite topic used to be the whole of it — an agent pulled into a
473
+ * huddle knew the room and the word "standup" and nothing else.
474
+ *
475
+ * Three reads, each independently fail-open:
476
+ * - `calling.getDetails` for the roster (who is actually in the room). It is
477
+ * participation-gated, so an invite not yet accepted can come back
478
+ * FORBIDDEN; that is a missing line, not a failed hydration.
479
+ * - `calling.getTranscript` for what has already been said, when the call is
480
+ * under way (an invite to a fresh call simply has no lines).
481
+ * - the room's recent messages, which is where the call was almost always
482
+ * agreed before it was started.
483
+ */
484
+ async function hydrateCall(c, facts, io, cache) {
247
485
  const chan = facts.channels instanceof Map ? facts.channels.get(s(c.ids.channelId)) : null;
248
486
  const topic = s(c.ids.topic);
487
+ const callId = s(c.ids.callId);
488
+
489
+ const details = callId && io
490
+ ? await cached(cache, `callDetails:${callId}`, async () => {
491
+ const frame = await io.call("calling.getDetails", { callId });
492
+ return frame && frame.ok === true ? resultOf(frame) : null;
493
+ })
494
+ : null;
495
+
496
+ // `limit` is asked for, and then enforced locally anyway: `ui-parity.mjs`
497
+ // documents the wire contract for `calling.getTranscript` as `{ callId }`
498
+ // alone, so a server that ignores the extra key would hand back every line of
499
+ // an hour-long call. The tail is the part that matters, so the tail is kept.
500
+ const transcript = callId && io
501
+ ? await cached(cache, `callTranscript:${callId}`, async () => {
502
+ const frame = await io.call("calling.getTranscript", { callId, limit: CALL_TRANSCRIPT_LIMIT });
503
+ const lines = frame && frame.ok === true ? arrayOf(resultOf(frame), "lines") : [];
504
+ return lines.length > CALL_TRANSCRIPT_LIMIT ? lines.slice(-CALL_TRANSCRIPT_LIMIT) : lines;
505
+ })
506
+ : [];
507
+
508
+ const lines = [topic ? `Call invite: ${topic}` : "Call invite"];
509
+ if (details) {
510
+ const roster = arrayOf(details, "participants")
511
+ .filter((p) => p && (p.inCall || p.joinedAt))
512
+ .map((p) => s(p.memberId || p.contactId))
513
+ .filter(Boolean);
514
+ if (roster.length) lines.push(`In the call: ${roster.join(", ")}`);
515
+ else if (Number.isFinite(Number(details.participantCount))) {
516
+ lines.push(`Participants: ${Number(details.participantCount)}`);
517
+ }
518
+ if (details.live === false) lines.push("This call has ended.");
519
+ }
520
+
521
+ const transcriptBlock = renderThread(
522
+ transcript.map((l) => ({
523
+ authorName: s(l && (l.memberName || l.memberId)) || "someone",
524
+ body: l && l.text,
525
+ createdAt: l && l.at,
526
+ })),
527
+ { limit: CALL_TRANSCRIPT_TURNS },
528
+ );
529
+ const room = await roomHistory(c.ids.channelId, facts, io, cache);
530
+
249
531
  return {
250
532
  ok: true,
251
- text: topic ? `Call invite: ${topic}` : "Call invite",
533
+ text: clip(lines.join("\n")),
252
534
  subjectDetail: topic,
253
- threadId: s(c.ids.callId),
535
+ threadId: callId,
536
+ threadContext: joinContext([room, transcriptBlock ? `Call transcript so far:\n${transcriptBlock}` : null]),
537
+ isReply: Boolean(transcriptBlock),
254
538
  from: { id: s(c.actor), name: s(c.actor) },
255
539
  channelId: s(c.ids.channelId),
256
540
  channelLabel: s((chan && (chan.name || chan.slug)) || c.ids.channelId || "cohort"),
@@ -261,12 +545,10 @@ function hydrateCall(c, facts) {
261
545
  async function hydrateTask(c, verdict, facts, io, cache) {
262
546
  const taskId = s(c.ids.taskId);
263
547
  const task = facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
264
- const comments = await cached(cache, `taskComments:${taskId}`, async () => {
265
- const frame = await io.call("board.taskComments", { taskId });
266
- return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
267
- });
548
+ const comments = await taskComments(taskId, io, cache);
268
549
 
269
550
  const title = s(task && task.title) || taskId;
551
+ const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
270
552
  const latest = comments.length ? comments[comments.length - 1] : null;
271
553
  const verb = describeBoardKind(c.kind);
272
554
  const lines = [`${verb} — "${title}"`];
@@ -282,15 +564,29 @@ async function hydrateTask(c, verdict, facts, io, cache) {
282
564
  text: clip(lines.join("\n")),
283
565
  subjectDetail: title,
284
566
  threadId: taskId,
285
- threadContext: renderThread(comments.slice(0, -1)),
286
- isReply: comments.length > 1,
567
+ threadContext: renderThread(prior),
568
+ isReply: prior.length > 0,
287
569
  from: { id: s(c.actor), name: s((latest && latest.authorName) || c.actor) },
288
570
  channelId: s(c.ids.channelId || (task && task.channelId) || ""),
289
571
  channelLabel: `task/${title}`,
290
572
  };
291
573
  }
292
574
 
293
- /** A chat-attached file's comment thread (`file.listComments`). */
575
+ /**
576
+ * A chat-attached file: its comment thread AND the file itself.
577
+ *
578
+ * FileComment had ZERO agent activity in 14 days (design §2), and this is one
579
+ * half of why: the hydrator read the comments and never the artifact, so the
580
+ * agent was asked to respond to "is this right?" about a thing it had not seen.
581
+ *
582
+ * What "the artifact" means here is bounded by the protocol, deliberately.
583
+ * `file.listPinned` is explicit that "the dataUrl bytes are NOT projected —
584
+ * only ids + metadata", so a chat attachment's BYTES are not on the wire at
585
+ * all and this cannot invent a read that would serve them. What it can join is
586
+ * the file's record (name, type, size) and the message that shared it, which
587
+ * together are what a human sees in the room before opening it. Both are
588
+ * fail-open: no record and no room simply means no extra lines.
589
+ */
294
590
  async function hydrateChatFile(c, facts, io, cache) {
295
591
  const fileKey = s(c.ids.fileKey);
296
592
  const channelId = s(c.ids.channelId);
@@ -304,21 +600,87 @@ async function hydrateChatFile(c, facts, io, cache) {
304
600
  const latest = comments.length ? comments[comments.length - 1] : null;
305
601
  if (!latest) return { ok: false, reason: "file_comment_unreadable", text: "" };
306
602
 
603
+ const record = await pinnedFileRecord(fileKey, channelId, facts, io, cache);
604
+ const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
307
605
  const chan = facts.channels instanceof Map ? facts.channels.get(channelId) : null;
606
+ const name = s(record && record.name) || fileKey;
607
+
608
+ const lines = [`Comment on file ${name}`];
609
+ if (record) lines.push(`File: ${describeFileRecord(record)}`);
610
+ lines.push(`${s(latest.authorName || latest.authorId)}: ${s(latest.body)}`);
611
+
308
612
  return {
309
613
  ok: true,
310
- text: clip(`Comment on file ${fileKey}\n${s(latest.authorName || latest.authorId)}: ${s(latest.body)}`),
311
- subjectDetail: fileKey,
614
+ text: clip(lines.join("\n")),
615
+ subjectDetail: name,
312
616
  threadId: fileKey,
313
- threadContext: renderThread(comments.slice(0, -1)),
314
- isReply: comments.length > 1,
617
+ threadContext: renderThread(prior),
618
+ isReply: prior.length > 0,
315
619
  from: { id: s(latest.authorId || c.actor), name: s(latest.authorName || latest.authorId || c.actor) },
316
620
  channelId,
317
621
  channelLabel: s((chan && (chan.name || chan.slug)) || `file/${fileKey}`),
318
622
  };
319
623
  }
320
624
 
321
- /** A workspace doc's comment thread (`files.comments`). */
625
+ /**
626
+ * The pinned-file record for an opaque chat `fileKey`, or null.
627
+ *
628
+ * ONE `file.listPinned` per CHANNEL per pull — the read is channel-scoped, so
629
+ * two comments on two files in the same room share it. Gated on channel
630
+ * visibility exactly as {@link roomHistory} is, unknown roster included: an
631
+ * absent roster declines rather than proceeding.
632
+ *
633
+ * `file.listPinned` takes no limit, so the row list is clamped here — a room
634
+ * with hundreds of pinned files must not turn one comment into an unbounded
635
+ * scan. The rows carry names and sizes, not bodies, so the clamp is generous.
636
+ */
637
+ async function pinnedFileRecord(fileKey, channelId, facts, io, cache) {
638
+ const key = s(fileKey);
639
+ const cid = s(channelId);
640
+ if (!key || !cid || !io) return null;
641
+ const visible = facts && facts.visibleChannelIds;
642
+ if (!(visible instanceof Set) || !visible.has(cid)) return null;
643
+
644
+ const rows = await cached(cache, `pinnedFiles:${cid}`, async () => {
645
+ const frame = await io.call("file.listPinned", { channelId: cid });
646
+ return frame && frame.ok === true
647
+ ? arrayOf(resultOf(frame), "files").slice(0, PINNED_FILES_MAX)
648
+ : [];
649
+ });
650
+ return rows.find((r) => r && (s(r.fileKey) === key || s(r.id) === key)) || null;
651
+ }
652
+
653
+ /** `quarterly.pdf · application/pdf · 1.2 MB` — what the room shows about a file. */
654
+ function describeFileRecord(record) {
655
+ const parts = [s(record.name) || "file"];
656
+ if (record.mimeType) parts.push(s(record.mimeType));
657
+ const bytes = Number(record.sizeBytes);
658
+ if (Number.isFinite(bytes) && bytes > 0) parts.push(formatBytes(bytes));
659
+ return parts.join(" · ");
660
+ }
661
+
662
+ /** Bytes as a person reads them. */
663
+ function formatBytes(bytes) {
664
+ if (bytes < 1024) return `${bytes} B`;
665
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
666
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
667
+ }
668
+
669
+ /**
670
+ * A workspace doc: its comment thread AND the document.
671
+ *
672
+ * `files.docRead` returns the doc's HEAD html through the SAME `files.read`
673
+ * scope `files.get` already used in the facts pass — it is the read the agent
674
+ * could make by hand, so joining it here widens nothing. The body is converted
675
+ * to text and clipped to {@link ARTIFACT_BODY_CHARS}: a 40-page doc must not
676
+ * spend the whole prompt, and the comment being answered is nearly always about
677
+ * the top of it.
678
+ *
679
+ * Only DOC-kind files are read. A sheet or a deck answers `BAD_REQUEST` from
680
+ * `files.docRead`, so when the facts pass already knows the kind we skip the
681
+ * call rather than spend a round trip on a refusal; when it does not, the
682
+ * refusal is fail-open like every other probe here.
683
+ */
322
684
  async function hydrateDoc(c, facts, io, cache) {
323
685
  const fileId = s(c.ids.fileId);
324
686
  const comments = await cached(cache, `docComments:${fileId}`, async () => {
@@ -327,25 +689,49 @@ async function hydrateDoc(c, facts, io, cache) {
327
689
  });
328
690
  const acl = facts.fileAcl instanceof Map ? facts.fileAcl.get(fileId) : null;
329
691
  const name = s(c.ids.name || (acl && acl.name) || fileId);
692
+ const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
330
693
  const latest = comments.length ? comments[comments.length - 1] : null;
331
- const head = describeFilesKind(c.kind, name);
332
- const text = latest
333
- ? `${head}\n${s(latest.authorName || latest.authorId)}: ${s(latest.body)}`
334
- : head;
694
+ const body = await docBody(fileId, acl, io, cache);
695
+
696
+ const lines = [describeFilesKind(c.kind, name)];
697
+ if (body) lines.push(`Document:\n${body}`);
698
+ // The comment is clipped on its own, not with the body: one `clip` over the
699
+ // join would spend the whole 4000 on the document and drop the very sentence
700
+ // being answered. The join is then clipped AGAIN at the shared ceiling, which
701
+ // every other hydrator applies — dropping it here made this the one surface
702
+ // that could exceed `clip`'s default, silently, by about a third.
703
+ if (latest) lines.push(`${s(latest.authorName || latest.authorId)}: ${clip(s(latest.body), COMMENT_CHARS)}`);
335
704
 
336
705
  return {
337
706
  ok: true,
338
- text: clip(text),
707
+ text: clip(lines.join("\n"), DOC_TEXT_CHARS),
339
708
  subjectDetail: name,
340
709
  threadId: fileId,
341
- threadContext: renderThread(comments.slice(0, -1)),
342
- isReply: comments.length > 1,
710
+ threadContext: renderThread(prior),
711
+ isReply: prior.length > 0,
343
712
  from: { id: s((latest && latest.authorId) || c.actor), name: s((latest && latest.authorName) || c.actor) },
344
713
  channelId: "",
345
714
  channelLabel: `doc/${name}`,
346
715
  };
347
716
  }
348
717
 
718
+ /** The doc's text, bounded, or null. Fail-open on every path. */
719
+ async function docBody(fileId, acl, io, cache) {
720
+ const id = s(fileId);
721
+ if (!id || !io) return null;
722
+ const kind = s(acl && acl.kind).toUpperCase();
723
+ if (kind && kind !== "DOC") return null;
724
+ const html = await cached(cache, `docBody:${id}`, async () => {
725
+ const frame = await io.call("files.docRead", { fileId: id });
726
+ const res = frame && frame.ok === true ? resultOf(frame) : null;
727
+ // Clamped HERE, not after conversion: this is what the cache holds and what
728
+ // htmlToText walks. See DOC_HTML_MAX_CHARS.
729
+ return res ? clampDocHtml(res.html) : "";
730
+ });
731
+ const text = htmlToText(html);
732
+ return text ? clip(text, ARTIFACT_BODY_CHARS) : null;
733
+ }
734
+
349
735
  /** A decision, its rationale, and its comment thread. */
350
736
  async function hydrateDecision(c, facts, io, cache) {
351
737
  const decisionId = s(c.ids.decisionId);
@@ -355,6 +741,7 @@ async function hydrateDecision(c, facts, io, cache) {
355
741
  return frame && frame.ok === true ? arrayOf(resultOf(frame), "comments") : [];
356
742
  });
357
743
  const title = s((d && d.title) || c.ids.title || decisionId);
744
+ const prior = priorComments(comments, { kind: c.kind, commentId: c.ids.commentId });
358
745
  const latest = comments.length ? comments[comments.length - 1] : null;
359
746
 
360
747
  const lines = [`${describeDecisionKind(c.kind)} — "${title}"`];
@@ -369,41 +756,67 @@ async function hydrateDecision(c, facts, io, cache) {
369
756
  text: clip(lines.join("\n")),
370
757
  subjectDetail: title,
371
758
  threadId: decisionId,
372
- threadContext: renderThread(comments.slice(0, -1)),
373
- isReply: comments.length > 1,
759
+ threadContext: renderThread(prior),
760
+ isReply: prior.length > 0,
374
761
  from: { id: s(c.ids.authorId || c.actor), name: s((latest && latest.authorName) || c.actor) },
375
762
  channelId: "",
376
763
  channelLabel: `decision/${title}`,
377
764
  };
378
765
  }
379
766
 
380
- /** An escalation — already fully read back by the facts pass. */
381
- function hydrateEscalation(c, facts) {
767
+ /**
768
+ * An escalation, plus the work it escalated.
769
+ *
770
+ * The row says a severity and a sentence of detail. What it never said is what
771
+ * the task is or what has already been tried on it — which is the whole of what
772
+ * an escalation is asking about. The escalated task's board row (free, already
773
+ * in `facts.tasks`) and its comment thread supply that.
774
+ */
775
+ async function hydrateEscalation(c, facts, io, cache) {
382
776
  const id = s(c.ids.escalationId);
383
777
  const e = facts.escalations instanceof Map ? facts.escalations.get(id) : null;
384
778
  const title = s((e && e.title) || id);
779
+ const taskId = s(c.ids.taskId || (e && e.taskId));
780
+ const task = taskId && facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
781
+
385
782
  const lines = [`${c.kind === "escalation.resolved" ? "Escalation resolved" : "Escalation raised"} — "${title}"`];
386
783
  if (e && e.severity) lines.push(`Severity: ${s(e.severity)}`);
387
784
  if (e && e.detail) lines.push(clip(e.detail, 1200));
388
785
  if (e && e.waitingOn) lines.push(`Waiting on: ${s(e.waitingOn)}`);
389
786
  if (e && e.resolution) lines.push(`Resolution: ${clip(e.resolution, 600)}`);
787
+ if (task) lines.push(describeTaskRow(task, taskId));
788
+
789
+ const comments = taskId ? await taskComments(taskId, io, cache) : [];
790
+
390
791
  return {
391
792
  ok: true,
392
793
  text: clip(lines.join("\n")),
393
794
  subjectDetail: title,
394
795
  threadId: id,
796
+ threadContext: renderThread(comments),
797
+ isReply: comments.length > 0,
395
798
  from: { id: s(c.actor), name: s(c.actor) },
396
799
  channelId: s(c.ids.channelId || (e && e.channelId) || ""),
397
800
  channelLabel: `escalation/${title}`,
398
801
  };
399
802
  }
400
803
 
804
+ /** One line of board row: `Board item "Ship it" — doing · priority 1 · due …`. */
805
+ function describeTaskRow(task, taskId) {
806
+ const bits = [];
807
+ if (task.col) bits.push(s(task.col));
808
+ if (task.priority != null) bits.push(`priority ${s(task.priority)}`);
809
+ if (task.dueAt) bits.push(`due ${s(task.dueAt)}`);
810
+ const head = `Board item "${s(task.title) || s(taskId)}"`;
811
+ return bits.length ? `${head} — ${bits.join(" · ")}` : head;
812
+ }
813
+
401
814
  /**
402
815
  * A calendar event. The body came back on the ACL'd `calendar.list` read in the
403
816
  * facts pass — that read IS the probe, so there is no second request here and
404
817
  * nothing is rendered that hq did not already hand this seat.
405
818
  */
406
- function hydrateCalendar(c, facts) {
819
+ async function hydrateCalendar(c, facts, io, cache) {
407
820
  const id = s(c.ids.eventId);
408
821
  const e = facts.myEvents instanceof Map ? facts.myEvents.get(id) : null;
409
822
  const title = s((e && e.title) || c.ids.title || id);
@@ -413,17 +826,56 @@ function hydrateCalendar(c, facts) {
413
826
  if (c.kind === "event.rsvp" && c.ids.rsvp) lines.push(`RSVP: ${s(c.ids.rsvp)}`);
414
827
  if (e && e.location) lines.push(`Location: ${s(e.location)}`);
415
828
  if (e && e.description) lines.push(clip(e.description, 1200));
829
+
830
+ const channelId = s(c.ids.channelId || (e && e.channelId) || "");
831
+ const room = await roomHistory(channelId, facts, io, cache);
832
+ const series = priorOccurrences(e || { id, title, startsAt }, facts);
833
+
416
834
  return {
417
835
  ok: true,
418
836
  text: clip(lines.join("\n")),
419
837
  subjectDetail: title,
420
838
  threadId: id,
839
+ threadContext: joinContext([room, series]),
840
+ isReply: Boolean(room || series),
421
841
  from: { id: s(c.actor), name: s(c.actor) },
422
- channelId: s(c.ids.channelId || (e && e.channelId) || ""),
842
+ channelId,
423
843
  channelLabel: `calendar/${title}`,
424
844
  };
425
845
  }
426
846
 
847
+ /**
848
+ * Earlier occurrences of the same recurring meeting, rendered as one block.
849
+ *
850
+ * hq's Meeting DTO carries no series id — recurrence is not modelled on the
851
+ * wire — so the series is identified the way a person identifies it: same
852
+ * title, earlier start. That is a HEURISTIC and is deliberately kept cheap and
853
+ * local: it reads only `facts.myEvents`, which the ACL'd `calendar.list` in the
854
+ * facts pass already produced, so it costs no request and cannot see an event
855
+ * the lens floor withheld. A same-titled one-off is at worst one extra line of
856
+ * true context; it is never a read the agent was not entitled to.
857
+ */
858
+ function priorOccurrences(event, facts) {
859
+ const all = facts && facts.myEvents instanceof Map ? Array.from(facts.myEvents.values()) : [];
860
+ if (all.length < 2) return null;
861
+ const title = s(event && event.title);
862
+ if (!title) return null;
863
+ const at = Date.parse(s(event && event.startsAt));
864
+
865
+ const earlier = all
866
+ .filter((m) => m && s(m.id) !== s(event && event.id) && s(m.title) === title)
867
+ .filter((m) => {
868
+ const t = Date.parse(s(m.startsAt));
869
+ return Number.isFinite(t) && (!Number.isFinite(at) || t < at);
870
+ })
871
+ .sort((a, b) => Date.parse(s(a.startsAt)) - Date.parse(s(b.startsAt)))
872
+ .slice(-CALENDAR_SERIES_TURNS);
873
+
874
+ if (earlier.length === 0) return null;
875
+ const rows = earlier.map((m) => `${s(m.startsAt)}${m.location ? ` · ${s(m.location)}` : ""}`);
876
+ return `Earlier in this series:\n${rows.join("\n")}`;
877
+ }
878
+
427
879
  /** Human phrasing for a `calendar.*` event kind. */
428
880
  export function describeCalendarKind(kind) {
429
881
  switch (kind) {
@@ -436,11 +888,23 @@ export function describeCalendarKind(kind) {
436
888
  }
437
889
  }
438
890
 
439
- /** An approval — already read back by the facts pass (that IS the probe). */
440
- function hydrateApproval(c, facts) {
891
+ /**
892
+ * An approval, plus the thread of whatever it is an approval OF.
893
+ *
894
+ * An action class and a requester is not enough to decide anything: the
895
+ * approver needs the discussion that produced the request. hq's Approval row
896
+ * carries `itemId`, which is a board item most of the time and a decision the
897
+ * rest, and the row does not say which — so the board thread is tried first and
898
+ * the decision thread only when that read REFUSES (an empty board thread is an
899
+ * answer, and must not cost a second probe).
900
+ */
901
+ async function hydrateApproval(c, facts, io, cache) {
441
902
  const id = s(c.ids.approvalId);
442
903
  const a = facts.approvals instanceof Map ? facts.approvals.get(id) : null;
443
904
  const actionClass = s((a && a.actionClass) || c.ids.actionClass);
905
+ const itemId = s((a && a.itemId) || c.ids.taskId);
906
+ const task = itemId && facts.tasks instanceof Map ? facts.tasks.get(itemId) : null;
907
+
444
908
  const lines = [`${describeApprovalKind(c.kind)} — ${actionClass || id}`];
445
909
  if (a) {
446
910
  lines.push(`Status: ${s(a.status)} · requester ${s(a.requester)}${a.approver ? ` · approver ${s(a.approver)}` : ""}`);
@@ -451,31 +915,68 @@ function hydrateApproval(c, facts) {
451
915
  lines.push(`Subject: ${clip(typeof a.subject === "string" ? a.subject : JSON.stringify(a.subject), 600)}`);
452
916
  }
453
917
  }
918
+ if (task) lines.push(describeTaskRow(task, itemId));
919
+
920
+ const comments = itemId ? await anchoredItemComments(itemId, io, cache) : [];
921
+
454
922
  return {
455
923
  ok: true,
456
924
  text: clip(lines.join("\n")),
457
925
  subjectDetail: actionClass || id,
458
926
  threadId: id,
927
+ threadContext: renderThread(comments),
928
+ isReply: comments.length > 0,
459
929
  from: { id: s((a && a.requester) || c.actor), name: s((a && a.requester) || c.actor) },
460
930
  channelId: "",
461
931
  channelLabel: `approval/${actionClass || id}`,
462
932
  };
463
933
  }
464
934
 
465
- /** A handoff — the payload already carries intent + both ends. */
466
- function hydrateHandoff(c, facts) {
935
+ /**
936
+ * The comment thread of an item that is EITHER a board task or a decision.
937
+ * Board first; the decision read happens only when the board read refuses, so
938
+ * the common case costs exactly one probe.
939
+ */
940
+ async function anchoredItemComments(itemId, io, cache) {
941
+ const id = s(itemId);
942
+ if (!id || !io) return [];
943
+ return cached(cache, `anchoredComments:${id}`, async () => {
944
+ const board = await io.call("board.taskComments", { taskId: id });
945
+ if (board && board.ok === true) return arrayOf(resultOf(board), "comments");
946
+ const decision = await io.call("decision.listComments", { decisionId: id });
947
+ if (decision && decision.ok === true) return arrayOf(resultOf(decision), "comments");
948
+ return [];
949
+ });
950
+ }
951
+
952
+ /**
953
+ * A handoff, plus the originating task's thread.
954
+ *
955
+ * The payload names both ends and an intent sentence, which tells the receiving
956
+ * agent what it is being asked to take but nothing about the state of the work.
957
+ * The originating task's comments are that state — and they are also where the
958
+ * handoff CHAIN is legible, because hq exposes no handoff-history read: every
959
+ * offer, accept and decline that mattered was narrated onto the item.
960
+ */
961
+ async function hydrateHandoff(c, facts, io, cache) {
467
962
  const taskId = s(c.ids.taskId);
468
963
  const task = facts.tasks instanceof Map ? facts.tasks.get(taskId) : null;
469
964
  const title = s((task && task.title) || taskId);
470
965
  const lines = [`${describeHandoffKind(c.kind)}${title ? ` — "${title}"` : ""}`];
471
966
  if (c.ids.intent) lines.push(`Intent: ${clip(c.ids.intent, 800)}`);
472
967
  lines.push(`From ${s(c.ids.from)} → to ${s(c.ids.to)}`);
968
+ if (task) lines.push(describeTaskRow(task, taskId));
473
969
  if (task && task.detail) lines.push(`Detail: ${clip(task.detail, 600)}`);
970
+
971
+ const comments = taskId ? await taskComments(taskId, io, cache) : [];
972
+
474
973
  return {
475
974
  ok: true,
476
975
  text: clip(lines.join("\n")),
477
976
  subjectDetail: title || s(c.ids.handoffId),
478
977
  threadId: s(c.ids.runId || c.ids.handoffId),
978
+ threadContext: renderThread(comments),
979
+ isReply: comments.length > 0,
479
980
  from: { id: s(c.ids.from || c.actor), name: s(c.ids.from || c.actor) },
480
981
  channelId: "",
481
982
  channelLabel: `handoff/${title || s(c.ids.handoffId)}`,
@@ -659,6 +1160,9 @@ export default {
659
1160
  hydrate,
660
1161
  clip,
661
1162
  renderThread,
1163
+ priorComments,
1164
+ isCommentKind,
1165
+ htmlToText,
662
1166
  describeBoardKind,
663
1167
  describeDecisionKind,
664
1168
  describeApprovalKind,