@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.
Files changed (221) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +148 -19
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +632 -49
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,301 @@
1
+ // Shared agent profiles, skills and commands (HWA-02 contract; registry HWA-14, protocol HWA-15, adapters HWA-16/17/
2
+ // 18, UI HWA-19). A profile is a versioned, hub-stored bundle (agent instructions, skills, commands) that can be
3
+ // assigned to a running session. Distinct from runtime.profile (sandbox configuration) and from roles and grants:
4
+ // assigning a profile confers no authority.
5
+ //
6
+ // Invariants frozen here:
7
+ // - Versions are immutable; edits create a new version. Running sessions never change silently: an assignment
8
+ // pins a version, and a newer version only reaches a session through an explicit re-apply.
9
+ // - Application is session-specific and acknowledged by the spoke (applied | rejected | unsupported) with the
10
+ // harness's reason. No adapter may rewrite global harness config; adapters write session-scoped state only.
11
+ // - Spokes announce the spoke feature `profileApply` (constant below) to receive the `profileApply` op; the hub
12
+ // never sends it to a spoke that did not announce it, and reports `unsupported` instead.
13
+ // - (HWA-15, additive) The spoke answers the op with a ProfileApplyResult echoing the session, profile, version and
14
+ // digest it applied; an answer naming anything else is stale. "restart_required": accepted, effective only after
15
+ // the harness session restarts. An assignment keeps the requested pin (profileID, version) and, apart, the
16
+ // `effective` version the session last confirmed (applied or partial).
17
+ import { DESCRIPTION_MAX, isLabel, isSlug, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
18
+ import { isObj } from "./wire.ts"
19
+
20
+ export const PROFILE_LIMITS = {
21
+ profilesPerUser: 200,
22
+ /** Retained versions per profile (older versions stay readable while any assignment pins them). */
23
+ versionsPerProfile: 100,
24
+ skillsPerProfile: 32,
25
+ commandsPerProfile: 64,
26
+ /** Characters of instructions, of each skill body and of each command template. */
27
+ instructionsChars: 32_000,
28
+ skillChars: 32_000,
29
+ commandChars: 8_000,
30
+ /** Total serialized size of one version. */
31
+ versionBytes: 512 * 1024,
32
+ /** (RP-08, additive) Project names one profile lists. */
33
+ projectsPerProfile: 16,
34
+ } as const
35
+
36
+ export const PROFILE_SCOPES = ["profiles:read", "profiles:manage", "profiles:assign"] as const
37
+
38
+ /** The spoke feature and op name for applying a profile to one session. */
39
+ export const PROFILE_APPLY_FEATURE = "profileApply"
40
+
41
+ export interface ProfileSkill {
42
+ readonly name: string
43
+ readonly description: string
44
+ readonly body: string
45
+ }
46
+
47
+ export interface ProfileCommand {
48
+ readonly name: string
49
+ readonly description: string
50
+ readonly template: string
51
+ }
52
+
53
+ export interface ProfileVersionContent {
54
+ readonly instructions?: string
55
+ /** Preferred model "provider/model"; adapters may ignore it with a reported reason. */
56
+ readonly model?: string
57
+ readonly skills: readonly ProfileSkill[]
58
+ readonly commands: readonly ProfileCommand[]
59
+ }
60
+
61
+ export interface AgentProfile extends Revisioned {
62
+ readonly v: 1
63
+ readonly id: string
64
+ readonly name: string
65
+ readonly title: string
66
+ readonly description?: string
67
+ readonly state: RecordState
68
+ readonly tags?: readonly string[]
69
+ /**
70
+ * (RP-08, additive) Optional bounded key/value details (the shared metadataProblem rule: ≤ 32 slug keys, values
71
+ * 1–200 characters, never secrets), e.g. `owner`, `purpose`. On the profile record, not on versions.
72
+ */
73
+ readonly metadata?: Readonly<Record<string, string>>
74
+ /** (RP-08, additive) Project names the profile is meant for (≤ projectsPerProfile, unique; filters). */
75
+ readonly projects?: readonly string[]
76
+ readonly latestVersion: number
77
+ readonly updatedAt: number
78
+ }
79
+
80
+ export interface AgentProfileVersion {
81
+ readonly v: 1
82
+ readonly profileID: string
83
+ readonly version: number
84
+ readonly content: ProfileVersionContent
85
+ /** sha256 hex of the canonical JSON of `content` (adapters echo it in the acknowledgment). */
86
+ readonly digest: string
87
+ readonly createdAt: number
88
+ }
89
+
90
+ /** A session's pinned profile version and the latest acknowledgment for it. */
91
+ export interface ProfileAssignment extends Revisioned {
92
+ readonly v: 1
93
+ readonly sessionID: string
94
+ readonly profileID: string
95
+ readonly version: number
96
+ readonly ack?: ProfileAck
97
+ /** (HWA-15) What the session runs now; absent until a spoke confirmed a version. */
98
+ readonly effective?: ProfileEffective
99
+ }
100
+
101
+ export type ProfileAckStatus = "pending" | "applied" | "partial" | "rejected" | "unsupported" | "restart_required"
102
+ export const PROFILE_ACK_STATUSES: readonly ProfileAckStatus[] = ["pending", "applied", "partial", "rejected", "unsupported", "restart_required"]
103
+
104
+ export interface ProfileAck {
105
+ readonly status: ProfileAckStatus
106
+ readonly digest: string
107
+ /** Per-part outcomes (e.g. "model ignored: harness pins model at launch"); bounded, no secrets. */
108
+ readonly notes?: readonly string[]
109
+ readonly at: number
110
+ /** (HWA-15) With pending: no spoke reports the session now; the hub applies the pin when one does. */
111
+ readonly offline?: true
112
+ }
113
+
114
+ /** (HWA-15) The version a session last confirmed running (applied or partial), kept across later pins. */
115
+ export interface ProfileEffective {
116
+ readonly profileID: string
117
+ readonly version: number
118
+ readonly digest: string
119
+ readonly status: "applied" | "partial"
120
+ readonly at: number
121
+ }
122
+
123
+ export const PROFILE_EVENT_TYPES = ["profile.created", "profile.version.created", "profile.archived", "profile.assigned", "profile.ack"] as const
124
+
125
+ /** Why a profile version body is not acceptable, or undefined. */
126
+ export function profileContentProblem(v: unknown): string | undefined {
127
+ if (!isObj(v)) return "content must be an object"
128
+ if (v.instructions !== undefined && !isText(v.instructions, PROFILE_LIMITS.instructionsChars)) return `instructions must be at most ${PROFILE_LIMITS.instructionsChars} characters`
129
+ if (v.model !== undefined && (typeof v.model !== "string" || !/^[A-Za-z0-9._-]{1,80}\/[A-Za-z0-9._:/-]{1,200}$/.test(v.model))) return "model must be provider/model"
130
+ const parts: [unknown, number, number, "body" | "template", string][] = [
131
+ [v.skills, PROFILE_LIMITS.skillsPerProfile, PROFILE_LIMITS.skillChars, "body", "skills"],
132
+ [v.commands, PROFILE_LIMITS.commandsPerProfile, PROFILE_LIMITS.commandChars, "template", "commands"],
133
+ ]
134
+ for (const [list, max, chars, field, label] of parts) {
135
+ if (!Array.isArray(list) || list.length > max) return `${label} must be a list of at most ${max}`
136
+ const names = new Set<string>()
137
+ for (const item of list) {
138
+ if (!isObj(item) || !isSlug(item.name)) return `each of ${label} needs a slug name`
139
+ if (names.has(item.name)) return `${label} names must be unique (${item.name})`
140
+ names.add(item.name)
141
+ if (!isLabel(item.description, 300)) return `${label}.${item.name}.description must be 1–300 characters`
142
+ if (!isText(item[field], chars) || (item[field] as string).trim() === "") return `${label}.${item.name}.${field} must be 1–${chars} characters`
143
+ }
144
+ }
145
+ if (new TextEncoder().encode(JSON.stringify(v)).length > PROFILE_LIMITS.versionBytes) return "profile version is too large"
146
+ return undefined
147
+ }
148
+
149
+ /** (RP-08) Why a profile's `projects` is not acceptable, or undefined (absent is fine). */
150
+ export function profileProjectsProblem(v: unknown): string | undefined {
151
+ if (v === undefined) return undefined
152
+ const max = PROFILE_LIMITS.projectsPerProfile
153
+ if (!Array.isArray(v) || v.length > max || !v.every((p) => isLabel(p)) || new Set(v).size !== v.length) return `projects must be at most ${max} unique project names`
154
+ return undefined
155
+ }
156
+
157
+ /** Why a create-profile body is not acceptable, or undefined. */
158
+ export function profileInputProblem(v: unknown): string | undefined {
159
+ if (!isObj(v)) return "body must be an object"
160
+ if (!isSlug(v.name)) return "name must be 2–40 lowercase letters, digits or dashes"
161
+ if (!isLabel(v.title)) return "title must be 1–120 characters of text"
162
+ if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
163
+ return tagsProblem(v.tags) ?? metadataProblem(v.metadata) ?? profileProjectsProblem(v.projects) ?? profileContentProblem(v.content)
164
+ }
165
+
166
+ // ---------- application (HWA-15) ----------
167
+
168
+ /** What a spoke answers to profileApply (never "pending": the hub alone records that). */
169
+ export type ProfileApplyStatus = Exclude<ProfileAckStatus, "pending">
170
+ export const PROFILE_APPLY_STATUSES: readonly ProfileApplyStatus[] = ["applied", "partial", "rejected", "unsupported", "restart_required"]
171
+
172
+ export const PROFILE_APPLY_LIMITS = {
173
+ /** Notes per acknowledgment. */
174
+ notes: 16,
175
+ /** Characters per note. */
176
+ noteChars: 300,
177
+ } as const
178
+
179
+ /** Hub → spoke: apply one immutable profile version to one session (feature `profileApply`). */
180
+ export interface ProfileApplyRequest {
181
+ readonly op: "profileApply"
182
+ readonly sessionID: string
183
+ readonly profileID: string
184
+ readonly version: number
185
+ /** sha256 hex of canonicalProfileJSON(content); the spoke checks it before applying and echoes it. */
186
+ readonly digest: string
187
+ readonly content: ProfileVersionContent
188
+ }
189
+
190
+ /** Spoke → hub: the outcome for exactly the session, profile, version and digest it was asked to apply. */
191
+ export interface ProfileApplyResult {
192
+ readonly sessionID: string
193
+ readonly profileID: string
194
+ readonly version: number
195
+ readonly digest: string
196
+ readonly status: ProfileApplyStatus
197
+ /** Bounded (PROFILE_APPLY_LIMITS), plain text; never secret values or profile content. */
198
+ readonly notes?: readonly string[]
199
+ }
200
+
201
+ const DIGEST = /^[0-9a-f]{64}$/
202
+ const PROFILE_REF = /^[A-Za-z0-9_.:-]{1,100}$/
203
+ const CONTENT_KEYS = new Set(["instructions", "model", "skills", "commands"])
204
+ const SKILL_KEYS = new Set(["name", "description", "body"])
205
+ const COMMAND_KEYS = new Set(["name", "description", "template"])
206
+ const isVersion = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 1
207
+ const onlyKeys = (v: Record<string, unknown>, keys: ReadonlySet<string>) => Object.keys(v).every((k) => keys.has(k))
208
+
209
+ /** Why `v` is not an acceptable profileApply request, or undefined. Content is exactly the known fields. */
210
+ export function profileApplyRequestProblem(v: unknown): string | undefined {
211
+ if (!isObj(v) || v.op !== "profileApply") return "op must be profileApply"
212
+ if (typeof v.sessionID !== "string" || !PROFILE_REF.test(v.sessionID)) return "sessionID must be a session ID"
213
+ if (typeof v.profileID !== "string" || !PROFILE_REF.test(v.profileID)) return "profileID must be a profile ID"
214
+ if (!isVersion(v.version)) return "version must be a version number"
215
+ if (typeof v.digest !== "string" || !DIGEST.test(v.digest)) return "digest must be 64 lowercase hex characters"
216
+ const content = v.content
217
+ if (!isObj(content) || !onlyKeys(content, CONTENT_KEYS)) return "content must hold only instructions, model, skills and commands"
218
+ if (Array.isArray(content.skills) && !content.skills.every((s) => isObj(s) && onlyKeys(s, SKILL_KEYS))) return "skills hold only name, description and body"
219
+ if (Array.isArray(content.commands) && !content.commands.every((c) => isObj(c) && onlyKeys(c, COMMAND_KEYS))) return "commands hold only name, description and template"
220
+ return profileContentProblem(content)
221
+ }
222
+
223
+ /**
224
+ * Why `v` is not an acceptable profileApply result, or undefined. With `request`, a result that names another
225
+ * session, profile, version or digest is stale (the hub never records it).
226
+ */
227
+ export function profileApplyResultProblem(v: unknown, request?: Pick<ProfileApplyRequest, "sessionID" | "profileID" | "version" | "digest">): string | undefined {
228
+ if (!isObj(v)) return "result must be an object"
229
+ if (!(PROFILE_APPLY_STATUSES as readonly unknown[]).includes(v.status)) return `status must be one of ${PROFILE_APPLY_STATUSES.join(", ")}`
230
+ if (v.notes !== undefined) {
231
+ const { notes: max, noteChars } = PROFILE_APPLY_LIMITS
232
+ if (!Array.isArray(v.notes) || v.notes.length > max || !v.notes.every((n) => typeof n === "string" && n.length <= noteChars && !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(n))) {
233
+ return `notes must be at most ${max} lines of at most ${noteChars} printable characters`
234
+ }
235
+ }
236
+ if (typeof v.sessionID !== "string" || typeof v.profileID !== "string" || !isVersion(v.version) || typeof v.digest !== "string" || !DIGEST.test(v.digest)) {
237
+ return "result must echo sessionID, profileID, version and digest"
238
+ }
239
+ if (!request) return undefined
240
+ if (v.sessionID !== request.sessionID) return "stale: the result names another session"
241
+ if (v.profileID !== request.profileID) return "stale: the result names another profile"
242
+ if (v.version !== request.version) return "stale: the result names another version"
243
+ if (v.digest !== request.digest) return "stale: the result names another digest"
244
+ return undefined
245
+ }
246
+
247
+ /** JSON with object keys sorted at every level (undefined members dropped): what the version digest hashes. */
248
+ export function canonicalProfileJSON(value: unknown): string {
249
+ if (Array.isArray(value)) return `[${value.map(canonicalProfileJSON).join(",")}]`
250
+ if (isObj(value)) {
251
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined).sort()
252
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalProfileJSON(value[k])}`).join(",")}}`
253
+ }
254
+ return JSON.stringify(value)
255
+ }
256
+
257
+ /** One part of a command template: literal text, a `{{param}}` or a `{{secret:NAME}}` reference. */
258
+ export type ProfileTemplatePart = { readonly text: string } | { readonly param: string } | { readonly secret: string; readonly raw: string }
259
+
260
+ const TEMPLATE_PARAM = /^[a-z][a-z0-9_]{0,39}$/
261
+ const TEMPLATE_SECRET = /^[A-Z][A-Z0-9_]{0,63}$/
262
+ const SECRET_PREFIX = "secret:"
263
+
264
+ /**
265
+ * Splits a command template (HWA-14 syntax: `{{param}}`, `{{arguments}}`, `{{secret:NAME}}`, `\{{` for a literal
266
+ * `{{`; nothing else) into parts, or says why it is refused. Problems name positions, never template text.
267
+ */
268
+ export function profileTemplateParts(template: string): ProfileTemplatePart[] | string {
269
+ const out: ProfileTemplatePart[] = []
270
+ let text = ""
271
+ let i = 0
272
+ while (i < template.length) {
273
+ if (template.startsWith("\\{{", i)) {
274
+ text += "{{"
275
+ i += 3
276
+ continue
277
+ }
278
+ if (!template.startsWith("{{", i)) {
279
+ text += template[i]
280
+ i++
281
+ continue
282
+ }
283
+ const end = template.indexOf("}}", i + 2)
284
+ if (end < 0) return `unclosed placeholder at character ${i + 1}`
285
+ const inner = template.slice(i + 2, end).trim()
286
+ if (inner.startsWith(SECRET_PREFIX)) {
287
+ if (!TEMPLATE_SECRET.test(inner.slice(SECRET_PREFIX.length))) return `secret reference at character ${i + 1} must be {{secret:NAME}} (uppercase letters, digits, _)`
288
+ if (text) out.push({ text })
289
+ text = ""
290
+ out.push({ secret: inner.slice(SECRET_PREFIX.length), raw: template.slice(i, end + 2) })
291
+ } else {
292
+ if (!TEMPLATE_PARAM.test(inner)) return `placeholder at character ${i + 1} is not a {{name}} parameter (no expression, condition or filter is allowed; write \\{{ for a literal {{)`
293
+ if (text) out.push({ text })
294
+ text = ""
295
+ out.push({ param: inner })
296
+ }
297
+ i = end + 2
298
+ }
299
+ if (text) out.push({ text })
300
+ return out
301
+ }
@@ -0,0 +1,204 @@
1
+ // Persistent teams (HWA-02 contract; definitions HWA-33, enforcement HWA-34, staffing HWA-35, intake HWA-36,
2
+ // API/SDK/CLI/MCP HWA-37, Watchtower team views HWA-38/39, team-aware discovery HWA-40). Decisions zltiwb4s,
3
+ // 5etngvgm (external contact), r3vv4eon (no-team sessions unchanged), qxzrdc6c (@@role#team).
4
+ //
5
+ // A team is a versioned definition (roles, one entry role, communication mode and paths, tags/metadata). It grants
6
+ // no authority: staffing a role with a session needs the existing grants. Membership is many-to-many: a session
7
+ // may hold roles in several teams, and more than one role in one team.
8
+ //
9
+ // teamDeliveryVerdict below is the normative delivery rule; HWA-34 calls it from central message delivery and
10
+ // must not re-derive it.
11
+ import { DESCRIPTION_MAX, isLabel, isSlug, isText, metadataProblem, tagsProblem, type RecordState, type Revisioned } from "./contracts.ts"
12
+ import { isObj } from "./wire.ts"
13
+
14
+ export const TEAM_LIMITS = {
15
+ teamsPerUser: 100,
16
+ rolesPerTeam: 32,
17
+ edgesPerTeam: 256,
18
+ /** Sessions staffed per role (several sessions may share a role). */
19
+ sessionsPerRole: 16,
20
+ /** Open intake items per team. */
21
+ openIntakePerTeam: 500,
22
+ } as const
23
+
24
+ export const TEAM_SCOPES = ["teams:read", "teams:manage", "teams:submit"] as const
25
+
26
+ /** "open": every member may msg/ask every other member (two-way). "restricted": only the drawn edges. */
27
+ export type TeamMode = "open" | "restricted"
28
+
29
+ /** Message kinds a path permits. Replies to a permitted ask never need a path. */
30
+ export type TeamMessageKind = "msg" | "ask"
31
+
32
+ export interface TeamRole {
33
+ readonly name: string
34
+ readonly label?: string
35
+ readonly description?: string
36
+ /** Decision 5etngvgm: holders of this role may contact sessions outside all their teams. Default false. */
37
+ readonly externalContact?: boolean
38
+ }
39
+
40
+ export interface TeamEdge {
41
+ readonly from: string
42
+ readonly to: string
43
+ readonly kinds: readonly TeamMessageKind[]
44
+ /** "two-way": `to` may also contact `from` with the same kinds. */
45
+ readonly direction: "one-way" | "two-way"
46
+ }
47
+
48
+ export interface TeamDefinition extends Revisioned {
49
+ readonly v: 1
50
+ readonly id: string
51
+ /** Slug used in addresses: `@@role#name`. Unique per user. */
52
+ readonly name: string
53
+ readonly title: string
54
+ readonly description?: string
55
+ readonly state: RecordState
56
+ readonly mode: TeamMode
57
+ readonly roles: readonly TeamRole[]
58
+ /** The intake role; receives submitted work and supervises members (via workspace supervision). */
59
+ readonly entryRole: string
60
+ /** Ignored in open mode (kept so switching modes is lossless). */
61
+ readonly edges: readonly TeamEdge[]
62
+ readonly tags?: readonly string[]
63
+ readonly metadata?: Readonly<Record<string, string>>
64
+ readonly updatedAt: number
65
+ }
66
+
67
+ /** A session staffed in a role of a team (HWA-35). A replacement is a new generation of the same slot. */
68
+ export interface TeamStaffing extends Revisioned {
69
+ readonly v: 1
70
+ readonly id: string
71
+ readonly teamID: string
72
+ readonly role: string
73
+ readonly sessionID: string
74
+ readonly generation: number
75
+ readonly since: number
76
+ readonly until?: number
77
+ }
78
+
79
+ /** Work submitted to a team (HWA-36): routed to the entry role, tracked as a workspace assignment. */
80
+ export interface TeamIntake extends Revisioned {
81
+ readonly v: 1
82
+ readonly id: string
83
+ readonly teamID: string
84
+ readonly title: string
85
+ readonly source: { readonly kind: "user" } | { readonly kind: "app"; readonly tokenID: string } | { readonly kind: "session"; readonly sessionID: string }
86
+ readonly ref?: string
87
+ readonly assignmentID?: string
88
+ readonly status: "queued" | "routed" | "done" | "cancelled" | "failed"
89
+ readonly createdAt: number
90
+ }
91
+
92
+ export const TEAM_EVENT_TYPES = ["team.created", "team.updated", "team.archived", "team.staffed", "team.unstaffed", "team.intake", "team.denied"] as const
93
+
94
+ // ---- Addressing (decision qxzrdc6c) --------------------------------------------------------------------------
95
+
96
+ export interface TeamRoleAddress {
97
+ readonly role: string
98
+ readonly project?: string
99
+ readonly team: string
100
+ }
101
+
102
+ /**
103
+ * Parses `@@role#team` and `@@role.project#team`. Undefined for anything else, including a plain `@@role`
104
+ * (which parseAddress handles; a team-less role address that resolves in several teams is ambiguous: HWA-34).
105
+ */
106
+ export function parseTeamRoleAddress(addr: string): TeamRoleAddress | undefined {
107
+ const match = /^@@([a-z0-9-]{2,40})(?:\.([^./\s#]+))?#([a-z0-9-]{2,40})$/i.exec(addr)
108
+ if (!match) return undefined
109
+ const [, role, project, team] = match
110
+ if (role !== role!.toLowerCase() || team !== team!.toLowerCase()) return undefined
111
+ return project ? { role: role!, project: project.toLowerCase(), team: team! } : { role: role!, team: team! }
112
+ }
113
+
114
+ export const formatTeamRoleAddress = (a: TeamRoleAddress): string => `@@${a.role}${a.project ? `.${a.project}` : ""}#${a.team}`
115
+
116
+ // ---- Delivery rule (decisions zltiwb4s, 5etngvgm, r3vv4eon) -----------------------------------------------------
117
+
118
+ /** One (team, role) a session holds. */
119
+ export interface TeamSeat {
120
+ readonly teamID: string
121
+ readonly role: string
122
+ }
123
+
124
+ export interface DeliveryQuestion {
125
+ readonly kind: TeamMessageKind | "reply"
126
+ /** The recipient is the user (@me escalation). */
127
+ readonly toUser: boolean
128
+ /** For replies: the ask being answered was itself delivered (permitted). */
129
+ readonly replyToPermittedAsk: boolean
130
+ readonly senderSeats: readonly TeamSeat[]
131
+ readonly recipientSeats: readonly TeamSeat[]
132
+ /** Explicit team context (`#team` address or API field): only that team's policy is consulted. */
133
+ readonly teamID?: string
134
+ readonly teams: ReadonlyMap<string, Pick<TeamDefinition, "mode" | "edges" | "roles" | "state">>
135
+ }
136
+
137
+ export type DeliveryVerdict =
138
+ | { readonly allow: true; readonly via: "user" | "reply" | "no_team" | "team"; readonly teamID?: string }
139
+ | { readonly allow: false; readonly reason: "policy" | "external" | "not_member" }
140
+
141
+ function edgePermits(team: Pick<TeamDefinition, "mode" | "edges">, from: string, to: string, kind: TeamMessageKind): boolean {
142
+ if (team.mode === "open") return true
143
+ return team.edges.some((e) => e.kinds.includes(kind) && ((e.from === from && e.to === to) || (e.direction === "two-way" && e.from === to && e.to === from)))
144
+ }
145
+
146
+ /**
147
+ * Whether central may deliver a msg/ask/reply between two sessions under team policy. Order:
148
+ * 1. @me escalation and replies to a permitted ask: always allowed.
149
+ * 2. A sender in no (active) team: today's behavior, allowed (r3vv4eon). Inbound to team members included.
150
+ * 3. With a team context: the sender must hold a seat there; only that team decides.
151
+ * 4. Shared teams: allowed if at least one shared team permits sender role → recipient role for this kind.
152
+ * 5. No shared team: allowed only if one of the sender's roles opts into external contact (5etngvgm).
153
+ */
154
+ export function teamDeliveryVerdict(q: DeliveryQuestion): DeliveryVerdict {
155
+ if (q.toUser) return { allow: true, via: "user" }
156
+ if (q.kind === "reply" && q.replyToPermittedAsk) return { allow: true, via: "reply" }
157
+ const active = (s: TeamSeat): boolean => q.teams.get(s.teamID)?.state === "active"
158
+ const sender = q.senderSeats.filter(active)
159
+ const recipient = q.recipientSeats.filter(active)
160
+ if (sender.length === 0) return { allow: true, via: "no_team" }
161
+ const kind: TeamMessageKind = q.kind === "reply" ? "msg" : q.kind
162
+ const consider = q.teamID === undefined ? sender : sender.filter((s) => s.teamID === q.teamID)
163
+ if (q.teamID !== undefined && consider.length === 0) return { allow: false, reason: "not_member" }
164
+ let shared = false
165
+ for (const s of consider) {
166
+ for (const r of recipient) {
167
+ if (r.teamID !== s.teamID) continue
168
+ shared = true
169
+ if (edgePermits(q.teams.get(s.teamID)!, s.role, r.role, kind)) return { allow: true, via: "team", teamID: s.teamID }
170
+ }
171
+ }
172
+ if (shared || q.teamID !== undefined) return { allow: false, reason: "policy" }
173
+ const external = sender.some((s) => q.teams.get(s.teamID)!.roles.some((r) => r.name === s.role && r.externalContact === true))
174
+ return external ? { allow: true, via: "team" } : { allow: false, reason: "external" }
175
+ }
176
+
177
+ // ---- Input checks -----------------------------------------------------------------------------------------------
178
+
179
+ export function teamInputProblem(v: unknown): string | undefined {
180
+ if (!isObj(v)) return "body must be an object"
181
+ if (!isSlug(v.name)) return "name must be 2–40 lowercase letters, digits or dashes"
182
+ if (!isLabel(v.title)) return "title must be 1–120 characters of text"
183
+ if (v.description !== undefined && !isText(v.description, DESCRIPTION_MAX)) return `description must be at most ${DESCRIPTION_MAX} characters`
184
+ if (v.mode !== "open" && v.mode !== "restricted") return "mode must be open or restricted"
185
+ if (!Array.isArray(v.roles) || v.roles.length === 0 || v.roles.length > TEAM_LIMITS.rolesPerTeam) return `roles must list 1–${TEAM_LIMITS.rolesPerTeam} roles`
186
+ const names = new Set<string>()
187
+ for (const role of v.roles) {
188
+ if (!isObj(role) || !isSlug(role.name)) return "each role needs a slug name"
189
+ if (names.has(role.name)) return `role ${role.name} is listed twice`
190
+ names.add(role.name)
191
+ if (role.label !== undefined && !isLabel(role.label)) return `role ${role.name} label must be 1–120 characters`
192
+ if (role.externalContact !== undefined && typeof role.externalContact !== "boolean") return `role ${role.name} externalContact must be true or false`
193
+ }
194
+ if (typeof v.entryRole !== "string" || !names.has(v.entryRole)) return "entryRole must name one of the roles"
195
+ const edges = v.edges ?? []
196
+ if (!Array.isArray(edges) || edges.length > TEAM_LIMITS.edgesPerTeam) return `edges must be a list of at most ${TEAM_LIMITS.edgesPerTeam}`
197
+ for (const e of edges) {
198
+ if (!isObj(e) || typeof e.from !== "string" || typeof e.to !== "string" || !names.has(e.from) || !names.has(e.to)) return "each edge must connect two listed roles"
199
+ if (e.from === e.to) return "an edge must connect two different roles"
200
+ if (!Array.isArray(e.kinds) || e.kinds.length === 0 || !e.kinds.every((k) => k === "msg" || k === "ask")) return "edge kinds must be msg and/or ask"
201
+ if (e.direction !== "one-way" && e.direction !== "two-way") return "edge direction must be one-way or two-way"
202
+ }
203
+ return tagsProblem(v.tags) ?? metadataProblem(v.metadata)
204
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-openshell",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "The `openshell` runtime of `hub node`: launches in NVIDIA OpenShell sandboxes, driven through the openshell CLI (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-runtime-sdk",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "The Runtime interface `hub node` runtimes implement (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-sdk",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
4
  "private": true,
5
5
  "description": "Typed client for the hub's /api/agent (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -9,8 +9,12 @@ export const validName = (name: string): boolean => NAME.test(name)
9
9
 
10
10
  export type Address = { kind: "id"; id: string } | { kind: "name"; name: string; project?: string } | { kind: "role"; role: string; project?: string }
11
11
 
12
- /** A project scope: any non-empty run of characters without dots, slashes or spaces, compared lowercased. */
13
- const SCOPED = /^([a-z0-9-]{2,40})(?:\.([^./\s]+))?$/i
12
+ /**
13
+ * A project scope: any non-empty run of characters without dots, slashes, spaces or `#`, compared lowercased.
14
+ * `#` is the team separator (`@@role#team`, `@@role.project#team`: protocol parseTeamRoleAddress), never part of
15
+ * a plain address, so a team address is never resolved as a global role.
16
+ */
17
+ const SCOPED = /^([a-z0-9-]{2,40})(?:\.([^./\s#]+))?$/i
14
18
 
15
19
  /** `ses_…` (an ID or ID prefix), `@name[.project]` or `@@role[.project]`; undefined when malformed. */
16
20
  export function parseAddress(addr: string): Address | undefined {
@@ -23,3 +27,47 @@ export function parseAddress(addr: string): Address | undefined {
23
27
  const scope = project ? { project } : {}
24
28
  return role ? { kind: "role", role: match[1]!, ...scope } : { kind: "name", name: match[1]!, ...scope }
25
29
  }
30
+
31
+ /** What a refused address answers: the accepted forms, and a hint derived from the address's shape only. */
32
+ export interface AddressProblem {
33
+ readonly message: string
34
+ readonly hint: string
35
+ }
36
+
37
+ /** The address forms, as error messages list them. */
38
+ export const ADDRESS_FORMS = "ses_…, @name, @@role or @@role#team"
39
+ const FORMS_HINT = "Addresses are ses_… (an ID or a prefix), @name[.project], @@role[.project], @@role#team (a role in one of your teams; quote # in a shell) or @me"
40
+ const TEAM_FORMS = "@@role#team or @@role.project#team"
41
+ const SLUG = /^[a-z0-9-]{2,40}$/
42
+ /** A well-formed team address (protocol parseTeamRoleAddress's syntax). */
43
+ const TEAM_ADDRESS = /^@@[a-z0-9-]{2,40}(?:\.[^./\s#]+)?#[a-z0-9-]{2,40}$/
44
+
45
+ /**
46
+ * Why `addr` is not an address here (parseAddress refused it). Pure syntax: it never says whether a session, role
47
+ * or team exists, and it quotes only parts of the input that are valid name slugs (never free text).
48
+ */
49
+ export function addressProblem(addr: string): AddressProblem {
50
+ const message = `Not an address: use ${ADDRESS_FORMS}`
51
+ const hash = addr.indexOf("#")
52
+ if (hash < 0) {
53
+ const lowercase = /[A-Z]/.test(addr) && parseAddress(addr.toLowerCase()) !== undefined
54
+ return { message, hint: lowercase ? `Names and roles are lowercase. ${FORMS_HINT}` : FORMS_HINT }
55
+ }
56
+ if (TEAM_ADDRESS.test(addr)) {
57
+ return {
58
+ message: "A team address (@@role#team) is not accepted here: use ses_…, @name or @@role",
59
+ hint: "Team addresses work for messages and reads (msg, ask, who, show, log, events); to act on a session, name it: ses_… or @name (see who)",
60
+ }
61
+ }
62
+ const head = addr.slice(0, hash)
63
+ const team = addr.slice(hash + 1)
64
+ const doubled = head.startsWith("@@")
65
+ const name = head.startsWith("@") ? head.slice(doubled ? 2 : 1).split(".")[0]! : undefined
66
+ if (name !== undefined && !doubled && SLUG.test(name) && SLUG.test(team)) {
67
+ return { message, hint: `A team address names a role, not a session: @@${name}#${team} (the ${name} role of team ${team}), or @${name} without #team for the session named ${name}` }
68
+ }
69
+ if (name !== undefined && SLUG.test(name.toLowerCase()) && SLUG.test(team.toLowerCase())) {
70
+ return { message, hint: `Role and team names are lowercase: ${TEAM_FORMS}` }
71
+ }
72
+ return { message, hint: `Team addresses are ${TEAM_FORMS}: lowercase a-z, 0-9 and -, 2–40 characters (quote # in a shell)` }
73
+ }