@huynle/hub 0.7.3 → 0.8.0

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