@huynle/hub 0.7.4 → 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.
- package/CHANGELOG.md +217 -0
- package/Dockerfile +7 -2
- package/README.md +148 -19
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +632 -49
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- 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
|
+
]
|