@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,158 @@
1
+ // MCP tools for team intake (HWA-36): team_submit gives work to a team (its entry role, e.g. a project manager,
2
+ // receives it with the team's context and owns it as a workspace assignment), team_intake reads one item or the
3
+ // list, team_intake_update reports progress or completion explicitly (nothing is inferred from idleness). `team` is
4
+ // the team's ID (team_…); name lookup arrives with the teams API (HWA-37). An app token needs scope teams:submit;
5
+ // sessions submit under team policy and, in the team's entry role, report status.
6
+ import { sanitizeText } from "../../sdk/src/envelope.ts"
7
+ import { reportIntakeStatus, TEAM_INTAKE_STATUS_INPUTS, type TeamIntake, type TeamIntakeApi, type TeamIntakePriority, type TeamIntakeStatus, type TeamIntakeStatusInput } from "../../sdk/src/team-intake.ts"
8
+
9
+ type Args = Record<string, unknown>
10
+
11
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
12
+ export interface TeamIntakeTool {
13
+ readonly name: string
14
+ readonly description: string
15
+ readonly properties: Record<string, unknown>
16
+ readonly required?: readonly string[]
17
+ readonly readOnly: boolean
18
+ readonly organizes?: boolean
19
+ readonly idempotent?: boolean
20
+ run(hub: { readonly teams: TeamIntakeApi }, args: Args, signal: AbortSignal): Promise<string>
21
+ }
22
+
23
+ const STRING = { type: "string" } as const
24
+ const TEAM = { type: "string", description: "The team's ID (team_…)." } as const
25
+ const INTAKE = { type: "string", description: "The intake item's ID (itk_…)." } as const
26
+ const PRIORITIES: readonly TeamIntakePriority[] = ["low", "normal", "high", "urgent"]
27
+ const STATUSES: readonly TeamIntakeStatus[] = ["queued", "routed", "done", "cancelled", "failed"]
28
+ const UPDATES: readonly TeamIntakeStatusInput[] = TEAM_INTAKE_STATUS_INPUTS
29
+
30
+ const need = (args: Args, key: string): string => {
31
+ const value = args[key]
32
+ if (typeof value !== "string" || !value.trim()) throw new Error(`"${key}" is required (a non-empty string)`)
33
+ return value
34
+ }
35
+ const optional = (args: Args, key: string): string | undefined => {
36
+ const value = args[key]
37
+ if (value === undefined || value === null) return undefined
38
+ if (typeof value !== "string") throw new Error(`"${key}" must be a string`)
39
+ return value.trim() ? value : undefined
40
+ }
41
+ const strings = (args: Args, key: string): string[] | undefined => {
42
+ const value = args[key]
43
+ if (value === undefined || value === null) return undefined
44
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new Error(`"${key}" must be a list of strings`)
45
+ return value
46
+ }
47
+ const oneOf = <T extends string>(args: Args, key: string, allowed: readonly T[]): T | undefined => {
48
+ const value = optional(args, key)
49
+ if (value !== undefined && !allowed.includes(value as T)) throw new Error(`"${key}" must be one of ${allowed.join(", ")}`)
50
+ return value as T | undefined
51
+ }
52
+
53
+ /** `due` as an ISO date/time or epoch milliseconds. */
54
+ function dueOf(args: Args): number | undefined {
55
+ const value = args.due
56
+ if (value === undefined || value === null || value === "") return undefined
57
+ const ms = typeof value === "number" ? value : typeof value === "string" ? Date.parse(value) : Number.NaN
58
+ if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error(`"due" must be an ISO date/time or epoch milliseconds`)
59
+ return ms
60
+ }
61
+
62
+ const line = (i: TeamIntake) =>
63
+ `${i.id} ${i.status}${i.progress ? `/${i.progress}` : ""} ${i.priority} r${i.revision} ${sanitizeText(i.title).replace(/\s+/g, " ")}`
64
+
65
+ function intakeText(i: TeamIntake): string {
66
+ const from = i.source.kind === "user" ? "the user" : i.source.kind === "app" ? `app ${i.source.tokenID}` : `session ${i.source.sessionID}`
67
+ return [
68
+ `${i.id} (team ${i.teamID}) revision ${i.revision}`,
69
+ `title: ${sanitizeText(i.title).replace(/\s+/g, " ")}`,
70
+ `status: ${i.status}${i.progress ? ` (${i.progress})` : ""} priority: ${i.priority} from: ${from}`,
71
+ ...(i.assignmentID ? [`assignment: ${i.assignmentID} in workspace ${i.workspaceID ?? "?"}${i.sessionID ? `, delivered to ${i.sessionID}` : ""}`] : []),
72
+ ...(i.due !== undefined ? [`due: ${new Date(i.due).toISOString()}`] : []),
73
+ ...(i.labels?.length ? [`labels: ${i.labels.join(", ")}`] : []),
74
+ ...(i.criteria?.length ? ["done means:", ...i.criteria.map((c) => `- ${sanitizeText(c)}`)] : []),
75
+ ...(i.ref ? [`ref: ${sanitizeText(i.ref)}`] : []),
76
+ ...(i.reason ? [`reason: ${sanitizeText(i.reason)}`] : []),
77
+ ...(i.lastError ? [`last routing error: ${i.lastError} (retried)`] : []),
78
+ ].join("\n")
79
+ }
80
+
81
+ export const teamIntakeTools: readonly TeamIntakeTool[] = [
82
+ {
83
+ name: "team_submit",
84
+ description:
85
+ "Give work to a team: its entry role (e.g. the project manager) receives the task with the team's context and owns it as an assignment in the team's workspace; while the role is vacant it waits queued. Pass `key` to make retries idempotent. App tokens need scope teams:submit; sessions submit under the team's communication policy.",
86
+ properties: {
87
+ team: TEAM,
88
+ title: { ...STRING, description: "One line, at most 120 characters." },
89
+ brief: { ...STRING, description: "The task's details (delivered in the message only; at most 16000 characters)." },
90
+ priority: { type: "string", enum: PRIORITIES, description: "Default normal." },
91
+ labels: { type: "array", items: STRING, description: "Slugs (lowercase letters, digits, dashes)." },
92
+ due: { ...STRING, description: "When it is due: an ISO date/time." },
93
+ criteria: { type: "array", items: STRING, description: "What done means (at most 8 lines)." },
94
+ ref: { ...STRING, description: "An external reference, e.g. a Brain task ID." },
95
+ key: { ...STRING, description: "Idempotency-Key: the same key and body return the original submission." },
96
+ },
97
+ required: ["team", "title"],
98
+ readOnly: false,
99
+ idempotent: false,
100
+ run: async (hub, args, signal) => {
101
+ const priority = oneOf(args, "priority", PRIORITIES)
102
+ const labels = strings(args, "labels")
103
+ const criteria = strings(args, "criteria")
104
+ const due = dueOf(args)
105
+ const brief = optional(args, "brief")
106
+ const ref = optional(args, "ref")
107
+ const key = optional(args, "key")
108
+ const { intake, created } = await hub.teams.submit(
109
+ need(args, "team"),
110
+ { title: need(args, "title"), ...(brief ? { brief } : {}), ...(priority ? { priority } : {}), ...(labels ? { labels } : {}), ...(due !== undefined ? { due } : {}), ...(criteria ? { criteria } : {}), ...(ref ? { ref } : {}) },
111
+ { signal, ...(key ? { idempotencyKey: key } : {}) },
112
+ )
113
+ return `${created ? "Submitted" : "Already submitted (same key)"}: ${intake.id} is ${intake.status}${intake.status === "queued" ? " (waiting for the entry role)" : ""}\n${intakeText(intake)}`
114
+ },
115
+ },
116
+ {
117
+ name: "team_intake",
118
+ description: "One intake item of a team (with `intake`), or the team's intake list (optionally by status). Apps with teams:submit see their own submissions.",
119
+ properties: { team: TEAM, intake: INTAKE, status: { type: "string", enum: STATUSES, description: "Only items in this status (list)." }, cursor: { ...STRING, description: "The cursor of the previous page (list)." } },
120
+ required: ["team"],
121
+ readOnly: true,
122
+ run: async (hub, args, signal) => {
123
+ const team = need(args, "team")
124
+ const id = optional(args, "intake")
125
+ if (id) return intakeText((await hub.teams.intake(team, id, { signal })).intake)
126
+ const status = oneOf(args, "status", STATUSES)
127
+ const cursor = optional(args, "cursor")
128
+ const page = await hub.teams.intakes(team, { signal, ...(status ? { status } : {}), ...(cursor ? { cursor } : {}) })
129
+ if (page.items.length === 0) return "No intake."
130
+ return [...page.items.map(line), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n")
131
+ },
132
+ },
133
+ {
134
+ name: "team_intake_update",
135
+ description:
136
+ "Report an intake item's progress or completion explicitly: in_progress, blocked, open, done, failed (the team's entry role or the user), or cancelled (also the submitter). Close it with done when finished, or failed with a one-line reason. expectedRevision is optional: without it the current revision is read first; a change in between is a conflict.",
137
+ properties: {
138
+ team: TEAM,
139
+ intake: INTAKE,
140
+ status: { type: "string", enum: UPDATES },
141
+ expectedRevision: { type: "integer", description: "The revision you read (optional: the current one is read when omitted)." },
142
+ reason: { ...STRING, description: "Why it failed or was cancelled (one line)." },
143
+ },
144
+ required: ["team", "intake", "status"],
145
+ readOnly: false,
146
+ idempotent: true,
147
+ run: async (hub, args, signal) => {
148
+ const status = oneOf(args, "status", UPDATES)
149
+ if (!status) throw new Error(`"status" is required: one of ${UPDATES.join(", ")}`)
150
+ const expectedRevision = args.expectedRevision
151
+ if (expectedRevision !== undefined && expectedRevision !== null && (typeof expectedRevision !== "number" || !Number.isSafeInteger(expectedRevision) || expectedRevision < 0))
152
+ throw new Error(`"expectedRevision" must be the revision you read (or left out)`)
153
+ const reason = optional(args, "reason")
154
+ const { intake, assignment } = await reportIntakeStatus(hub.teams, need(args, "team"), need(args, "intake"), { status, ...(typeof expectedRevision === "number" ? { expectedRevision } : {}), ...(reason ? { reason } : {}) }, { signal })
155
+ return `${intake.id} is ${intake.status}${intake.progress ? ` (${intake.progress})` : ""}, revision ${intake.revision}${assignment ? `; assignment ${assignment.id} ${assignment.status}` : ""}`
156
+ },
157
+ },
158
+ ]
@@ -0,0 +1,403 @@
1
+ // MCP tools for persistent teams (HWA-37): assemble a team (roles, one entry role, open or restricted communication
2
+ // with one-way/two-way paths), edit it as a draft and publish it, staff its roles with sessions, and operate it
3
+ // (status, pause/resume, archive/restore). Work is given to a team with team_submit (team-intake-mcp.ts, HWA-36).
4
+ // `team` is the team's ID (team_…) or its name.
5
+ //
6
+ // Annotations for gateway review (packages/cli/src/mcp.ts maps them): reads are readOnlyHint; draft edits, create and
7
+ // pause/resume only reorganize (destructiveHint false: nothing applies until publish, and they are reversible);
8
+ // publish (changes the enforced communication policy), discard (drops unpublished edits), archive/restore (turn
9
+ // policy enforcement off/on) and staffing (seats confer the team's policy on a session; launches start sessions) keep
10
+ // the default destructiveHint. Changes of existing records send expectedRevision, so repeating one is idempotent.
11
+ // Decision answers and grant administration stay console-only: there is no tool for them here.
12
+ import { sanitizeText } from "../../sdk/src/envelope.ts"
13
+ import type { LaunchSpec } from "../../sdk/src/types.ts"
14
+ import type { Team, TeamDraft, TeamEdge, TeamPatch, TeamRole, TeamRoster, Teams, TeamSpec } from "../../sdk/src/teams.ts"
15
+
16
+ type Args = Record<string, unknown>
17
+
18
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
19
+ export interface TeamTool {
20
+ readonly name: string
21
+ readonly description: string
22
+ readonly properties: Record<string, unknown>
23
+ readonly required?: readonly string[]
24
+ readonly readOnly: boolean
25
+ readonly organizes?: boolean
26
+ readonly idempotent?: boolean
27
+ run(hub: { readonly teams: Teams }, args: Args, signal: AbortSignal): Promise<string>
28
+ }
29
+
30
+ const STRING = { type: "string" } as const
31
+ const STRINGS = { type: "array", items: STRING } as const
32
+ const TEAM = { type: "string", description: "The team: its ID (team_…) or its name." } as const
33
+ const REVISION = { type: "integer", minimum: 0, description: "The team's revision you read (team_show); a stale one answers conflict with the current revision." } as const
34
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
35
+ const KINDS = { type: "array", items: { type: "string", enum: ["msg", "ask"] }, description: "What the path permits: msg and/or ask (replies to a permitted ask never need a path)." } as const
36
+ const DIRECTION = { type: "string", enum: ["one-way", "two-way"], description: "two-way: `to` may also contact `from` with the same kinds." } as const
37
+ const ROLE_FIELDS = {
38
+ label: { ...STRING, description: "A display label (confers no authority)." },
39
+ description: STRING,
40
+ externalContact: { type: "boolean", description: "Holders may contact sessions outside all their teams (default false)." },
41
+ responsibilities: { ...STRINGS, description: "Short lines (at most 16)." },
42
+ profile: { type: "object", properties: { profileID: STRING, version: { type: "integer", minimum: 1 } }, required: ["profileID"], description: "The agent profile members should run (reported, never pinned by staffing)." },
43
+ prefs: { type: "object", properties: { harness: STRING, model: STRING, machineID: STRING }, description: "Placement preferences for launched members (advisory)." },
44
+ minMembers: { type: "integer", minimum: 0, maximum: 16 },
45
+ maxMembers: { type: "integer", minimum: 1, maximum: 16 },
46
+ } as const
47
+ const ROLE = { type: "object", properties: { name: { ...STRING, description: "A slug: lowercase letters, digits, dashes." }, ...ROLE_FIELDS }, required: ["name"] } as const
48
+ const EDGE = { type: "object", properties: { from: STRING, to: STRING, kinds: KINDS, direction: DIRECTION }, required: ["from", "to", "kinds", "direction"] } as const
49
+ const SPEC_FIELDS = {
50
+ title: { ...STRING, description: "1–120 characters." },
51
+ description: STRING,
52
+ mode: { type: "string", enum: ["open", "restricted"], description: "open: every member may msg/ask every other member. restricted: only the drawn paths (edges)." },
53
+ roles: { type: "array", items: ROLE, description: "1–32 roles." },
54
+ entryRole: { ...STRING, description: "The role that receives submitted work (e.g. pm) and supervises members." },
55
+ edges: { type: "array", items: EDGE, description: "Communication paths between roles (restricted mode)." },
56
+ tags: { ...STRINGS, description: "Slugs." },
57
+ metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (keys are slugs)." },
58
+ projects: { ...STRINGS, description: "Project names the team works on." },
59
+ } as const
60
+ const SCOPES = "App tokens need scope teams:read (reads) or teams:manage (changes); seats, status and staffing also need sessions:read. Session tokens read only teams they are seated in and never change definitions. Team roles confer no authority."
61
+
62
+ class ArgError extends Error {}
63
+
64
+ const need = (args: Args, key: string): string => {
65
+ const value = args[key]
66
+ if (typeof value !== "string" || !value.trim()) throw new ArgError(`"${key}" is required (a non-empty string)`)
67
+ return value
68
+ }
69
+ const optional = (args: Args, key: string): string | undefined => {
70
+ const value = args[key]
71
+ if (value === undefined || value === null) return undefined
72
+ if (typeof value !== "string") throw new ArgError(`"${key}" must be a string`)
73
+ return value.trim() ? value : undefined
74
+ }
75
+ const strings = (args: Args, key: string): string[] | undefined => {
76
+ const value = args[key]
77
+ if (value === undefined || value === null) return undefined
78
+ if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) throw new ArgError(`"${key}" must be a list of strings`)
79
+ return value
80
+ }
81
+ const revision = (args: Args): number => {
82
+ const value = args.expectedRevision
83
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ArgError(`"expectedRevision" is required: the team's revision you read (team_show)`)
84
+ return value
85
+ }
86
+ const int = (args: Args, key: string, min: number, max: number): number | undefined => {
87
+ const value = args[key]
88
+ if (value === undefined || value === null) return undefined
89
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) throw new ArgError(`"${key}" must be a whole number from ${min} to ${max}`)
90
+ return value
91
+ }
92
+ const record = (args: Args, key: string): Record<string, string> | undefined => {
93
+ const value = args[key]
94
+ if (value === undefined || value === null) return undefined
95
+ if (typeof value !== "object" || Array.isArray(value) || !Object.values(value).every((v) => typeof v === "string")) throw new ArgError(`"${key}" must be an object of strings`)
96
+ return value as Record<string, string>
97
+ }
98
+ const launchOf = (args: Args): LaunchSpec | undefined => {
99
+ const value = args.launch
100
+ if (value === undefined || value === null) return undefined
101
+ if (typeof value !== "object" || Array.isArray(value)) throw new ArgError(`"launch" must be a launch spec object (as the launch tool takes)`)
102
+ return value as LaunchSpec
103
+ }
104
+ /** The given fields of `keys` (undefined ones dropped). */
105
+ const picked = (args: Args, keys: readonly string[]): Record<string, unknown> => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
106
+
107
+ const one = (text: string) => sanitizeText(text).replace(/\s+/g, " ")
108
+ const when = (ms: number) => new Date(ms).toISOString()
109
+
110
+ /** `team_… name state[ paused] vN rN[ draft pending] title` */
111
+ export const teamLine = (t: Team): string =>
112
+ `${t.id} ${t.name} ${t.state}${t.paused ? " paused" : ""} v${t.version} r${t.revision}${t.draft ? " (draft pending)" : ""} ${one(t.title)}`
113
+
114
+ const edgeText = (e: TeamEdge) => `${e.from} ${e.direction === "two-way" ? "⇄" : "→"} ${e.to} (${e.kinds.join(", ")})`
115
+
116
+ function specLines(spec: TeamSpec): string[] {
117
+ const role = (r: TeamRole) =>
118
+ ` ${r.name}${r.name === spec.entryRole ? " (entry)" : ""}${r.label ? ` "${one(r.label)}"` : ""}${r.externalContact ? " external contact" : ""}${r.minMembers !== undefined || r.maxMembers !== undefined ? ` members ${r.minMembers ?? 0}–${r.maxMembers ?? 16}` : ""}${r.profile ? ` profile ${r.profile.profileID}${r.profile.version ? `@${r.profile.version}` : ""}` : ""}`
119
+ return [
120
+ `mode: ${spec.mode}${spec.mode === "open" ? " (every member may msg/ask every other)" : " (only the paths below)"}`,
121
+ `entry role: ${spec.entryRole}`,
122
+ "roles:",
123
+ ...spec.roles.map(role),
124
+ ...(spec.edges.length ? ["paths:", ...spec.edges.map((e) => ` ${edgeText(e)}`)] : ["paths: none"]),
125
+ ...(spec.tags?.length ? [`tags: ${spec.tags.join(", ")}`] : []),
126
+ ...(spec.projects?.length ? [`projects: ${spec.projects.map(one).join(", ")}`] : []),
127
+ ...(spec.metadata && Object.keys(spec.metadata).length ? [`metadata: ${Object.entries(spec.metadata).map(([k, v]) => `${k}=${one(v)}`).join(", ")}`] : []),
128
+ ...(spec.description ? [`description: ${one(spec.description)}`] : []),
129
+ ]
130
+ }
131
+
132
+ const draftText = (prefix: string, team: Team, draft: TeamDraft | undefined) =>
133
+ [`${prefix}: ${teamLine(team)}`, ...(draft ? [`draft (on v${draft.baseVersion}, saved ${when(draft.savedAt)}; publish with team_publish):`, ...specLines(draft.spec)] : [])].join("\n")
134
+
135
+ function rosterLines(roster: TeamRoster): string[] {
136
+ const lines = [`roster (v${roster.version}${roster.paused ? ", paused" : ""})${roster.workspace ? ` workspace ${roster.workspace.id} ${roster.workspace.state}` : ""}:`]
137
+ for (const r of roster.roles) {
138
+ const flags = [r.vacant ? "vacant" : "", r.understaffed ? `understaffed (min ${r.minMembers})` : "", r.launching ? `${r.launching} launching` : ""].filter(Boolean)
139
+ lines.push(` ${r.role}${r.entry ? " (entry)" : ""} ${r.seats.length}/${r.maxMembers} ${r.online} online${flags.length ? ` ${flags.join(", ")}` : ""}`)
140
+ for (const s of r.seats)
141
+ lines.push(` ${s.staffing.id} ${s.staffing.sessionID} ${s.presence} gen ${s.staffing.generation} r${s.staffing.revision}${s.profile ? ` profile ${s.profile.status}${s.profile.matches ? "" : " (mismatch)"}` : ""}`)
142
+ }
143
+ const failed = roster.requests.filter((q) => q.state !== "staffed").slice(-5)
144
+ for (const q of failed) lines.push(` launch ${q.id} ${q.role} ${q.state}${q.reason ? `: ${one(q.reason)}` : ""}`)
145
+ return lines
146
+ }
147
+
148
+ const statusTool = (verb: "pause" | "resume" | "archive" | "restore", description: string, organizes: boolean): TeamTool => ({
149
+ name: `team_${verb}`,
150
+ description: `${description} ${SCOPES}`,
151
+ properties: { team: TEAM, expectedRevision: REVISION },
152
+ required: ["team", "expectedRevision"],
153
+ readOnly: false,
154
+ ...(organizes ? { organizes: true } : {}),
155
+ idempotent: true,
156
+ run: async (hub, args, signal) => {
157
+ const expectedRevision = revision(args)
158
+ const { team } = await hub.teams[verb](need(args, "team"), expectedRevision, { signal })
159
+ return `${team.revision === expectedRevision ? "unchanged" : { pause: "paused", resume: "resumed", archive: "archived", restore: "restored" }[verb]}: ${teamLine(team)}`
160
+ },
161
+ })
162
+
163
+ const draftTool = (name: string, description: string, properties: Record<string, unknown>, required: readonly string[], edit: (teams: Teams, team: string, args: Args, expectedRevision: number, signal: AbortSignal) => Promise<{ team: Team; draft: TeamDraft }>): TeamTool => ({
164
+ name,
165
+ description: `${description} Changes the draft only: nothing applies until team_publish. ${SCOPES}`,
166
+ properties: { team: TEAM, ...properties, expectedRevision: REVISION },
167
+ required: ["team", ...required, "expectedRevision"],
168
+ readOnly: false,
169
+ organizes: true,
170
+ idempotent: true,
171
+ run: async (hub, args, signal) => {
172
+ const expectedRevision = revision(args)
173
+ const { team, draft } = await edit(hub.teams, need(args, "team"), args, expectedRevision, signal)
174
+ return draftText("draft saved", team, draft)
175
+ },
176
+ })
177
+
178
+ export const teamTools: readonly TeamTool[] = [
179
+ // ---------- reads ----------
180
+ {
181
+ name: "teams",
182
+ description: `List the user's teams (id, name, state, version, revision, title), filtered by tags (all of them), project, metadata pairs, state and pause; paged. ${SCOPES}`,
183
+ properties: {
184
+ state: { type: "string", enum: ["active", "archived", "all"] },
185
+ tags: STRINGS,
186
+ project: STRING,
187
+ metadata: { type: "object", additionalProperties: STRING },
188
+ paused: { type: "boolean" },
189
+ limit: { type: "integer", minimum: 1, maximum: 200 },
190
+ cursor: STRING,
191
+ },
192
+ readOnly: true,
193
+ run: async (hub, args, signal) => {
194
+ const state = optional(args, "state")
195
+ if (state !== undefined && !["active", "archived", "all"].includes(state)) throw new ArgError(`"state" is one of active, archived, all`)
196
+ const tags = strings(args, "tags")
197
+ const project = optional(args, "project")
198
+ const metadata = record(args, "metadata")
199
+ const limit = int(args, "limit", 1, 200)
200
+ const cursor = optional(args, "cursor")
201
+ if (args.paused !== undefined && typeof args.paused !== "boolean") throw new ArgError(`"paused" must be true or false`)
202
+ const page = await hub.teams.list({
203
+ signal,
204
+ ...(state ? { state: state as "active" | "archived" | "all" } : {}),
205
+ ...(tags ? { tags } : {}),
206
+ ...(project ? { project } : {}),
207
+ ...(metadata ? { metadata } : {}),
208
+ ...(typeof args.paused === "boolean" ? { paused: args.paused } : {}),
209
+ ...(limit !== undefined ? { limit } : {}),
210
+ ...(cursor ? { cursor } : {}),
211
+ })
212
+ if (page.items.length === 0) return "no teams"
213
+ return [...page.items.map(teamLine), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n")
214
+ },
215
+ },
216
+ {
217
+ name: "team_show",
218
+ description: `One team: its published definition (mode, entry role, roles, communication paths, tags, projects, metadata), revision, and the pending draft if any. ${SCOPES}`,
219
+ properties: { team: TEAM },
220
+ required: ["team"],
221
+ readOnly: true,
222
+ run: async (hub, args, signal) => {
223
+ const { team, draft } = await hub.teams.get(need(args, "team"), { signal })
224
+ return [teamLine(team), ...specLines(team), ...(draft ? [`draft pending (on v${draft.baseVersion}, saved ${when(draft.savedAt)}): see team_diff`] : [])].join("\n")
225
+ },
226
+ },
227
+ {
228
+ name: "team_status",
229
+ description: `A team's state, roster (each role's seats with session, presence, generation and revision; vacant/understaffed roles; launches) and intake counts. ${SCOPES}`,
230
+ properties: { team: TEAM },
231
+ required: ["team"],
232
+ readOnly: true,
233
+ run: async (hub, args, signal) => {
234
+ const { team, roster, intake } = await hub.teams.status(need(args, "team"), { signal })
235
+ const lines = [teamLine(team), ...rosterLines(roster)]
236
+ if (intake)
237
+ lines.push(
238
+ `intake: ${intake.queued} queued, ${intake.routed} routed (${intake.open} open, ${intake.inProgress} in progress, ${intake.blocked} blocked), ${intake.done} done, ${intake.failed} failed, ${intake.cancelled} cancelled${intake.oldestQueuedAt !== undefined ? `; oldest queued ${when(intake.oldestQueuedAt)}` : ""}`,
239
+ )
240
+ return lines.join("\n")
241
+ },
242
+ },
243
+ {
244
+ name: "team_history",
245
+ description: `A team's retained published versions, newest first (version, digest, when). ${SCOPES}`,
246
+ properties: { team: TEAM, cursor: STRING },
247
+ required: ["team"],
248
+ readOnly: true,
249
+ run: async (hub, args, signal) => {
250
+ const cursor = optional(args, "cursor")
251
+ const page = await hub.teams.versions(need(args, "team"), { signal, ...(cursor ? { cursor } : {}) })
252
+ return [...page.items.map((v) => `v${v.version} ${v.digest.slice(0, 12)} ${when(v.publishedAt)}`), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n")
253
+ },
254
+ },
255
+ {
256
+ name: "team_diff",
257
+ description: `What changed between two versions, or from a version (default: the published one) to the draft (default). Names and keys only. ${SCOPES}`,
258
+ properties: { team: TEAM, from: { type: "integer", minimum: 1 }, to: { description: "A version number or \"draft\" (default)." } },
259
+ required: ["team"],
260
+ readOnly: true,
261
+ run: async (hub, args, signal) => {
262
+ const from = int(args, "from", 1, 1e9)
263
+ const to = args.to === undefined || args.to === null || args.to === "draft" ? "draft" : int(args, "to", 1, 1e9)!
264
+ const { from: a, to: b, diff } = await hub.teams.diff(need(args, "team"), { signal, ...(from !== undefined ? { from } : {}), to })
265
+ const part = (label: string, d: { added?: readonly string[]; removed?: readonly string[]; changed?: readonly string[] }) =>
266
+ (["added", "removed", "changed"] as const).flatMap((k) => (d[k]?.length ? [`${label} ${k}: ${d[k]!.join(", ")}`] : []))
267
+ const lines = [
268
+ ...(diff.fields.length ? [`fields changed: ${diff.fields.join(", ")}`] : []),
269
+ ...part("roles", diff.roles),
270
+ ...part("paths", diff.edges),
271
+ ...part("tags", diff.tags),
272
+ ...part("metadata", diff.metadata),
273
+ ...part("projects", diff.projects),
274
+ ]
275
+ return [`v${a} → ${b === "draft" ? "draft" : `v${b}`}`, ...(lines.length ? lines : ["no changes"])].join("\n")
276
+ },
277
+ },
278
+
279
+ // ---------- definitions ----------
280
+ {
281
+ name: "team_create",
282
+ description: `Assemble a persistent team: roles (one is the entry role that receives submitted work, e.g. a project manager), open or restricted communication with one-way/two-way paths, tags, projects and metadata. Published as version 1 at once; the name is the team's address (@@role#name) and never changes. ${SCOPES}`,
283
+ properties: { name: { ...STRING, description: "A slug (2–40 lowercase letters, digits, dashes), unique per user." }, ...SPEC_FIELDS, idempotencyKey: IDEMPOTENCY },
284
+ required: ["name", "title", "mode", "roles", "entryRole"],
285
+ readOnly: false,
286
+ organizes: true,
287
+ idempotent: true,
288
+ run: async (hub, args, signal) => {
289
+ need(args, "name")
290
+ need(args, "title")
291
+ const key = optional(args, "idempotencyKey")
292
+ const spec = picked(args, ["name", "title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as unknown as TeamSpec
293
+ const { team, created } = await hub.teams.create({ ...spec, edges: spec.edges ?? [] }, { signal, ...(key ? { idempotencyKey: key } : {}) })
294
+ return [`${created ? "created" : "already created (same key)"}: ${teamLine(team)}`, ...specLines(team)].join("\n")
295
+ },
296
+ },
297
+ {
298
+ name: "team_update",
299
+ description: `Save changes to a team's draft: title, description, mode, entry role, roles and paths (wholesale lists), tags, metadata, projects (null clears an optional field). Nothing applies until team_publish. ${SCOPES}`,
300
+ properties: { team: TEAM, ...SPEC_FIELDS, expectedRevision: REVISION },
301
+ required: ["team", "expectedRevision"],
302
+ readOnly: false,
303
+ organizes: true,
304
+ idempotent: true,
305
+ run: async (hub, args, signal) => {
306
+ const expectedRevision = revision(args)
307
+ const patch = picked(args, ["title", "description", "mode", "roles", "entryRole", "edges", "tags", "metadata", "projects"]) as TeamPatch
308
+ if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
309
+ const id = need(args, "team")
310
+ const { team } = await hub.teams.update(id, patch, expectedRevision, { signal })
311
+ return draftText("draft saved", team, (await hub.teams.draft(team.id, { signal })).draft)
312
+ },
313
+ },
314
+ draftTool("team_role_set", "Add a role to a team's draft, or replace the role of that name (all its fields).", { role: { ...STRING, description: "The role's name (a slug)." }, ...ROLE_FIELDS }, ["role"], (teams, team, args, rev, signal) =>
315
+ teams.setRole(team, { name: need(args, "role"), ...picked(args, Object.keys(ROLE_FIELDS)) } as TeamRole, rev, { signal }),
316
+ ),
317
+ draftTool("team_role_remove", "Remove a role (not the entry role) and every path touching it from a team's draft.", { role: STRING }, ["role"], (teams, team, args, rev, signal) => teams.removeRole(team, need(args, "role"), rev, { signal })),
318
+ draftTool("team_edge_set", "Draw a communication path from one role to another in a team's draft (replaces the path drawn between them).", { from: STRING, to: STRING, kinds: KINDS, direction: DIRECTION }, ["from", "to", "kinds", "direction"], (teams, team, args, rev, signal) => {
319
+ const kinds = strings(args, "kinds")
320
+ if (!kinds?.length) throw new ArgError(`"kinds" is a list of msg and/or ask`)
321
+ const direction = need(args, "direction")
322
+ return teams.setEdge(team, { from: need(args, "from"), to: need(args, "to"), kinds: kinds as TeamEdge["kinds"], direction: direction as TeamEdge["direction"] }, rev, { signal })
323
+ }),
324
+ draftTool("team_edge_remove", "Remove the path drawn from one role to another (or a two-way path between them) from a team's draft.", { from: STRING, to: STRING }, ["from", "to"], (teams, team, args, rev, signal) => teams.removeEdge(team, need(args, "from"), need(args, "to"), rev, { signal })),
325
+ {
326
+ name: "team_discard_draft",
327
+ description: `Drop a team's pending draft (unpublished edits are lost; the published version stays). ${SCOPES}`,
328
+ properties: { team: TEAM, expectedRevision: REVISION },
329
+ required: ["team", "expectedRevision"],
330
+ readOnly: false,
331
+ idempotent: true,
332
+ run: async (hub, args, signal) => `draft discarded: ${teamLine((await hub.teams.discardDraft(need(args, "team"), revision(args), { signal })).team)}`,
333
+ },
334
+ {
335
+ name: "team_publish",
336
+ description: `Publish a team's draft as its next version: delivery enforces the new communication policy from the next message, and staffing uses the new roles. Refused while sessions are staffed in a role the draft removes. ${SCOPES}`,
337
+ properties: { team: TEAM, expectedRevision: REVISION },
338
+ required: ["team", "expectedRevision"],
339
+ readOnly: false,
340
+ idempotent: true,
341
+ run: async (hub, args, signal) => {
342
+ const { team, published } = await hub.teams.publish(need(args, "team"), revision(args), { signal })
343
+ return `${published ? `published version ${team.version}` : "the draft equalled the published version; dropped"}: ${teamLine(team)}`
344
+ },
345
+ },
346
+ statusTool("pause", "Pause a team: it takes no new intake and no new members; its communication policy still applies.", true),
347
+ statusTool("resume", "Resume a paused team (queued intake is routed again).", true),
348
+ statusTool("archive", "Archive a team: its policy no longer applies and it takes no work; seats are kept; reversible with team_restore.", false),
349
+ statusTool("restore", "Restore an archived team: its policy applies again.", false),
350
+
351
+ // ---------- staffing ----------
352
+ {
353
+ name: "team_staff",
354
+ description: `Staff a role: attach an existing session (sessionID, any machine) or launch a new member (launch: a launch spec as the launch tool takes). An agent needs the user's grants (roles for the session's project; launch for a repo) or the hub answers needs_user; staffing never issues grants. ${SCOPES}`,
355
+ properties: { team: TEAM, role: STRING, sessionID: { ...STRING, description: "An existing session (ses_…)." }, launch: { type: "object", description: "A launch spec (repo, harness, model, prompt, …)." }, idempotencyKey: IDEMPOTENCY },
356
+ required: ["team", "role"],
357
+ readOnly: false,
358
+ run: async (hub, args, signal) => {
359
+ const team = need(args, "team")
360
+ const role = need(args, "role")
361
+ const sessionID = optional(args, "sessionID")
362
+ const launch = launchOf(args)
363
+ if ((sessionID === undefined) === (launch === undefined)) throw new ArgError(`give exactly one of sessionID or launch`)
364
+ const key = optional(args, "idempotencyKey")
365
+ const options = { signal, ...(key ? { idempotencyKey: key } : {}) }
366
+ if (sessionID !== undefined) {
367
+ const r = await hub.teams.staff(team, { role, sessionID }, options)
368
+ return `${r.created ? "staffed" : "already staffed"} ${r.staffing.sessionID} as ${r.staffing.role} (${r.staffing.id}, generation ${r.staffing.generation}, r${r.staffing.revision})${r.workspace ? `; workspace ${r.workspace.id}` : ""}`
369
+ }
370
+ const r = await hub.teams.launchMember(team, { role, launch: launch! }, options)
371
+ return `${r.created ? "launching" : "already launching"} a ${role} member: request ${r.request.id} ${r.request.state}, launch ${r.launch.id} ${r.launch.state}`
372
+ },
373
+ },
374
+ {
375
+ name: "team_unstaff",
376
+ description: `End a seat (the session leaves the role and loses the team's policy rights). Send the seat's revision (team_status). ${SCOPES}`,
377
+ properties: { team: TEAM, staffingID: { ...STRING, description: "The seat (stf_…)." }, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
378
+ required: ["team", "staffingID", "expectedRevision"],
379
+ readOnly: false,
380
+ idempotent: true,
381
+ run: async (hub, args, signal) => {
382
+ const { staffing } = await hub.teams.unstaff(need(args, "team"), need(args, "staffingID"), revision(args), { signal })
383
+ return `unstaffed ${staffing.id} (${staffing.sessionID} as ${staffing.role})`
384
+ },
385
+ },
386
+ {
387
+ name: "team_replace",
388
+ description: `Replace a seat by an existing session (sessionID) or a new launch (launch): the old seat ends at once and the new member gets the slot's next generation. Send the seat's revision (team_status). ${SCOPES}`,
389
+ properties: { team: TEAM, staffingID: STRING, sessionID: STRING, launch: { type: "object" }, expectedRevision: { ...REVISION, description: "The seat's revision you read (team_status)." } },
390
+ required: ["team", "staffingID", "expectedRevision"],
391
+ readOnly: false,
392
+ run: async (hub, args, signal) => {
393
+ const expectedRevision = revision(args)
394
+ const sessionID = optional(args, "sessionID")
395
+ const launch = launchOf(args)
396
+ if ((sessionID === undefined) === (launch === undefined)) throw new ArgError(`give exactly one of sessionID or launch`)
397
+ const input = sessionID !== undefined ? { expectedRevision, sessionID } : { expectedRevision, launch: launch! }
398
+ const r = await hub.teams.replace(need(args, "team"), need(args, "staffingID"), input, { signal })
399
+ const next = r.staffing ? `${r.staffing.sessionID} (${r.staffing.id}, generation ${r.staffing.generation})` : `launch ${r.launch?.id ?? "?"} (request ${r.request?.id ?? "?"})`
400
+ return `replaced ${r.ended.sessionID} in ${r.ended.role} by ${next}`
401
+ },
402
+ },
403
+ ]