@huynle/hub 0.7.4 → 0.8.1

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 (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,786 @@
1
+ // Team intake (HWA-36; contract docs/plans/2026-10-30-hub-workspaces-artifacts-contracts.md, Teams and the
2
+ // HWA-33/34/35/04/06/11 clarifications; record TeamIntake in packages/protocol/src/teams.ts).
3
+ //
4
+ // Work is given to a TEAM, never to a session: the user (console/MCP), an app token with `teams:submit` (automations
5
+ // such as a Brain connector: docs/teams-intake.md) or a session submits {title, brief?, priority?, labels?, due?,
6
+ // criteria?, ref?}. The hub
7
+ // 1. stores a TeamIntake (`queued`) in collection `team-intake` (one aggregate per team, key: team ID: items,
8
+ // Idempotency-Keys) and the brief alone in `team-intake-brief` (key: intake ID) until it is delivered;
9
+ // 2. routes it (drain) to a current seat of the team's entry role — an online one first (earliest staffed), else
10
+ // an offline one (Delivery keeps the message pending until its spoke reports the session): creates a
11
+ // WorkspaceAssignment (HWA-04) of that seat's member in the team's workspace (HWA-35, one per team) with
12
+ // `ref` = intake ID, then delivers the task as a hub `msg` and marks the intake `routed` (`progress: open`);
13
+ // 3. while the entry role is vacant (no current seat), the team is paused, or its workspace is not linked yet, the
14
+ // intake stays `queued` (durable, capped at TEAM_LIMITS.openIntakePerTeam open items per team). onEvent drains a
15
+ // team on `team.staffed`, `team.updated`/`team.archived` (resume/restore), and `session.added` or
16
+ // `workspace.member.joined` of an entry seat (staffing emits team.staffed before the member joins); every
17
+ // submit and read drains too, so a trigger missed across a restart is picked up on the next touch.
18
+ //
19
+ // Sender (decided for HWA-36, Brain decision in project hub): the task is delivered from the fixed hub party
20
+ // `app:team-intake` (like HWA-11's `app:workspace-coordinator`; no real token ID has this shape), never as the user
21
+ // or the submitting app. Reasons: queued items are delivered later without the submitter's credential (an app token
22
+ // may be revoked by then); a user-sent message would reset hop chains and pose as a human turn; and the party sits
23
+ // in no team, so delivery policy admits it (r3vv4eon) while who submitted is named in the text. Consequence
24
+ // (HWA-34): the entry seat's reply to this `msg` is a msg to an outsider and is denied without `externalContact`;
25
+ // progress is reported explicitly through setStatus (POST /teams/:id/intake/:intakeID/status, MCP
26
+ // team_intake_update, the OpenCode hub tool's intake_update verb), which the submitter reads (GET, `team.intake`
27
+ // events). The text says so, with the exact verb and IDs (reportingLines).
28
+ // Hops (0.8.1): a session's submission continues its hop chain — the submitter's depth + 1 at submit time (hopOf)
29
+ // is kept on the row (never shown) and delivered as SendOptions.chainHop, so intake is not a way to restart a chain
30
+ // at hop 1; the user's and apps' submissions start their own.
31
+ //
32
+ // Status (explicit, never inferred from idleness/presence/departure; HWA-04/06): intake `queued` → `routed` →
33
+ // `done` | `failed` | `cancelled` (queued may be cancelled). While routed, `progress` mirrors the assignment:
34
+ // open | in_progress | blocked. Mapping to the assignment: done → done; failed and cancelled → cancelled (an
35
+ // assignment has no failed). Closing the assignment through the workspace routes is reflected on the next read of
36
+ // the intake (get / setStatus). Who: the user and apps with teams:manage — anything; a session holding a current
37
+ // seat of the entry role — anything; the submitter (app or session) — cancel only.
38
+ // Reads: users all; apps with teams:read or teams:manage all, with teams:submit their own submissions; sessions
39
+ // holding a current seat in the team all, other sessions their own submissions. Another user's team: not_found.
40
+ // Sessions submit under team policy: teamDeliveryVerdict for a `msg` from the session to the entry role (no-team
41
+ // sessions: allowed; members need a path or externalContact) — intake is not a way around the drawn edges.
42
+ //
43
+ // Delivery is at-least-once: a routing claim (claimMs) keeps two drains from delivering the same item; a crash
44
+ // after delivery and before the routed write re-delivers after the claim expires (the assignment is reused).
45
+ // Events (`team.intake`, family manage) carry IDs, enums and revisions only: never title, brief, labels or criteria.
46
+ import { createHash, randomBytes } from "node:crypto"
47
+ import {
48
+ ID_PREFIXES,
49
+ IDEMPOTENCY_KEY,
50
+ IDEMPOTENCY_TTL_MS,
51
+ type IdKind,
52
+ isExpectedRevision,
53
+ isLabel,
54
+ isText,
55
+ LIST_LIMIT_DEFAULT,
56
+ LIST_LIMIT_MAX,
57
+ type Page,
58
+ tagsProblem,
59
+ TEAM_LIMITS,
60
+ type TeamIntake,
61
+ type TeamSeat,
62
+ teamDeliveryVerdict,
63
+ } from "../../protocol"
64
+ import type { AgentActor, AppActor } from "../agent/actor"
65
+ import { sanitizeLabel } from "../agent/envelope"
66
+ import { AgentError } from "../agent/errors"
67
+ import type { AgentEvents, EventInput } from "../agent/events"
68
+ import { canonicalBody } from "../agent/idempotency"
69
+ import { updateVersioned } from "../agent/versioned"
70
+ import type { Clock, StateStore } from "../backend/types"
71
+ import { assignmentChangedEvent } from "../workspaces/events"
72
+ import type { MembershipStore, SessionPresence, SessionPresenceLookup, StoredAssignment } from "../workspaces/members"
73
+ import type { TeamSeatStore } from "./seats"
74
+ import type { TeamStaffingService } from "./staffing"
75
+ import type { Team, TeamStore } from "./store"
76
+
77
+ /** One aggregate per team (key: team ID): intake items and their Idempotency-Keys. */
78
+ export const TEAM_INTAKE = "team-intake"
79
+ /** Briefs of undelivered intake (key: intake ID); removed once delivered or cancelled. */
80
+ export const TEAM_INTAKE_BRIEFS = "team-intake-brief"
81
+ /** The fixed party intake is delivered from (an app party no real token can be: those are 16 hex digits). */
82
+ export const INTAKE_TOKEN = "team-intake"
83
+ /** The party intake is delivered as (never the user or the submitter; it holds no scopes and no seats). */
84
+ export const intakeActor = (userID: string): AppActor => ({ kind: "app", userID, tokenID: INTAKE_TOKEN, scopes: [], label: "team intake" })
85
+
86
+ export const INTAKE_PRIORITIES = ["low", "normal", "high", "urgent"] as const
87
+ export type IntakePriority = (typeof INTAKE_PRIORITIES)[number]
88
+ export type IntakeProgress = "open" | "in_progress" | "blocked"
89
+ export type IntakeStatus = TeamIntake["status"]
90
+ /** What setStatus accepts: progress while routed, or a closing status. */
91
+ export const INTAKE_STATUS_INPUTS = ["open", "in_progress", "blocked", "done", "failed", "cancelled"] as const
92
+ export type IntakeStatusInput = (typeof INTAKE_STATUS_INPUTS)[number]
93
+
94
+ export interface IntakeLimits {
95
+ /** Open (queued or routed) items per team. */
96
+ readonly openPerTeam: number
97
+ /** Submissions per team per rolling minute. */
98
+ readonly submitsPerMinute: number
99
+ /** Closed items kept per team (oldest closed dropped). */
100
+ readonly closedRetained: number
101
+ readonly briefMax: number
102
+ readonly refMax: number
103
+ readonly reasonMax: number
104
+ readonly criteriaPerIntake: number
105
+ readonly criterionMax: number
106
+ /** Live Idempotency-Keys kept per team. */
107
+ readonly keysPerTeam: number
108
+ /** How long a routing claim holds before another drain may retry the item. */
109
+ readonly claimMs: number
110
+ }
111
+
112
+ export const INTAKE_LIMITS: IntakeLimits = {
113
+ openPerTeam: TEAM_LIMITS.openIntakePerTeam,
114
+ submitsPerMinute: 60,
115
+ closedRetained: 200,
116
+ briefMax: 16_000,
117
+ refMax: 200,
118
+ reasonMax: 200,
119
+ criteriaPerIntake: 8,
120
+ criterionMax: 200,
121
+ keysPerTeam: 1_000,
122
+ claimMs: 60_000,
123
+ }
124
+
125
+ /** A TeamIntake as stored and read: the contract record plus additive fields (HWA-36). */
126
+ export interface StoredIntake extends TeamIntake {
127
+ readonly priority: IntakePriority
128
+ readonly labels?: readonly string[]
129
+ /** Due time (epoch ms). */
130
+ readonly due?: number
131
+ readonly criteria?: readonly string[]
132
+ readonly workspaceID?: string
133
+ /** The entry seat's session it was delivered to. */
134
+ readonly sessionID?: string
135
+ readonly messageID?: string
136
+ /** While routed: the assignment's status. */
137
+ readonly progress?: IntakeProgress
138
+ /** Why it failed or was cancelled (bounded, never in events). */
139
+ readonly reason?: string
140
+ /** The last routing failure's error code (the item stays queued and is retried). */
141
+ readonly lastError?: string
142
+ readonly updatedAt: number
143
+ readonly routedAt?: number
144
+ readonly closedAt?: number
145
+ }
146
+
147
+ interface IntakeRow extends StoredIntake {
148
+ readonly claim?: { readonly owner: string; readonly at: number }
149
+ /** A session submitter's depth + 1 at submit time: the delivery continues its hop chain (never shown). */
150
+ readonly hop?: number
151
+ }
152
+
153
+ interface IntakeKey {
154
+ readonly intakeID: string
155
+ readonly digest: string
156
+ readonly at: number
157
+ }
158
+
159
+ interface IntakeDoc {
160
+ readonly v: 1
161
+ readonly teamID: string
162
+ readonly items: readonly IntakeRow[]
163
+ /** `<party>|<key>` → the intake it created. */
164
+ readonly keys: Readonly<Record<string, IntakeKey>>
165
+ }
166
+
167
+ /**
168
+ * Delivers one task text to a session (production: Delivery.send as intakeActor); `hop`: the chain it continues (a
169
+ * submitting session's depth + 1; production passes it as SendOptions.chainHop).
170
+ */
171
+ export type IntakeDeliver = (userID: string, sessionID: string, text: string, options?: { readonly hop?: number }) => Promise<{ readonly messageID: string }>
172
+
173
+ /** The hop a message from the session would carry now (production: Delivery.hops.next). */
174
+ export type IntakeHopOf = (userID: string, sessionID: string) => Promise<number>
175
+
176
+ export interface TeamIntakeDeps {
177
+ readonly state: StateStore
178
+ readonly teams: Pick<TeamStore, "get" | "require">
179
+ readonly seats: Pick<TeamSeatStore, "list" | "seatsOf" | "deliveryContext">
180
+ readonly staffing: Pick<TeamStaffingService, "workspaceOf">
181
+ readonly members: Pick<MembershipStore, "members" | "assign" | "assignment" | "setStatus" | "reassign">
182
+ readonly presence: SessionPresenceLookup
183
+ readonly deliver: IntakeDeliver
184
+ /** A session submitter's hop chain (absent: deliveries start their own chain). */
185
+ readonly hopOf?: IntakeHopOf
186
+ readonly events?: Pick<AgentEvents, "append">
187
+ readonly report?: (cause: unknown) => void
188
+ readonly now?: Clock
189
+ readonly newID?: (kind: IdKind) => string
190
+ readonly limits?: Partial<IntakeLimits>
191
+ }
192
+
193
+ export interface IntakeListQuery {
194
+ readonly status?: string
195
+ readonly limit?: number
196
+ readonly cursor?: string
197
+ }
198
+
199
+ /** Intake counts of one team (team status, HWA-37): by status, and routed items by progress. */
200
+ export interface IntakeSummary {
201
+ readonly queued: number
202
+ readonly routed: number
203
+ readonly done: number
204
+ readonly cancelled: number
205
+ readonly failed: number
206
+ /** Routed items by progress. */
207
+ readonly open: number
208
+ readonly inProgress: number
209
+ readonly blocked: number
210
+ /** When the oldest still-queued item was submitted (epoch ms). */
211
+ readonly oldestQueuedAt?: number
212
+ }
213
+
214
+ const SUBMIT_FIELDS = new Set(["title", "brief", "priority", "labels", "due", "criteria", "ref"])
215
+ const STATUS_FIELDS = new Set(["status", "expectedRevision", "reason"])
216
+ const CLOSED: ReadonlySet<IntakeStatus> = new Set(["done", "cancelled", "failed"])
217
+ const OPEN_ASSIGNMENT: ReadonlySet<string> = new Set(["open", "in_progress", "blocked"])
218
+
219
+ const isObj = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
220
+ const message = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause))
221
+ const digestOf = (v: unknown) => createHash("sha256").update(canonicalBody(v)).digest("hex")
222
+
223
+ function unknownField(input: Record<string, unknown>, allowed: ReadonlySet<string>): void {
224
+ const unknown = Object.keys(input).find((k) => !allowed.has(k))
225
+ if (unknown !== undefined) throw new AgentError("invalid", `unknown field ${/^[A-Za-z0-9_-]{1,40}$/.test(unknown) ? unknown : "(unprintable)"}`)
226
+ }
227
+
228
+ function readDoc(raw: unknown, teamID: string): IntakeDoc {
229
+ if (raw === undefined) return { v: 1, teamID, items: [], keys: {} }
230
+ if (isObj(raw) && typeof raw.v === "number" && raw.v > 1)
231
+ throw new AgentError("unsupported", `The intake of team ${teamID} was written by a newer hub`, { hint: "Upgrade this hub", extra: { feature: TEAM_INTAKE } })
232
+ if (!isObj(raw) || raw.v !== 1 || raw.teamID !== teamID || !Array.isArray(raw.items)) throw new AgentError("internal", `Stored intake of team ${teamID} is malformed`)
233
+ return { v: 1, teamID, items: raw.items as IntakeRow[], keys: isObj(raw.keys) ? (raw.keys as Record<string, IntakeKey>) : {} }
234
+ }
235
+
236
+ /** The source of a submission (the contract's TeamIntake.source). */
237
+ function sourceOf(actor: AgentActor): TeamIntake["source"] {
238
+ switch (actor.kind) {
239
+ case "user":
240
+ return { kind: "user" }
241
+ case "app":
242
+ return { kind: "app", tokenID: actor.tokenID }
243
+ case "session":
244
+ return { kind: "session", sessionID: actor.sessionID }
245
+ }
246
+ }
247
+
248
+ const partyOf = (actor: AgentActor): string => (actor.kind === "user" ? "user" : actor.kind === "app" ? `app:${actor.tokenID}` : `ses:${actor.sessionID}`)
249
+
250
+ const sameSource = (row: StoredIntake, actor: AgentActor): boolean =>
251
+ (actor.kind === "app" && row.source.kind === "app" && row.source.tokenID === actor.tokenID) ||
252
+ (actor.kind === "session" && row.source.kind === "session" && row.source.sessionID === actor.sessionID) ||
253
+ (actor.kind === "user" && row.source.kind === "user")
254
+
255
+ /** The stored row as reads show it (no routing claim, no hop). */
256
+ function view(row: IntakeRow): StoredIntake {
257
+ const { claim: _claim, hop: _hop, ...rest } = row
258
+ return rest
259
+ }
260
+
261
+ const intakeEvent = (row: StoredIntake): EventInput => ({
262
+ type: "team.intake",
263
+ family: "manage",
264
+ subject: {
265
+ teamID: row.teamID,
266
+ intakeID: row.id,
267
+ ...(row.assignmentID !== undefined ? { assignmentID: row.assignmentID } : {}),
268
+ ...(row.workspaceID !== undefined ? { workspaceID: row.workspaceID } : {}),
269
+ ...(row.sessionID !== undefined ? { sessionID: row.sessionID } : {}),
270
+ },
271
+ data: { status: row.status, revision: row.revision, source: row.source.kind, priority: row.priority, ...(row.progress !== undefined ? { progress: row.progress } : {}) },
272
+ })
273
+
274
+ interface Body {
275
+ readonly title: string
276
+ readonly brief?: string
277
+ readonly priority: IntakePriority
278
+ readonly labels?: readonly string[]
279
+ readonly due?: number
280
+ readonly criteria?: readonly string[]
281
+ readonly ref?: string
282
+ }
283
+
284
+ interface Target {
285
+ readonly sessionID: string
286
+ readonly workspaceID: string
287
+ readonly memberID: string
288
+ }
289
+
290
+ export class TeamIntakeService {
291
+ private readonly state: StateStore
292
+ private readonly now: Clock
293
+ private readonly newID: (kind: IdKind) => string
294
+ private readonly report: (cause: unknown) => void
295
+ private readonly limits: IntakeLimits
296
+ private readonly owner = `intake-${process.pid}-${randomBytes(4).toString("hex")}`
297
+ /** Per user and team: the drain in progress (one at a time in this process). */
298
+ private readonly draining = new Map<string, Promise<number>>()
299
+
300
+ constructor(private readonly deps: TeamIntakeDeps) {
301
+ this.state = deps.state
302
+ this.now = deps.now ?? Date.now
303
+ this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
304
+ this.report = deps.report ?? ((cause) => console.error(`[hub] team intake: ${message(cause)}`))
305
+ this.limits = { ...INTAKE_LIMITS, ...deps.limits }
306
+ }
307
+
308
+ // ---------- submit ----------
309
+
310
+ /** Submits work to a team; routes it at once when an entry seat is staffed, else it waits queued. */
311
+ async submit(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ intake: StoredIntake; created: boolean }> {
312
+ if (actor.kind === "app" && !actor.scopes.includes("teams:submit")) throw new AgentError("forbidden", "Submitting to a team needs scope teams:submit")
313
+ const body = this.body(input)
314
+ const key = options.idempotencyKey
315
+ if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
316
+ const team = await this.deps.teams.require(userID, teamID)
317
+ if (team.state === "archived") throw new AgentError("invalid", `Team ${team.id} is archived`, { hint: "Restore it first" })
318
+ if (team.paused) throw new AgentError("invalid", `Team ${team.id} is paused: it takes no new work`, { hint: "Resume it first" })
319
+ if (actor.kind === "session") await this.checkPolicy(userID, team, actor.sessionID)
320
+
321
+ const digest = digestOf(body)
322
+ const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
323
+ const id = this.newID("intake")
324
+ const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
325
+ if (body.brief !== undefined) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
326
+ let result: { row: IntakeRow; created: boolean } | undefined
327
+ try {
328
+ await this.writeDoc(userID, teamID, (doc) => {
329
+ const at = this.now()
330
+ const keys = this.liveKeys(doc, at)
331
+ const held = slot === undefined ? undefined : keys[slot]
332
+ if (held) {
333
+ if (held.digest !== digest) throw new AgentError("conflict", `Idempotency-Key ${key} was used for another submission`, { hint: "Use a new key for a different body, or resend the original body" })
334
+ const existing = doc.items.find((i) => i.id === held.intakeID)
335
+ if (existing) {
336
+ result = { row: existing, created: false }
337
+ return undefined
338
+ }
339
+ }
340
+ const recent = doc.items.filter((i) => at - i.createdAt < 60_000)
341
+ if (recent.length >= this.limits.submitsPerMinute) {
342
+ const oldest = Math.min(...recent.map((i) => i.createdAt))
343
+ throw new AgentError("rate_limited", `A team takes at most ${this.limits.submitsPerMinute} submissions a minute`, { extra: { retryAfter: Math.max(1, Math.ceil((oldest + 60_000 - at) / 1000)) } })
344
+ }
345
+ if (doc.items.filter((i) => !CLOSED.has(i.status)).length >= this.limits.openPerTeam)
346
+ throw new AgentError("rate_limited", `A team holds at most ${this.limits.openPerTeam} open submissions`, { hint: "Close or cancel some first", extra: { limit: this.limits.openPerTeam } })
347
+ const { brief: _brief, ...fields } = body
348
+ const row: IntakeRow = { v: 1, id, teamID, ...fields, source: sourceOf(actor), status: "queued", revision: 1, createdAt: at, updatedAt: at, ...(hop === undefined ? {} : { hop }) }
349
+ result = { row, created: true }
350
+ const nextKeys = slot === undefined ? keys : { ...keys, [slot]: { intakeID: id, digest, at } }
351
+ return { ...doc, items: this.retained([...doc.items, row]), keys: this.boundedKeys(nextKeys) }
352
+ })
353
+ } catch (cause) {
354
+ if (body.brief !== undefined) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
355
+ throw cause
356
+ }
357
+ if (!result!.created) {
358
+ if (body.brief !== undefined) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
359
+ return { intake: view(await this.reconciled(userID, result!.row)), created: false }
360
+ }
361
+ await this.emit(userID, intakeEvent(result!.row))
362
+ await this.drainSafely(userID, teamID)
363
+ return { intake: view((await this.row(userID, teamID, id)) ?? result!.row), created: true }
364
+ }
365
+
366
+ // ---------- reads ----------
367
+
368
+ async get(userID: string, actor: AgentActor, teamID: string, intakeID: string): Promise<StoredIntake> {
369
+ const team = await this.deps.teams.require(userID, teamID)
370
+ const scope = await this.readScope(userID, actor, team)
371
+ await this.drainSafely(userID, teamID)
372
+ const row = await this.row(userID, teamID, intakeID)
373
+ if (!row || (scope === "own" && !sameSource(row, actor))) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
374
+ return view(await this.reconciled(userID, row))
375
+ }
376
+
377
+ /** The team's intake the caller may see, oldest first, paged. */
378
+ async list(userID: string, actor: AgentActor, teamID: string, query: IntakeListQuery = {}): Promise<Page<StoredIntake>> {
379
+ const limit = query.limit ?? LIST_LIMIT_DEFAULT
380
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > LIST_LIMIT_MAX) throw new AgentError("invalid", `limit must be 1–${LIST_LIMIT_MAX}`)
381
+ if (query.status !== undefined && !["queued", "routed", "done", "cancelled", "failed"].includes(query.status))
382
+ throw new AgentError("invalid", "status must be one of queued, routed, done, cancelled, failed")
383
+ const after = query.cursor === undefined ? undefined : decodeCursor(query.cursor)
384
+ const team = await this.deps.teams.require(userID, teamID)
385
+ const scope = await this.readScope(userID, actor, team)
386
+ await this.drainSafely(userID, teamID)
387
+ const doc = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
388
+ const all = doc.items
389
+ .filter((i) => scope === "all" || sameSource(i, actor))
390
+ .filter((i) => query.status === undefined || i.status === query.status)
391
+ .filter((i) => !after || i.createdAt > after[0] || (i.createdAt === after[0] && i.id > after[1]))
392
+ .sort(byCreation)
393
+ const items = all.slice(0, limit).map(view)
394
+ return all.length > limit ? { items, cursor: encodeCursor(items.at(-1)!) } : { items }
395
+ }
396
+
397
+ /**
398
+ * Counts of the team's intake by status (and routed items by progress), for team status (HWA-37). The caller
399
+ * authorizes the read; counts carry no text.
400
+ */
401
+ async summary(userID: string, teamID: string): Promise<IntakeSummary> {
402
+ await this.deps.teams.require(userID, teamID)
403
+ const doc = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
404
+ const out = { queued: 0, routed: 0, done: 0, cancelled: 0, failed: 0, open: 0, inProgress: 0, blocked: 0 }
405
+ let oldestQueuedAt: number | undefined
406
+ for (const item of doc.items) {
407
+ out[item.status]++
408
+ if (item.status === "queued") oldestQueuedAt = Math.min(oldestQueuedAt ?? item.createdAt, item.createdAt)
409
+ if (item.status === "routed") {
410
+ if (item.progress === "in_progress") out.inProgress++
411
+ else if (item.progress === "blocked") out.blocked++
412
+ else out.open++
413
+ }
414
+ }
415
+ return { ...out, ...(oldestQueuedAt !== undefined ? { oldestQueuedAt } : {}) }
416
+ }
417
+
418
+ // ---------- status ----------
419
+
420
+ /** Explicit progress or completion (see the header for who may do what). */
421
+ async setStatus(userID: string, actor: AgentActor, teamID: string, intakeID: string, input: unknown): Promise<{ intake: StoredIntake; assignment?: StoredAssignment }> {
422
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
423
+ unknownField(input, STATUS_FIELDS)
424
+ const { status, expectedRevision, reason } = input
425
+ if (!INTAKE_STATUS_INPUTS.includes(status as IntakeStatusInput)) throw new AgentError("invalid", `status must be one of ${INTAKE_STATUS_INPUTS.join(", ")}`)
426
+ if (!isExpectedRevision(expectedRevision)) throw new AgentError("invalid", "expectedRevision is required: the revision you read", { hint: "Read the intake and send its revision" })
427
+ if (reason !== undefined && !isLabel(reason, this.limits.reasonMax)) throw new AgentError("invalid", `reason must be 1–${this.limits.reasonMax} characters of text`)
428
+ const wanted = status as IntakeStatusInput
429
+ const team = await this.deps.teams.require(userID, teamID)
430
+ const scope = await this.readScope(userID, actor, team)
431
+ const stored = await this.row(userID, teamID, intakeID)
432
+ if (!stored || (scope === "own" && !sameSource(stored, actor))) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
433
+ if (!(await this.mayChange(userID, actor, team, stored, wanted)))
434
+ throw new AgentError("forbidden", wanted === "cancelled" ? "Only the user, the team's entry role or the submitter may cancel this" : "Only the user or a session in the team's entry role reports progress", {
435
+ hint: "The submitter may only cancel",
436
+ })
437
+ const row = await this.reconciled(userID, stored)
438
+ if (CLOSED.has(row.status)) {
439
+ if (row.status === wanted) return { intake: view(row) }
440
+ throw new AgentError("invalid", `Intake ${intakeID} is ${row.status}`, { hint: "Submit new work instead" })
441
+ }
442
+ if (row.revision !== expectedRevision)
443
+ throw new AgentError("conflict", `Intake ${intakeID} changed since you read it (revision ${row.revision} now)`, { hint: "Read it again and retry", extra: { currentRevision: row.revision } })
444
+ if (row.status === "queued" && wanted !== "cancelled") throw new AgentError("invalid", `Intake ${intakeID} is still queued for the entry role`, { hint: "Only cancelling is possible before it is routed" })
445
+
446
+ let assignment: StoredAssignment | undefined
447
+ if (row.status === "routed" && row.assignmentID !== undefined && row.workspaceID !== undefined) {
448
+ const target = wanted === "done" ? "done" : wanted === "failed" || wanted === "cancelled" ? "cancelled" : wanted
449
+ const before = await this.deps.members.assignment(userID, row.workspaceID, row.assignmentID).catch(() => undefined)
450
+ if (before) {
451
+ assignment = await this.deps.members.setStatus(userID, row.workspaceID, row.assignmentID, target)
452
+ if (assignment.revision !== before.revision) await this.emit(userID, assignmentChangedEvent(assignment, "status"))
453
+ }
454
+ }
455
+ const closing = wanted === "done" || wanted === "failed" || wanted === "cancelled"
456
+ const next = await this.mutate(userID, teamID, intakeID, (current) => {
457
+ if (current.revision !== expectedRevision)
458
+ throw new AgentError("conflict", `Intake ${intakeID} changed since you read it (revision ${current.revision} now)`, { extra: { currentRevision: current.revision } })
459
+ const { progress: _p, claim: _c, ...rest } = current
460
+ if (closing) return { ...rest, status: wanted, ...(reason !== undefined ? { reason: reason as string } : {}), closedAt: this.now() }
461
+ if (current.progress === wanted) return undefined
462
+ return { ...rest, ...(current.claim ? { claim: current.claim } : {}), progress: wanted as IntakeProgress }
463
+ })
464
+ if (next.revision !== row.revision) await this.emit(userID, intakeEvent(next))
465
+ if (closing && row.status === "queued") await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
466
+ return { intake: view(next), ...(assignment ? { assignment } : {}) }
467
+ }
468
+
469
+ // ---------- routing ----------
470
+
471
+ /** Routes the team's queued intake, oldest first, while an entry seat is reachable. Returns how many it routed. */
472
+ drain(userID: string, teamID: string): Promise<number> {
473
+ const key = `${userID}\u0000${teamID}`
474
+ const previous = this.draining.get(key) ?? Promise.resolve(0)
475
+ const run = previous.catch(() => 0).then(() => this.drainNow(userID, teamID))
476
+ this.draining.set(key, run)
477
+ void run.finally(() => {
478
+ if (this.draining.get(key) === run) this.draining.delete(key)
479
+ }).catch(() => undefined)
480
+ return run
481
+ }
482
+
483
+ /** A journal event appended (http.ts onAppend): drains the teams it may unblock. Never throws. */
484
+ async onEvent(userID: string, event: { readonly type: string; readonly subject?: Readonly<Record<string, string>> }): Promise<void> {
485
+ try {
486
+ const teamID = event.subject?.teamID
487
+ if ((event.type === "team.staffed" || event.type === "team.updated" || event.type === "team.archived") && teamID !== undefined) {
488
+ await this.drain(userID, teamID)
489
+ } else if ((event.type === "session.added" || event.type === "workspace.member.joined") && event.subject?.sessionID !== undefined) {
490
+ for (const seat of await this.deps.seats.seatsOf(userID, event.subject.sessionID)) {
491
+ const team = await this.deps.teams.get(userID, seat.teamID)
492
+ if (team && team.entryRole === seat.role) await this.drain(userID, seat.teamID)
493
+ }
494
+ }
495
+ } catch (cause) {
496
+ this.report(cause)
497
+ }
498
+ }
499
+
500
+ private async drainSafely(userID: string, teamID: string): Promise<void> {
501
+ await this.drain(userID, teamID).catch(this.report)
502
+ }
503
+
504
+ private async drainNow(userID: string, teamID: string): Promise<number> {
505
+ const team = await this.deps.teams.get(userID, teamID)
506
+ if (!team || team.state !== "active" || team.paused) return 0
507
+ const queued = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
508
+ .items.filter((i) => i.status === "queued")
509
+ .sort(byCreation)
510
+ if (queued.length === 0) return 0
511
+ const target = await this.target(userID, team)
512
+ if (!target) return 0
513
+ let routed = 0
514
+ for (const item of queued) {
515
+ const outcome = await this.route(userID, team, item.id, target)
516
+ if (outcome === "failed") break
517
+ if (outcome === "routed") routed++
518
+ }
519
+ return routed
520
+ }
521
+
522
+ /** The entry seat to route to (online first, else offline; earliest staffed) and its workspace member. */
523
+ private async target(userID: string, team: Team): Promise<Target | undefined> {
524
+ const entry = (await this.deps.seats.list(userID, team.id, { current: true }))
525
+ .filter((s) => s.role === team.entryRole)
526
+ .sort((a, b) => a.since - b.since || (a.id < b.id ? -1 : 1))
527
+ if (entry.length === 0) return undefined
528
+ const presence = new Map<string, SessionPresence>()
529
+ for (const s of entry) if (!presence.has(s.sessionID)) presence.set(s.sessionID, await this.deps.presence(userID, s.sessionID))
530
+ const pick = entry.find((s) => presence.get(s.sessionID) === "online") ?? entry.find((s) => presence.get(s.sessionID) === "offline")
531
+ if (!pick) return undefined
532
+ const workspaceID = await this.deps.staffing.workspaceOf(userID, team.id)
533
+ if (workspaceID === undefined) return undefined
534
+ const member = (await this.deps.members.members(userID, workspaceID).catch(() => [])).find((m) => m.current && m.member.sessionID === pick.sessionID)
535
+ if (!member) return undefined
536
+ return { sessionID: pick.sessionID, workspaceID, memberID: member.member.id }
537
+ }
538
+
539
+ /** Claims, assigns, delivers and marks one queued item routed. */
540
+ private async route(userID: string, team: Team, intakeID: string, target: Target): Promise<"routed" | "skipped" | "failed"> {
541
+ const at = this.now()
542
+ let claimed: IntakeRow | undefined
543
+ await this.writeDoc(userID, team.id, (doc) => {
544
+ const row = doc.items.find((i) => i.id === intakeID)
545
+ if (!row || row.status !== "queued") return undefined
546
+ if (row.claim && row.claim.owner !== this.owner && at - row.claim.at < this.limits.claimMs) return undefined
547
+ claimed = { ...row, claim: { owner: this.owner, at } }
548
+ return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? claimed! : i)) }
549
+ })
550
+ if (!claimed) return "skipped"
551
+ try {
552
+ const assignment = await this.assignmentFor(userID, claimed, target)
553
+ if (claimed.assignmentID !== assignment.id || claimed.workspaceID !== target.workspaceID)
554
+ await this.mutate(userID, team.id, intakeID, (row) => ({ ...row, assignmentID: assignment.id, workspaceID: target.workspaceID }), false)
555
+ const brief = (await this.state.get<{ brief?: unknown }>(userID, TEAM_INTAKE_BRIEFS, intakeID))?.value?.brief
556
+ const text = this.text(team, claimed, assignment.id, target, typeof brief === "string" ? brief : undefined)
557
+ const sent = await this.deps.deliver(userID, target.sessionID, text, claimed.hop === undefined ? {} : { hop: claimed.hop })
558
+ let cancelled = false
559
+ let routed: IntakeRow | undefined
560
+ await this.writeDoc(userID, team.id, (doc) => {
561
+ const row = doc.items.find((i) => i.id === intakeID)
562
+ if (!row) {
563
+ cancelled = true
564
+ return undefined
565
+ }
566
+ const { claim, lastError: _e, ...rest } = row
567
+ if (row.status !== "queued") {
568
+ cancelled = true
569
+ return claim ? { ...doc, items: doc.items.map((i) => (i.id === intakeID ? rest : i)) } : undefined
570
+ }
571
+ const t = this.now()
572
+ routed = { ...rest, status: "routed", progress: "open", assignmentID: assignment.id, workspaceID: target.workspaceID, sessionID: target.sessionID, messageID: sent.messageID, routedAt: t, revision: row.revision + 1, updatedAt: t }
573
+ return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? routed! : i)) }
574
+ })
575
+ if (cancelled) {
576
+ await this.deps.members.setStatus(userID, target.workspaceID, assignment.id, "cancelled").catch(this.report)
577
+ return "skipped"
578
+ }
579
+ await this.state.delete(userID, TEAM_INTAKE_BRIEFS, intakeID).catch(this.report)
580
+ await this.emit(userID, intakeEvent(routed!))
581
+ return "routed"
582
+ } catch (cause) {
583
+ const code = cause instanceof AgentError ? cause.code : "internal"
584
+ this.report(cause)
585
+ await this.mutate(userID, team.id, intakeID, (row) => {
586
+ const { claim: _c, ...rest } = row
587
+ return { ...rest, lastError: code }
588
+ }, false).catch(this.report)
589
+ return "failed"
590
+ }
591
+ }
592
+
593
+ /** The item's assignment for this target: reused (moved to the member when needed) or created idempotently. */
594
+ private async assignmentFor(userID: string, row: IntakeRow, target: Target): Promise<StoredAssignment> {
595
+ if (row.assignmentID !== undefined && row.workspaceID === target.workspaceID) {
596
+ const existing = await this.deps.members.assignment(userID, target.workspaceID, row.assignmentID).catch(() => undefined)
597
+ if (existing && OPEN_ASSIGNMENT.has(existing.status)) {
598
+ if (existing.memberID === target.memberID) return existing
599
+ const moved = await this.deps.members.reassign(userID, target.workspaceID, existing.id, target.memberID)
600
+ await this.emit(userID, assignmentChangedEvent(moved, "reassigned"))
601
+ return moved
602
+ }
603
+ }
604
+ const { assignment, created } = await this.deps.members.assign(
605
+ userID,
606
+ target.workspaceID,
607
+ { memberID: target.memberID, title: row.title, status: "open", ref: row.id, ...(row.criteria ? { criteria: [...row.criteria] } : {}) },
608
+ { idempotencyKey: `team-intake.${row.id}.${target.memberID}` },
609
+ )
610
+ if (created) await this.emit(userID, assignmentChangedEvent(assignment, "created"))
611
+ return assignment
612
+ }
613
+
614
+ /** The delivered task: team context first (a session may serve several teams), then the brief. */
615
+ private text(team: Team, row: IntakeRow, assignmentID: string, target: Target, brief: string | undefined): string {
616
+ const from = row.source.kind === "user" ? "the user" : row.source.kind === "app" ? `app ${row.source.tokenID}` : `session ${row.source.sessionID}`
617
+ const facts = [`intake ${row.id}`, `revision ${row.revision + 1}`, `assignment ${assignmentID}`, `workspace ${target.workspaceID}`, `priority ${row.priority}`]
618
+ if (row.due !== undefined) facts.push(`due ${new Date(row.due).toISOString()}`)
619
+ if (row.ref !== undefined) facts.push(`ref ${sanitizeLabel(row.ref, this.limits.refMax)}`)
620
+ const lines = [
621
+ `[hub] team intake for #${team.name} (team ${team.id}, entry role ${team.entryRole}), submitted by ${from}`,
622
+ `Title: ${sanitizeLabel(row.title)}`,
623
+ facts.join(" · "),
624
+ ]
625
+ if (row.labels?.length) lines.push(`Labels: ${row.labels.join(", ")}`)
626
+ if (row.criteria?.length) lines.push("Done means:", ...row.criteria.map((c) => `- ${sanitizeLabel(c, this.limits.criterionMax)}`))
627
+ lines.push("", brief !== undefined && brief.trim() ? brief : "(no brief)", "")
628
+ lines.push(...reportingLines(team.id, row.id))
629
+ return lines.join("\n")
630
+ }
631
+
632
+ // ---------- authority ----------
633
+
634
+ /** "all" or "own" (the caller's submissions); forbidden for apps without a teams scope. */
635
+ private async readScope(userID: string, actor: AgentActor, team: Team): Promise<"all" | "own"> {
636
+ if (actor.kind === "user") return "all"
637
+ if (actor.kind === "app") {
638
+ if (actor.scopes.includes("teams:read") || actor.scopes.includes("teams:manage")) return "all"
639
+ if (actor.scopes.includes("teams:submit")) return "own"
640
+ throw new AgentError("forbidden", "Team intake needs scope teams:submit (own submissions) or teams:read")
641
+ }
642
+ const seats = await this.deps.seats.list(userID, team.id, { current: true })
643
+ return seats.some((s) => s.sessionID === actor.sessionID) ? "all" : "own"
644
+ }
645
+
646
+ private async mayChange(userID: string, actor: AgentActor, team: Team, row: StoredIntake, wanted: IntakeStatusInput): Promise<boolean> {
647
+ if (actor.kind === "user") return true
648
+ if (actor.kind === "app" && actor.scopes.includes("teams:manage")) return true
649
+ if (actor.kind === "session") {
650
+ const seats = await this.deps.seats.list(userID, team.id, { current: true })
651
+ if (seats.some((s) => s.sessionID === actor.sessionID && s.role === team.entryRole)) return true
652
+ }
653
+ return wanted === "cancelled" && sameSource(row, actor)
654
+ }
655
+
656
+ /** A session submits only where it could `msg` the entry role (teamDeliveryVerdict, no team context). */
657
+ private async checkPolicy(userID: string, team: Team, sessionID: string): Promise<void> {
658
+ const context = await this.deps.seats.deliveryContext(userID, [sessionID])
659
+ const teams = new Map(context.teams)
660
+ teams.set(team.id, { mode: team.mode, edges: team.edges, roles: team.roles, state: team.state })
661
+ const recipient: TeamSeat[] = [{ teamID: team.id, role: team.entryRole }]
662
+ const verdict = teamDeliveryVerdict({ kind: "msg", toUser: false, replyToPermittedAsk: false, senderSeats: context.seats.get(sessionID) ?? [], recipientSeats: recipient, teams })
663
+ if (!verdict.allow) throw new AgentError("forbidden", "Team policy does not let this session give work to that team", { extra: { policy: verdict.reason } })
664
+ }
665
+
666
+ // ---------- internals ----------
667
+
668
+ private body(input: unknown): Body {
669
+ if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
670
+ unknownField(input, SUBMIT_FIELDS)
671
+ const { title, brief, priority, labels, due, criteria, ref } = input
672
+ const l = this.limits
673
+ if (!isLabel(title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
674
+ if (brief !== undefined && !isText(brief, l.briefMax)) throw new AgentError("invalid", `brief must be at most ${l.briefMax} characters of text`)
675
+ if (priority !== undefined && !INTAKE_PRIORITIES.includes(priority as IntakePriority)) throw new AgentError("invalid", `priority must be one of ${INTAKE_PRIORITIES.join(", ")}`)
676
+ const labelProblem = tagsProblem(labels)
677
+ if (labelProblem) throw new AgentError("invalid", labelProblem.replace(/^tags/, "labels").replace(/each tag/, "each label"))
678
+ if (due !== undefined && !(typeof due === "number" && Number.isSafeInteger(due) && due > 0)) throw new AgentError("invalid", "due must be a time in epoch milliseconds")
679
+ if (criteria !== undefined && (!Array.isArray(criteria) || criteria.length > l.criteriaPerIntake || !criteria.every((c) => isLabel(c, l.criterionMax))))
680
+ throw new AgentError("invalid", `criteria must be a list of at most ${l.criteriaPerIntake} lines of 1–${l.criterionMax} characters`)
681
+ if (ref !== undefined && !isLabel(ref, l.refMax)) throw new AgentError("invalid", `ref must be 1–${l.refMax} characters of text`)
682
+ return {
683
+ title: title as string,
684
+ ...(brief !== undefined ? { brief: brief as string } : {}),
685
+ priority: (priority as IntakePriority | undefined) ?? "normal",
686
+ ...(labels !== undefined ? { labels: [...(labels as string[])] } : {}),
687
+ ...(due !== undefined ? { due: due as number } : {}),
688
+ ...(criteria !== undefined ? { criteria: [...(criteria as string[])] } : {}),
689
+ ...(ref !== undefined ? { ref: ref as string } : {}),
690
+ }
691
+ }
692
+
693
+ /** Reflects an assignment changed through the workspace routes (closed, or another progress) on a routed item. */
694
+ private async reconciled(userID: string, row: IntakeRow): Promise<IntakeRow> {
695
+ if (row.status !== "routed" || row.assignmentID === undefined || row.workspaceID === undefined) return row
696
+ const assignment = await this.deps.members.assignment(userID, row.workspaceID, row.assignmentID).catch(() => undefined)
697
+ if (!assignment) return row
698
+ const closed = assignment.status === "done" || assignment.status === "cancelled"
699
+ if (!closed && assignment.status === row.progress) return row
700
+ const next = await this.mutate(userID, row.teamID, row.id, (current) => {
701
+ if (current.status !== "routed") return undefined
702
+ const { progress: _p, ...rest } = current
703
+ if (closed) return { ...rest, status: assignment.status as "done" | "cancelled", closedAt: this.now() }
704
+ return current.progress === assignment.status ? undefined : { ...current, progress: assignment.status as IntakeProgress }
705
+ })
706
+ if (next.revision !== row.revision) await this.emit(userID, intakeEvent(next))
707
+ return next
708
+ }
709
+
710
+ private async row(userID: string, teamID: string, intakeID: string): Promise<IntakeRow | undefined> {
711
+ return readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID).items.find((i) => i.id === intakeID)
712
+ }
713
+
714
+ /**
715
+ * Version-checked change of one item. `bump` (default) counts it as a visible change: revision + 1 and updatedAt.
716
+ * `change` returning undefined leaves it alone. Resolves with the item as stored.
717
+ */
718
+ private async mutate(userID: string, teamID: string, intakeID: string, change: (row: IntakeRow) => IntakeRow | undefined, bump = true): Promise<IntakeRow> {
719
+ let out: IntakeRow | undefined
720
+ await this.writeDoc(userID, teamID, (doc) => {
721
+ const row = doc.items.find((i) => i.id === intakeID)
722
+ if (!row) throw new AgentError("not_found", `No intake ${intakeID} in team ${teamID}`)
723
+ const next = change(row)
724
+ if (!next) {
725
+ out = row
726
+ return undefined
727
+ }
728
+ out = bump ? { ...next, revision: row.revision + 1, updatedAt: this.now() } : next
729
+ return { ...doc, items: this.retained(doc.items.map((i) => (i.id === intakeID ? out! : i))) }
730
+ })
731
+ return out!
732
+ }
733
+
734
+ private async writeDoc(userID: string, teamID: string, change: (doc: IntakeDoc) => IntakeDoc | undefined): Promise<void> {
735
+ await updateVersioned<IntakeDoc>(this.state, userID, TEAM_INTAKE, teamID, (raw) => change(readDoc(raw, teamID)))
736
+ }
737
+
738
+ /** Open items plus the newest closed ones. */
739
+ private retained(items: readonly IntakeRow[]): IntakeRow[] {
740
+ const closed = items.filter((i) => CLOSED.has(i.status)).sort((a, b) => (b.closedAt ?? b.updatedAt) - (a.closedAt ?? a.updatedAt))
741
+ const keep = new Set(closed.slice(0, this.limits.closedRetained).map((i) => i.id))
742
+ return items.filter((i) => !CLOSED.has(i.status) || keep.has(i.id))
743
+ }
744
+
745
+ private liveKeys(doc: IntakeDoc, at: number): Record<string, IntakeKey> {
746
+ const out: Record<string, IntakeKey> = {}
747
+ for (const [k, v] of Object.entries(doc.keys)) if (isObj(v) && typeof v.at === "number" && at - v.at < IDEMPOTENCY_TTL_MS) out[k] = v
748
+ return out
749
+ }
750
+
751
+ private boundedKeys(keys: Readonly<Record<string, IntakeKey>>): Record<string, IntakeKey> {
752
+ const entries = Object.entries(keys)
753
+ if (entries.length <= this.limits.keysPerTeam) return { ...keys }
754
+ return Object.fromEntries(entries.sort((a, b) => b[1].at - a[1].at).slice(0, this.limits.keysPerTeam))
755
+ }
756
+
757
+ private async emit(userID: string, ...events: EventInput[]): Promise<void> {
758
+ if (!this.deps.events) return
759
+ for (const event of events) await this.deps.events.append(userID, event).catch(this.report)
760
+ }
761
+ }
762
+
763
+ const byCreation = (a: StoredIntake, b: StoredIntake) => a.createdAt - b.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
764
+
765
+ function encodeCursor(i: StoredIntake): string {
766
+ return Buffer.from(`${i.createdAt}:${i.id}`).toString("base64url")
767
+ }
768
+
769
+ function decodeCursor(cursor: string): [number, string] {
770
+ const match = /^([0-9]{1,15}):([A-Za-z0-9_]{1,64})$/.exec(Buffer.from(cursor, "base64url").toString())
771
+ if (!match) throw new AgentError("invalid", "cursor is not one this list gave", { hint: "Start again without a cursor" })
772
+ return [Number(match[1]), match[2]!]
773
+ }
774
+
775
+ /**
776
+ * How the entry seat reports and closes the item (RP-07 #9), the same in every harness: the OpenCode `hub` tool
777
+ * verb, the MCP tool (Claude Code, Codex) and the route. Nothing is inferred from idleness.
778
+ */
779
+ export function reportingLines(teamID: string, intakeID: string): string[] {
780
+ return [
781
+ "Report progress and close this item explicitly — nothing is inferred from idleness or from replies (replies to this message do not reach the submitter):",
782
+ `- hub tool: {verb: "intake_update", team: "${teamID}", id: "${intakeID}", status: "in_progress"} (or "blocked"); when finished: status "done", or "failed" with reason: "<one line>"`,
783
+ `- MCP tool team_intake_update {team: "${teamID}", intake: "${intakeID}", status: in_progress|blocked|done|failed, reason?}`,
784
+ `- HTTP: POST /api/agent/teams/${teamID}/intake/${intakeID}/status {status, expectedRevision, reason?}`,
785
+ ]
786
+ }