@huynle/hub 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) hide show
  1. package/CHANGELOG.md +430 -1
  2. package/Dockerfile +7 -2
  3. package/README.md +230 -32
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/paths.ts +3 -3
  9. package/packages/claude-code/src/profile.ts +173 -0
  10. package/packages/claude-code/src/sessions.ts +22 -4
  11. package/packages/cli/dist/hub-cli.js +6871 -3460
  12. package/packages/cli/package.json +1 -1
  13. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  14. package/packages/cli/src/artifacts-mcp.ts +160 -0
  15. package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
  16. package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
  17. package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
  18. package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
  19. package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
  20. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  21. package/packages/cli/src/cli.ts +96 -14
  22. package/packages/cli/src/format.ts +48 -10
  23. package/packages/cli/src/http.ts +2 -0
  24. package/packages/cli/src/intents.ts +23 -0
  25. package/packages/cli/src/launches.ts +9 -3
  26. package/packages/cli/src/login.ts +20 -6
  27. package/packages/cli/src/mcp.ts +385 -10
  28. package/packages/cli/src/messaging.ts +7 -3
  29. package/packages/cli/src/profiles-mcp.ts +301 -0
  30. package/packages/cli/src/setup/commands.ts +23 -4
  31. package/packages/cli/src/setup/runtime.ts +3 -2
  32. package/packages/cli/src/spoke-services.ts +1 -1
  33. package/packages/cli/src/team-intake-mcp.ts +158 -0
  34. package/packages/cli/src/teams-mcp.ts +460 -0
  35. package/packages/cli/src/watchlist.ts +135 -0
  36. package/packages/codex/package.json +1 -1
  37. package/packages/codex/src/paths.ts +3 -3
  38. package/packages/codex/src/profile.ts +163 -0
  39. package/packages/codex/src/sessions.ts +12 -0
  40. package/packages/codex/src/spoke.ts +19 -1
  41. package/packages/node/package.json +1 -1
  42. package/packages/node/src/config.ts +104 -2
  43. package/packages/node/src/opencode-warm.ts +344 -0
  44. package/packages/node/src/run.ts +28 -1
  45. package/packages/preset/package.json +1 -1
  46. package/packages/preset/src/profile.ts +34 -0
  47. package/packages/preset/src/spoke.ts +4 -0
  48. package/packages/protocol/package.json +1 -1
  49. package/packages/protocol/src/artifacts.ts +142 -0
  50. package/packages/protocol/src/concierge.ts +366 -0
  51. package/packages/protocol/src/contracts.ts +104 -0
  52. package/packages/protocol/src/graph.ts +70 -0
  53. package/packages/protocol/src/hub-workspace.ts +201 -0
  54. package/packages/protocol/src/index.ts +175 -6
  55. package/packages/protocol/src/launch.ts +36 -0
  56. package/packages/protocol/src/profiles.ts +301 -0
  57. package/packages/protocol/src/teams.ts +204 -0
  58. package/packages/runtime-openshell/package.json +1 -1
  59. package/packages/runtime-sdk/package.json +1 -1
  60. package/packages/sdk/package.json +1 -1
  61. package/packages/sdk/src/address.ts +50 -2
  62. package/packages/sdk/src/artifact-jobs.ts +194 -0
  63. package/packages/sdk/src/artifacts.ts +187 -0
  64. package/packages/sdk/src/client.ts +226 -5
  65. package/packages/sdk/src/concierge.ts +88 -0
  66. package/packages/sdk/src/errors.ts +4 -1
  67. package/packages/sdk/src/index.ts +6 -0
  68. package/packages/sdk/src/team-intake.ts +122 -0
  69. package/packages/sdk/src/teams.ts +346 -0
  70. package/packages/sdk/src/types.ts +568 -1
  71. package/packages/sdk/src/wake.ts +20 -0
  72. package/packages/spoke-sdk/package.json +1 -1
  73. package/packages/spoke-sdk/src/index.ts +1 -0
  74. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  75. package/src/central/actions.ts +7 -0
  76. package/src/central/agent/acting-routes.ts +2 -1
  77. package/src/central/agent/acting-switch.ts +2 -1
  78. package/src/central/agent/acting.ts +218 -10
  79. package/src/central/agent/app-tokens.ts +3 -2
  80. package/src/central/agent/audit.ts +18 -1
  81. package/src/central/agent/body.ts +163 -0
  82. package/src/central/agent/decisions.ts +125 -2
  83. package/src/central/agent/delivery.ts +194 -40
  84. package/src/central/agent/directory-routes.ts +37 -7
  85. package/src/central/agent/directory.ts +70 -14
  86. package/src/central/agent/errors.ts +7 -0
  87. package/src/central/agent/escalation.ts +10 -2
  88. package/src/central/agent/events-routes.ts +21 -4
  89. package/src/central/agent/events.ts +18 -4
  90. package/src/central/agent/form-check.ts +85 -0
  91. package/src/central/agent/grant-requests.ts +2 -1
  92. package/src/central/agent/grant-routes.ts +4 -3
  93. package/src/central/agent/grants.ts +29 -2
  94. package/src/central/agent/idempotency.ts +133 -0
  95. package/src/central/agent/intent-routes.ts +116 -0
  96. package/src/central/agent/intents.ts +505 -0
  97. package/src/central/agent/interaction-graph-routes.ts +212 -0
  98. package/src/central/agent/interaction-graph.ts +865 -0
  99. package/src/central/agent/launch-engine.ts +189 -32
  100. package/src/central/agent/launch-routes.ts +26 -10
  101. package/src/central/agent/launch-spec.ts +40 -3
  102. package/src/central/agent/launch-store.ts +55 -2
  103. package/src/central/agent/message-limits.ts +89 -11
  104. package/src/central/agent/message-routes.ts +10 -4
  105. package/src/central/agent/messages.ts +28 -2
  106. package/src/central/agent/names.ts +12 -2
  107. package/src/central/agent/placement.ts +93 -18
  108. package/src/central/agent/read-routes.ts +89 -19
  109. package/src/central/agent/routes.ts +178 -5
  110. package/src/central/agent/webhook-routes.ts +2 -1
  111. package/src/central/agent/webhooks.ts +17 -2
  112. package/src/central/agent-data.ts +4 -0
  113. package/src/central/artifacts/access.ts +44 -0
  114. package/src/central/artifacts/blob-store.ts +113 -0
  115. package/src/central/artifacts/blob-support.ts +98 -0
  116. package/src/central/artifacts/events.ts +27 -0
  117. package/src/central/artifacts/host-caps.ts +162 -0
  118. package/src/central/artifacts/host.ts +315 -0
  119. package/src/central/artifacts/idempotency.ts +109 -0
  120. package/src/central/artifacts/jobs-routes.ts +776 -0
  121. package/src/central/artifacts/jobs.ts +344 -0
  122. package/src/central/artifacts/maintenance.ts +103 -0
  123. package/src/central/artifacts/routes.ts +360 -0
  124. package/src/central/artifacts/store.ts +776 -0
  125. package/src/central/artifacts/workspaces.ts +31 -0
  126. package/src/central/backend/file-blobs.ts +377 -0
  127. package/src/central/backend/index.ts +12 -3
  128. package/src/central/backend/memory-blobs.ts +116 -0
  129. package/src/central/backend/postgres-blobs.ts +513 -0
  130. package/src/central/backend/postgres-migrate.ts +33 -0
  131. package/src/central/backend/postgres-state.ts +19 -0
  132. package/src/central/backend/types.ts +8 -0
  133. package/src/central/concierge/activity.ts +142 -0
  134. package/src/central/concierge/arm.ts +63 -0
  135. package/src/central/concierge/board-render.ts +190 -0
  136. package/src/central/concierge/board-results.ts +169 -0
  137. package/src/central/concierge/board-routes.ts +350 -0
  138. package/src/central/concierge/board.ts +249 -0
  139. package/src/central/concierge/context.ts +221 -0
  140. package/src/central/concierge/entitlements.ts +13 -0
  141. package/src/central/concierge/guard.ts +120 -0
  142. package/src/central/concierge/inbound.ts +86 -0
  143. package/src/central/concierge/index.ts +84 -0
  144. package/src/central/concierge/notes.ts +41 -0
  145. package/src/central/concierge/notifier.ts +986 -0
  146. package/src/central/concierge/prompt.ts +131 -0
  147. package/src/central/concierge/proposals.ts +268 -0
  148. package/src/central/concierge/push-remap.ts +49 -0
  149. package/src/central/concierge/routes.ts +122 -0
  150. package/src/central/concierge/schedule.ts +113 -0
  151. package/src/central/concierge/service.ts +243 -0
  152. package/src/central/concierge/session-routes.ts +125 -0
  153. package/src/central/concierge/settings.ts +217 -0
  154. package/src/central/concierge/supervisor.ts +767 -0
  155. package/src/central/concierge/tiers.ts +260 -0
  156. package/src/central/concierge/watchlist-routes.ts +279 -0
  157. package/src/central/concierge/watchlist.ts +273 -0
  158. package/src/central/config.ts +28 -6
  159. package/src/central/http.ts +660 -54
  160. package/src/central/main.ts +1 -1
  161. package/src/central/mcp-http.ts +10 -21
  162. package/src/central/profiles/apply.ts +333 -0
  163. package/src/central/profiles/content.ts +174 -0
  164. package/src/central/profiles/events.ts +40 -0
  165. package/src/central/profiles/idempotency.ts +14 -0
  166. package/src/central/profiles/import.ts +139 -0
  167. package/src/central/profiles/routes.ts +356 -0
  168. package/src/central/profiles/store.ts +597 -0
  169. package/src/central/profiles/usage-ports.ts +120 -0
  170. package/src/central/profiles/usage.ts +364 -0
  171. package/src/central/push.ts +7 -0
  172. package/src/central/registry.ts +69 -7
  173. package/src/central/store.ts +4 -0
  174. package/src/central/teams/collections.ts +34 -0
  175. package/src/central/teams/discovery.ts +346 -0
  176. package/src/central/teams/draft-edits.ts +46 -0
  177. package/src/central/teams/events.ts +46 -0
  178. package/src/central/teams/intake-routes.ts +100 -0
  179. package/src/central/teams/intake.ts +786 -0
  180. package/src/central/teams/policy.ts +231 -0
  181. package/src/central/teams/routes.ts +516 -0
  182. package/src/central/teams/seats.ts +272 -0
  183. package/src/central/teams/staffing.ts +910 -0
  184. package/src/central/teams/store.ts +667 -0
  185. package/src/central/teams/validation.ts +256 -0
  186. package/src/central/workspaces/board.ts +408 -0
  187. package/src/central/workspaces/checkpoints.ts +316 -0
  188. package/src/central/workspaces/coordinator.ts +841 -0
  189. package/src/central/workspaces/events.ts +85 -0
  190. package/src/central/workspaces/lifecycle.ts +93 -0
  191. package/src/central/workspaces/master.ts +490 -0
  192. package/src/central/workspaces/members.ts +632 -0
  193. package/src/central/workspaces/routes.ts +354 -0
  194. package/src/central/workspaces/sidechats-routes.ts +410 -0
  195. package/src/central/workspaces/sidechats.ts +482 -0
  196. package/src/central/workspaces/store.ts +571 -0
  197. package/src/central/workspaces/supervision.ts +355 -0
  198. package/src/index.ts +126 -23
  199. package/src/migrate.ts +109 -3
  200. package/src/spoke/concierge-agent.ts +33 -0
  201. package/src/spoke/deliver.ts +22 -4
  202. package/src/spoke/env-inject.ts +8 -1
  203. package/src/spoke/fork-revert.ts +142 -0
  204. package/src/spoke/hub-rpc.ts +41 -5
  205. package/src/spoke/hub-tool.ts +404 -25
  206. package/src/spoke/launchers.ts +6 -4
  207. package/src/spoke/listing.ts +65 -10
  208. package/src/spoke/localhub.ts +14 -4
  209. package/src/spoke/locations.ts +40 -0
  210. package/src/spoke/profile-apply.ts +298 -0
  211. package/src/spoke/session-context.ts +256 -0
  212. package/src/spoke/spoke.ts +330 -23
  213. package/src/spoke/timeline.ts +6 -1
  214. package/src/spoke/tracker.ts +23 -1
  215. package/src/spoke/who-teams.ts +81 -0
  216. package/src/tui-messaging.ts +32 -18
  217. package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
  218. package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
  219. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  220. package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
  221. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  222. package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
  223. package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
  224. package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
  225. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  226. package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
  227. package/web/dist/assets/index-BabrNDB3.css +1 -0
  228. package/web/dist/assets/index-lIOfSYck.js +34 -0
  229. package/web/dist/assets/live-BDq0AABg.js +5 -0
  230. package/web/dist/assets/live-BUofsQpz.css +1 -0
  231. package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
  232. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  233. package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
  234. package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
  235. package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
  236. package/web/dist/assets/source-CDU04kYw.js +1 -0
  237. package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
  238. package/web/dist/index.html +5 -2
  239. package/web/dist/sw.js +22 -2
  240. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  241. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,301 @@
1
+ // MCP tools for the profile lifecycle (FX-03, m8sthu85): show a profile and its versions, create one, edit its
2
+ // metadata, write new versions (whole content, a rollback, or one skill or command attached/detached), archive and
3
+ // restore it, and pin a version to a session. The read tools a session uses on its own pin (profile, profile_skill,
4
+ // profile_command) and the listing (profiles, profile_usage) stay in mcp.ts.
5
+ //
6
+ // Profiles are immutable versioned bundles (HWA-14, RP-08): nothing here edits a version. Every content change is
7
+ // POST /profiles/:id/versions with the profile's revision; attaching or detaching a skill or command reads the latest
8
+ // version and writes its content with that one change as the next version (a stale revision answers conflict, so a
9
+ // concurrent change is never overwritten). No server behaviour is added: each tool is one existing route of
10
+ // src/central/profiles/routes.ts (plus the reads it needs).
11
+ //
12
+ // Annotations for gateway review (packages/cli/src/mcp.ts maps them): reads are readOnlyHint; create, metadata edits
13
+ // and new versions only reorganize (destructiveHint false: pinned sessions keep their explicit version, and an old
14
+ // version can be restored with fromVersion); archive/restore (no new versions or pins) and pins (applied to a running
15
+ // session) keep the default destructiveHint. Changes send expectedRevision, so repeating one is idempotent.
16
+ import { sanitizeText } from "../../sdk/src/envelope.ts"
17
+ import type { Profiles } from "../../sdk/src/client.ts"
18
+ import type { ProfileCommand, ProfileContent, ProfileSkill, ProfileVersionSummary, ProfileView } from "../../sdk/src/types.ts"
19
+
20
+ type Args = Record<string, unknown>
21
+
22
+ /** The shape of an MCP tool (as packages/cli/src/mcp.ts registers them). */
23
+ export interface ProfileTool {
24
+ readonly name: string
25
+ readonly description: string
26
+ readonly properties: Record<string, unknown>
27
+ readonly required?: readonly string[]
28
+ readonly readOnly: boolean
29
+ readonly organizes?: boolean
30
+ readonly idempotent?: boolean
31
+ run(hub: { readonly profiles: Profiles }, args: Args, signal: AbortSignal): Promise<string>
32
+ }
33
+
34
+ const STRING = { type: "string" } as const
35
+ const STRINGS = { type: "array", items: STRING } as const
36
+ const ID = { type: "string", description: "The profile's ID (prof_…; see profiles)." } as const
37
+ const REVISION = { type: "integer", minimum: 0, description: "The profile's revision you read (profile_show); a stale one answers conflict with the current revision." } as const
38
+ const IDEMPOTENCY = { type: "string", description: "Idempotency-Key: the same key and arguments within 24 h answer the first result again." } as const
39
+ const SKILL = { type: "object", properties: { name: { ...STRING, description: "A slug." }, description: STRING, body: { ...STRING, description: "The skill's full text (loaded on demand)." } }, required: ["name", "description", "body"] } as const
40
+ const COMMAND = { type: "object", properties: { name: { ...STRING, description: "A slug." }, description: STRING, template: { ...STRING, description: "Text with {{name}} parameters and {{secret:NAME}} references (never resolved by the hub)." } }, required: ["name", "description", "template"] } as const
41
+ const CONTENT_FIELDS = {
42
+ instructions: { type: ["string", "null"], description: "The profile's instructions (AGENTS.md-like text)." },
43
+ model: { type: ["string", "null"], description: "provider/model the profile suggests." },
44
+ skills: { type: "array", items: SKILL, description: "Skills, wholesale." },
45
+ commands: { type: "array", items: COMMAND, description: "Commands, wholesale." },
46
+ } as const
47
+ const META_FIELDS = {
48
+ title: { ...STRING, description: "1–120 characters." },
49
+ description: STRING,
50
+ tags: { ...STRINGS, description: "Slugs." },
51
+ metadata: { type: "object", additionalProperties: STRING, description: "String key/value pairs (≤ 32 slug keys)." },
52
+ projects: { ...STRINGS, description: "Project names the profile is meant for (≤ 16)." },
53
+ } as const
54
+ const SCOPES = "App tokens need scope profiles:read (reads) or profiles:manage (changes; profile_version_create with content fields and the skill/command set/remove tools read the latest version first, so they also need profiles:read); pins need profiles:assign and sessions:read (an app also needs a prompt grant on the session). Session tokens never change profiles. A profile confers no authority."
55
+
56
+ /** A bad tool argument: mcp.ts answers it as `invalid: …` (an isError result). */
57
+ export class ProfileArgError extends Error {}
58
+ const ArgError = ProfileArgError
59
+
60
+ const need = (args: Args, key: string): string => {
61
+ const value = args[key]
62
+ if (typeof value !== "string" || !value.trim()) throw new ArgError(`"${key}" is required (a non-empty string)`)
63
+ return value
64
+ }
65
+ const optional = (args: Args, key: string): string | undefined => {
66
+ const value = args[key]
67
+ if (value === undefined || value === null) return undefined
68
+ if (typeof value !== "string") throw new ArgError(`"${key}" must be a string`)
69
+ return value.trim() ? value : undefined
70
+ }
71
+ const whole = (args: Args, key: string, min: number, what: string): number => {
72
+ const value = args[key]
73
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min) throw new ArgError(`"${key}" is required: ${what}`)
74
+ return value
75
+ }
76
+ const revision = (args: Args): number => whole(args, "expectedRevision", 0, "the revision you read (profile_show; 0 for a session with no pin)")
77
+ const idempotency = (args: Args) => {
78
+ const key = optional(args, "idempotencyKey")
79
+ return key ? { idempotencyKey: key } : {}
80
+ }
81
+ /** The given fields of `keys` (undefined ones dropped). */
82
+ const picked = (args: Args, keys: readonly string[]): Record<string, unknown> => Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]))
83
+
84
+ const one = (text: string) => sanitizeText(text).replace(/\s+/g, " ")
85
+
86
+ /** `prof_… name vN rN state title` */
87
+ export const profileLine = (p: ProfileView): string => `${p.id} ${p.name} v${p.latestVersion} r${p.revision} ${p.state} ${one(p.title)}`
88
+
89
+ /** A version's contents by name (never bodies). */
90
+ function summaryLines(v: ProfileVersionSummary | undefined): string[] {
91
+ if (!v) return []
92
+ return [
93
+ `version ${v.version} (${v.digest.slice(0, 12)}, ${v.bytes} bytes): instructions ${v.instructions ? "yes" : "no"}${v.model ? `, model ${one(v.model)}` : ""}`,
94
+ `skills: ${v.skills.length ? v.skills.map((s) => s.name).join(", ") : "none"}`,
95
+ `commands: ${v.commands.length ? v.commands.map((c) => `${c.name}(${c.params.join(", ")})`).join(", ") : "none"}`,
96
+ ...(v.secretRefs.length ? [`secret references: ${v.secretRefs.join(", ")}`] : []),
97
+ ]
98
+ }
99
+
100
+ const versionText = (r: { profile: ProfileView; version: ProfileVersionSummary }) => [`new version ${r.version.version}: ${profileLine(r.profile)}`, ...summaryLines(r.version)].join("\n")
101
+
102
+ /** The content of a profile's latest version (what an attach/detach starts from). */
103
+ async function latestContent(profiles: Profiles, id: string, signal: AbortSignal): Promise<{ latest: number; content: ProfileContent }> {
104
+ const { profile } = await profiles.get(id, { signal })
105
+ const { version } = await profiles.version(profile.id, profile.latestVersion, { signal })
106
+ return { latest: version.version, content: version.content }
107
+ }
108
+
109
+ /** A tool writing the latest version's content with one change as the next version. */
110
+ const contentTool = (
111
+ name: string,
112
+ description: string,
113
+ properties: Record<string, unknown>,
114
+ required: readonly string[],
115
+ change: (content: ProfileContent, args: Args, latest: number) => ProfileContent,
116
+ ): ProfileTool => ({
117
+ name,
118
+ description: `${description} Writes the latest version's content with this change as the next version (versions are immutable; pinned sessions keep theirs). ${SCOPES}`,
119
+ properties: { id: ID, ...properties, expectedRevision: REVISION },
120
+ required: ["id", ...required, "expectedRevision"],
121
+ readOnly: false,
122
+ organizes: true,
123
+ idempotent: true,
124
+ run: async (hub, args, signal) => {
125
+ const expectedRevision = revision(args)
126
+ const id = need(args, "id")
127
+ const { latest, content } = await latestContent(hub.profiles, id, signal)
128
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, content: change(content, args, latest) }, { signal }))
129
+ },
130
+ })
131
+
132
+ /** `items` with `item` replacing the one of its name (in place), else appended. */
133
+ const setNamed = <T extends { readonly name: string }>(items: readonly T[], item: T): T[] =>
134
+ items.some((i) => i.name === item.name) ? items.map((i) => (i.name === item.name ? item : i)) : [...items, item]
135
+ const removeNamed = <T extends { readonly name: string }>(items: readonly T[], name: string, what: string, latest: number): T[] => {
136
+ if (!items.some((i) => i.name === name)) throw new ArgError(`version ${latest} has no ${what} ${name}`)
137
+ return items.filter((i) => i.name !== name)
138
+ }
139
+
140
+ const stateTool = (verb: "archive" | "restore", description: string): ProfileTool => ({
141
+ name: `profile_${verb}`,
142
+ description: `${description} ${SCOPES}`,
143
+ properties: { id: ID, expectedRevision: REVISION },
144
+ required: ["id", "expectedRevision"],
145
+ readOnly: false,
146
+ idempotent: true,
147
+ run: async (hub, args, signal) => {
148
+ const expectedRevision = revision(args)
149
+ const { profile } = await hub.profiles.update(need(args, "id"), { expectedRevision, state: verb === "archive" ? "archived" : "active" }, { signal })
150
+ return `${verb === "archive" ? "archived" : "restored"}: ${profileLine(profile)}`
151
+ },
152
+ })
153
+
154
+ export const profileLifecycleTools: readonly ProfileTool[] = [
155
+ // ---------- reads ----------
156
+ {
157
+ name: "profile_show",
158
+ description: `One profile: name, title, state, revision (send it as expectedRevision), tags, projects, metadata, and its latest version's instructions, model, skill and command names (never bodies). ${SCOPES}`,
159
+ properties: { id: ID },
160
+ required: ["id"],
161
+ readOnly: true,
162
+ run: async (hub, args, signal) => {
163
+ const { profile, latest } = await hub.profiles.get(need(args, "id"), { signal })
164
+ return [
165
+ profileLine(profile),
166
+ ...(profile.description ? [`description: ${one(profile.description)}`] : []),
167
+ ...(profile.tags?.length ? [`tags: ${profile.tags.join(", ")}`] : []),
168
+ ...(profile.projects?.length ? [`projects: ${profile.projects.map(one).join(", ")}`] : []),
169
+ ...(profile.metadata && Object.keys(profile.metadata).length ? [`metadata: ${Object.entries(profile.metadata).map(([k, v]) => `${k}=${one(v)}`).join(", ")}`] : []),
170
+ ...summaryLines(latest),
171
+ ].join("\n")
172
+ },
173
+ },
174
+ {
175
+ name: "profile_versions",
176
+ description: `A profile's retained versions, newest first (version, digest, bytes, when, skill and command counts). ${SCOPES}`,
177
+ properties: { id: ID, limit: { type: "integer", minimum: 1, maximum: 200 }, cursor: STRING },
178
+ required: ["id"],
179
+ readOnly: true,
180
+ run: async (hub, args, signal) => {
181
+ const limit = args.limit === undefined ? undefined : whole(args, "limit", 1, "a whole number from 1 to 200")
182
+ const cursor = optional(args, "cursor")
183
+ const page = await hub.profiles.versions(need(args, "id"), { signal, ...(limit !== undefined ? { limit } : {}), ...(cursor ? { cursor } : {}) })
184
+ const counts = (v: (typeof page.items)[number]) => (v.skills !== undefined ? ` ${v.skills} skills, ${v.commands ?? 0} commands${v.instructions ? ", instructions" : ""}` : "")
185
+ return [...page.items.map((v) => `v${v.version} ${v.digest.slice(0, 12)} ${v.bytes} bytes ${new Date(v.createdAt).toISOString()}${counts(v)}`), ...(page.cursor ? [`more: cursor ${page.cursor}`] : [])].join("\n") || "no versions"
186
+ },
187
+ },
188
+
189
+ // ---------- definitions ----------
190
+ {
191
+ name: "profile_create",
192
+ description: `Create a profile: a versioned bundle of instructions, skills and commands, published as version 1 at once. The name is unique per user. Attach it to a team role with team_role_set (profile), pin it with profile_assign. ${SCOPES}`,
193
+ properties: { name: { ...STRING, description: "A slug, unique per user." }, ...META_FIELDS, ...CONTENT_FIELDS, idempotencyKey: IDEMPOTENCY },
194
+ required: ["name", "title"],
195
+ readOnly: false,
196
+ organizes: true,
197
+ idempotent: true,
198
+ run: async (hub, args, signal) => {
199
+ need(args, "name")
200
+ need(args, "title")
201
+ const meta = picked(args, ["name", "title", "description", "tags", "metadata", "projects"]) as { name: string; title: string }
202
+ const text = Object.entries(picked(args, ["instructions", "model"])).filter(([, v]) => v !== null)
203
+ const content = { ...Object.fromEntries(text), skills: (args.skills ?? []) as ProfileSkill[], commands: (args.commands ?? []) as ProfileCommand[] } as ProfileContent
204
+ const r = await hub.profiles.create({ ...meta, content }, { signal, ...idempotency(args) })
205
+ // A replay of the same idempotencyKey answers the first result again (the API does not mark replays).
206
+ return [`created: ${profileLine(r.profile)}`, ...summaryLines(r.version)].join("\n")
207
+ },
208
+ },
209
+ {
210
+ name: "profile_update",
211
+ description: `Edit a profile's metadata: title, description, tags, metadata, projects (null clears description, metadata or projects). Content changes are new versions (profile_version_create, profile_skill_set, …). ${SCOPES}`,
212
+ properties: { id: ID, ...META_FIELDS, description: { type: ["string", "null"] }, metadata: { ...META_FIELDS.metadata, type: ["object", "null"] }, projects: { ...META_FIELDS.projects, type: ["array", "null"] }, expectedRevision: REVISION },
213
+ required: ["id", "expectedRevision"],
214
+ readOnly: false,
215
+ organizes: true,
216
+ idempotent: true,
217
+ run: async (hub, args, signal) => {
218
+ const expectedRevision = revision(args)
219
+ const patch = picked(args, ["title", "description", "tags", "metadata", "projects"])
220
+ if (Object.keys(patch).length === 0) throw new ArgError("give at least one field to change")
221
+ const { profile } = await hub.profiles.update(need(args, "id"), { expectedRevision, ...patch }, { signal })
222
+ return `updated: ${profileLine(profile)}`
223
+ },
224
+ },
225
+ {
226
+ name: "profile_version_create",
227
+ description: `Write a profile's next version: either content fields (instructions, model, skills, commands; the ones you omit are kept from the latest version, null clears instructions or model) or fromVersion (an older version's content again: an explicit rollback). Versions are immutable; pinned sessions keep theirs until re-pinned. ${SCOPES}`,
228
+ properties: { id: ID, ...CONTENT_FIELDS, fromVersion: { type: "integer", minimum: 1, description: "A retained version to restore as the next one." }, expectedRevision: REVISION, idempotencyKey: IDEMPOTENCY },
229
+ required: ["id", "expectedRevision"],
230
+ readOnly: false,
231
+ organizes: true,
232
+ idempotent: true,
233
+ run: async (hub, args, signal) => {
234
+ const expectedRevision = revision(args)
235
+ const id = need(args, "id")
236
+ const given = Object.keys(picked(args, Object.keys(CONTENT_FIELDS)))
237
+ const options = { signal, ...idempotency(args) }
238
+ if (args.fromVersion !== undefined) {
239
+ if (given.length) throw new ArgError(`give either fromVersion or content fields (${given.join(", ")}), not both`)
240
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, fromVersion: whole(args, "fromVersion", 1, "a version number") }, options))
241
+ }
242
+ if (!given.length) throw new ArgError("give content fields (instructions, model, skills, commands) or fromVersion")
243
+ const { content } = await latestContent(hub.profiles, id, signal)
244
+ const next: Record<string, unknown> = { ...content }
245
+ for (const key of given) {
246
+ if (args[key] === null) delete next[key]
247
+ else next[key] = args[key]
248
+ }
249
+ return versionText(await hub.profiles.createVersion(id, { expectedRevision, content: next as unknown as ProfileContent }, options))
250
+ },
251
+ },
252
+ contentTool("profile_skill_set", "Attach a skill to a profile, or replace the skill of that name.", { name: { ...STRING, description: "The skill's name (a slug)." }, description: STRING, body: { ...STRING, description: "The skill's full text." } }, ["name", "description", "body"], (content, args) => ({
253
+ ...content,
254
+ skills: setNamed(content.skills, { name: need(args, "name"), description: need(args, "description"), body: need(args, "body") }),
255
+ })),
256
+ contentTool("profile_skill_remove", "Detach a skill from a profile.", { name: { ...STRING, description: "The skill's name." } }, ["name"], (content, args, latest) => ({
257
+ ...content,
258
+ skills: removeNamed(content.skills, need(args, "name"), "skill", latest),
259
+ })),
260
+ contentTool(
261
+ "profile_command_set",
262
+ "Attach a command to a profile, or replace the command of that name.",
263
+ { name: { ...STRING, description: "The command's name (a slug)." }, description: STRING, template: COMMAND.properties.template },
264
+ ["name", "description", "template"],
265
+ (content, args) => ({ ...content, commands: setNamed(content.commands, { name: need(args, "name"), description: need(args, "description"), template: need(args, "template") }) }),
266
+ ),
267
+ contentTool("profile_command_remove", "Detach a command from a profile.", { name: { ...STRING, description: "The command's name." } }, ["name"], (content, args, latest) => ({
268
+ ...content,
269
+ commands: removeNamed(content.commands, need(args, "name"), "command", latest),
270
+ })),
271
+ stateTool("archive", "Archive a profile: it takes no new versions or pins; pinned sessions keep theirs; reversible with profile_restore."),
272
+ stateTool("restore", "Restore an archived profile (it takes versions and pins again)."),
273
+
274
+ // ---------- pins ----------
275
+ {
276
+ name: "profile_assign",
277
+ description: `Pin a profile version to a session (applied through its machine; the ack tells whether it took). Send the session's pin revision (0 when it has none; see profile). Pinning confers no authority. ${SCOPES}`,
278
+ properties: { addr: { ...STRING, description: "The session: @name, ses_… ID or other address." }, profileID: ID, version: { type: "integer", minimum: 1 }, expectedRevision: { ...REVISION, description: "The session's pin revision (0 when it has none)." } },
279
+ required: ["addr", "profileID", "version", "expectedRevision"],
280
+ readOnly: false,
281
+ idempotent: true,
282
+ run: async (hub, args, signal) => {
283
+ const expectedRevision = revision(args)
284
+ const { assignment: a } = await hub.profiles.assign(need(args, "addr"), { profileID: need(args, "profileID"), version: whole(args, "version", 1, "a version number"), expectedRevision }, { signal })
285
+ return `${a.sessionID} pins ${a.profileID} v${a.version} r${a.revision}${a.ack ? `, ack ${a.ack.status}${a.ack.offline ? " (offline)" : ""}` : ""}`
286
+ },
287
+ },
288
+ {
289
+ name: "profile_unassign",
290
+ description: `Remove a session's pin (what it already applied stays until it restarts). Send the pin's revision (profile_assign's answer). ${SCOPES}`,
291
+ properties: { addr: { ...STRING, description: "The session." }, expectedRevision: { ...REVISION, description: "The session's pin revision." } },
292
+ required: ["addr", "expectedRevision"],
293
+ readOnly: false,
294
+ idempotent: true,
295
+ run: async (hub, args, signal) => {
296
+ const addr = need(args, "addr")
297
+ await hub.profiles.unassign(addr, revision(args), { signal })
298
+ return `unpinned ${addr}`
299
+ },
300
+ },
301
+ ]
@@ -56,7 +56,8 @@ import { TxStore } from "../bootstrap/transaction/store.ts"
56
56
  import type { TxOutcome } from "../bootstrap/transaction/engine.ts"
57
57
  import { readNodeConfig, nodeConfigPath } from "../../../node/src/config.ts"
58
58
  import { str, UsageError, type FlagKind, type Parsed } from "../args"
59
- import { resolveCredentials, type Env } from "../credentials"
59
+ import { resolveCredentials, type Credentials, type Env } from "../credentials"
60
+ import { isInsecureStoredURL, storedInsecureWarning } from "../bootstrap/enrollment/policy.ts"
60
61
  import { clientFor } from "../http"
61
62
  import { hubLaunchDriver } from "./launch-driver.ts"
62
63
  import { errorCodeOf, issuesText, PlanRefusal } from "./contributors.ts"
@@ -94,7 +95,7 @@ const CONSENT_FLAGS = { escalate: "bool", "confirm-activation": "bool", "allow-d
94
95
 
95
96
  /** The flags of each command (args.ts CommandSpec). */
96
97
  export const BOOTSTRAP_FLAGS: Readonly<Record<string, Readonly<Record<string, FlagKind>>>> = {
97
- "machine enroll": { server: "string", name: "string", reenroll: "bool", "no-verify": "bool" },
98
+ "machine enroll": { server: "string", name: "string", reenroll: "bool", "no-verify": "bool", "allow-insecure-http": "bool" },
98
99
  "machine status": { "no-verify": "bool" },
99
100
  "setup discover": {},
100
101
  "setup preview": { profile: "string", manifest: "string", out: "string", enrollment: "string", "workspace-root": "multi", "verify-launch": "string", server: "string", name: "string", ...CONSENT_FLAGS },
@@ -214,11 +215,28 @@ function readState(snapshot: DiscoverySnapshot): BootstrapState {
214
215
  }
215
216
  }
216
217
 
218
+ /**
219
+ * This shell's hub login for the verification launch; a stored plain-http, non-loopback login is warned about once
220
+ * on stderr, as every `hub` command does.
221
+ */
222
+ export function launchCredentials(io: Pick<SetupIo, "env" | "home" | "stderr">): () => Credentials | undefined {
223
+ let warned = false
224
+ return () => {
225
+ const found = resolveCredentials(io.env as Env, io.home)
226
+ if (found?.source === "login" && !warned && isInsecureStoredURL(found.url)) {
227
+ warned = true
228
+ io.stderr(`hubctl: warning: ${storedInsecureWarning(found.url)}\n`)
229
+ }
230
+ return found
231
+ }
232
+ }
233
+
217
234
  /** The verification launch over the hub launch API, with this shell's hub login (resolved when it starts). */
218
235
  function launchDriverFor(io: SetupIo) {
219
236
  const store = credentialStoreFor(io.env, io.home)
237
+ const login = launchCredentials(io)
220
238
  const client = () => {
221
- const credentials = resolveCredentials(io.env as Env, io.home)
239
+ const credentials = login()
222
240
  if (!credentials) throw new Error("not logged in to the hub (run `hub login --scope sessions:launch`; an app token launches under a grant with verb launch)")
223
241
  return clientFor(credentials)
224
242
  }
@@ -327,9 +345,10 @@ async function machine(verb: string, flags: Parsed["flags"], io: SetupIo, json:
327
345
  return printMachine(io, json, r.envelope, r.exit)
328
346
  }
329
347
  const server = str(flags, "server")
330
- if (!server) throw new UsageError("needs --server <hub URL> (https; plain http only for a loopback hub)")
348
+ if (!server) throw new UsageError("needs --server <hub URL> (https; plain http only for a loopback hub, or a private-network IP address with --allow-insecure-http)")
331
349
  const r = await machineEnroll({
332
350
  server: server.replace(/\/+$/, ""),
351
+ ...(flags["allow-insecure-http"] === true ? { allowInsecureHTTP: true } : {}),
333
352
  ...(str(flags, "name") ? { machineName: str(flags, "name")! } : {}),
334
353
  store: credentialStoreFor(io.env, io.home, { migrate: true }),
335
354
  ...(flags.reenroll === true ? { reenroll: true } : {}),
@@ -447,8 +447,9 @@ export function createSetupRuntime(deps: RuntimeDeps): SetupRuntime {
447
447
 
448
448
  /** What `machine enroll` (and an inline enrollment) shows while it waits; never a credential. */
449
449
  export function enrollPromptText(p: EnrollPrompt): string {
450
- if (p.autoApproved) return "hubctl: the local hub approved this machine by itself\n"
451
- return `hubctl: approve this machine on the hub: ${p.approveURL}\nhubctl: and check it shows the code ${p.userCode} (it expires at ${new Date(p.expiresAt).toISOString()})\n${p.warning ? `hubctl: warning: ${p.warning}\n` : ""}`
450
+ const insecure = p.insecure ? `hubctl: warning: ${p.insecure}\n` : ""
451
+ if (p.autoApproved) return `${insecure}hubctl: the local hub approved this machine by itself\n`
452
+ return `${insecure}hubctl: approve this machine on the hub: ${p.approveURL}\n${p.localURL ? `hubctl: (on this machine: ${p.localURL})\n` : ""}hubctl: and check it shows the code ${p.userCode} (it expires at ${new Date(p.expiresAt).toISOString()})\n${p.warning ? `hubctl: warning: ${p.warning}\n` : ""}`
452
453
  }
453
454
 
454
455
  export function launchReportText(r: LaunchReport): string {
@@ -18,7 +18,7 @@ export type ServiceVerb = (typeof SERVICE_VERBS)[number]
18
18
  export const isServiceVerb = (verb: string | undefined): verb is ServiceVerb => (SERVICE_VERBS as readonly string[]).includes(verb ?? "")
19
19
 
20
20
  /** Where config.json and the hub state dir are (shared by every hub spoke). */
21
- const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR"] as const
21
+ const HUB_PLACES = ["HUB_SPOKE_CONFIG", "XDG_CONFIG_HOME", "HUB_STATE_DIR", "XDG_STATE_HOME"] as const
22
22
 
23
23
  export const CLAUDE_CODE_SERVICE: ServiceSpec = {
24
24
  service: "claude-code",
@@ -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
+ ]