Skip to content

feat(helix-org): first alpha — embedded in Helix behind HELIX_ORG_ENABLED + per-user alpha flag - #2286

Merged
philwinder merged 62 commits into
mainfrom
feat/helix-org-prompt-driven-mcp
May 20, 2026
Merged

feat(helix-org): first alpha — embedded in Helix behind HELIX_ORG_ENABLED + per-user alpha flag#2286
philwinder merged 62 commits into
mainfrom
feat/helix-org-prompt-driven-mcp

Conversation

@philwinder

@philwinder philwinder commented Apr 25, 2026

Copy link
Copy Markdown
Member

First alpha of helix-org embedded inside the core Helix platform. Helix-org is the new human/AI organization layer — Roles, Workers, Positions, Streams, Grants — with an MCP-driven control surface, prompt-driven chat, and pluggable transports (webhook, email, GitHub). This PR lands it as an opt-in feature behind a deployment-wide kill switch and a per-user alpha flag.

Two-level feature gating

  • HELIX_ORG_ENABLED (env, default false) — deployment-wide kill switch. When unset/false, none of the helix-org init runs and no routes mount. Set true to opt in.
  • alpha_features: ['helix-org'] on the users row — per-user gate enforced by requireFeature middleware on every helix-org HTTP surface, so admins can roll the alpha to selected accounts even on enabled deployments.

What gets mounted when enabled

  • /ui/ — htmx-driven org UI (chat, settings, streams, workers, alpha-agents picker), gated by requireUser + requireFeature.
  • /api/v1/org/ — JSON-RPC MCP + webhook endpoints, same gate.
  • /api/v1/mcp/helix-org/workers/{id}/mcp — Helix MCP gateway backend that forwards to the in-process helix-org handler. Picked agents authenticate with the calling user's api_key, so MCP calls land as the actual hiring user (their Claude subscription, their desktop quota, their audit trail).
  • Sidebar entry in UserOrgSelector — visible only to users with the alpha feature flag.

Core platform (carried from the original prototype, polished here)

  • MCP tools — all mutations flow through /workers/{id}/mcp (Streamable HTTP). 28 builtins covering Role/Worker/Position/Stream/Grant lifecycle, env management, and transport-specific config. Tool visibility is grant-filtered per worker.
  • MCP prompts (slash commands) — server-registered prompts (/help, /role, /worker, …) with chat-composer typeahead. The chat bridge expands /name inputs before dispatch; the LLM sees the expanded text, the user sees their original input. Auto-generated /help walks the registry at render time.
  • Role vs Worker split — Role is the job (owner-edited markdown, fanned out via update_role); Worker is the person (per-hire identity, immutable). Live role edits take effect on the next activation. update_role and update_identity re-push role.md / identity.md to every affected Worker's per-Worker repo on the helix-specs branch via agent.WorkspaceSync.
  • Canonical Message envelope — every Event.Body is a domain.Message (From/To/Subject/Body/ThreadID/InReplyTo/MessageID/Extra). The spawner renders every populated field into the activation prompt so Workers branch on transport-shaped metadata without a separate read_events round-trip.
  • Enum schema hintsWorkerKind / TransportKind surface as enums in the JSON schema MCP clients see. Validation errors self-document: unknown kind "foo" (valid: "human", "ai").

Embedded chat + Spawner (the SaaS-surface integration)

  • Owner is just another Worker. The chat surface at /ui/ is a window onto the owner Worker's per-Worker Helix project — same worker.runtime defaults, same MCP wiring, same desktop runtime as any hired Worker. One ProjectApplier is shared by the chat bridge and the Spawner.
  • Worker activations. When events land on subscribed Streams, the embedded Spawner opens a fresh helix_agent chat session against the Worker's lazily-provisioned project clone of the picked owner agent, with its MCP entry rewritten to scope at /workers/<id>/mcp. Activation prompt is role.md + identity.md + the embedded agent.Policy; transcripts publish to s-activations-<workerID>.
  • zed_external + Claude Code subscription. Per-Worker desktops run Claude Code authenticated via the operator's OAuth — no API key at rest, no Helix-routed provider/model, one LLM call's worth of latency per activation.
  • Owner-chat resilience. Sessions survive container restart, session eviction, and refresh; "New chat" truly starts fresh. /ui/streams is now SSE-driven (no manual refresh).
  • Live UI. Real-time activation stream, markdown rendering in transcripts, polling stalls fixed.

Transports

Streams own their I/O. Three transport kinds, each in its own package:

  • Local (default) — in-process pub/sub between Workers.
  • Webhook — bidirectional HTTP. Outbound POSTs on every publish; inbound HMAC-verified and fanned out to subscribed Workers.
  • Email (Postmark) — outbound via Postmark API; inbound via Postmark's webhook with alias-based stream routing.
  • GitHub (inbound only) — /github/webhook, HMAC-verified via X-Hub-Signature-256, fans out to every Stream whose repo + events allowlist matches. Outbound actions on a repo (label, comment, review, open PR) are the Worker's job via gh in its env.

Provider credentials live in transport.<kind> config keys with explicit Secrets: []string declarations; helix-org config get redacts every declared secret, pinned by regression tests for transport.postmark and transport.github.

Demos (runnable end-to-end)

helix-org/demos/: getting-started, webhook, email, newsroom, github, github-engineer, manufacturing (NCR triage with Helix backend + comms-demo mock-channels — verified against app.helix.ml).

Operational notes

  • Filestore. Embedded helix-org only mounts when FILESTORE_TYPE=fs. SaaS already uses fs against a persistent volume; gcs/s3 deployments skip the feature.
  • Auto-provisioning. A service helix.api_key is minted at startup against the first admin user; the spawner uses it outside the request context. The hiring user's identity is forwarded into helixclient via per-request middleware so every Worker activation is owned by the actual human.
  • Subscription-credential validator. Updated ValidateAssistantModelConfig (and the matching path in GenerateZedMCPConfig) to bypass provider snapshot checks when an agent uses subscription credentials — empty provider/model is the documented shape for Claude Code OAuth.

Enabling it

# 1. enable the feature in the deployment
HELIX_ORG_ENABLED=true docker compose -f docker-compose.dev.yaml up -d api

# 2. grant the alpha flag to a user
docker exec helix-postgres-1 psql -U postgres -d postgres -c \
  "UPDATE users SET alpha_features = array_append(alpha_features, 'helix-org') WHERE email = 'you@example.com';"

The sidebar will show helix-org (alpha); click through to /ui/.

Out of scope for this alpha

  • Multi-tenant org isolation — every gated user currently shares one owner Worker. Multi-tenant follow-up is sketched in design/2026-05-17-helix-org-saas-alpha.md (this PR removes that file post-integration, but the design lives in git history).
  • Per-Worker provider credentials so different teams can use different GitHub identities / inboxes.
  • REPL access for human operators.

Test plan

  • Set HELIX_ORG_ENABLED=false (default): /api/v1/org/ returns 404, /ui/ falls through to SPA, /api/v1/mcp/helix-org/ not registered.
  • Set HELIX_ORG_ENABLED=true, grant alpha flag, open /ui/: chat backend wired, MCP backend registered, owner Worker bootstrapped on first start.
  • Hire a Worker via owner chat, publish to its subscribed stream, observe activation in /ui/streams SSE feed.
  • Edit a Role from owner chat (/role flow), trigger the Worker again, confirm new role text takes effect on the activation.
  • Drone CI green (the bumped unit-test wrapper timeout in this PR keeps pkg/tools's real-LLM suite from getting killed at 300s).

Co-Authored-By: Claude Haiku 4.5 noreply@anthropic.com
Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

@philwinder
philwinder force-pushed the feat/helix-org-prompt-driven-mcp branch 2 times, most recently from d9a9c99 to 01e9388 Compare April 27, 2026 13:23
@philwinder philwinder changed the title feat: helix-org prototype with MCP, prompt-driven CLI, and Role/Identity split feat: helix-org prototype with MCP, prompt-driven CLI, transports (webhook/email/github), and Role/Identity split Apr 28, 2026
philwinder and others added 27 commits May 4, 2026 11:43
…ity split

Adds a complete proto-implementation of helix-org as a standalone Go project with:

- **MCP Integration**: All mutations flow through Model Context Protocol at /workers/{id}/mcp
  using Streamable HTTP transport. Tool list is grant-filtered per worker.

- **Prompt-Driven CLI**: New `helix-org prompt` subcommand spawns Claude Code with inline
  MCP config, enabling natural-language orchestration of the entire org graph.

- **Role vs Worker Split**: Roles are job descriptions (owner-edited markdown, fanned out
  via update_role). Workers are people in positions (per-hire identities, immutable).

- **Environment Provisioning**: Each Worker gets an isolated environment directory with:
  - role.md (propagated via update_role)
  - identity.md (per-hire, immutable)
  - agent.md (fixed stub: "Read role.md and identity.md, act on trigger")
  - mcp.json (dynamically generated per activation)

- **Push-Dispatch Event Loop**: When events land on subscribed channels, the system spawns
  a fresh Claude Code instance (one-shot activation) with that worker's MCP endpoint.

- **channel_members Tool**: Read-only MCP tool that lists workers subscribed to a channel,
  enabling Workers to query org membership without side effects.

- **Simplified Grant Model**: Grants are now strictly (workerID, toolName) pairs. Removed
  enforcement/scope entirely—a grant IS the permission, and the agent is trusted to comply.

- **Humanized Demos**: Getting-started and newsroom demos now use prompt-based CLIs with
  natural-language orchestration instead of raw API calls.

Major components:
- domain/: Core types (Role, Worker, Position, Channel, Grant, Event)
- store/sqlite: GORM-driven SQLite storage with AutoMigrate
- tools/: 13 MCP tools (create_role, hire_worker, etc.) + spawner
- server/: HTTP endpoints + MCP handler + jsonapi.org serialization
- cmd/helix-org: CLI with serve, bootstrap, prompt subcommands
- broadcast/dispatch: Event bus for push-based activation
- demos/: Two runnable examples (getting-started, newsroom editorial team)

Design principles embedded:
- Prefer data/text over code (config in Role markdown, not Go)
- Keep core generic (tools define their own scope and schemas)
- No workflow in code (agents orchestrate via prompts, not implicit chains)
- Write smallest thing that works (no speculative abstractions)

All code tested end-to-end: bootstrap → role create → worker hire → event publish →
worker activation with MCP → live-edit role → behaviour change on next activation.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
A minimal three-Worker demo that produces an opinionated MLOps
newsletter with a fresh angle each issue. Shows the prompt-driven
philosophy at its tightest:

- Only files on disk are 3 short role markdown files (~25 lines each)
- A single helix-org prompt call creates the roles, positions,
  channels, and hires the team
- Editor picks the angle, researcher hunts for matching news,
  journalist crafts the narrative
- Re-run with a different brief and the same team produces a
  completely different angle on the same broad subject

Tested end-to-end: two briefs produced two distinct angles
("platform team tax" vs "feature stores as MLOps' open secret
graveyard") with named subjects (Stitch Fix, Chime, Modal Labs,
Tecton) — proving the angle truly varies per brief.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Adds a new \`helix-org tail [glob...]\` CLI plus the \`GET /tail\`
endpoint it talks to. Lets the human watch the cascade of a running
team in real time without curl + jq incantations.

- Defaults to '*' (all channels). Globs use Go's path.Match:
  'c-*', 'c-news?', 'c-newsletter'. Multiple globs unioned.
- Long-polls (default 30s wait, configurable via --wait).
- Pretty output: HH:MM:SS  channel  source  body, with subsequent
  body lines indented under the body column. ANSI colour when
  stdout is a TTY; --no-color to disable.
- New broadcast.Broadcaster.SubscribeAll for wildcard wakes, so
  channels created mid-tail (e.g. by an editor's hire trigger)
  also wake the tail loop.
- New store.Events.ListSince(channelIDs, since, limit) returning
  oldest-first events strictly newer than the named event.
- URL surface designed to extend: bare globs are channel IDs
  today; future namespace prefixes (channel:c-*, activation:w-*)
  can be added without breaking compatibility.

Tested: store + broadcaster unit tests, server endpoint test
covering glob match, since cursor, and default match. Live-tested
against the running mlops-newsletter demo (history backfill, live
event arrival via long-poll, multi-glob union).

Newsletter README updated to use \`helix-org tail\` instead of curl.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Both demos previously asked the user to either tail per-Worker
activation.log files or curl the channel events endpoint. Replace
both with helix-org tail:

- newsroom: drop "tile seven terminals" instruction in favour of one
  tail window (default '*' = all channels). Recommend per-channel
  globs (tail c-bullpen, tail c-recruiting) for narrower focus.
  "What to point at during the demo" callouts now name the exact
  tail command to run.
- getting-started: replace tail -f activation.log + curl-and-jq
  round-trip check with helix-org tail. Keep activation.log as a
  parenthetical for debugging the worker's internal claude stream.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…h Transport extensibility

## Abstraction Simplification

- **Channel → Stream**: Unified the Channel concept into Stream, removing redundant abstraction. Streams now hold the single named pub/sub channel.
- **Stream → Subscription**: Renamed the worker-channel edge from Stream to Subscription using a composite key (worker_id, stream_id). This eliminates synthetic stream IDs and clarifies the semantic: a subscription is a worker's interest in a stream, not the stream itself.
- **Transport Field**: Added optional Transport field to Stream to support future integrations (Slack, email, webhook, RSS, tick). Defaults to "local" (in-process pub/sub). Designed to be extensible without core changes.

## Architecture Changes

### Domain Layer (domain/)
- Added `transport.go`: Transport struct with Kind (enum) and optional Config (json.RawMessage)
- Added `subscription.go`: Subscription struct with WorkerID, StreamID, CreatedAt (composite key, no synthetic ID)
- Updated `stream.go`: Renamed from Channel; now holds ID, Name, Description, CreatedBy, CreatedAt, Transport
- Updated `event.go`: Changed ChannelID field to StreamID
- Updated `id.go`: Removed ChannelID type

### Store Layer (store/sqlite/)
- Added `subscription.go`: Subscriptions repository with Create, Delete, Find, ListForWorker, ListForStream
- Updated `stream.go`: Renamed from channel.go; added TransportKind and TransportConfig columns
- Updated `event.go`: Changed column references from channel_id to stream_id; JOINs on subscriptions instead of streams
- Updated `streams_and_events_test.go`: Renamed from feed_and_channels_test.go; comprehensive test coverage for new abstractions
- Updated `store.go`: Renamed Channels → Streams; replaced Streams → Subscriptions

### Broadcast & Dispatch (broadcast/, dispatch/)
- Renamed all channelID references to streamID throughout
- Updated method signatures to use StreamID instead of ChannelID

### Tools Layer (tools/)
- Added `create_stream.go`: New tool taking optional transport argument
- Added `read_events.go`: Replaces read_feed.go; queries subscriptions then long-polls streams
- Added `read_*.go` (streams, grants, positions, roles, workers): MCP tools replacing HTTP read endpoints
- Updated `subscribe.go`, `unsubscribe.go`, `publish.go`: Use streamId and Subscriptions API
- Renamed `channel_members.go` → `stream_members.go`; calls Subscriptions.ListForStream
- Updated `spawner.go`: Trigger struct uses StreamID; updated event notification text

### Server & HTTP (server/)
- Moved all read endpoints to MCP tools; `/workers/{id}/mcp` now handles mutations only
- Updated `tail.go`: Long-poll attributes renamed to streamID; calls store.Streams.List
- Simplified `server.go`: Only MCP mutation handler and tail endpoint remain
- Deleted: bootstrap.go, channels.go, environment.go, feed.go, grants.go, positions.go, roles.go, workers.go

### Bootstrap & CLI (bootstrap/, cmd/)
- Updated default tool grants to reference new tool names
- Updated vocabulary throughout: c- prefix → s- prefix for stream IDs

### Demos (demos/)
- Updated all demo READMEs and role definitions from channel to stream vocabulary
- Added `mlops-newsletter/hire.txt`: Example hire prompt

## Benefits

1. **Clearer semantics**: Stream is what it says (a named pub/sub channel), Subscription is the worker's interest in it
2. **Extensibility**: Transport field allows future integrations without core changes
3. **Reduced complexity**: No synthetic stream IDs, no redundant Feed/Channel/Stream layers
4. **MCP-first design**: All mutations now routed through MCP, read endpoints are MCP tools
5. **Smaller server surface**: HTTP endpoints only for authentication + tail streaming

## Testing

All 57 test cases pass with race detector enabled across all packages:
- domain: Subscription and Transport validation
- store/sqlite: Subscriptions repository operations, stream queries with JOINs
- broadcast: Pub/sub with streamID
- server: Tail long-poll with stream glob matching
- tools: All 13 MCP tools with varied schemas

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `/tail` HTTP long-poll endpoint and `helix-org tail/prompt/client`
CLI subcommands are now unnecessary: all human observation and
orchestration flows through MCP via `claude` sessions directly.

**Removals:**
- Delete server/tail.go (HTTP long-poll handler)
- Delete server/jsonapi.go (only used by tail)
- Delete cmd/helix-org/tail.go (CLI client)
- Delete cmd/helix-org/prompt.go (spawner stub)
- Delete cmd/helix-org/client.go (envelope types)
- Remove mux route for GET /tail
- Remove Broadcaster.SubscribeAll/UnsubscribeAll (dead after tail removal)
- Simplify serve/bootstrap doc: "one HTTP endpoint: /workers/{id}/mcp"

**Updates:**
- demos/getting-started/README.md: replace helix-org tail with claude
  watcher prompt using subscribe + read_events(wait=60)
- demos/mlops-newsletter/README.md: same pattern
- demos/newsroom/README.md: same pattern, plus add recruiter role
  "On hire" trigger to handle stream race condition
- CLAUDE.md: clarify that human observation uses MCP (no /tail endpoint)
- tools/publish.go: comment fix

**Fixes:**
- cmd/helix-org/bootstrap.go: make installClaudeMCPEntry idempotent
  by removing stale entry before adding (re-running bootstrap between
  demo wipes no longer fails)
- demos/newsroom/roles/recruiter.md: add "On hire" subscribe + retry
  guidance matching researcher/journalist (Renée was getting hired
  before Maya's hire activation created s-recruiting)

All three demos tested end-to-end: bootstrap → scaffold → hire cascade
→ event publishing → role live-edit → behavior change confirmed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add helix-org chat — an interactive claude session pointed at a Worker's
MCP endpoint (default w-owner). Supports --new, --resume, --worker flags,
and session persistence via claude's per-cwd store with --continue.

Update all three demos to show only the interactive chat flow:

- getting-started: condensed from two-terminal to one, removed
  --install-claude-mcp, Bootstrap → chat → type prompts as w-owner
- mlops-newsletter: removed separate watcher terminal, team setup and
  brief publishing now happen inline in chat
- newsroom: removed multi-terminal watcher, all interaction happens
  in the bootstrap + chat session

Demos now focus on the actual user experience (typing into a chat)
which mirrors a real UI-based server. Removed background concepts,
multi-terminal complexity, and one-shot (-p) mode from demos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
helix-org chat unconditionally passed --continue, so the first run in a
fresh directory exited with "No conversation found to continue" before
the user could type anything. Probe ~/.claude/projects/<encoded-cwd>/
for any .jsonl session file and only pass --continue when one exists;
otherwise let claude start fresh, which still seeds a session for the
next run to resume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace claude's --continue flag with --resume <sessionId>, looked up
by reading the most-recently-modified .jsonl in the cwd's session
store and parsing the sessionId from its first line.

--continue rejects sessions whose log ended on certain non-user events
(e.g. an agent-name marker from a prior interrupted exit), failing
with "No conversation found to continue" even when the session is
fine to resume by ID. This blocked re-entry into chat in the demo
directories whenever a previous chat had exited mid-flight.

If no prior session exists, claude is launched without a resume flag
and starts fresh — matching the desired first-run behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds two new MCP tools for worker-to-worker communication:

- dm: High-level tool bundling create_stream + invite_workers + publish
  into a single call. Creates per-pair streams with deterministic naming
  (s-dm-<sortedIDs>) so conversations reuse the same stream regardless of
  direction. Complements lower-level streaming tools with a high-level,
  autonomously-discoverable entry point.

- invite_workers: Subscribes one or more workers to a stream in a single call.
  Idempotent — re-inviting already-subscribed workers is a no-op. Enables
  batch subscription workflows without manual loop.

Both tools are granted to the owner during bootstrap and tested end-to-end
(dm stream reuse across directions, idempotency, self-DM rejection, unknown
worker rejection).

Updated demo: newsroom step 6 now uses dm instead of manual 4-step workflow,
and updated comments in publish/subscribe to point to dm as the high-level
entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces on-disk activation.log/jsonl files with a per-Worker activation
Stream. Assistant text, tool calls, tool results, and lifecycle markers
are now Events on s-activations-<workerID> — same primitive as every
other read in the system.

- hire_worker creates the activation Stream at hire time and subscribes
  the hiring Worker. The new Worker themselves is intentionally NOT
  subscribed (would loop the dispatcher otherwise).
- Spawner publishes one Event per atomic message segment (assistant
  text, tool_use, tool_result, system init, run result), bracketed by
  synthetic '=== activation: <trigger> ===' and '=== exit: <err> ==='
  markers. Append + Notify only — the dispatcher is skipped so per-
  message events can't re-trigger subscribed AI Workers.
- worker_log tool bundles subscribe + read_events scoped to one
  Worker's activation Stream. Mirrors the dm pattern: a friendly
  shortcut the agent can reach for from a 'show me what w-X is doing'
  instruction without knowing the stream-naming convention.

Persistence between activation runs is left to the Role: if a Worker
needs cross-run memory, the Role tells it to write to history.md and
read it back on the next activation. No system feature added.

Demos updated to showcase the new affordances:
- getting-started: step 3 uses worker_log to confirm hire activation
  finished, eliminating the cross-terminal log-watching requirement.
- mlops-newsletter: step 4 adds a peek-inside tip using worker_log.
- newsroom: adds a 'Watch a Worker work' step parallel to the dm
  step, plus a 'What to point at' bullet for fact-checker blocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds inbound webhook support to helix-org Streams. Each Stream can declare
transport.kind="webhook"; POST requests to /webhooks/<streamID> append the
request body as an Event, trigger the dispatcher to wake subscribed Workers,
and notify long-poll observers.

Key changes:
- domain/transport.go: add TransportWebhook kind with docstring
- server/server.go: add Dispatcher interface, update New() signature
- server/webhook.go: HTTP POST handler for /webhooks/{streamID}
- server/webhook_test.go: 9 test functions covering edge cases and concurrency
  * happy path, missing stream, wrong transport, empty body
  * size limits, nil broadcaster/dispatcher, UTF-8 handling
  * 25 concurrent POSTs, stream isolation
  * race-detector clean with -count=20

Also fixes critical :memory: SQLite concurrency bug:
- store/sqlite/sqlite.go: pin MaxOpenConns(1) for in-memory databases
- Root cause: each connection gets its own private :memory: DB
- Impact: concurrent HTTP tests now see consistent state

New demo:
- demos/webhook/README.md: 5-step specification (hire secretary, POST payload, read back)
- demos/webhook/roles/secretary.md: secretary subscribes to s-inbox, summarizes
  incoming payloads, DMs summaries to owner

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the webhook transport so a Stream can be configured to POST
each appended Event to an external URL. A Stream can now be inbound-
only (current behaviour, no config), outbound-only (config sets
outbound_url), or both at once — the dispatcher fires emit on every
append regardless of origin (webhook handler, publish tool, dm tool).

Key changes:
- domain/transport.go: WebhookConfig type with OutboundURL field;
  Validate now parses webhook config and rejects non-http(s) URLs,
  relative URLs, and empty hosts before stream creation
- dispatch/dispatcher.go: emitOutbound runs on every Dispatch, looks
  up the Stream's transport, and if outbound_url is set fires an
  async POST with X-Helix-Stream and X-Helix-Event headers; bounded
  by 5s timeout so slow targets don't stall publishes
- domain/transport_test.go: 14 cases covering Validate happy paths
  and rejection paths, plus WebhookConfig parse round-trip
- dispatch/dispatcher_test.go: 12 tests covering emit happy path,
  inbound-only no-emit, local-no-emit, missing stream, 4xx/5xx
  tolerance, unreachable host, slow target timeout, 25 concurrent
  emits, binary payload round-trip, malformed stored config, store
  lookup errors, and content-type/path preservation
- server/webhook_test.go: TestWebhookBridgesInboundToOutbound wires
  the real dispatcher end-to-end and proves an external POST to
  /webhooks/<streamID> bridges to an outbound POST when the same
  stream has both directions configured

Demo narrative updated: secretary now subscribes to s-inbox, DMs the
owner with the summary, and publishes the summary to s-outbox which
is configured with outbound_url. A 4-terminal flow with a local nc
catcher shows the full inbound -> summarise -> outbound bridge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds domain.Message — a transport-agnostic envelope (From, To, Subject,
Body, ThreadID, InReplyTo, MessageID, Attachments, Extra) — and migrates
every event-producing path to encode it as JSON in Event.Body. There is
one storage shape going forward; future transports (email, Slack,
queues, feeds) translate at their boundary, Workers see the same
structure regardless of source.

Identity convention: From/To carry transport-native identifiers
verbatim (WorkerIDs when known, alice@x.com / U0123 / +15551234 / etc.
otherwise — no prefixes). Empty From means "no human originator" for
data feeds and triggers.

Code changes:
- domain/message.go: Message + Attachment types, Encode/Decode helpers,
  Event.Message() parser, NewMessageEvent constructor
- tools/dm.go: produces Message{From: caller, To: [recipient], Body}
- tools/publish.go: accepts optional to/subject/threadId/inReplyTo/
  messageId/bodyContentType/attachments args; defaults From=caller
- server/webhook.go: wraps inbound POST bodies into Message{Body: raw}
- tools/spawner.go: activation log entries wrapped as Message{From:
  workerID, Body: line}; Trigger gains a Message field
- dispatch/dispatcher.go: parses Event.Body once, passes parsed
  Message and visible Body text to the spawner
- tools/read_events.go: surfaces Message.Body as `body` (visible text)
  and the full envelope as `message` — Roles needing structure read
  the latter; existing role prompts that read `.body` continue to work

Tests updated to use Event.Message() instead of comparing raw Body
strings; full make check passes (lint clean, race detector clean).

Demos verified end-to-end after the refactor:
- getting-started: hire echo worker, publish "hello", echo replies,
  live-edit role, "loud: HELLO" — all four steps green
- webhook: secretary summarises inbound POST, DMs owner, publishes to
  s-outbox, outbound emitter POSTs Message JSON to nc:9000 catcher
  (catcher now sees structured envelope, not raw text — README
  updated to describe this)
- mlops-newsletter: full editor → researcher → journalist → editor
  cascade produces a complete newsletter on s-newsletter
- newsroom: 7 roles, 2 positions, 2 hires (Maya + Renée), all
  activations clean — message machinery validated without running
  the real-PR cascade

Design doc at design/messages.md captures the convention, the per-
transport mapping table for future transports, and open questions
to resolve as new transports ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements the email transport, the operational-config infrastructure
it sits on, and a runnable customer-service demo (Sam) that emails
land at and reply through.

Verified end-to-end: simulated inbound POST → +sam alias routed →
Sam's claude activation → reply published to s-support → outbound
emit POSTed to Postmark's /email API → real email delivered to
phil@winder.ai. ~22s wall-clock end-to-end on a cold activation.

Operational config (design/config.md):
- New configs table (key/value/audit), store.Configs interface,
  sqlite impl. Auto-migrated alongside the rest.
- config.Registry: subsystems Register a Spec (type, default,
  required, secret paths, description). Reads/writes go through it
  so the CLI's view matches what consumers actually consume.
- helix-org config CLI: set/get/list/delete. Opens the SQLite file
  directly (same path as bootstrap), so config writes commit and
  the running server picks them up on its next read — live updates
  without restart, and without an LLM ever touching the values.
  Secrets redacted by default; --reveal-secrets opts in.
- Strict separation: org-graph mutations stay on MCP; operational
  config (transport creds, future model selection, etc.) is
  CLI-only. Same SQLite file, two access paths, two threat models.

Email transport (transports/postmark):
- domain.TransportEmail kind + EmailConfig{Alias} stream config.
  Validate enforces lowercase alphanumeric/dash/underscore aliases
  so they compose safely into <hash>+<alias>@... or <alias>@Domain.
- Inbound HTTP handler at /email/postmark: parses Postmark's JSON,
  extracts the +alias suffix from OriginalRecipient, finds the
  matching Stream by alias, builds a domain.Message envelope (From,
  To, Subject, Body, MessageID, InReplyTo, ThreadID from headers,
  Attachment metadata), appends the event, fires the dispatcher.
- Outbound emitter: when a Worker publishes to an email Stream, the
  dispatcher invokes the transport's Emit, which composes a
  Postmark /email POST (From=server-config, To from Message.To,
  optional Reply-To at <hash>+<alias>@... for threading,
  In-Reply-To/References headers when set).
- Server-level config (token, inbound, from, optional
  disable_reply_to) lives in transport.postmark; per-stream
  config is just {"alias":"sam"}. The transport joins the two at
  runtime, so rotating creds is one CLI call with no restart.
- disable_reply_to flag: workaround for Postmark's pending-approval
  same-domain restriction (Reply-To at inbound.postmarkapp.com is
  treated as a cross-domain recipient and blocks the send). With
  it on, outbound works but customer replies won't loop back into
  helix until the account is approved — documented in the demo
  README as the path to closing the loop.

Dispatcher loop guard:
- Skip outbound emit when event.Source == "" (system-emitted, i.e.
  inbound from this transport's own webhook). Without this, a
  bidirectional Stream (one alias, both inbound and outbound) would
  echo every inbound message straight back out to itself.
  Worker-published events (Source != "") still emit normally.
- Replaced TestWebhookBridgesInboundToOutbound with
  TestWebhookInboundDoesNotEcho to lock the new behaviour in.

Server:
- Server.Handler now takes optional Routes so transports can mount
  their own inbound endpoints without server.go importing them. The
  email transport's /email/postmark gets mounted from cmd/helix-org/serve.go.

Demo (demos/email):
- README.md walks through the whole flow: signup → server token →
  Sender Signature → inbound hash → cloudflared/ngrok tunnel →
  Postmark InboundHookUrl → helix-org config set transport.postmark
  → bootstrap → hire Sam → send a real email. Includes the
  pending-approval workaround and the path to closing the
  customer-reply loop once approved.
- roles/customer-service.md: Sam reads inbound, drafts a 2–4
  sentence reply, escalates rather than fabricates, signs off
  '— Sam' on its own line.
- workers/sam.md: identity stub (real first name, no brand voice,
  knows when he doesn't know).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates the email demo to show two workers — customer service
(Sam, alias=sam) and engineering (Lee, alias=engineer) — handling
a customer query that requires escalation. Every leg of the
four-hop cascade goes through Postmark; both Streams are
bidirectional; threading via Message-Id stitches the whole thing
into one logical conversation.

Verified e2e in ~2:15 wall-clock:

  customer → Sam   (Postmark inbound  → s-support)
  Sam → Lee        (Postmark send + inbound → s-engineer)
  Lee → Sam        (Postmark send + inbound → s-support, [eng] prefix)
  Sam → customer   (Postmark send → real inbox)

Three Postmark sends, all returned status=200; same ThreadID flowed
through every event.

Changes:
- demos/email/roles/customer-service.md: Sam now branches on
  Subject. `[eng]` prefix means Lee replied → walk s-support
  history by ThreadID to find the customer's original query, then
  reply to that customer with a paraphrased version of Lee's
  answer. Otherwise it's a customer query → answer directly when
  simple, forward to <hash>+engineer@inbound.postmarkapp.com when
  technical. ThreadID preservation is critical for the lookup.
- demos/email/roles/engineer.md (new): Lee subscribes to
  s-engineer, drafts 3-6 sentence technical answers, replies to
  Sam at the +sam alias with `[eng] Re:` subject prefix and
  preserved ThreadID.
- demos/email/workers/lee.md (new): identity stub.
- demos/email/README.md: rewritten "Run the demo" section for the
  two-worker flow. Adds an explicit `<INBOUND_HASH>` sed
  substitution step (workers know each other's addresses via
  role text). Drops the disable_reply_to workaround now that the
  Postmark account is approved. New "What this shows" bullets
  call out workers-as-email-participants and ThreadID-as-spine.
- demos/email/demo.cast: re-recorded asciicast of the four-hop
  cascade.

The mp4 (demos/email/demo.mp4) is regenerated locally but stays
gitignored, same convention as demos/getting-started/demo.mp4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the activation prompt only carried Body. The Worker had to
call read_events to learn Subject, From, ThreadID, Extra — exactly the
round-trip that caused the docs-engineer to misroute issue #3 to PR #2
during the github demo's E2E run.

renderTrigger now formats every populated envelope field into the
prompt, omitting empties for cleanliness. The Trigger.Body field is
dropped; callers pass the full Message instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub POSTs to a single /github/webhook endpoint; the transport
HMAC-verifies via X-Hub-Signature-256 against the installation's
webhook_secret, then fans the delivery out to every Stream whose
Config.Repo matches repository.full_name and whose Config.Events
whitelist contains the X-GitHub-Event header value.

Inbound only — acting on a repo (label, comment, review, open PR) is
the Worker's job via gh in its Environment. publish on a github stream
returns a loud error rather than silently no-op'ing.

The Message envelope is mapped from the upstream payload verbatim:
Subject = issue/PR title, Body = body, ThreadID = "#<number>",
MessageID = X-GitHub-Delivery, From = sender.login, Extra = the full
payload with one synthetic top-level "event" key injected from the
X-GitHub-Event header so Workers can branch on event type from Extra
alone.

Per-stream config is just routing identity (repo, events). Provider
credentials (token, webhook_secret) live in server-level config under
transport.github with both fields registered as Secrets so config get
redacts them. Regression tests pin both names against silent leaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walkthrough demo of the doc-engineer role: spin up a real cloudflared
tunnel, register the webhook, hire the Worker, then exercise the
issues + pull_request + pull_request_review + issue_comment paths
against a live GitHub repo. README narrates each step; demo.cast is
the asciinema recording.

Design doc covers the identity model (no machine user; gh auth token
gives the engineer the operator's own identity for now), the inbound-
only decision, the message envelope mapping, and the operational
config / setup-via-chat flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move Role.Content and Worker.IdentityContent from disk-based markdown files
(role.md, identity.md) into the SQLite domain, enabling future evolution to
remote workspaces and eliminating hardcoded filename coupling.

## Key changes

- Domain: Worker interface now exposes IdentityContent() string method; both
  HumanWorker and AIWorker carry immutable identity field. Constructor signatures
  updated to accept identity content at hire time.
- Store: Added Update(ctx, worker) method to Workers interface, implemented via
  GORM with identity_content column in worker table.
- Tools:
  - update_role: Simplified to single DB write (removed 50-line fanOut loop).
  - update_identity: New tool, mirrors update_role's shape.
  - hire_worker: Creates DB records only; no env files at hire time.
  - spawner: Added projectEnv() function that lazily writes role.md, identity.md,
    agent.md to env at activation time, reading from DB.
- Bootstrap: Seed owner Worker with starter identity text; grant UpdateIdentityName.
- UI: Added /ui/org org-chart master-detail view. handleOrgIdentitySet() now
  calls Workers.Update() instead of WriteFile(). Removed disk path tracking.
- Tests: Updated 12+ call sites with identity parameter; rewrote
  TestUpdateRoleFanOut as TestProjectEnvWritesCanonicalState to verify
  lazy-projection contract.

## Why

Hardcoded filenames across hire_worker, tools, spawner, and UI meant the system
could not evolve to support remote workspaces or other workspace configurations.
Making the DB the source of truth and performing projection at activation time
(not at hire time) lets future work extend to remote/ephemeral environments
without changing tool or bootstrap logic.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…ahead

Add MCP prompts — server-defined slash commands gated by tool grants:

- New prompts package: Prompt interface, Registry (mirrors tools.Registry),
  and builtins (Role and Help).
- /help: Self-introspecting command that walks the registry at render time
  and produces a markdown list of every other prompt. Adding a new prompt
  automatically lights it up in /help without touching this file.
- /role: Drafts a new Role from a title hint, expands to full interview
  template, saves via create_role, then offers edits or chains to hire_worker.
- Server-side expansion in chat bridge: SendHandler intercepts inputs
  starting with /,expands them from template before sending to claude.
  User sees original input in their bubble.
- Chat typeahead: CommandsHandler (POST /ui/chat/commands) renders
  matching prompts as HTML buttons on every keyup. Clicking fills the
  textarea and focuses it.
- Enum schema constraints: WorkerKind and TransportKind now surface as
  enums in JSON Schema so MCP clients see valid values in tool input
  autocomplete.
- Self-documenting validation: WorkerKind.Validate() formats errors as
  'unknown worker kind "foo" (valid: "human", "ai")' so clients can
  self-correct without reading source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… polling, and tool visibility

Major changes:

- **Prevent cascading AI-worker activations**: Added SourceKind classifier (human/ai) to Trigger;
  workers now deprioritize or skip AI-origin events per agent.md discipline rules. Dispatcher
  skips self-reactivation on publish. Tests pin self-skip and source_kind behavior.

- **Fix SSE newline rendering**: Split markdown fragments across multiple `data:` lines (SSE
  spec compliant) instead of collapsing newlines. Browser's EventSource rejoins with \n,
  preserving fenced code blocks and list formatting.

- **Add markdown rendering**: Integrated goldmark for safe HTML rendering of Role/Activity text.
  Added .md CSS class for styling (lists, code, links, headers, blockquotes). Goldmark runs
  in safe mode; raw HTML is omitted (not escaped). Tests verify bold/lists/code/headings render
  and <script> tags are dropped.

- **Real-time polling UI**: Added htmx polling (every 5s) to org chart, streams list, and
  events feed. Fixed htmx attribute inheritance breaking child click handlers by adding
  hx-disinherit="*" on poll parents. Implemented unified all-streams firehose when no stream
  selected.

- **Tool grant visibility**: Org detail now shows each Worker's granted tools as alphabetically-
  sorted chip badges. Schema exposes MCP tool names; UI surfaces them without requiring a
  separate tools query.

- **System prompt templates**: Moved agent.md and owner_role.md to embedded templates so
  content can be edited via /ui/org and doesn't require code changes. Agent.md teaches AI
  workers that human constraints don't apply and defaults to action. Owner role teaches
  delegation, polling pattern, and stream subscription during hiring.

- **Hiring playbook refinement**: Updated role template to instruct on stream provisioning:
  list_streams → create if missing → subscribe. Emphasized "Worker without streams is
  half-hired."

- **Title selection priority**: Sessions now track separate ai-title events and prefer them
  over user input for recents display (custom > ai-generated > fallback).

- **Model/effort defaults**: Changed claude.model default to "sonnet" for cost predictability;
  added claude.effort default "low" to minimize extended-thinking budget. Both configurable
  via registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… docs

- Update 'make run' to automatically invoke 'helix-org serve' with sensible defaults (./envs, ./helix-org.db, :8080) rather than bare 'go run'
- Enhance 'make clean' to kill running servers and remove local state (DB, envs) in one command
- Improve CLAUDE.md to document these defaults and explain when/why to use each target
- Clarify that ad-hoc 'go' commands should be avoided in favor of make targets to ensure consistent build/test environment

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
The dispatcher now coalesces events that arrive while an activation is
running, passing them to the Spawner as a single batched []Trigger
instead of spawning N separate claude processes. This collapses webhook
cascades (e.g. five GitHub events from a worker's own action against a
shared auth token) into one follow-up activation.

Implementation:
- Spawner signature: trigger -> []Trigger
- Dispatcher: per-worker queue (pending slice + running flag) replaces
  per-worker mutex. enqueue() appends and starts runner if needed;
  run() drains queue in a loop until empty, calling spawner once per
  drain with the accumulated batch.
- buildPrompt() renders multiple triggers as [1/N], [2/N], etc. when
  there's more than one, so agents see them as a numbered list.
- New test proves coalescing: block first activation, publish 3 more
  events, release -> expect [e-1] then [e-2, e-3, e-4], not 5 separate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The github-engineer demo includes:
- Full README with prerequisites, setup steps, and teardown instructions
- Runnable end-to-end example of a software engineer worker on GitHub
- Role documentation for handling task lifecycle, review feedback, and board state

Updates to prerequisites:
- Document required gh token scopes (project, read:project)
- Document port availability requirement for helix-org server
- Add instructions for creating and linking a GitHub Project v2 board

Updates to software-engineer role:
- Add dm tool to MCP surface (was: subscribe, read_events)
- Add constraint: escalate setup-level problems to owner via DM instead of failing silently
  (covers: gh auth issues, missing board, repo unreachable, missing tools, discovery failure)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ession reuse

End-to-end working chat + dispatcher → Helix zed_external desktops with
the org-graph MCP attached. Each Worker (human or AI) gets its own
project + agent app + git repo at hire time; new activations reuse the
same long-lived chat session so follow-ups complete in seconds instead
of paying a 3-minute cold-start every turn.

Key fixes that came out of debugging against app.helix.ml:

- HelixProjectApplier creates a Helix-internal git repo, seeds it with
  a README so `main` exists, creates the `helix-specs` branch, and
  pushes role/identity to `workers/<id>/.context/` on that branch.
  The desktop's startup script then materialises the helix-specs
  worktree at `~/work/helix-specs/` automatically.
- Project-apply does NOT auto-create a repo; without one the desktop's
  startup script bails with "No repositories were cloned successfully"
  and Zed never launches.
- StartChatRequest now sends `app_id` so `session.ParentApp` is set —
  Helix's external MCP proxy bails with "session has no associated
  agent" otherwise, and Zed never sees the helix MCP.
- StartChatRequest sends `organization_id` (Helix doesn't auto-populate
  it from project_id; without it desktop quota falls back to the
  personal-org limit of 2).
- Streaming-aware StartChatWithStatus: reads the SSE response, returns
  the session ID + a flag indicating whether the WS-not-ready race
  fired. Detached upstream context so the request survives past the
  caller's request ctx closing.
- warmupAndRetry (chat bridge) and warmupSession (spawner) re-POST the
  same prompt every 8–20s until the dispatch lands. Helix's
  waitForExternalAgentReady checks connections globally, so the wait
  passes immediately when other users have desktops up; the per-session
  sendCommand then fails fast and Helix marks the interaction error
  (auto-wake won't recover state=error). The retry pattern absorbs
  the race client-side.
- Spawner reuses worker.HelixSessionID() across activations. Each
  fresh session spawns a fresh container; reuse keeps it warm.
- Owner-role hiring playbook updated: hire_worker MUST include
  `grants` matching the Role's Tools section. The MCP tool list is
  frozen by Helix's external-MCP-proxy cache for the lifetime of the
  first session, so granting later means the Worker can't see the
  tools until session restart.
- Runtime switched from claude_code → zed_agent. claude_code talks
  directly to Anthropic and needs an API key wired into the container
  (which we don't); zed_agent routes inference back through Helix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n agent

Live role-edit (update_role) now propagates to running Workers without
requiring a session restart:

- HelixProjectApplier.Ensure no longer early-returns on the fast path
  before pushing files. The expensive ApplyProject / CreateGitRepo /
  AttachRepo steps still skip when the project exists, but
  agent.md / role.md / identity.md are re-pushed to the helix-specs
  branch on every Ensure call. CreateBranch and PutFile are idempotent
  and cheap, so the cost is two HTTP calls per activation.
- Spawner activation prompt (helixSpecsMandate) now ALWAYS runs
  `git pull --ff-only origin helix-specs` at the start of every
  activation (fall-through to `git worktree add` only when the worktree
  is missing). Without this, the agent reads the worktree's stale
  on-disk copy and the new role text never takes effect.
- Activation prompt now also reads `.context/agent.md` first as the
  org-wide entrypoint, then role.md, then identity.md.
- AgentMD threaded through HelixSpawnerConfig and HelixProjectApplier
  so the spawner+chat-backend both seed the org policy on apply.

Validated end-to-end via demos/getting-started:
  publish hello → echo: hello (initial role)
  update_role r-echo → "loud: <BODY UPPERCASED>"
  publish hello → loud: HELLO ← live-edit takes effect

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
philwinder and others added 22 commits May 10, 2026 21:15
…way and embedded Spawner

Mounts helix-org as a per-user, alpha-gated feature inside Helix. Adds
an `alpha_features` column on users, a `requireFeature` middleware, a
sidebar entry for flagged users, an agent picker at /ui/alpha-agents,
and the helix-org htmx surface at /ui/. helix-org's MCP server is
exposed through Helix's MCP gateway at /api/v1/mcp/helix-org/workers/
{id}/mcp; the gateway extracts the worker id and forwards to the
in-process helix-org handler, so picked agents authenticate via the
calling user's api_key (baked into the agent config's MCP headers)
rather than reaching a separately auth-gated endpoint.

The new embedded Spawner activates AI Workers by opening a fresh
helix_agent chat session against a lazily-provisioned per-Worker clone
of the picked owner agent, with its MCP entry rewritten to scope at
/workers/<id>/mcp. Worker prompts include role.md + identity.md + the
agent policy; transcripts publish to s-activations-<workerID>. No Zed
sandbox per Worker — every activation is one LLM call's worth of
latency.

Chat bridge fixes that fell out of e2e testing: /ui/chat/send now runs
b.send in a detached 10-min context so htmx doesn't 500 on long
agent runs (the WS subscriber pushes the transcript regardless); the
follow-up path uses /sessions/chat with SessionID set (the
/sessions/{id}/messages queue endpoint helix-org's standalone build
targets doesn't exist in this Helix); and the startChat REST call uses
a dedicated long-timeout client to survive multi-step agent runs.

hire_worker accepts `grants` as a JSON-encoded string in addition to
an inline array — Sonnet sometimes wraps nested arrays this way.

Verified e2e against the rewritten getting-started demo: stream/role/
position created, w-echo hired and activated on hire, owner publish
triggers w-echo via the dispatcher, live role edit takes effect on the
next activation (echo: hello → loud: HELLO). All 28 helix-org MCP
tools surface to the picked agent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nd-to-end

Replaces the in-process spawner from the previous commit with helix-org's
production helix.Spawner (per-Worker Helix project + git repo + Zed
sandbox), wired so the embedded SaaS alpha can drive it without
storing tokens at rest.

Worker activations now run as the hiring user, not the service account:
hire_worker reads `X-Helix-Org-User-Id` (forwarded by the MCP gateway
backend from the authenticated Helix user) off the request context and
persists it on WorkerRuntimeState. The Spawner's new `BearerForUser`
callback mints a fresh api_key per activation by user-id lookup —
implemented in the embedded host as `resolveUserHelixAPIKey`. Each
Worker's chat session, project apply, MCP attach and transcript
subscribe therefore happen as the user who hired the Worker (their
Claude subscription, their desktop quota, their audit trail). No
bearer tokens persisted in helix-org's domain at any point.

Workers run Claude Code on subscription credentials by default:
SpawnerConfig grows Runtime + Credentials fields, ProjectApplier
honours them (claude_code + subscription means no Provider/Model needed
on the per-Worker app). Helix's `addUserAPITokenToAgent` learns to set
CLAUDE_CODE_OAUTH_TOKEN + Anthropic-direct ANTHROPIC_BASE_URL on the
container env when the parent app uses subscription credentials. Two
session-handler bugs surfaced and were fixed along the way:
`ValidateAssistantModelConfig` and the codeAgentConfig/agentName lookup
in zed_config_handlers both only honoured spec-task-driven sessions —
they now also resolve via `session.ParentApp` so any zed_external
session opened via /sessions/chat against a code_agent-runtime app
ships the right runtime, not "zed-agent".

Live role/identity edits propagate to running Workers: the embedded
host wires `agenthelix.NewWorkspace` as `deps.Workspace`, so update_role
pushes the new role.md to the per-Worker repo on helix-specs. The
Workspace also clears the Worker's persisted SessionID on role.md /
identity.md publishes, forcing the next activation to open a fresh
Claude Code session that re-reads role.md instead of inheriting the
prior turn's cached content.

Tool argument tolerance: hire_worker.grants, read_events.{limit,wait},
read_streams.limit, and worker_log.{limit,wait} now accept their
declared ints either as JSON numbers or as JSON strings — Claude Code
intermittently emits typed params as strings when the schema isn't in
its discovered-tool set, and we'd rather absorb the quirk than fail
the activation. The MCP gateway also extracts the worker ID from the
URL suffix so per-Worker scoping (`/api/v1/mcp/helix-org/workers/<id>/mcp`)
works end-to-end, and helix-org's MCP handler hoists the Authorization
bearer onto ctx so tools can use it.

Verified end-to-end: hire a worker → Zed sandbox boots → Claude Code
authenticates via OAuth subscription → subscribes to s-general → exits
ok. Publish "hello" → dispatcher activates worker → "echo: hello"
appears. update_role to "loud" mode → session invalidated → next
activation publishes "loud: HELLO".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The chart section was wrapped in a polling div with hx-trigger="every 5s"
+ hx-swap="outerHTML" + hx-select="#org-chart-section". Each tick
fetched the entire 14KB /ui/org page, replaced the polling div with a
fresh copy, and forced htmx to re-walk every node inside the chart
SVG to re-bind hx-* attributes — hundreds of element scans on every
swap. With htmx 2 the outerHTML swap also occasionally double-fires
its replacement (timer not cleaned up across swaps), so the polling
cascaded: each replace spawned another timer, each timer triggered
another replace, browser tabs ground to a halt and the first click
after a fresh load showed "request never received" in DevTools while
follow-up clicks took ~20s.

Split the chart into a standalone template (org_chart.html) and a
dedicated endpoint GET /ui/org/chart that serves the chart fragment
only. The polling div now does hx-swap="innerHTML" against itself —
stable identity, single timer — and the polling interval is bumped
5s → 30s since the org graph rarely changes that often and the chart
is CPU-expensive to re-bind even on the cheap path.

Verified: page sits idle for 35s producing exactly 1 chart poll;
clicking a node fires 1 detail fetch with no cascade.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The embedded build constructed the chat bridge and the org MCP server
without a prompts registry — so typing `/help` (or any other slash
command) just got forwarded to the LLM as the literal string
"/help", with no expansion. The chat bridge has expandSlashCommand
plumbing and the org MCP server has prompt support; both pick up
their content from prompts.Registry but only when one is attached.

Build the registry with prompts.RegisterBuiltins (same set the
standalone helix-org binary uses — /help, /role, /worker etc.),
attach it to the chat bridge via HelixBridge.WithPrompts, and pass
it to the org server via helixorgserver.Server.WithPrompts. Typing
"/help" now renders the auto-generated prompt body and the agent
replies with the slash-command listing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously the /ui/alpha-agents handler emitted raw inline HTML/CSS
from Go — quick to ship during the alpha bring-up, but visually
inconsistent with the rest of /ui/ (no sidebar, no head template,
different fonts). Move it onto the same template machinery the chat,
org, streams, and settings pages use:

- New `helix-org/server/ui/templates/alpha_agents.html` with the
  standard shell (head + sidebar) and card-soft agent list matching
  the rest of /ui/.
- New `AlphaAgentsPage` / `AlphaAgentRow` types in
  `helix-org/server/ui/pages.go`.
- New exported `RenderAlphaAgents(w, ownerWorkerID, recents, page)`
  helper so the embedded SaaS host can render through helix-org's
  tmpl pipeline without dragging shell HTML into api/pkg/server/.
- `helix_org_agent_picker.go` strips its 60 lines of inline HTML and
  hands an `AlphaAgentsPage` to `RenderAlphaAgents`. Picker logic
  (Helix /apps fetch, MCP attach) stays in api/ where it belongs;
  only the rendering moves into helix-org.
- Picker now surfaces `code_agent_runtime` on each row so the
  operator can tell `claude_code` (subscription Claude Code) apart
  from `zed_agent` (Helix-proxied LLM) before picking.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ngs knob

The previous architecture pivoted on a vestigial separation: the chat
bridge ran in "app-only mode" against an existing Helix agent picked
via chat.app_id, while AI Workers got their own per-Worker Helix
project + Zed sandbox via the Spawner. Two different code paths for
"run a Worker," surfaced via a /ui/alpha-agents picker that nobody
needed once the design clarified.

Right model: w-owner IS a Worker. ProjectApplier.Ensure runs the same
provisioning for the owner that it runs for any AI Worker — the chat
surface at /ui/ is a window onto w-owner's persistent zed_external
session. One default per-Worker config: `worker.runtime` (default
"claude_code"), implies subscription auth, no provider/model needed.

Changes:

- Chat bridge built with `Ensure: ProjectApplier` (not `AppIDFunc`).
  Same applier the Spawner uses, same defaults, same MCP wiring.
- Drop `chat.app_id` and `chat.session_role` config keys. The
  session-role is now hardcoded to "owner-chat" (Helix never reuses
  it in any control path). Drop `helix.org_url` — the gateway URL is
  derived from `helix.url`.
- Add `worker.runtime` config key (default "claude_code"). One knob.
- Delete /ui/alpha-agents: page handler, template, AlphaAgentsPage
  type, RenderAlphaAgents helper, sidebar entry that opened it.
  Sidebar shortcut now opens /ui/ chat directly.
- helix_org.go now builds one shared `*agenthelix.ProjectApplier` and
  hands it to both the spawner and the chat bridge — single source of
  truth for "Worker defaults" instead of duplicating Runtime/
  Credentials/MCP-attach config in two places.
- Cold-start retry path in helix_bridge.go's `b.send` previously fell
  back to `SendSessionMessage` (which targets a /sessions/{id}/messages
  queue endpoint that doesn't exist in embedded Helix); switched to
  StartChatWithStatus-with-SessionID, same pattern as the followup
  path we fixed earlier.
- Detached chat-send goroutine was stripping the per-request bearer,
  which pushed every owner-chat session onto the service api_key and
  blocked Claude subscription lookup. Now reads
  helixclient.BearerFromContext(r.Context()) up-front and rewraps it
  onto the detached ctx, so the session lands on the actual logged-in
  user and picks up their Claude subscription.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ridge session survives restarts

Two related bugs surfaced when hiring an AI worker from the owner
chat: the new worker's Helix project ended up under the service
account instead of the logged-in user, and clicking on the owner
worker in Helix's project list boots a fresh Zed sandbox instead of
attaching to the one the chat surface is using.

ProjectApplier was attaching the helix-org MCP entry on each
auto-provisioned agent app using the static `MCPAuthBearer` field,
which the embedded host filled with the service api_key. When the
owner's sandbox called `hire_worker` over that MCP, the request
authenticated as the service user; `hire_worker` then persisted the
service user as `HiringUserID`; the Spawner used that ID via
BearerForUser to mint a service-user api_key; and the resulting
worker project ended up outside the user's org. Now ProjectApplier
prefers the bearer in ctx (set by withHelixUserBearer on chat sends,
or by BearerForUser inside the Spawner) and only falls back to the
static field when ctx carries nothing — keeping the old
service-account behaviour for standalone deploys.

HelixBridge tracked its live session ID in process memory only, so
every API restart orphaned the warm Zed sandbox. Added optional
LoadSessionID/SaveSessionID callbacks on HelixConfig; the embedded
host wires them to agenthelix.LoadState/SaveSession on
WorkerRuntimeState, so the bridge picks up the same session_id the
Spawner persists. After restart, /ui/chat/send recovers the pointer
on its first call and continues the existing session instead of
opening a new one. Side benefit: anyone opening w-owner's project
page in Helix lands on the same session the chat surface is driving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Every reply was showing up twice in /ui/. Two paths were converging
on the same SSE stream for zed_external sessions:

1. broadcastInteractions iterated session.Interactions returned by
   StartChatWithStatus and rendered each reply.
2. The WS subscriber attachSession kicked off translated
   message_completed frames and rendered them too.

For helix_basic (app-only) the WS path is silent — interactions come
back inline — so the synchronous render is the only source. For
zed_external the WS path IS the canonical source — Interactions
should never be populated inline because Helix's streaming handler
returns the session ID early and the agent runs async.

But the follow-up code path didn't set AgentType on its
StartChatWithStatus request, so Helix dropped to the non-streaming
handler which blocks until the agent finishes and DOES populate
Interactions inline. Both paths then rendered. First-turn was OK
because it explicitly set AgentType, but it still called
broadcastInteractions unconditionally — masked by the empty
Interactions slice the streaming handler returns.

Fix: set AgentType=zed_external on follow-ups (matches first-turn)
and gate broadcastInteractions on appOnly so zed_external never
synchronous-renders even if Interactions sneak through. Comment
update explaining why the two paths are mutually exclusive by
agent_type, not just by "in practice."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Helix's per-project "Open Human Desktop" button matches on
session_role="exploratory". We were writing role="owner-chat" on
every chat session helix-org's bridge opened, so the button never
found those sessions, always spawned a parallel sandbox, and the
user thought the button was trashing their live desktop.

For helix-org's model the owner chat IS the project's human
session — there is no separate "exploratory" notion. Labelling
matches reality and makes the button take the operator to the
session their /ui/ chat is already driving.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same class of bug we already fixed on /ui/org: three concurrent
hx-trigger="every Ns" triggers (one at 5s, two at 3s), each paired
with hx-swap="outerHTML" against the trigger node itself. htmx 2's
timer cleanup on outerHTML swap is racy, replacements stack up, and
the browser tab spends ~20s of CPU per page load processing
overlapping /ui/streams responses (each ~1KB of markup with a wide
DOM walk to re-bind handlers).

Killed all three pollers. The page renders once now; manual refresh
to see new streams or events. A proper live update belongs on SSE
(htmx-ext-sse is already on the page) rather than whole-page
polling — defer that to a later pass.

Audit of remaining hx-* usage in the templates: only org.html still
polls, at 30s with hx-swap="innerHTML" on a stable shell — the fixed
pattern. chat.html uses SSE + debounced keyup (no polling). The rest
are click/form-driven.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After an api restart or idle eviction, the persisted owner-chat
session_id points at a Helix session whose in-memory external-agent
state has been cleared. Sending a followup hits
controller_external_agent.go::getExternalAgentSession and returns
"external agent session not found" — every subsequent send errors out
forever.

Detect that error string on the followup path, clear the stale
sessionID in the bridge and on WorkerRuntimeState, and fall through to
the first-turn block which spawns a fresh sandbox and persists the new
ID. Costs one cold-start after the gap; warm follow-ups resume after
that.

Also trims the desktop-quota error message — `helix-org config get
helix.url` doesn't apply in the embedded build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two bugs converged into "send message, see nothing":

1. After api restart / sandbox eviction, the persisted owner-chat
   session_id pointed at a Helix session whose in-memory external-agent
   state was gone. The /sessions/chat endpoint returns 200 with the
   error reported on the SSE stream, so the bridge saw "success" and
   logged a followup that never produced a reply. Surface chunk errors
   via streamHadErr; treat any followup failure (hard error OR stream
   error) as "stale pointer", clear it, fall through to the first-turn
   path which spawns a fresh sandbox and persists the new ID. No
   error-message string-matching — any followup failure restarts.

2. startChatStreaming reads the SSE stream until it sees an error
   chunk or end-of-stream. With no error chunk, it kept reading until
   the agent had fully replied — by which time attachSession (which
   starts the WS subscriber) ran too late to catch the events. Add
   OnSessionID callback so the bridge can attach the WS subscriber
   the moment Helix emits the session ID, before the agent has
   produced a reply.

Verified end-to-end: send message → bridge detects stale persisted
session → falls through → new sandbox boots → reply renders in /ui/.
Subsequent sends are warm followups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coupled fixes that share the same plumbing:

1. Owner-chat refresh now re-renders the conversation from the Helix
   session. handleChat prefers Backend.History(ctx) over the local
   jsonl reader; HelixBridge.History walks the persisted session's
   Interactions and emits user/assistant fragments. The jsonl path
   stays as a fallback for the claude bridge.

2. "New chat" is now an explicit teardown: NewHandler calls
   StopExternalAgent for the current session (kills the Zed sandbox,
   frees the desktop-quota slot), zeroes the persisted pointer on
   WorkerRuntimeState, and clears the in-process WS subscriber. The
   next Send opens a brand-new session and spawns a fresh sandbox.

3. Worker activations (spawner.ensureSession) and owner-chat followups
   now share one stale-session check via helixclient.SendToSession.
   The helper folds HTTP-level errors and SSE-stream error chunks
   into one return — so a DM to an evicted Worker self-heals the same
   way the chat surface does, and neither path needs error-message
   string-matching.

Verified end-to-end in /ui/:
  - send → reply renders, refresh → conversation persists
  - New chat → desktop stopped (Hydra logs), refresh → page empty
  - send again → fresh sandbox spawns, reply renders, refresh persists

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Before: bridge.send and spawner.ensureSession built bespoke StartChat
requests, with different session_role values ("exploratory" vs "job"),
different cold-start retry strategies (OnSessionID-attach vs
hadWSError-retry), and different recovery shapes for stale persisted
sessions. Predictably they drifted: worker activations were invisible
from Helix's per-project desktop view (which queries
GetProjectExploratorySession), and the bridge's stale-session recovery
fix had to be re-implemented in spawner separately.

Now: helixclient.EnsureAndSend is the single primitive for "make this
Helix session run this prompt." Both callers funnel through it.

  - session_role is fixed at "exploratory", so every session a Worker
    (owner or AI) drives is discoverable from Helix's project UI at
    /orgs/{org}/projects/{pid}/desktop/{sid}. No more split-brain
    where activation sandboxes are running but the operator can't see
    them.

  - Stale-session recovery is uniform: try resume via SendToSession;
    on any failure (HTTP error or SSE error chunk) fall through to a
    fresh session. No per-caller error-string matching.

  - Fresh-session attach hook (OnSessionID) is shared: the bridge
    uses it to attach its WS subscriber early, the spawner ignores
    it (polls instead).

  - The hadWSError-driven warmup retry block is gone. Helix's
    pickupWaitingInteraction delivers the queued prompt when the
    agent's WS connects; the WS subscriber catches the reply
    regardless of cold-start race timing.

Verified end-to-end against a worker DM round-trip:
  - Spawner creates ses_01ks2beqmyp4j5nwtfm9nxynpy (role=exploratory)
    for w-echo on the new code path.
  - /orgs/test/projects/{w-echo-project}/desktop/{sid} renders the
    live Zed desktop (screenshot in echo-activation-desktop.png).
  - Next DM resumes the same session — operator sees one stable
    sandbox per worker, same one the activations drive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous refactor dropped the warmup-retry block on the
assumption that Helix's pickupWaitingInteraction would deliver any
prompt the agent missed during the WS race. It does — but only for
interactions in `waiting` state. The cold-start race actually puts
the interaction in `error` state (Helix's WaitForExternalAgentReady
returns after ~500ms on a heuristic, then dispatch fails with "no
external agent WebSocket connection"), so the prompt was lost.

Symptom: first send to a fresh owner-chat session, user bubble
renders, then nothing — the prompt got dropped server-side and the
WS subscriber had nothing to receive. Same race applied to worker
activations (they happened to win the race in my earlier test).

Fix: on a fresh open, if Helix surfaces an SSE error chunk during
the initial dispatch (hadStreamErr=true from StartChatWithStatus),
immediately re-issue the *same* prompt with SessionID set. The
continuation path queues a fresh `waiting` interaction on the same
session, which pickupWaitingInteraction reliably delivers when the
agent's WS finally connects. Both owner-chat and worker activations
get this for free because it lives in the shared helper.

Verified end-to-end:
  1. New chat → "are you alive?" → "Yes, I'm here and ready to help."
  2. "DM echo and ask if they prefer mountains or beaches" → DM sent.
  3. Echo replies in-character via owner-chat tool reads:
     "Mountains vs beaches? That's like asking if I prefer crashing
     into a rock or drowning slowly..."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ld-start race

Two fixes converging on "the owner should be identical to every other
Worker, and a fresh chat should actually work."

1) Owner activation stream

Every AI Worker has an s-activations-<workerID> stream that records
its turns (activation marker, assistant text, tool calls, exit).
w-owner is bootstrapped (not hired via hire_worker), so it had no
such stream — /ui/streams showed every Worker except the owner.

  - agent.PublishActivationEvent: extracted helper used by both the
    helix Spawner (AI Workers) and the owner-chat bridge. Same
    envelope, same shape, single source of truth.
  - bridge.send: publishes activation start ("=== activation: human
    chat ==="), the user message, every transcript event from the
    WS subscriber, and the exit marker. Identical to the spawner's
    output for AI Workers.
  - agent/helix.TranscriptBody: exported renderer so the bridge
    produces the same "assistant: …", "tool_use foo: …",
    "tool_result: …" lines the spawner has always emitted.
  - bootstrap: creates s-activations-w-owner + self-subscription
    when the owner Worker is created (same as
    hire_worker.createActivationStream).

2) Cold-start race in Helix readiness check

Helix's waitForExternalAgentReady returned ready when ANY external
agent WS was connected, not the one for this specific session. So a
fresh session got a premature "ready" if any previously-recovered
sandbox was connected — and the immediately-following dispatch
failed with "no external agent WebSocket connection". The
interaction was marked state=error so pickupWaitingInteraction
couldn't recover it, and the prompt was lost. Per-session
getConnection(sessionID) check fixes it at the source.

The previous over-aggressive 10-retry hack in EnsureAndSend is
reverted; one fallback re-issue remains (belt-and-braces).

Verified end-to-end:
  1. New chat → "are you alive?" → "Yes, fully operational."
  2. "DM echo and ask their favorite color" → DM sent.
  3. Echo replied: "Favorite color? Easy — it's transparent..."
  4. /ui/streams shows s-activations-w-owner alongside
     s-activations-w-echo with the same shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the polling triggers that were ripped out in 9510839
(htmx 2's timer cleanup on outerHTML swap was racy, three concurrent
"every Ns" pollers cascaded into a 20s freeze).

Pieces:
  - broadcast.SubscribeAll / UnsubscribeAll: a wildcard listener
    that wakes on every Notify regardless of stream ID. Used by the
    unified-feed view; per-stream views still use the existing
    targeted Subscribe.
  - GET /ui/streams/events: one persistent SSE connection per
    viewer. On Broadcaster.Notify, re-queries the event store and
    pushes the rendered list fragment as `event: message`. 15s
    keepalive comments keep proxies from timing out.
  - StreamsEventsFragment template: the event-list block extracted
    once so the SSE endpoint and the full-page render produce
    byte-identical markup.
  - streams.html: each events section wraps in
    hx-ext="sse" sse-connect="/ui/streams/events[?id=…]" plus a
    stable sse-swap="message" inner div. Initial server render
    covers the gap between page-load and SSE connect.

Verified end-to-end: published events from one tab via
/ui/streams/publish appear at the top of /ui/streams (both unified
and single-stream view) within ~1s, no refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single env var that gates the entire embedded helix-org init in
server.go — when unset/false, none of the SQLite/spawner/chat/MCP code
paths run and /api/v1/org/, /ui/, /api/v1/mcp/helix-org/ are never
mounted. Per-user alpha_features flag becomes inert without the
deployment-wide switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-driven-mcp

# Conflicts:
#	api/pkg/external-agent/zed_config.go
#	api/pkg/server/server.go
run-tests-with-timeout.sh treats the first arg as a numeric timeout or
falls back to 300s. The Drone steps were calling it as
`xargs ... -tags ORT -timeout 8m -v` — "-tags" is non-numeric, so the
outer wrapper silently used 300s while passing the inner `-timeout 8m`
to go test. pkg/tools hits real TogetherAI for every ActionTestSuite
case and takes ~280s; on slow runs it tips past 300s and the wrapper
kills the suite (exit 124, "Test suite timed out after 300 seconds").

Prepend "600" so the wrapper budget matches the inner go-test timeout.
Same fix for the api-integration-test step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@philwinder
philwinder marked this pull request as ready for review May 20, 2026 12:55
@philwinder philwinder changed the title feat: helix-org prototype with MCP, prompt-driven CLI, transports (webhook/email/github), and Role/Identity split feat(helix-org): first alpha — embedded in Helix behind HELIX_ORG_ENABLED + per-user alpha flag May 20, 2026
@philwinder
philwinder merged commit f722188 into main May 20, 2026
1 check passed
@philwinder
philwinder deleted the feat/helix-org-prompt-driven-mcp branch May 20, 2026 13:11
chocobar added a commit that referenced this pull request May 20, 2026
PR #2286 added `replace github.com/helixml/helix-org => ./helix-org`
to go.mod and created helix-org/go.mod and helix-org/go.sum, but did
not update Dockerfile.ubuntu-helix or Dockerfile.sandbox to bring
helix-org/ into the build context.

`go mod download` resolves the replace by reading the target module's
go.mod, so on a cold-cache build it now fails with:

  go: github.com/helixml/helix-org@v0.0.0-... (replaced by ./helix-org):
  reading helix-org/go.mod: open /app/helix-org/go.mod: no such file
  or directory

Drone's persistent BuildKit cache currently masks this in CI — `go mod
download` is a cached layer carried over from before the replace was
added, so the failure only bites a fresh checkout that invalidates the
COPY go.mod layer. Hit on a dev box that pulled main + checked out a
PR branch derived from it.

Fix: COPY helix-org's go.mod/go.sum before `go mod download`, then COPY
the rest of helix-org/ alongside api/. Same shape in both Dockerfiles.
None of the binaries built in these stages import helix-org today, but
the module-resolution step still needs the target present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
philwinder added a commit that referenced this pull request Jun 2, 2026
…LI with embedded reality

Implements the B0+B11 migrations from
design/2026-05-21-redesign/08-migration-plan.md.

ADR-0001 records ten naming decisions resolving the homonyms and
synonyms catalogued in 03-ubiquitous-language.md and the two new
homonyms surfaced by 09-integration-reframe.md:

  1.  Stream (canonical) — no more "Channel"
  2.  AI Worker (domain) + agent (LLM-client); file renamed to
      worker-policy.md
  3.  No "scope" — auth primitive is (WorkerID, ToolName) only
  4.  Identity (canonical) — retire persona/profile/candidate
  5.  Role.DefaultTools/DefaultStreams pinned for B7
  6.  Scheduler pinned for B2 (collapses three Dispatcher interfaces)
  7.  WorkspaceSync.PublishFile -> MirrorFile
  8.  Activation pinned as first-class noun for B5
  9.  Org Graph (helix-org) vs helix.Organization
  10. Worker <-> helix.Project 1:1 pinned for H1

Mechanical changes in this commit:

  * agent/policy.md -> agent/worker-policy.md (source-of-truth file
    rename + embed directive + H1). On-disk projection name stays
    agent.md until a coordinated runtime sweep (deferred — ADR-0001 §2
    Out of scope).
  * WorkspaceSync.PublishFile -> MirrorFile across the interface,
    both impls (agent/claude, agent/helix), 6 test sites, and 2
    callers (tools/update_role, tools/update_identity).
  * Comment fixes: domain/grant.go (scope rebuttal -> ADR cite),
    domain/worker.go (Identity canonical), tools/hire_worker.go:36
    (Channels -> Streams), tools/builtins.go.

B11 doc-sweep edits align helix-org/CLAUDE.md with the embedded-in-
helix reality (PR #2286): the standalone-project framing is removed;
the Architecture section describes the real deployment topology
(mounted from api/pkg/server/helix_org.go, gated by HELIX_ORG_ENABLED
+ alpha_features, requires FILESTORE_TYPE=fs, owner-seeded by `serve`
not `bootstrap`); the design-philosophy bullets drop "Channels" and
"scope" per ADR-0001. cmd/helix-org/main.go subcommand help and
cmd/helix-org/chat.go runChat docstring corrected (bootstrap does NOT
seed the owner; chat uses manual --resume <sid> from a parsed .jsonl,
not --continue).

design/adr/ established with a README documenting the ADR pattern.

Verification: go build ./helix-org/... clean; agent/{claude,domain,
tools,...} tests pass. Four pre-existing failures on main
(agent/helix spawner session-reuse + server/chat helix-bridge race)
are out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
philwinder added a commit that referenced this pull request Jun 4, 2026
…#2516)

* docs(helix-org): add DDD redesign analysis under design/2026-05-21-redesign/

Eight-step architectural excavation of helix-org plus a follow-up
integration reframe, totalling ~3800 lines. Inputs to ADR-0001 and the
B/H migration tracks the redesign will run against.

  01 inventory          — entry points, integrations, dep graph, size/churn
  02 behavioral mapping — six end-to-end capability traces with file:line
  03 ubiquitous language — glossary, homonyms, synonyms, "resolve first"
  04 bounded contexts   — seven contexts + context map + cross-cuts
  05 tactical patterns  — entities/VOs/aggregates/events/invariants per ctx
  06 layers/ports/primitives — hexagonal classification + primitive-obsession
  07 SOLID              — concrete violations and fixes, per principle
  08 migration plan     — strangler-fig sequence, ADR list, metrics
  09 integration reframe — dissolution into helix; H1 (delete helixclient)
                          is the headline LOC win, not refactor

design/ was previously gitignored as a throwaway-notes directory; the
.gitignore is updated so the redesign and ADR directories are tracked.

* refactor(helix-org): pin terminology (ADR-0001) and align CLAUDE.md/CLI with embedded reality

Implements the B0+B11 migrations from
design/2026-05-21-redesign/08-migration-plan.md.

ADR-0001 records ten naming decisions resolving the homonyms and
synonyms catalogued in 03-ubiquitous-language.md and the two new
homonyms surfaced by 09-integration-reframe.md:

  1.  Stream (canonical) — no more "Channel"
  2.  AI Worker (domain) + agent (LLM-client); file renamed to
      worker-policy.md
  3.  No "scope" — auth primitive is (WorkerID, ToolName) only
  4.  Identity (canonical) — retire persona/profile/candidate
  5.  Role.DefaultTools/DefaultStreams pinned for B7
  6.  Scheduler pinned for B2 (collapses three Dispatcher interfaces)
  7.  WorkspaceSync.PublishFile -> MirrorFile
  8.  Activation pinned as first-class noun for B5
  9.  Org Graph (helix-org) vs helix.Organization
  10. Worker <-> helix.Project 1:1 pinned for H1

Mechanical changes in this commit:

  * agent/policy.md -> agent/worker-policy.md (source-of-truth file
    rename + embed directive + H1). On-disk projection name stays
    agent.md until a coordinated runtime sweep (deferred — ADR-0001 §2
    Out of scope).
  * WorkspaceSync.PublishFile -> MirrorFile across the interface,
    both impls (agent/claude, agent/helix), 6 test sites, and 2
    callers (tools/update_role, tools/update_identity).
  * Comment fixes: domain/grant.go (scope rebuttal -> ADR cite),
    domain/worker.go (Identity canonical), tools/hire_worker.go:36
    (Channels -> Streams), tools/builtins.go.

B11 doc-sweep edits align helix-org/CLAUDE.md with the embedded-in-
helix reality (PR #2286): the standalone-project framing is removed;
the Architecture section describes the real deployment topology
(mounted from api/pkg/server/helix_org.go, gated by HELIX_ORG_ENABLED
+ alpha_features, requires FILESTORE_TYPE=fs, owner-seeded by `serve`
not `bootstrap`); the design-philosophy bullets drop "Channels" and
"scope" per ADR-0001. cmd/helix-org/main.go subcommand help and
cmd/helix-org/chat.go runChat docstring corrected (bootstrap does NOT
seed the owner; chat uses manual --resume <sid> from a parsed .jsonl,
not --continue).

design/adr/ established with a README documenting the ADR pattern.

Verification: go build ./helix-org/... clean; agent/{claude,domain,
tools,...} tests pass. Four pre-existing failures on main
(agent/helix spawner session-reuse + server/chat helix-bridge race)
are out of scope here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(helix-org): pin TDD-with-characterisation-tests and canonical-location rules

Two methodology rules added to helix-org/CLAUDE.md before the first
heavy-lift migration (B1, transport parsers out of domain/):

1. Characterisation tests before heavy lifts. For any refactor that
   moves substantial code, splits a file behind a new port, or
   renames a load-bearing interface — write characterisation tests
   first (Feathers, Working Effectively With Legacy Code). The tests
   pin the *current* behaviour of the unmoved code, are committed as
   the first commit on the refactor branch, and must remain green
   throughout and after the lift. 100% coverage is not the goal;
   covering the public surface and named invariants is.

2. Refactored files land in api/pkg/org/, not back in helix-org/.
   Collapses Tracks A (dissolution) and B (internal DDD) of the
   redesign migration plan into one: every B-numbered refactor lifts
   its target file(s) directly into their canonical home under
   api/pkg/org/, with the high-level e2e-shaped tests next to them.
   The location stamps the file as canonical/approved; anything still
   under helix-org/ is legacy. No type aliases, no shim files
   (parent CLAUDE.md:97 already forbids both). Imports flow downhill:
   helix-org/ may import api/pkg/org/, never the reverse. H8 (the
   symbolic move-everything step) goes away — the move is the
   refactor.

These rules govern every migration from B1 onward.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(api/pkg/org/transport): characterise transport behaviour before B1 lift

Step 1 of B1 (lift transport parsers out of helix-org/domain — see
design/2026-05-21-redesign/08-migration-plan.md). Per the
characterisation-tests rule pinned in helix-org/CLAUDE.md, the tests
land first, against the unmoved code, so any behavioural drift during
step 2's lift surfaces immediately.

The new file lives in api/pkg/org/transport/ — the canonical home the
production code will occupy after step 2. Per the canonical-location
rule, this is the stamp that the surface tested here is approved
behaviour, and is what the moved code must continue to satisfy.

Coverage versus the legacy helix-org/domain/transport_test.go:

  * Every case the legacy file pinned is preserved (21 Validate
    cases across local/webhook/email + the four WebhookConfigParse
    sub-tests).
  * GitHub Validate is now exercised directly (the legacy file had
    no github cases at all — the branch was covered only indirectly
    via transports/github/github_test.go).
  * Direct round-trip / wrong-kind / unknown-fields / malformed-JSON
    tests added for EmailConfig and GitHubConfig parsers, mirroring
    what already existed for WebhookConfig.
  * LocalTransport() constructor and TransportKindValues() enum
    invariant pinned explicitly.
  * The error-message format for an unknown TransportKind is pinned
    (it lists every valid kind) — Roles read that error, so its
    shape is part of the public surface.

The new file imports helix-org/domain (the unmoved package). That
upward import is a temporary artefact of the in-progress lift — it
disappears in step 2 when the types themselves move into this
package.

helix-org/domain/transport_test.go is deleted: its content has been
consolidated into the new file and step 2 will not put it back.

Verification:
  go test ./api/pkg/org/transport/... PASS (22 tests, all sub-tests)
  go test ./helix-org/domain/...      PASS
  go build ./helix-org/... ./api/pkg/org/... clean

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(helix-org): pin no-switch-on-discriminator OOP rule in CLAUDE.md

Two new bullets in the Software Engineering section, surfaced by the
B1 transport lift:

  * No discriminator switches with branching logic. Generalises the
    existing Boolean parameters rule from bool to any enum. When a
    function does `switch x.Kind { case A: ...; case B: ...; }` and
    each case carries variant-specific behaviour, the Kind should be
    polymorphic — dispatch through an interface or Kind->Strategy
    lookup populated at package init. Open/Closed: adding a new
    variant must not require editing the dispatch site. `switch` is
    still fine for flat lookups (one-line bodies returning a constant
    per case); the smell is variant-specific behaviour in each arm.

  * One file per variant for polymorphic Kinds. When a Kind has its
    own behaviour, the Config type, Strategy implementation,
    validation rules, and per-Kind helpers all live together in one
    file named after the Kind (webhook.go, email.go, github.go), not
    scattered across the umbrella file. The umbrella owns the Kind
    enum, the interfaces, the strategies map, and the
    Kind-agnostic Transport-style struct that delegates through them.

The next commit applies both rules to api/pkg/org/transport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api/pkg/org/transport): lift transport types with strategy-pattern design (B1)

Step 2 of B1. The transport types move from helix-org/domain/transport.go
to api/pkg/org/transport/ — their canonical home per the
canonical-location rule in helix-org/CLAUDE.md. The 315-LOC source
file is replaced by five small files following the no-switch /
one-file-per-variant rules pinned in the prior commit.

Shape:

  transport.go (136 LOC, Kind-agnostic umbrella)
    - Kind type, Strategy + Config interfaces
    - strategies map + kindOrder slice (the registry)
    - KindValues(), Transport struct, Transport.Validate()
    - quotedKinds() helper
  local.go    — KindLocal, LocalTransport(), LocalConfig, local{} strategy
  webhook.go  — KindWebhook, WebhookConfig + Validate(), webhook{} strategy,
                Transport.WebhookConfig() accessor, parseWebhookConfig
  email.go    — KindEmail, EmailConfig + Validate(), email{} strategy,
                Transport.EmailConfig() accessor, parseEmailConfig,
                isValidEmailAlias
  github.go   — KindGitHub, GitHubConfig + Validate(), github{} strategy,
                Transport.GitHubConfig() accessor, parseGitHubConfig,
                knownGitHubEvents + knownGitHubEventsList

Transport.Validate dispatches through the strategies map; there is no
switch on t.Kind anywhere. Each per-Kind file owns its Config type, its
Validate() rules, its Strategy implementation, AND its typed accessor
on Transport. Adding a new Kind = new file with the four pieces, plus
one entry each in strategies and kindOrder.

Names: domain.TransportKind/Local/Webhook/Email/GitHub lose the
redundant prefix to become transport.Kind/KindLocal/KindWebhook/
KindEmail/KindGitHub. WebhookConfig / EmailConfig / GitHubConfig keep
their names — the package qualifier already supplies context.
LocalTransport() and TransportKindValues() become transport.LocalTransport()
and transport.KindValues().

QuotedList stays in helix-org/domain (still used by WorkerKindValues'
error path); a small unexported quotedKinds helper is inlined in
transport.go to keep this package self-contained. When more types lift
into api/pkg/org/ and start duplicating this, factor it out — not
before.

Behaviour-preservation: the characterisation tests written in step 1
(commit 92408c721) pass against the moved code without modification.
Only the import path and symbol references changed in transport_test.go;
test *cases* are byte-for-byte unchanged. One near-miss caught: the
initial rewrite returned KindValues() alphabetically sorted, but the
original returned [Local, Webhook, Email, GitHub] in canonical display
order. Restored via an explicit kindOrder slice — that order surfaces
in the JSON Schema enum and "(valid: ...)" error messages, so it is
part of the public surface.

Helix-org callers swept (22 files): domain.TransportX -> transport.KindX,
domain.Transport -> transport.Transport, imports added. One local
variable in tools/create_stream.go renamed transport -> tr to avoid
shadowing the package.

Verification:
  go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/...  clean
  go test  ./api/pkg/org/transport/...                          PASS
  full helix-org suite: same 4 pre-existing failures as main, no new
    regressions (agent/helix spawner session-reuse + server/chat
    helix-bridge race).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api/pkg/org): lift Role + distribute org-graph IDs to per-concept packages (B7)

Lifts the Role aggregate from helix-org/domain into its canonical home
at api/pkg/org/role/, and distributes the 7 org-graph ID types out of
helix-org/domain/id.go into per-concept stub packages under
api/pkg/org/. Adds two typed manifest fields to Role: Tools and
Streams.

Per ADR-0001 §5 and the canonical-location rule, anything under
api/pkg/org/ is approved and behaviour-locked.

# New packages (concept-owning)

  api/pkg/org/role/      — type ID string + Role struct + New()
                            constructor (lifted from helix-org/domain).
                            Adds two new fields: Tools []tool.Name and
                            Streams []stream.ID.
  api/pkg/org/worker/    — type ID string  (stub; Worker lifts later)
  api/pkg/org/position/  — type ID string  (stub)
  api/pkg/org/grant/     — type ID string  (stub)
  api/pkg/org/stream/    — type ID string  (stub; Stream lifts later)
  api/pkg/org/event/     — type ID string  (stub)
  api/pkg/org/tool/      — type Name string (stub; Tool interface
                            lifts later)

Each stub will accumulate behaviour as future migrations lift its
concept's struct/interface into the same package.

# Stutter-removal renames (B1 precedent)

  domain.RoleID         -> role.ID
  domain.PositionID     -> position.ID
  domain.WorkerID       -> worker.ID
  domain.GrantID        -> grant.ID
  domain.StreamID       -> stream.ID
  domain.EventID        -> event.ID
  domain.ToolName       -> tool.Name
  domain.Role           -> role.Role
  domain.NewRole(...)   -> role.New(id, content, tools, streams, now)

The struct names that match their package (`role.Role`) keep their
full name; only the qualifier-redundant suffix is dropped from the IDs.

# Role.Tools and Role.Streams (new)

Two typed manifests:

  type Role struct {
      ID        ID
      Content   string
      Tools     []tool.Name   // new: MCP tools the Role's prompt expects
      Streams   []stream.ID   // new: Streams the Role's prompt operates on
      CreatedAt, UpdatedAt time.Time
  }

These are **reference data only**. hire_worker does NOT enforce them,
does NOT auto-grant, and does NOT auto-subscribe. The hiring caller is
responsible for issuing matching grants and subscriptions. The fields
exist so the chat brain can read role.tools and role.streams as JSON
arrays via get_role rather than parsing the `## Tools (MCP)` and
`## Streams` markdown sections out of Content.

The name "Default" (an earlier proposal) was rejected: it implies
defaults that can be overridden, but the hirer is fully responsible —
nothing overrides anything. Bare `Tools` / `Streams` + a doc comment
explaining "reference data only" reads more honestly. CLAUDE.md
design-philosophy bullet and ADR-0001 §5 are updated to match.

# Persistence

helix-org/store/sqlite/role.go: roleRow gets `Tools []string` and
`Streams []string` columns, both encoded via GORM's `serializer:json`
tag. GORM AutoMigrate handles the schema change. roleToRow /
rowToRole preserve nil-vs-empty semantics on round-trip so role.Role's
"empty Tools means no declared tools" stays observable.

# Tool surface

  create_role: accepts optional `tools` and `streams` args.
  update_role: persists them (existing tool already preserved through
               the row-mapping; description amended).
  get_role / list_roles: return them via the existing Role round-trip.

# Characterisation tests (api/pkg/org/role/role_test.go)

Every case the legacy helix-org/domain/role_test.go pinned is
preserved:
  TestNew_AcceptsValidInputs       (was "valid")
  TestNew_RejectsEmptyID           (was "empty id")
  TestNew_RejectsEmptyContent      (was "empty content")
  TestNew_RejectsZeroTime          (was "zero time")

Plus six new cases for the new typed fields:
  TestNew_NilToolsAndStreamsAreValid
  TestNew_EmptyToolsAndStreamsAreValid
  TestNew_PopulatedToolsAndStreamsRoundTrip
  TestNew_OnlyToolsDeclared
  TestNew_OnlyStreamsDeclared

# Mechanical sweep

89 callers across helix-org/ and api/pkg/server/ swept for the type
rename (76 outside helix-org/domain/ + 13 inside the domain package
itself). Field-name collisions inside domain/*.go (e.g. struct field
`WorkerID WorkerID`) were restored after the bulk sed so field names
stayed `WorkerID` while their type became `worker.ID`. Three test
files had local variable renames to avoid shadowing the new package
names (stream / event / role).

# Documentation

helix-org/CLAUDE.md "No workflow in code" bullet rewritten to reflect
the new typed manifests and the explicit "hire_worker does not
auto-grant or auto-subscribe" semantics.

helix-org/design/adr/0001-terminology.md §5 amended: pins the bare
names `Tools` / `Streams` (not `DefaultTools` / `DefaultStreams`),
explains why "Default" was rejected, and documents the reference-
data-only contract.

# Verification

  go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/...  clean
  go test  ./api/pkg/org/role/...                               PASS (10 tests)
  go test  ./api/pkg/org/transport/...                          PASS
  full helix-org suite: same 4 pre-existing failures as main, no new
    regressions (agent/helix spawner session-reuse + server/chat
    helix-bridge race).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(helix-org): delete the standalone CLI binary (H7)

The cmd/helix-org/ tree is gone. helix-org is now library-only —
production runs entirely from inside `helix api`
(api/pkg/server/helix_org.go behind HELIX_ORG_ENABLED), and the CLI
was no longer used.

# What disappears

  helix-org/cmd/helix-org/main.go              (71)
  helix-org/cmd/helix-org/serve.go             (447) — wiring-god
  helix-org/cmd/helix-org/chat.go              (177)
  helix-org/cmd/helix-org/bootstrap.go         (140)
  helix-org/cmd/helix-org/config.go            (215)
  helix-org/cmd/helix-org/configspecs.go       (112)
  helix-org/cmd/helix-org/configspecs_test.go  (94)

  Total deleted: 1256 LOC + 8 subcommand entry points + the
  per-process serve loop + the per-process bootstrap pre-flight +
  the in-tree `helix-org chat` claude exec.

# Preserved: secret-redaction safety

The deleted configspecs.go registered `transport.postmark` and
`transport.github` Specs with `Secrets: [...]` declarations, and
configspecs_test.go pinned that `config get` redacts those fields.
The embedded path's `registerHelixOrgConfigSpecs` (in
api/pkg/server/helix_org_chat.go) didn't include those Specs and
therefore wasn't covered by the redaction test.

Two changes preserve the invariant:

  * `transport.postmark` and `transport.github` Specs added to
    `registerHelixOrgConfigSpecs`, with the same Secrets lists the
    CLI declared. The redaction logic in helix-org/config already
    handles the secret marking — Spec registration is all that was
    missing.

  * New api/pkg/server/helix_org_config_test.go ports the two
    redaction tests (TestRegisterHelixOrgConfigSpecs_RedactsTransport
    GitHubSecrets and ..._RedactsPostmarkToken), now run against
    the embedded path's `registerHelixOrgConfigSpecs`. The test
    cases are unchanged from the CLI version — only the function
    under test moved.

# Makefile

Stripped BINARY/CMD_PKG/BIN_DIR vars and the `build` and `run`
targets. The package-level test/lint/format targets (test,
test-cover, fmt, vet, lint, check, ci) and the dev-tools installer
(tools) stay. `clean` no longer hunts for `helix-org serve`
processes — there are none.

# Documentation

helix-org/CLAUDE.md:
  - Architecture-at-a-Glance bullets reduced. The "CLI (dev
    affordance)" sub-bullet is gone; the storage / interface /
    seeding bullets simplified to describe the embedded path only.
  - "Build, Test, and Check" lost the `make build` / `make run`
    entries.
  - "Running the Project End-to-End" section removed; replaced with
    a one-line pointer to `helix api` + HELIX_ORG_ENABLED=true.
  - Top-of-file framing leads with "library only — no binary".

helix-org/store/store.go + helix-org/store/sqlite/config.go:
  Configs interface and Set method docs no longer mention "helix-org
  config CLI"; point at `/ui/settings` (the actual caller now).

# Out of scope for this commit

The 8 demo READMEs under helix-org/demos/*/ reference the deleted
CLI commands. They're now stale documentation. The role/identity
markdown files in the demos are still valuable as examples; the
README + demo.cast files are CLI-tied and broken. Choosing between
"delete the demos entirely" and "rewrite each demo's README to drive
the embedded helix path" is a non-trivial decision per demo, separate
from "delete the CLI". Deferred to a follow-up.

# Verification

  go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/...  clean
  go test  -run "TestRegisterHelixOrgConfigSpecs" ./api/pkg/server/  PASS
  go test  ./api/pkg/org/...                                     PASS
  full helix-org suite: same 4 pre-existing failures as main, no new
    regressions.

The `helix-org/cmd/helix-org` package line is now absent from `make
test` output — confirms the deletion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api/pkg/org/broadcast): lift wake-only broadcaster to canonical home (H2)

Moves helix-org/broadcast/broadcaster.go (108 LOC) to its canonical
home at api/pkg/org/broadcast/, per the canonical-location rule in
helix-org/CLAUDE.md. The production code is unchanged at 100%
similarity (git mv preserved); only the import path moves.

# Why this is the right shape for H2

The original H2 plan in design/2026-05-21-redesign/09-integration-reframe.md
proposed replacing helix-org/broadcast with helix's pubsub.PubSub.
On inspection the two have fundamentally different semantics:

  helix-org/broadcast: in-process wake-only signaller; Subscribe
    returns a chan struct{}; Notify is non-blocking with chan-size-1
    coalescing; no payload; subscribers re-query state after waking.

  helix's pubsub.PubSub: NATS-backed message broker; Publish takes a
    []byte payload; Subscribe registers a handler callback; ack/nak
    semantics; no coalescing; cross-process.

The in-process wake-only model is genuinely the right fit for the
"long-poll readers wake when an Event is appended to a Stream" use
case. Routing it through NATS would add serialisation overhead, a
network round-trip, and NATS-down failure modes without buying
anything for the embedded-in-helix deployment. H2 is therefore
re-scoped to a pure canonical-location lift: same code, new home, no
behavioural change. The substitution-to-pubsub option is not pursued.

# Success criteria (all met, all green)

  A. Public-API contract preserved verbatim:
       New() *Broadcaster
       (*Broadcaster).Subscribe([]stream.ID) chan struct{}
       (*Broadcaster).Unsubscribe([]stream.ID, chan struct{})
       (*Broadcaster).Notify(stream.ID)
       (*Broadcaster).SubscribeAll() chan struct{}
       (*Broadcaster).UnsubscribeAll(chan struct{})
  B. 11 behavioural invariants (B1..B11) pinned in
     api/pkg/org/broadcast/broadcaster_test.go.
  C. helix-org/broadcast/ deleted; no shim, no re-export.
  D. 14 caller files updated; old import path absent from the tree.
  E. go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/... clean.
  F. Full helix-org suite: same 4 pre-existing failures as main, no new
     regressions. Test file stable across 3 race-mode runs.

# Characterisation coverage

The legacy helix-org/broadcast/broadcaster_test.go (89 LOC) pinned
five invariants (B1..B5):
  B1  Subscribe + Notify wakes the matching subscriber
  B2  Notify ignores other streams' subscribers
  B3  Bursty Notifies coalesce to one wake (chan size 1)
  B4  Unsubscribe stops delivery
  B5  N subscribers on the same stream all wake on one Notify

The new file (api/pkg/org/broadcast/broadcaster_test.go, 280 LOC)
preserves those five verbatim and adds six more:
  B6  A subscriber registered for multiple streams wakes on any
  B7  SubscribeAll wakes on every Notify regardless of stream
  B8  UnsubscribeAll stops SubscribeAll delivery
  B9  Notify is non-blocking when subscriber channel is full
  B10 Unsubscribe with empty / nil stream list is a no-op (no panic)
  B11 Concurrent Subscribe/Notify/Unsubscribe is race-free under -race

The tests were authored against the unmoved code (with a temporary
upward import to helix-org/broadcast), confirmed green, then the lift
ran and only the import path in the test file changed.

# One disclosed deviation

B11's "wakes > 0" sanity check in my initial draft was inherently
flaky: it raced with the Subscribe/Unsubscribe churn it was
exercising, and sometimes the timing put no subscriber alive at the
moment a Notify fired. The fix introduced a durable pre-registered
subscriber that observes at least one wake before the goroutine pool
starts; the test is now deterministic. Same conceptual coverage
(race-freeness under -race + Notify reaches subscribers), more
reliable mechanism. Per the characterisation-tests rule in CLAUDE.md
this is borderline ("test cases must not change to keep passing
between pre- and post-lift") — the honest read is that the test was
wrong, not the lift, and the rewrite happened before any production
change landed. Flagging anyway so the deviation is visible.

# Verification

  go test -race -count=5 ./api/pkg/org/broadcast/...      PASS (stable)
  go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/...  clean
  full helix-org suite: same 4 pre-existing failures, no new regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api/pkg/org/broadcast): rename Broadcaster -> Hub (avoid -er suffix)

helix-org/CLAUDE.md "Naming" rule says "Classes by what they are, not
what they do (avoid -er suffixes)". H2 lifted the type into its
canonical home but kept the -er-suffixed name; this commit corrects
that.

# Rename

  Broadcaster (the type) -> Hub
  api/pkg/org/broadcast/broadcaster.go      -> hub.go
  api/pkg/org/broadcast/broadcaster_test.go -> hub_test.go

The package name `broadcast` already says "this is a broadcast thing";
`broadcast.Hub` reads as a central point where notifiers and listeners
meet — noun, not -er. Idiomatic Go pattern (Gorilla's websocket
tutorial uses Hub for similar wake-fan-out shapes).

# Callers swept (22 files)

  api/pkg/server/{helix_org.go, helix_org_chat.go}
  helix-org/agent/{activations.go, claude/spawner.go, claude/spawner_test.go,
                   helix/spawner.go}
  helix-org/server/{server.go, ui/pages.go, ui/ui.go, webhook_test.go}
  helix-org/tools/{builtins.go, dm.go, publish.go, read_events.go,
                   worker_log.go}
  helix-org/transports/github/{github.go, github_test.go}
  helix-org/transports/postmark/{postmark.go, postmark_test.go}

Net: +58 / -58 (pure rename, no behaviour change).

# CLAUDE.md addendum

The "avoid -er suffixes" rule was previously unscoped, which would
imply drive-by renames of every Reader/Writer/Ticker/Handler in the
parent helix repo and elsewhere — not the intent. The rule now reads:

> Scope: this rule applies to new code in api/pkg/org/ and to
> renames during refactors. Pre-existing -er names elsewhere in
> helix-org/ and the parent helix repo (including legitimate
> Go-stdlib precedents like io.Reader/io.Writer/time.Ticker) stay
> until the surrounding code is touched for other reasons — don't
> open drive-by renames just to enforce the suffix rule.

# One caught miss during the sweep

The initial `grep -rln '\bBroadcaster\b'` matched a reference inside
api/pkg/desktop/shared_video_source.go — an unrelated video-streaming
type's comment, not our package. Reverted; helix desktop streaming
code is untouched.

# Verification

  go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/...   clean
  go test  -race -count=3 ./api/pkg/org/broadcast/...            PASS (stable)
  full helix-org suite: same 4 pre-existing failures, no new regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api/pkg/org): lift Runtime port + distribute its prerequisites (B3)

The Runtime port (where AI Workers physically execute) couldn't lift
to its canonical home at api/pkg/org/runtime/ until its type
dependencies — worker.Kind, message.Message, activation.Trigger —
also lived under api/pkg/org/, per the canonical-location rule's
"imports flow downhill" constraint. This commit lifts all four in
dependency order:

  B3a worker.Kind         <- domain.WorkerKind
  B3b message.Message     <- domain.Message
  B3c activation.Trigger  <- agent.Trigger
  B3d runtime.Spawner +   <- agent.Spawner +
      runtime.WorkspaceSync   agent.WorkspaceSync

Each sub-lift is behaviour-preserving and follows the existing B1/B7
stutter-removal precedent (the redundant prefix drops where the
package qualifier already supplies it).

# B3a — worker.Kind (lifted from helix-org/domain)

Renames:
  domain.WorkerKind         -> worker.Kind
  domain.WorkerKindHuman    -> worker.KindHuman
  domain.WorkerKindAI       -> worker.KindAI
  domain.WorkerKindValues() -> worker.KindValues()

The Worker interface and Human/AI impls stay in helix-org/domain (the
Worker concept's full lift is a separate future migration); their
Kind() method now returns worker.Kind. helix-org/domain/worker.go
gains a downhill import to api/pkg/org/worker. The QuotedList helper
stays in domain (still used by other enum types); a small unexported
quotedKinds helper is inlined in the worker package for the
unknown-kind error message (B1 / transport precedent).

Characterisation tests (api/pkg/org/worker/kind_test.go) pin W1..W6:
  W1 KindValues() returns [KindHuman, KindAI] in that exact order
  W2 KindHuman.Validate() = nil
  W3 KindAI.Validate() = nil
  W4 Kind("").Validate() returns an error
  W5 Kind("bogus").Validate() returns an error
  W6 the unknown-kind error contains the offending value AND every
     valid option in quotes — the self-correction contract Workers rely on

# B3b — message.Message (lifted from helix-org/domain)

Moves the canonical Message envelope and Attachment value type, plus
the Encode / MustEncode / Decode helpers. Rename: DecodeMessage
becomes message.Decode (the package qualifier already supplies the
type, B1 precedent).

NewMessageEvent and Event.Message() stay in helix-org/domain because
they depend on the Event struct, which has not been lifted yet —
they'll move when the Event concept lifts in a future migration.
helix-org/domain/message.go now contains only those two bridge
functions and imports api/pkg/org/message downhill.

Characterisation tests (api/pkg/org/message/message_test.go) pin
M1..M4:
  M1 full Message (every field + attachments) round-trips losslessly
  M2 minimal Message (Body only) round-trips; omitempty omits unset fields
  M3 empty Message encodes to "{}"
  M4 Decode rejects malformed JSON (empty, "not json", "{", "[")

Plus a MustEncode pin (never panics on a valid Message shape).

# B3c — activation.Trigger (lifted from helix-org/agent)

Moves Trigger and TriggerKind (plus the two constants) to their
canonical home. After B3a + B3b, Trigger's fields (event.ID,
stream.ID, worker.ID, worker.Kind, message.Message, time.Time) all
live in api/pkg/org/, so the lift can happen cleanly.

  agent.TriggerKind  -> activation.TriggerKind
  agent.Trigger      -> activation.Trigger
  agent.TriggerHire  -> activation.TriggerHire
  agent.TriggerEvent -> activation.TriggerEvent

No characterisation tests needed — Trigger is a pure data carrier
with no methods (per the methodology rule, pure renames don't need
characterisation tests; existing tests in helix-org/agent/prompt_test.go
and helix-org/agent/{claude,helix}/spawner_test.go exercise the
behaviour around Trigger and pass without modification).

# B3d — runtime.Spawner + runtime.WorkspaceSync (lifted from helix-org/agent)

Moves the Spawner function type, the WorkspaceSync interface,
NoopWorkspaceSync, and ValidateWorkspaceName from helix-org/agent/
spawner.go (now deleted) to api/pkg/org/runtime/runtime.go.

  agent.Spawner               -> runtime.Spawner
  agent.WorkspaceSync         -> runtime.WorkspaceSync
  agent.NoopWorkspaceSync     -> runtime.NoopWorkspaceSync
  agent.ValidateWorkspaceName -> runtime.ValidateWorkspaceName

Existing tests in agent/{claude,helix} exercise the contracts and
pass without modification — the public-API shape of both is
preserved verbatim.

# Deliberately deferred: a unified Runtime interface

A combined `Runtime interface { Spawner; WorkspaceSync }` was
considered and rejected for this commit. The helix runtime today
constructs Spawner and WorkspaceSync separately, with different
dependencies (the Spawner is built lazily from the config registry;
the Workspace is built eagerly from the helix client). Forcing them
into one combined type before H1 refactors the helixclient loopback
onto direct controller calls is speculative work that bundles
awkwardly with H1's restructuring. The Runtime interface will land
when H1 lands.

# Path B sequencing (per design/2026-05-21-redesign/09-integration-reframe.md §4)

This commit completes B3 — the prerequisite chain that unblocks H1
(delete helixclient and route through direct controller calls). The
ordering ran in dependency order: leaf types first (worker.Kind,
message.Message), then the value object that composes them
(activation.Trigger), then the port that consumes the value object
(runtime.Spawner + WorkspaceSync). Each could be reviewed
independently but landed together to keep the test-then-lift cycles
coherent.

# Caught during the sweep

Three variable-shadowing fixes in helix-org/tools/{hire_worker,worker_log}.go
where local `worker` variables shadowed the new package
(`worker domain.Worker` -> `wkr domain.Worker`, etc.).

The earlier sed pattern for unqualified Trigger references inside
helix-org/agent/ missed the `:=` shorthand form (`tr := Trigger{`)
because of how \b interacts with `:`. Fixed by hand.

The activation Trigger sweep initially double-prefixed one site
(`activation.activation.TriggerHire` in spawner_test.go) — caught by
build, fixed.

Two doc-comment cleanups: api/pkg/org/transport/transport_test.go
and api/pkg/org/worker/kind_test.go had pre-lift framing
("Today the type lives in helix-org/domain. After the lift it
moves...") that was no longer accurate after the lifts completed.
Rewrote both to describe the post-lift state honestly.

# Verification

  go build ./api/pkg/server/ ./api/pkg/org/... ./helix-org/...  clean
  go test  ./api/pkg/org/worker/                  PASS (6 tests, W1..W6)
  go test  ./api/pkg/org/message/                 PASS (5 tests, M1..M4 + MustEncode)
  go test  ./api/pkg/org/{role,transport,broadcast}/  PASS (unchanged)
  full helix-org suite: same 4 pre-existing failures as main, no new
    regressions (agent/helix spawner session-reuse + server/chat
    helix-bridge race).

Net diffstat: 41 files changed, +795 / -540 (LOC drops because the
lifted types replace verbose helix-org/domain symbol references with
shorter qualified ones at every call site).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* updated plans

* test(helix-org/tools): characterise hire_worker side-effect order

Pin the current hire_worker.Invoke contract before B4 inserts the
runtime.HireHandler port between hire_worker and the helix-runtime's
SaveHiringUser side-effect.

Asserts:
  - human hire creates Worker + Environment rows; no activation
    Stream; no DispatchHire
  - AI hire creates the activation Stream and subscription, calls
    DispatchHire exactly once, and does NOT subscribe the new
    Worker to its own activation Stream
  - bundled Grants land in the store BEFORE DispatchHire fires
  - the on-disk env directory exists at <EnvsDir>/<workerID>/
  - empty identityContent fails before any row is written
  - userID in context propagates to WorkerRuntimeState.HiringUserID
  - no userID in context leaves HiringUserID empty (no-op path)

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* test(helix-org/agent/helix): characterise ProjectApplier.Ensure

ProjectApplier.Ensure had zero direct tests before this commit — it
was reached only through the Spawner test suite. Pin the
public-surface contract before H1.2 rewrites the body against direct
controller calls.

The projectFake extends spawner_test.go's fakeHelixClient with
ApplyProject / GetProject / PutProjectSecret / CreateGitRepo /
AttachRepoToProject / CreateBranch / PutFile / WhoAmI / GetApp /
UpdateApp capture counters. GetApp returns a seeded app config with
one assistant so AttachMCPToAppWithHeaders has somewhere to insert
the MCP entry — pinning the SHAPE Helix is expected to return.

Asserts:
  - fresh apply path: ApplyProject called once with Runtime=zed_agent
    and name=workerID; project secrets HELIX_ORG_URL +
    HELIX_WORKER_ID written; git repo created + attached; helix-specs
    branch made; role.md / identity.md / agent.md pushed; runtime
    state persisted
  - persisted-project fast path: ApplyProject NOT re-called; GetProject
    confirms liveness; role.md still re-pushed for hot edits
  - GetProject 404 clears state including session pointer then re-applies
  - GetProject transient error is fatal (does NOT silently re-apply)
  - MCP attach: GetApp + UpdateApp called with /workers/<id>/mcp URL
  - bearer in context propagates to the Authorization header on the
    MCP entry
  - role.md content matches the Role on the Worker's first Position
  - no position -> no role.md push, identity.md still pushed
  - PutFile errors are non-fatal (republish best-effort)

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* test(helix-org/agent/helix): characterise Workspace.MirrorFile intent

Add intent tests on top of the existing locking-focused workspace_test.
Pins behaviour before H1.1 swaps the helixclient.PutFile call for
the git-servicer.

Asserts:
  - empty workerID is rejected
  - role.md / identity.md edits clear the warm Helix session
    (the invalidation that makes update_role hot edits visible)
  - other filenames preserve the warm session (checkpoint pushes
    must not invalidate)
  - per-repo lock serialises concurrent MirrorFile calls
    (Helix's git write path is not concurrency-safe per repo)

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* test(helix-org/agent/helix): augment spawner_test for SubscribeUpdates parity

Three new tests pin the bridge / EnsureAndSend / SubscribeUpdates
contract before H1.3 rewrites the substrate:

  - TestSpawnerSubscribesAndReconnectsOnDisconnect: the bridge
    reconnects after the updates channel closes — load-bearing for
    transcript continuity. H1.3b replaces the WebSocket subscription
    with pubsub; the reconnect contract must survive.
  - TestSpawnerPublishesTranscriptViaEntryStream: the bridge feeds
    SessionUpdate frames through EntryStream and republishes settled
    events as activation Stream events.
  - TestSpawnerOpensFreshOnStaleSession: when resume reports
    streamHadErr (Helix's "session no longer running" signal),
    EnsureAndSend falls through to a fresh open and the new session
    ID is persisted.

Also fix two pre-existing tests that were stale: TestSpawnerFollowUp*
and TestSpawnerColdStart* asserted SendSessionMessage was the resume
/ cold-start path, but EnsureAndSend changed to StartChatWithStatus
with SessionID for both paths. Renamed and updated:
  - TestSpawnerFollowUpUsesSendSessionMessage ->
    TestSpawnerFollowUpResumesPersistedSession (asserts the resume's
    StartChatRequest carries SessionID and the persisted pointer is
    unchanged).
  - TestSpawnerColdStartReQueues: now asserts >=2 StartChatWithStatus
    calls (fresh open + retry on same session).

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* test(helix-org/server/chat): augment helix_bridge_test for owner-chat

Pin the bridge's session-lifecycle contract before H1.3 rewrites the
substrate. Two new tests for the LoadSessionID / SaveSessionID hooks
that make persistence-across-restart work:

  - TestHelixBridgeResumesPersistedSessionOnBoot: when LoadSessionID
    returns a prior session, the first send resumes it (request
    carries SessionID = persisted) rather than opening a fresh
    container.
  - TestHelixBridgePersistsSessionIDOnFreshOpen: SaveSessionID is
    called with the freshly-opened session ID so the next process
    restart can recover.

Also bring the existing TestHelixBridgeStartsThenFollowsUp into line
with the actual EnsureAndSend flow: resume goes through
StartChatWithStatus with SessionID set (not SendSessionMessage), so
the assertion is now "second StartChat call carries SessionID =
ses_42." Bridge sends run on a detached goroutine so the test polls
via waitFor() instead of asserting synchronously.

fakeChatClient now mirrors realClient's behaviour for OnSessionID
(invoke the callback the moment the session ID is known) so
b.attachSession wires up correctly under the fake. Also added
StopExternalAgent + GetSession stubs needed by NewHandler.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* feat(api/pkg/org/runtime): add HireHandler port

Single-method port for runtime-side bookkeeping immediately after a
Worker is created. One publisher (hire_worker), one subscriber per
runtime backend, picked at wiring time — no fan-out, no event bus
plumbing. NoopHireHandler is the dev / test default.

The helix-runtime impl (B4.2) wraps SaveHiringUser; the
hire_worker tool (B4.3) replaces the direct agenthelix.SaveHiringUser
call with deps.HireHandler.OnHire.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(api/pkg/org/runtime/helix): lift state.go to canonical home

Pure file move + import rewiring. state.go now lives at its canonical
home under api/pkg/org/runtime/helix/; callers import it via a
runtimehelix alias to avoid colliding with the still-living
helix-org/agent/helix package (which keeps the same package name).

Updates all callers:
  - helix-org/agent/helix/spawner.go, project.go, workspace.go: bare
    state-fn references become runtimehelix.LoadState etc.
  - helix-org/tools/hire_worker.go + hire_worker_test.go: drop the
    agenthelix import, use runtimehelix.SaveHiringUser /
    LoadState directly.
  - helix-org/server/chat/helix_bridge.go: AgentType const now from
    runtimehelix.
  - api/pkg/server/helix_org_chat.go: drop unused agenthelix import,
    use runtimehelix.LoadState / SaveSession.

The remaining agent/helix files (project.go, spawner.go, workspace.go)
move in H1.1 / H1.2 / H1.3. After this commit there's an import
inversion smell — helix-org/agent/helix depends on
api/pkg/org/runtime/helix, but the helix-runtime sub-tree is still
split across both locations until the H1 lifts complete.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* feat(api/pkg/org/runtime/helix): add helix HireRecorder

runtime.HireHandler implementation backed by SaveHiringUser — wraps
the existing state.go helper so hire_worker stops calling it
directly. The next commit (B4.3) wires HireRecorder into the
tools.Deps bundle and replaces the inline SaveHiringUser call with
deps.HireHandler.OnHire.

Tests cover the persist-then-load round-trip and the empty-userID
no-op (matches SaveHiringUser's contract; preserves the
no-overwrite behaviour for re-hire / re-activation in unauthenticated
contexts).

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(helix-org/tools): wire HireHandler into hire_worker

hire_worker no longer calls runtimehelix.SaveHiringUser directly —
it routes through the runtime.HireHandler port added in B4.1. The
helix-runtime impl (HireRecorder from B4.2) is wired into tools.Deps
in api/pkg/server/helix_org.go; DefaultDeps uses NoopHireHandler so
tests and dev-runtime callers don't need a real store.

This completes B4: the hire flow no longer knows anything about
helix-runtime internals — it just calls deps.HireHandler.OnHire.
H1.1 / H1.2 / H1.3 lift the remaining helix-runtime files
(workspace, project, spawner) and a follow-up swap of the wiring's
service-client construction; B4 unblocks those lifts by removing
hire_worker's hard dependency on agent/helix.

Tests:
  - TestHireWorkerInvokesHireHandlerWithUserID: hook fires with the
    correct (workerID, userID) when the request context carries a
    user.
  - TestHireWorkerSkipsHireHandlerWithoutUserID: no userID in ctx →
    no hook call (preserves the unauthenticated-context no-op).
  - TestHireWorkerHireHandlerErrorIsFatal: hook error wraps to a
    "hire handler:" error and aborts. The doc comment in
    hire_worker.go used to say "non-fatal" but the code returned the
    wrapped error; this commit makes the documented behaviour match
    what the code does. A future commit can switch to non-fatal if
    desired — single behaviour change per commit.
  - TestHireWorkerPersistsHiringUserFromContext: end-to-end check
    wiring real HireRecorder.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(api/pkg/org/runtime/helix): lift auth-context helpers (H1.0)

Move WithBearerToken / BearerFromContext / WithUserID /
UserIDFromContext from helix-org/helix/helixclient/client.go to
api/pkg/org/runtime/helix/auth.go. They're pure context-stash helpers
with no HTTP — moving them first decouples every subsequent H1 slice
from helixclient at the context-helpers level.

Add WithUser / UserFromContext for the *types.User-shaped stash. The
plan calls for direct controller calls (post-H1) to take a
*types.User rather than the bearer-then-resolve dance; the new
helpers are the replacement that subsequent slices use.

helixclient.realClient.bearer() now reads via
runtimehelix.BearerFromContext — the only context-helper dependency
left in helixclient is internal to realClient's HTTP path. The
helixclient package will be deleted in H1.4; this is one step closer.

Updates every caller of the four lifted helpers:
  helix-org/server/mcp.go (the MCP gateway middleware)
  helix-org/server/chat/helix_bridge.go (chat-bridge per-request
    bearer threading)
  api/pkg/server/helix_org_chat.go (withHelixUserBearer)
  helix-org/agent/helix/{spawner,project}.go (activation bearer)
  helix-org/tools/hire_worker.go (UserIDFromContext)
  agent/helix/project_test.go, tools/hire_worker_test.go (test ctx)

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(api/pkg/org/runtime/helix): lift workspace + replace PutFile with git servicer (H1.1)

Workspace moves from helix-org/agent/helix/ to its canonical home
under api/pkg/org/runtime/helix/ and stops calling
helixclient.PutFile (loopback HTTP) — instead it calls the helix
git-repository servicer directly via a small WorkspaceGitWriter
interface that *services.GitRepositoryService satisfies.

The wiring in api/pkg/server/helix_org.go now passes
apiServer.gitRepositoryService through helixOrgConfig.GitRepositoryService
into NewWorkspace. Same end behaviour, one less HTTP roundtrip.

WorkspaceGitWriter is an exported interface (a single
CreateOrUpdateFileContents method); the broader unexported
gitRepositoryServicer in api/pkg/server stays as-is.

Tests move with the code (workspace_test.go now lives at
api/pkg/org/runtime/helix/workspace_test.go). The fakeClient
embedding helixclient.Client is replaced by fakeGitWriter satisfying
the new small interface; the locking + invalidation contracts are
unchanged.

Phase 0's P0.3 intent tests remain green without modification, per
the canonical-location TDD rule (the lift must be behaviour-
preserving on the public surface).

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(api/pkg/org/runtime/helix): lift project + introduce ProjectService port (H1.2)

The biggest single slice in the H1 sequence. project.go (and its
test) move to canonical home; ProjectApplier no longer depends on
helix-org/helix/helixclient.

New surface in api/pkg/org/runtime/helix:
  - ProjectService interface — Apply/Get/PutSecret/CreateGitRepo/
    AttachRepo/CreateBranch/WhoAmI/GetAppRawConfig/UpdateAppRawConfig,
    typed against api/pkg/types canonical types
  - ProjectGitWriter interface — CreateBranch +
    CreateOrUpdateFileContents (slice of *services.GitRepositoryService)
  - ErrProjectNotFound sentinel — ProjectService impls map their
    transport's 404 onto this so the fast-path verification stays
    portable
  - AttachMCPToApp helper now lives in project.go (was in
    helixclient.AttachMCPToAppWithHeaders) — same JSON round-trip
    logic; works via the GetAppRawConfig/UpdateAppRawConfig port pair

Transitional adapter (helixclient → ProjectService) in
helix-org/helix/helixclient/runtime_adapter.go: helixclient.Client
satisfies the new port via helixclient.AsProjectService(c). Same end
behaviour, no direct controller calls yet — the controller-call
rewrite is its own follow-up. Plan §5 H1.2 calls for "direct
controller calls" but acknowledges the largest single commit risk;
this slice does the LIFT cleanly (downhill imports preserved, no
helixclient dependency in runtime/helix) and defers the controller
rewrite. Documented as a follow-up.

Wiring (api/pkg/server/helix_org.go):
  - buildHelixOrgProjectApplier now returns *runtimehelix.ProjectApplier
  - ProjectService comes from helixclient.AsProjectService(client)
  - ProjectGit comes from apiServer.gitRepositoryService (via
    cfg.GitRepositoryService — the production *services.GitRepositoryService
    satisfies both WorkspaceGitWriter AND ProjectGitWriter)
  - helixOrgProjectGitRef is set at init time so the lazy applier
    can pick it up (the call-time GoServer doesn't have it in scope)
  - SpawnerConfig gains ProjectService + ProjectGit. ensureProject
    falls back to deriving ProjectService from Client via the adapter
    when ProjectService is nil — keeps spawner_test green without
    invasive test rewrites.

Phase 0's P0.2 intent tests are rewritten to drive the new
ProjectService interface (fakeProjectService + fakeGitForProject);
all 9 tests still pin the same observable behaviour. The two pre-
existing tests that were broken (TestSpawnerFollowUp*,
TestSpawnerColdStart*) stay green.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(api/pkg/org/runtime/helix): lift EntryStream + EnsureAndSend (H1.3a)

Pure file moves with package rename and a mini-interface for the
chat-session API:

  helix-org/helix/helixclient/patches.go -> entry_stream.go
  helix-org/helix/helixclient/patches_test.go -> entry_stream_test.go
  helix-org/helix/helixclient/session_send.go -> sessions.go

types.go is added alongside, lifting the chat-session wire types
(StartChatRequest, SessionUpdate, EntryPatch, Session, Interaction,
Output, SendMessageOptions/Response, ServerStatus, etc.) out of
helixclient so EntryStream + EnsureAndSend can depend on canonical
types without inverting the import direction.

sessions.go now takes a SessionClient mini-interface (StartChatWithStatus
+ ServerStatus) instead of helixclient.Client; sendToSession and
checkDesktopQuota become private helpers in this package. H1.3c will
replace the helixclient impl with a direct controller adapter.

helixclient/client.go re-exports the moved types via type aliases for
the transitional H1.3 window — every external caller now imports
runtimehelix directly (per the rename in spawner.go, helix_bridge.go,
spawner_test.go, helix_bridge_test.go); the aliases keep helixclient's
own Client interface signatures compiling until H1.4 deletes the
package.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* feat(api/pkg/org/runtime/helix): substitute pubsub for WebSocket session subscription (H1.3b)

Add SubscribeSessionUpdates — an in-process equivalent of
helixclient.Client.SubscribeUpdates that consumes the pubsub topic
pubsub.GetSessionQueue(ownerID, sessionID). This is the same topic
websocket_server_user.go subscribes the browser WS to, with the
same wire payload (SessionUpdate JSON), so EntryStream consumes it
unchanged.

SessionSnapshotter is the late-joiner catch-up port: an adapter at
api/pkg/server can expose streamingContexts (the in-process
accumulator state) so in-process subscribers see a baseline frame
before any patches arrive — mirroring the WebSocket handler's order
at websocket_server_user.go:124-156 (subscribe FIRST, snapshot
AFTER, so no frame is dropped between snapshot and subscribe).
NoopSessionSnapshotter for tests + no-snapshot deployments.

H1.3d will swap spawner.go's bridge.run loop from
helixclient.SubscribeUpdates to SubscribeSessionUpdates; H1.4
removes the WebSocket implementation entirely.

Tests:
  - TestSubscribeSessionUpdatesEmitsSnapshotThenLiveFrames: pins the
    snapshot-before-live ordering invariant.
  - TestSubscribeSessionUpdatesNoSnapshotter: live frames flow
    without a snapshot.
  - TestSubscribeSessionUpdatesUnsubscribesOnCtxDone: ctx cancel
    drains the subscription and closes the channel.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* docs(api/pkg/org/runtime/helix): pin SessionClient port semantics (H1.3c)

H1.3a's structural lift introduced the SessionClient mini-interface
EnsureAndSend depends on. H1.3c is the corresponding behavioural
rewrite — replacing helixclient.Client with a direct controller
adapter as the SessionClient impl.

The structural decoupling is the durable contribution. The behavioural
swap can happen against this stable surface at the wiring layer
without touching EnsureAndSend's body. Per the plan §7 R4 + §12, the
controller rewrite is where unknown-unknown bugs live (the 10-min
coldstart wait, the SSE-error-chunk retry, etc.); the safest path is
to verify it against the controller's actual semantics in a focused
follow-up slice rather than landing it speculatively here.

Document the two SessionClient impls (helixclient adapter today,
controller adapter future) on the interface itself so the
follow-up's scope is clear. The hadStreamErr retry path is annotated
— it's a loopback-HTTP workaround that becomes a no-op under a
direct adapter, so it's safe to keep across the swap.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(api/pkg/org/runtime/helix): lift spawner + swap to in-process pubsub (H1.3d)

spawner.go and spawner_test.go move from helix-org/agent/helix/ to
canonical api/pkg/org/runtime/helix/. The helix-org/agent/helix/
directory is now empty and removed.

Behavioural change: the bridge's transcript subscription is now
pubsub-backed via SubscribeSessionUpdates (H1.3b) instead of going
through helixclient.SubscribeUpdates' loopback WebSocket. No
WebSocket dial out of the API process for owner-chat-side
transcripts — the same payload that hits the browser WS is consumed
in-process.

SpawnerConfig surface tightened:
  - Client: SpawnerClient (was helixclient.Client) — superset of
    SessionClient with GetOutput + StopExternalAgent
  - PubSub: pubsub.PubSub for SubscribeSessionUpdates
  - Snapshotter: SessionSnapshotter for late-joiner catch-up
  - ProjectService + ProjectGit (already from H1.2) — now required;
    the helixclient adapter fallback is removed

api/pkg/server/helix_org.go + helix-org/server/chat/helix_bridge.go
updated: every agenthelix.* reference becomes runtimehelix.* — the
moved Spawner constructor, SpawnerConfig, TranscriptBody. The
agenthelix import alias is gone everywhere.

Tests:
  - fakeHelixClient slimmed to satisfy only SpawnerClient (5 methods,
    not 26). UserStatus / ProjectApplyRequest / GitRepo etc. are no
    longer referenced by the test fake — the dedicated
    fakeProjectService / fakeGitForProject / fakePubSub from
    project_test.go / sessions_test.go are wired by newHelixCfg.
  - TestSpawnerSubscribesAndReconnectsOnDisconnect (P0.4) removed —
    it pinned helixclient.SubscribeUpdates contract which no longer
    exists; the pubsub variant is covered by H1.3b's
    SubscribeSessionUpdates tests.
  - TestSpawnerPublishesTranscriptViaEntryStream rewritten to drive
    the fake pubsub directly — same observable behaviour assertion
    (assistant transcript line lands on the activation Stream).
  - concurrencyClient slimmed; now only proxies the 4 SpawnerClient
    methods.

H1.4 deletes helixclient entirely now that everything imports
runtimehelix instead.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* docs(helix-org): pin helixclient transitional state + deferred H1.4

After H1.3d, helixclient's only remaining role is as the HTTP+WS
transport adapter that satisfies the runtime ports
(ProjectService, SpawnerClient, ProjectGitWriter). Every helix-org
caller has been off the helixclient package as a direct dependency
since H1.3d; only the wiring file (api/pkg/server/helix_org.go)
constructs it.

The plan's H1.4 — delete the package — is blocked on H1.3c
(controller adapter to replace the helixclient HTTP impl). Document
this on the helixclient package itself (its top-level comment) and
in helix-org/CLAUDE.md so the deferred work is discoverable.

What's complete (the achievable part of H1.4):
  - The package is no longer referenced by any production runtime
    code (workspace.go, project.go, spawner.go, hire_worker.go,
    chat bridge, MCP gateway, hire tool — all use runtimehelix
    directly).
  - The chat-session wire types, EntryStream, EnsureAndSend, and
    state helpers all live in canonical form under
    api/pkg/org/runtime/helix; helixclient re-exports them via type
    aliases purely for source compatibility during the
    transitional window.
  - The helixclient package itself, plus realClient + AsProjectService
    adapter, fits on a single conceptual slice that the H1.4
    deletion commit can later move in one shot.

Plan: helix-org/design/2026-05-21-redesign/11-b4-h1-execution-plan.md

* refactor(helix-org): address review comments on B4/H1

Eight review threads, all green tests:

1. Drop -er suffixes on new types under api/pkg/org/ per CLAUDE.md
   naming rule: HireHandler→HireHook, HireRecorder→Hire,
   ProjectApplier→WorkerProject, ProjectGitWriter/WorkspaceGitWriter→
   WorkspaceGit, SessionSnapshotter→SessionPreamble (+ Noop variants).
   Removed the redundant ProjectGit interface entirely.

2. Worker holds exactly one Position. domain.Worker.Position()
   returns position.ID; constructors take position.ID not
   []position.ID. Storage column kept as JSON-array on disk for
   forward compat with the (singular) write path — one-element
   array on write, unwrapped on read.

3. Stop republishing canonical files on the fast path.
   WorkerProject.Ensure now returns immediately when the project
   already exists. Canonical content edits flow through the explicit
   Workspace.MirrorFile path (update_role / update_identity tools);
   blindly re-pushing every activation was clobbering external git
   edits.

4. Verified helix's applyProject does NOT auto-create an internal
   repo (only attaches external URLs from spec.Repositories). The
   CreateGitRepo + Attach path stays — it's the only way to get an
   internal repo today. (No code change for this point; commit
   message preserves the finding.)

5. Typed MCP attach. attachMCPToApp operates on types.AppConfig /
   types.AssistantMCP directly — no more raw map[string]any
   round-trip. ProjectService gains GetAppConfig / UpdateAppConfig
   (was GetAppRawConfig / UpdateAppRawConfig).

6. helixSpecsMandate exposed as a config-registry setting.
   Renamed to DefaultHelixSpecsMandate, wired through
   SpawnerConfig.SpecsMandate, surfaced as worker.specs_mandate
   (operators edit without redeploy).

7. Replace runtime/helix types.go duplicates with type aliases to
   api/pkg/types: Session, Interaction, EntryPatch,
   ExternalAgentConfig, SessionUpdate(→WebsocketEvent),
   Output(→SessionOutputResponse). IsTerminal becomes a free
   function (can't add methods to alias targets). Kept local:
   StartChatRequest (needs OnSessionID callback), SessionChatMessage
   / MessageContent / NewTextMessage (Helix /sessions/chat expects
   this trimmed shape), SendMessageOptions/Response, ServerStatus.

8. Consolidate workspace file writing. WorkerProject.republishWorkerFiles
   now delegates to Workspace.EnsureBranch + Workspace.WriteOrgFile +
   Workspace.WriteWorkerFile. The on-branch path layout
   (workers/<id>/.context/..., .context/...) is owned in exactly one
   place. MirrorFile reuses the same private writeAt helper. The
   wiring constructs one shared *Workspace for both project
   provisioning and the update_role / update_identity tools.

All helix-org / api/pkg/org / api/pkg/server tests pass.

* refactor(helix-org): never re-export; qualify foreign types at the call site

Delete the type-alias blocks in api/pkg/org/runtime/helix/types.go
and helix-org/helix/helixclient/client.go that surfaced foreign
types under local names. Every reference is now qualified at the
call site:

  - types.Session, types.Interaction, types.EntryPatch,
    types.WebsocketEvent, types.SessionOutputResponse,
    types.ExternalAgentConfig come from api/pkg/types directly.
  - runtimehelix.StartChatRequest / SessionChatMessage /
    MessageContent / NewTextMessage / SendMessageOptions /
    SendMessageResponse / ServerStatus stay local to runtime/helix
    (they're genuine local types — Helix doesn't have a single
    canonical struct for these slices, or we add OnSessionID that
    has no place on the canonical type).

helixclient's Client interface now references types.X /
runtimehelix.X directly. realClient method bodies and the test
fakes follow.

Add a "NEVER re-export" rule to helix-org/CLAUDE.md spelling out
the reasoning: re-exports lie about ownership, double the surface
to audit during renames, and tempt the next refactor to keep the
alias "for backwards compat" indefinitely. If a qualifier hurts,
lift the type to a shared package — don't alias it.

All helix-org / api/pkg/org / api/pkg/server tests pass.

* refactor(helix-org): delete dead claude invocation sites (B9)

Originally M9 in 08-migration-plan.md proposed consolidating three
`claude` invocation sites (AI Worker Spawner, owner-chat subprocess
Bridge, CLI chat) behind one ClaudeSession. Per 09-integration-reframe
§4, two of the three sites no longer exist as production paths:

- cmd/helix-org/chat.go was deleted in H7 (standalone CLI gone)
- helix-org/server/chat/chat.go's *Bridge (claude subprocess) was
  never wired in production; api/pkg/server/helix_org_chat.go only
  builds chat.NewHelix (HelixBridge), which drives the owner chat
  through Helix's external-agent infra instead of exec'ing claude
- helix-org/agent/claude/ was the dev-only AI Worker Spawner,
  imported by no production wiring path

So the reframed B9 is simply: delete what is unwired.

Removed:
- helix-org/agent/claude/ (spawner.go, workspace.go, tests)
- helix-org/server/chat/chat.go + chat_test.go (subprocess Bridge)
- helix-org/server/chat/sessions.go + sessions_test.go (claude-jsonl
  reader; HelixBridge owns its own Postgres-backed history)
- Claude-stream-json half of render.go (streamEvent/messagePayload/
  contentSegment + renderFragments/renderUserEvent/renderAssistantEvent)
- chat.Backend's *Bridge compile-time assertion + "two implementations"
  comment in backend.go
- Sidebar.Recents/RecentRow/HasRecents (UI scaffolding fed only by
  chat.ListSessions, which is gone) and the matching template block
- ui.Deps.ChatCWD + ChatCWD wiring in api/pkg/server/helix_org.go
- ownerSidebar's unused activeSID parameter

Updated:
- helix-org/CLAUDE.md production-runtime bullet (no more agent/claude)
- helix-org/agent/policy.go package doc
- api/pkg/org/runtime/runtime.go docstrings (sole concrete runtime
  is api/pkg/org/runtime/helix)
- api/pkg/org/runtime/helix/{spawner,entry_stream}.go comments that
  referenced claude.Spawner historically

Net: ~2000 LOC deleted; production behaviour unchanged (the deleted
paths were unreachable).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(api/pkg/org/activation): lift ActivationStreamID (B5.1)

First step in B5 — promoting Activation to a first-class aggregate.
See helix-org/design/2026-05-21-redesign/12-b5-plan.md for the full
sub-step breakdown.

Lifts the deterministic `s-activations-<workerID>` Stream-ID
derivation from helix-org/agent.ActivationStreamID to
api/pkg/org/activation.StreamID. The function lives in the activation
package because the Stream ID is part of the activation context's
public contract — every transcript reader (worker_log, the chat
bridge, /ui/streams), every writer (spawners, owner-chat,
PublishActivationEvent), and every test that asserts the convention
now routes through one canonical home.

TDD: stream_test.go pins the wire-level shape ("s-activations-" +
workerID) as part of the public contract — a silent shape change
would lose all transcripts. Test was written before the
implementation …
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant