# ChannelGate — AGENTS.md Canonical instruction file for Claude Code, Codex and contributors. `CLAUDE.md` symlinks to this file — edit here only. A deployment may add private rules in a gitignored `AGENTS.local.md` (see Contributor workflow). ## What this is A **self-hosted Linux daemon** (Node ESM, Express, `node:sqlite`) that runs coding agents inside team chat. Every conversation — a Slack DM, group or channel; Microsoft Teams and Google Chat in Beta — gets its **own work folder** (`~/ChannelGate///`), its **own rootless Podman containers for project and admin authors**, its own persistent memory and one engine session per thread and runtime lane. Each ordinary message runs a **headless engine turn inside that container**: Claude Code (`claude -p`, the primary engine), OpenAI Codex (`codex exec`), or OpenCode (a proof adapter admitted only read-only and network-off). An organization admin can explicitly turn one Slack thread into a direct-host `/sudo` thread; only admins may then message it. The container is the default confinement boundary; the folder's `.claude/settings.json` carries the tool policy. A built-in admin web UI (same process) manages users, channels, engines, MCP connections, skills, per-channel secrets, schedules, usage and the license. `src/ee/` is the proprietary licensing plane (see the rules). Project records: `FEATURES.md` (shipped behavior), `TEST-PLAN.md` (the cumulative regression and the live acceptance gates) and `CHANGELOG.md` (user-visible changes). There is no `TASKS.md` and no mandatory planning phase (rule below). Operator and design references: `README.md`, `INSTALL.md`, `docs/OPERATIONS.md`, `docs/PLATFORMS.md`, `docs/SKILLS.md`, `docs/ENGINE-CAPABILITIES.md`, `docs/COMPATIBILITY.md`, `docs/OPENCODE-ADAPTER.md`, `docs/LICENSE-KEYS.md`. The bundled skills `channelgate`, `headless-app-creator` and `skill-authoring` (`src/gateway/skills/bundled/`) describe this repository's own conventions to agents working on it. ## Architecture (the contract) ``` Chat surface: Slack (Socket Mode, @slack/bolt) · Google Chat (Pub/Sub pull) · Teams (JWKS-verified webhook) │ one inbound shape (src/platforms/inbound.js); Slack pipeline src/slack/message-pipeline.js, │ the other surfaces src/platforms/ingest.js ▼ Gateway daemon │ gate: DM → no mention needed · elsewhere → explicit @bot mention │ authz: isAuthorized() — admin or approved user, the channel's access policy, per-channel guest grant │ license admission (src/ee/limits.js), then provision ~/ChannelGate/// │ (.claude/settings.json lockdown, managed CLAUDE.md block, gateway-usage + channel-memory skills, │ granted catalog skills) │ resolve runtime: normal → ensure the channel container; admin-authenticated `/sudo` thread → host │ (container: HOME volume, work folder + clean workspace + artifact dir at identical paths, │ control socket read-only, --network none + the per-channel egress proxy socket; │ host: daemon OS account and native HOME/toolchain) │ resolve session: thread key → engine session id (resume) | fresh (memory catalog prepended) │ build MCP config: gateway control (socket bridge) + composio-user (author) + composio-agent │ (channel → org token) + selected catalog/plugin servers → a 0600 file in the artifact dir ▼ exec in the resolved runtime (Claude, cold): claude -p --output-format stream-json --verbose --include-partial-messages --setting-sources "" --settings --append-system-prompt-file [--model M] [--effort E] (--session-id | -r ) --mcp-config --strict-mcp-config --permission-prompt-tool mcp__gateway__permission_prompt [--plugin-dir …] [--dangerously-skip-permissions ← admin author in an Admin channel only] warm pool: the same flags, prompt over stdin with --input-format stream-json Codex: codex exec [resume ] --json -c … (sandbox, MCP, approval policy as -c overrides) │ parse NDJSON → deltas/events → progress card, final text, tokens/cost ▼ post/edit the reply in the thread (degraded to the surface's capabilities) → usage ledger → memory review ``` ## Where things live - `src/start.js` + `src/platform-gate.js` + `src/server.js` — entry and boot. `start.js` checks the Node floor (≥ 22.13, `node:sqlite`) with NO static imports, the gate refuses every non-Linux host with one plain line, then `server.js` boots in this order: `.env`, the legacy-layout migration FIRST, the runtime root + database + singleton lock, settings over env, the skills catalog, hardening, the container CLI probe (FATAL without one), the MCP socket, Express, license verification (started, never awaited), Slack + the other transports, automation. - `src/config/` — `paths.js` (every location; env `CHANNELGATE_DIR`, `CHANNELGATE_DB`, `CG_WORKSPACE_DIR`; the pre-rename `CLAUDE_GATEWAY_*` names still resolve with a one-time warning), `settings.js` (UI-managed settings, getters, engine enablement, the runtime-change policy), `store.js` (users/channels CRUD), `schedules.js`, `acks.js`, `harden.js` (0700/0600 on the runtime root and its secret files at boot, fatal on failure; the first-boot admin password), `channel-env.js` (per-channel environment secrets: write-only, provider-shaped, reserved-name validation, `safeSpawnEnv`), `cli-catalog.js` (the deploy CLIs the image ships and the env names each reads), `channel-audit.js` (`channel_meta_changed` events carrying only the changed keys), `dead-fields.js` (retired fields stripped on every write). - `src/db/` — `index.js` (the one lazy `node:sqlite` connection: WAL, `busy_timeout`, `foreign_keys`, migrations on open, the one-time legacy JSON import behind `_meta` flags), `migrations.js` (versioned on `PRAGMA user_version`, currently 33 — append, never edit), `import-legacy.js`, `fts.js` (the optional FTS5 `channel_memory_fts` index; without FTS5 memory search degrades to a scan). - `src/gateway/run.js` — the run orchestrator: engine adapter selection and precedence (per-run override → thread pin → channel → gateway default), Claude↔Codex failover, warm-vs-cold, session recovery, license admission, MCP assembly, and the per-attempt preambles (runtime identity, container access facts, channel credential names, the memory catalog). The heart of a turn. - `src/gateway/folders.js` + `library-skills.js` — channel folder provisioning: the generated `.claude/settings.json` (curated `permissions.allow`/`ask`/`deny`, `allowedMcpServers`, `autoMemoryEnabled:false`, `autoDreamEnabled:false`, the Stop hook — no `sandbox` block, no host path except the operator home in the admin variant's `additionalDirectories`), the marker-guarded managed block in `CLAUDE.md` (`AGENTS.md` is a symlink to it: the channel's Mode/Auto/Lean/Network switches, the hard rules, the admin's global instructions), the `channel-memory` protocol and the granted catalog skills materialized as REAL files under `.claude/skills/` with `.agents/skills -> ../.claude/skills` for Codex. Conflicting local entries are archived under the runtime root's `skill-backups/` before replacement. The engine hooks shipped into the image live in `src/gateway/hooks/` (today only the Claude Stop hook that keeps a turn open while its background subagents run). - `src/gateway/guide.js` + `gateway-usage/` — the **`gateway-usage` skill**, the chat operating manual injected into EVERY channel folder each run (all modes, Lean included), resolved for the channel's platform: `platforms//.md` overlays the shared `references/.md`, the adapter's `guideDrop` removes files for absent capabilities, `{{PLATFORM}}`/`{{PLATFORM_ID}}`/ `{{CONTAINER_ACCESS}}` are substituted at materialization. The default ships in git (the restorable baseline); admins override any file live via `update_gateway_guide` / `reset_gateway_guide` / `get_gateway_guide`, stored per file under `~/.channelgate/config/gateway-usage/` (override wins per file, so a `git pull` still surfaces new default files). - `src/gateway/mcp.js` + `run-engine-mcp.js` + `mcp-catalog.js` + `mcp-discovery.js` + `mcp-capability.js` — the per-run MCP payload: the `gateway` control server (always; over the read-only socket bridge in containers, carrying only a signed HMAC run capability with a toolset and TTL), `composio-user` (the author's personal token only), `composio-agent` (channel token → organization default; suppressed in DMs), the toolbox/Make servers when a token resolves, then — through the engine adapter — the channel/organization-selected catalog servers ("Cloud MCP") and plugin-package servers mapped onto explicitly selected connections, plus the custom MCP connections people add by URL + Bearer token (`custom-mcps.js` rules, `custom-mcp-store.js` writes; relayed with a per-dial public-address check, `src/mcp/public-fetch.js`). Lean injects nothing. Slack beyond the gateway's own bot tools is the **Composio** Slack toolkit — there is no separate hosted Slack MCP. Composio SDK mode is Enterprise-only (`src/ee/`). - `src/gateway/modes.js` — the three base modes `read` (Read-only), `worker` (shell + file writes) and `admin` (Worker for members; `--dangerously-skip-permissions` only for a trusted admin author), the two independent options **Auto** (`autoMode`: tool requests auto-approved) and **Lean** (`cleanMode`: no optional skills or connectors; an admin author in an Admin channel keeps full context), the orthogonal network switch (enforced by the egress proxy), plus `isAuthorized()` (who may talk) and `canManage()` (who may change a channel's access settings). A mode is a TOOL preset; what the container mounts is decided by the runtime backend alone. - `src/gateway/skills/` + `access-grants.js` + `plugin-runtime.js` + `run-grant-artifacts.js` — the **skills platform** (`docs/SKILLS.md`): a SQLite catalog of immutable content-hashed revisions holding the exact bytes of every file; owners `bundled` / `folder` (the operator's `~/.claude/skills`, `~/.agents/skills`, `GATEWAY_SKILL_SOURCES`) / `git` (tarball sync, webhook, publish back) / peer gateways; grants as the union organization ∪ channel (own + assigned template) ∪ personal, `requires:` resolved per message; governance flags Enabled / Discoverable / Mandatory; plugin packages (`.claude-plugin` / `.codex-plugin`) stored as one revision, their hooks admitted only on an authorized live admin turn. Shared grants materialize durably into `.claude/skills/`; personal grants become a per-run temporary plugin dir (`--plugin-dir`) for Claude and an explicit per-turn catalog for Codex, removed after the run and invisible to other authors. The catalog's own MCP endpoint (`POST /mcp/skills`, `src/web/skills-mcp.js`) and its access tokens sit outside the admin session. - `src/gateway/channel-memory.js` + `memory-search.js` + `memory-review.js` — channel memory: `MEMORY.md` + `memory/.md` are the UNCAPPED, portable source of truth (I/O bounds only), written atomically in batches behind `update_channel_memory`; a fresh session gets a **catalog** prepended (fact and topic counts, topic file names — never the contents) and retrieves on demand via `search_channel_memory` (FTS5-backed when available) and `read_channel_memory`; the post-reply background reviewer is a reduced-tool `claude -p` in the same gated folder (`CG_TOOLSET=memory-review`, origin `memory_review`, every mutating tool denied, only the save tool exposed) that saves what the model did not and announces it in the thread. - `src/gateway/claude-login.js` + `claude-token-relay.js` + `login-watch.js` — the one home for "WHICH Claude login does the gateway use, and what does a run receive?". Precedence: a configured `claude setup-token` → the OPERATOR's `$CLAUDE_CONFIG_DIR`/`~/.claude` login → a login signed in to the gateway's engine home → `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` → a named remedy. The operator's login is NEVER copied, linked or mounted; every container run receives a RELAY of the resolved login's current ACCESS token in `CLAUDE_CODE_OAUTH_TOKEN` (behind the egress proxy, a placeholder the proxy swaps for it), refreshed by a cheap turn in that login's own config dir. The container credential modes, the health probe, the boot log, `/status` and the hourly login watch (which DMs admins at most once a day per message class while the login is expiring or missing) all read the same resolver. Codex's twins are `src/engines/codex-auth.js` (is Codex signed in) and `src/gateway/codex-token-relay.js` (which login a container run relays, its cheap-turn refresh in the login's own `CODEX_HOME`, and the access-only `auth.json` a container receives). - `src/gateway/{background,scheduler,followups,nudges,loops,api-runs}.js` — daemon-side automation: background shell jobs and background agents that outlive the turn and report back into the thread, cron/one-time schedules (with the daily-thread delivery mode), pending-response digests, no-response nudges, the durable `/loop` pacing (the harness's own `ScheduleWakeup`/ `CronCreate` calls become schedule rows with a tick budget), and the HTTP run API (`POST /api/runs`: API-key or admin-session auth, a synthetic `api` channel or a real thread, idempotency window, in-flight cap, `api_jobs`; the key is an admin credential and every run acts as the fixed admin `api` principal with no personal scope — the caller-supplied author is attribution only, never trusted for personal tokens or admin state). - `src/gateway/{approval-requests,instruction-approvals,approval-link-tokens}.js` + `src/slack/approvals.js` + `src/web/approval-links.js` + `src/platforms/approval-delivery.js` — approvals: one decision path with scopes once / thread / forever ("forever" is admin-only), the `permission_prompt` and `request_approval` cards, durable approvals (`background_shell`, `channel_instructions`) that never expire and re-post after a restart, and signed single-use browser links (`/approve/`: GET decides nothing, POST decides exactly once and re-checks authority) for surfaces without Block Kit and for automation. - `src/gateway/egress/` — the **egress proxy** (container-secrets P2), a proxy-mode container's only network. The core (no database, no settings: `proxy.js` the HTTP/1.1 CONNECT proxy that TLS-terminates with the per-deployment CA of `ca.js`/`x509.js`, `policy.js` the destination policy with SSRF-checked pinned addresses, `rules.js` the placeholder → value swap in headers and query, `scrub.js` the response scrubber, `placeholders.js` the `cgph_…` format; `index.js` is its barrel) and the integration: `service.js` (boot, one unix listener per channel under `/eg/<12 hex>/`, the per-request policy from the channel's CURRENT meta, the swap gate, audit, the container backend's hook), `grants.js` (migration-30 `egress_grants`: placeholder → scope/channel/owner/name, never a value; live value resolution; revocation; `resolveEgressRunEnv`, `containerClaudeCredential` and `containerCodexCredential` for every spawn site), `liveness.js` (turns, jobs, reviews, SSH sessions per channel — personal grants swap only while their owner is live and no other person's turn, job or SSH session is live there), `catalog-rules.js` (built-in rules per credential name + validation of an entry's "used on hosts"), `engine-hosts.js`. The container half: `src/runtimes/container/egress-hook.js` (the plan a target carries), `egress-env.js` (the ONE proxy/CA env list), `src/mcp/egress-forwarder.js` (staged into the image as `bin/cg-egress.mjs`) and `src/mcp/egress-connect.js` (the SSH `ProxyCommand` helper, staged as `bin/cg-egress-connect.mjs` behind the POSIX shim `containers/bin/cg-egress-connect`). - `src/gateway/ssh-access.js` + `ssh-broker.js` + `ssh-session.js` + `src/mcp/tools/ssh-access.js` — SSH access to channel containers (`docs/SSH-ACCESS.md`): the per-person key registry (`ssh_keys`), the per-channel `sshUsers` grant list, the host `authorized_keys` export (every line `restrict,command=`), the in-container sshd files, the session records (`ssh_sessions`), and the daemon's attach socket under `/var/lib/channelgate-ssh` (`CHANNELGATE_SSH_DIR`) — bound only once `scripts/install-ssh-access.sh` has set the host up. A session authorizes key → user → channel → grant → not operator-home-mounted, holds a container lease for its whole life, and pipes the developer's SSH stream into `exec -i cg-sshd` (an unprivileged inetd-mode sshd in the image). `scripts/cg-ssh-attach.mjs` is the host wrapper (self-contained, copied root-owned by the installer). Nothing listens on a port anywhere. - `src/gateway/{updater,update-state,update-smoke,restart}.js` + `scripts/update-*` — the transactional self-update (the automatic path is an Enterprise entitlement; `scripts/update.sh` stays available to every operator): one durable transaction and lock, a detached built-ins-only runner (preflight, smoke, snapshot, install → restart → verify, automatic rollback), results on `/api/health`; `restart_gateway` drains turns, jobs, API runs and update transactions first. - `src/gateway/system-health.js` + `system-health-collect.js` + `system-health-store.js` — daemon-side read-only Linux metrics, five-second samples, minute SQLite resource aggregates (30 days), storage history (186 days, hourly after 30 days), hardware snapshots/change events, and a data-dependent capacity forecast. Admin routes in `src/web/routes/system-health.js`, browser page in `public/admin-system-health.js`, isolated canary in `scripts/system-health-soak.mjs`. - Smaller gateway modules: `sessions.js` + `thread-engine.js` (thread key ↔ engine session, per-thread pins), `session-adopt.js` + `session-carry.js` (`/resume` of a local session whose cwd is this channel; host → container carry-over), `stopped-turns.js`, `active-runs.js` (durable rows so interactive turns recover after a restart), `usage.js` + `usage-repair.js`, `model-info.js`, `runtime-identity.js` + `runtime-access.js` + `channel-credentials.js` (the per-attempt engine/model, mount and credential-name facts), `diagnosis.js` (a failed run opens a root-cause thread and proposes, never applies), `drivesync.js`, `transcribe.js` (local Whisper), `browser-env.js`, `make-toolbox.js`, `safe-fs.js` (no-follow writes under agent-writable folders), `shutdown.js`. - `src/engines/` — engines. `adapters.js` holds the validated manifests (`claude`, `codex`, `opencode`); `registry.js` derives ids, the fallback graph (claude ↔ codex only) and the optional `credentialState()` fingerprint the orchestrator only ever COMPARES; `contract.js` enforces the required facts (cli, session identity, MCP transport, settings/meta keys, instruction file + skills dir, models/efforts, context window, resume command) and methods (`compileConfinement`, `run`, `interrupt`, `discoverMcps`, `health`). Add an engine by adding a runner + a manifest, never by adding another `engine === "codex"` branch. Runners: `claude.js` + `session-pool.js`/`persistent-session.js` (warm pool), `codex.js`, `opencode.js` (read-only, network-off, no MCP, outside the failover graph; its CLI is not shipped in the runtime image). Shared: `stream.js` (NDJSON → deltas/events), `watchdog.js` (the ONE stall watchdog), `child-env.js` (the child env and passthrough list; `buildClaudeEnv`/`buildCodexEnv`/ `buildOpenCodeEnv` live in their runners), `network-policy.js` (the switch checked against the engine's declared `networkModes`, and `networkEnforcedFor(target)` — enforced by the egress proxy, advisory only under the legacy bridge mode or a channel's `rawNetwork`), `model-discovery.js`, `engine-health.js`, `codex-auth.js` (is Codex signed in — reads the CLI's own `auth.json`), `loop-wakeup.js`, `runtime-target.js`. `adapters.js` reads `claude-login.js` lazily because a static import would close a cycle through `config/settings.js`. - `src/runtimes/` — WHERE an engine process runs: `contract.js` (the RuntimeBackend contract), `resolve.js` (the one place that builds the RuntimeTarget a turn, a background job and the memory reviewer each receive at their OWN spawn), `registry.js` (registers the default container backend plus the admin-authenticated `/sudo` host backend; `local.js` remains an unregistered daemon-internal spawner kept for direct-runner tests), `host.js` (direct daemon-account spawn) and `container/` — the rootless Podman backend: the CLI probe (`podman`, then `docker`), the image (expected version + digest over every file in `containers/` compared with the built image's labels; a missing or stale image fails the run closed with the `npm run build:image` remedy), the lifecycle (create/start/stop, recreate by create-time fingerprint and image ID, leases, the idle reaper), exec (session-leader helpers so a later exec can signal the whole tree), credentials, the per-channel HOME volume and the mounts, the operator's VS Code lease (`npm run vscode -- `). - `containers/` — the runtime image: `Containerfile` (`node:22-bookworm-slim`, the `agent` user at the daemon's real uid/gid, tini, a root-owned toolchain under `/opt/channelgate`), `versions.json` (`imageSpecVersion` + every pin: Claude Code, Codex, `mcp-remote`, `vercel`, `supabase`, Playwright + `agent-browser` with Chromium, Python with OpenCV and faster-whisper; apt adds `gh`, `git`, `ffmpeg`, `ripgrep`, `psql` and friends) and `bin/` (the POSIX `cg-*` helpers: init, exec, probe, signal, sweep, the MCP socket bridge shim; `cg-init` also starts the egress forwarder under `CG_EGRESS=proxy`). `scripts/build-image.mjs` stages only the import closure of the engine-spawned helpers plus the verbatim socket bridge and egress forwarder, never the checkout. - `src/mcp/` — `gateway-server.js` is a thin assembler over `tools/` (`background`, `channel-admin`, `license`, `schedules`, `skills`, `slack-native`, `tokens`, `workspace-read`), each registering its tools behind the verified capability; `socket-server.js` serves it on the daemon side over `~/.channelgate/run/mcp.sock` (bind-mounted read-only at `/run/channelgate`, no DB mount, no port), `socket-bridge.js` is the container-side stdio↔socket pipe copied into the image verbatim, `secret-env-bridge.js` launches credentialed stdio MCPs from a 0600 bundle so a secret never rides argv (`remote-secret-bridge.js` was retired in container-secrets P4). In a container the header-bearing remotes (Composio token mode, the toolboxes) are the socket's `remote-mcp` service instead: `remote-mcp-registry.js` holds their URL + headers in daemon memory under the capability's jti, `remote-relay.js` dials them and relays the tools, so the credential never enters the container. `egress-forwarder.js` is the container-side half of the egress proxy (127.0.0.1:3128 → the channel's egress socket), copied into the image verbatim like `socket-bridge.js`; `egress-connect.js` is the SSH `ProxyCommand` over that forwarder (`CONNECT host:port`). - `src/platforms/` — the CHAT-SURFACE layer, to chat platforms what `src/engines/` is to engines. `contract.js` (the closed capability spec + fail-closed adapter validation), `registry.js` + `adapters.js` + `slack.js`/`googlechat.js`/`msteams.js` (per-platform FACTS: what renders, what streams, edit budget, threading, native artifacts, mention syntax, attachment reach, `folderName`, `guideDrop`, GA/Beta status), `ids.js` (namespaced conversation ids; Slack's stay bare), `format/` (`degrade.js` capability-driven Markdown degradation, `mentions.js` the shared matcher, `gchat.js`, `teams.js`), `connector.js` + `notify.js` (`postNotice`/`postDirectMessage`, the ONE path unattended posts take), `inbound.js` (the one message shape every transport produces), `ingest.js` (gate → authorize → run → answer for the non-Slack surfaces), `durable-inbox.js` (SQLite at-least-once intake, resumed after a restart), the platform-neutral attachment, voice, text-command, reply-session and approval-delivery helpers, `live.js` (which transports are CONNECTED), `manager.js`, `boot.js` (what `server.js` calls). `googlechat/` and `msteams/` are the two non-Slack TRANSPORTS (auth, REST client, inbound normalization, connector; the Pub/Sub pull loop, or the JWKS-verified webhook at `/api/teams/messages`). Setup: `docs/PLATFORMS.md`. - `src/slack/` — Bolt Socket Mode. `app.js` (handlers, modals, slash commands, reactions) and `message-pipeline.js` (the whole inbound turn: gating, authorization, attachments, in-thread commands, run, delivery) with `message-normalize.js`, `download.js` (`uploads//…`), `util.js` (dedupe, the per-thread run queue) and `members.js`; `progress.js` (assistant status, the timeline card, the heartbeat, the streamed answer), `deliver.js` (the one unattended delivery path), `footer.js` (stats + Resume / Files / Secrets / Settings / file buttons), `format.js` (mrkdwn), `manager.js` (live connect); the native bot-token artifacts `charts.js`, `tables.js`, `lists.js`, `upload.js`, `read.js`; the modals `channel-settings.js` (runtime, MCP, skills, secrets, access tabs), `secret-explorer.js` (list and write, never read), `file-explorer.js`, `model-wizard.js` (`/model`); and the cards `approvals.js`, `busy-thread-choice.js` (Steer / Queue / Cancel), `engine-switch-choice.js`. - `src/web/` + `public/` — the admin API and the vanilla-JS admin UI. `app.js` mounts the public self-authenticating routes (Teams webhook, `/approve/`, `/mcp/skills`, the GitHub webhook) ahead of the admin auth stack; `routes/admin.js` is an aggregator over `routes/{settings, observability, approvals, schedules, channels, users, skills}.js`, with `routes/runs.js` (the run API) and `routes/approve.js` mounted separately. `secrets.js` is the name-resolved allowlist behind `POST /api/secrets/reveal` (in `routes/settings.js`); `security.js` holds scrypt password hashing, the Host/Origin (DNS-rebinding) guard, the SSRF check over `ip-policy.js`, path containment and the login limiter; `auth.js` (admin session + the run-API key, an admin credential scoped to `/api/runs`); `file-editor.js` / `file-download.js` / `file-upload.js` (one-time grant URLs → per-editor cookies, re-authorized on every request); `assets.js` (content-hash cache busting for the build-less UI); `skills-mcp.js`. - `src/ee/` — the **proprietary, source-visible** licensing plane (`LICENSE-EE.md`): `license.js` (key storage, Ed25519-verified platform responses, the state machine), `tiers.js`, `limits.js` (`licenseAdmission()`: conversation admission + the monthly per-conversation message cap, the usage report), `composio-*.js` (the Enterprise Composio SDK sessions), `update-entitlement.js`. - `src/util/` — `redact.js` (the secret redactor for replies, streams and job output), `bounded-bytes.js` (inbound byte caps), `semaphore.js` (the global run slots), `proc.js` (process-group kills), `process-outcome.js` (human descriptions of exits), `logger.js` (the `events` table), `timezone.js` (daemon-local time vs UTC containers), `cron.js`, `keyed-lock.js`, `singleton.js`, `drops.js`, `tail.js`. - `scripts/` — install and service (`install.sh`, `install-systemd.sh`, `install-ssh-access.sh` + `cg-ssh-attach.mjs` + `cg-ssh-authorized-keys`, `install-whisper.mjs`), updates (`update.sh` → `update-runner.mjs`), backup and restore (`backup-config.sh`, `restore-config.sh`, `restore-drill.sh`, `runtime-maintenance.mjs`), the image (`build-image.mjs`), the checks (`run-tests.mjs`, `static-check.mjs` + `static-secret-writes.mjs` — no secret-resolver value written under an artifact dir, `secret-scan.mjs`, `security-coverage.mjs`, `check-dco.mjs`), the nightly CLI canaries, release (`release-artifacts.mjs`, `check-release-candidate.mjs`, `reviewed-artifact-fixtures.json`), the landing lock (`with-landing-lock.mjs`, a Git ref with liveness checks) and the one-time migrations: `migrate-channelgate.mjs` (the pre-rename layout move `server.js` runs first at boot and never fails the boot on; `--dry-run`, `--repath --from --to `, `--verify`), `migrate-workspace.mjs`, `migrate-skills-manager.mjs`. - `test/` — a flat directory of `*.test.js` files run by `scripts/run-tests.mjs` (never a bare glob), `helpers.js` (`ensureTestEnv()` pins a scratch runtime root — call it before importing anything that opens the database; the runner fails if a test touched the real home), `fixtures/` (fake engines, plugins, a fake runtime backend), and two `*.live.test.js` files that self-skip unless `CG_LIVE_CONTAINER=1` / `CG_LIVE_PLUGIN_CONTAINER=1`. - `~/.channelgate/` — the runtime root (`gateway.db`, `config/` (with `egress-ca/`), `channels///` metadata mirrors, `logs/`, `run/mcp.sock` + `run/egress-ca.pem` (the trust bundle), `eg/<12 hex>/egress.sock` (per-channel egress sockets), `run-tmp/`, `engine-state/`, `clean-workspaces/`, `skill-backups/`, backups, update state). `~/ChannelGate///` — the visible work folders (or a per-channel custom path), with the hidden `.runtime//` artifact tree beside them. The `` component is the adapter's `folderName` fact (`slack` / `teams` / `google-chat`) through `platformFolderName()`, never a literal; an unknown or missing platform is Slack's. None of it is in the repo. ## Config model (single source of truth) Operational data lives in **one SQLite database**, `~/.channelgate/gateway.db` (built-in `node:sqlite`, WAL). The daemon serves the control MCP in-process (one instance per socket connection); the stdio-child entry of the same server opens the database concurrently, which SQLite locking makes safe (the old shared JSON files were not). All access goes through `src/db/` and the store modules; a container never sees the file, and a run reads gateway state only through the control MCP. - **To evolve the schema, append a new migration, never edit an old one.** `git pull` + restart auto-applies pending migrations on any machine. - Tables: identity and conversations (`users`, `channels`, `channel_meta`, `sessions`, `thread_overrides`, `conversation_reply_sessions`, `active_runs`, `stopped_turns`, `inbound_events`, `teams_graph_subscriptions`); automation (`schedules`, `acks`, `followup_threads`, `followup_done`, `followup_digest_messages`, `bg_jobs`, `api_jobs`, `api_conversations`, `schedule_runs`); approvals and questions (`approval_requests`, `approval_link_tokens`, `question_requests`); SSH access (`ssh_keys`, `ssh_sessions`); skills (`skills`, `skill_revisions`, `skill_revision_files`, `skill_sources`, `skill_templates`, `skill_usage`, `skill_proposals`, `skill_access_tokens`); Composio SDK (`composio_sessions`); licensing (`license_usage`); dashboard data (`usage`, `usage_components`, `usage_requests`, `usage_repair_batches`, `events` — typed, indexed columns for day/week/month/channel/user rollups); system telemetry (`system_health_resources`, `system_health_storage`, `system_health_hardware`, `system_health_changes`); plus `_meta` (key/value, created at open) and the optional FTS5 `channel_memory_fts`. Config-shaped rows (`channel_meta`, `users`, `bg_jobs`) keep their full record in a JSON `data` blob so every field survives without a migration; `dead-fields.js` strips retired fields on write. Config that stays as **files** (read wholesale / bootstrap, hand-editable): - `~/.channelgate/config/settings.json` — UI-managed daemon settings (platform tokens, engine defaults, container runtime, license key). Gitignored; **overrides `.env`** (copied into `process.env` at boot and on save). Slack and the other transports can be (re)connected live via the managers — no process restart. - `~/.channelgate/config/mcp-catalog.json` — the admin-curated MCP server catalog. - `~/.channelgate/config/gateway-usage/` — per-file overrides of the `gateway-usage` skill. - `~/.channelgate/channels///.claude/settings.json` — the per-channel lockdown contract Claude Code itself reads (must be a file): tool permissions, the MCP allowlist, memory-off and the Stop hook. No `sandbox` block — the container is the ordinary-run boundary; a sudo-host turn is explicitly outside it. - `containers/versions.json` — the image contract: the spec version and every toolchain pin (`docs/COMPATIBILITY.md`; the nightly canaries read the same file). ## Non-negotiable rules - **Confinement is the product, and the container is the default boundary.** Every ordinary turn — foreground, background job, schedule, API run, memory review — runs inside the channel's selected author container (rootless Podman, image-shipped toolchain, `--cap-drop ALL`, no `sudo`): a per-channel HOME volume at `/home/agent` (engine sessions, CLI logins, installed tools) and, bind-mounted at their identical absolute paths, ONLY the channel's work folder, its clean workspace and its artifact dir (`~/ChannelGate/.runtime//`, which also backs `/tmp` and `/var/tmp`), plus the read-only control socket, the channel's OWN read-only egress socket directory and the CA trust bundle. Nothing else exists on that side: no host home, no gateway root, no `gateway.db`, no other channel's folder or egress socket, no daemon checkout, no operator `~/.claude`/`~/.codex`. Every container runs with `--network none`: its only way out is the daemon's egress proxy (`src/gateway/egress/`) over that per-channel socket, whose PATH is the channel identity. The per-channel *Allow network* switch (`allowNetwork`) is the proxy's live policy — off admits only the engine endpoints and the channel's selected remote MCPs, on admits public destinations, and private/loopback/metadata addresses are always refused — and is also checked against the engine's declared `networkModes` (OpenCode refuses "on", Codex read mode refuses network on its own); there is no per-domain allow-list. A proxy-mode container holds no ruled secret and no Claude login: it holds placeholders the proxy swaps for real values only on their declared hosts and only while the channel has live work (`NETWORK_POLICY_ENFORCED = true`, asked per target through `networkEnforcedFor`). Two operator/admin escapes make the switch advisory again and are reported as such everywhere: the gateway-wide LEGACY `containerEgressMode = "bridge"` (the open bridge and raw secret values) and a channel's admin-set `rawNetwork` (the bridge beside the proxy). A proxy that cannot start never opens the bridge: proxy-mode runs fail closed with the remedy. Every channel folder still gets the lockdown file (`autoMemoryEnabled:false`, `autoDreamEnabled:false`, curated `permissions.allow`, the MCP allowlist, the Stop hook) — it carries POLICY, never a `sandbox` block, and nothing a run can do changes what its container mounts. The one process-boundary escape hatch is typed Slack `/sudo`, scoped to exactly one thread: only a current organization admin can enable or disable it, every sender and background launch is re-authorized as an admin, non-admin messages are rejected before hydration or process spawn, and the resolved engine runs directly as the daemon OS user with its host filesystem, processes, HOME, commands and network. The flag is a thread posture, never reusable authority; stored channel metadata and run-API overrides cannot select the host. Turning it off restores the container, and native session state is carried across the boundary when possible. Admin channels otherwise run in containers too: the admin author's live turn adds the bypass flag, and the work folder is mounted read-write like any other's — so an admin channel whose work folder is a host directory (the gateway's own checkout, say) hands that directory, and only that directory, to its container, everything in it included. That is the intended trust model for admin channels; put nothing in such a folder that the channel must not see. The one operator-chosen widening is the gateway-wide *Admin channels can access the host home* switch (Settings → Container runtime, `containerFullAccessHome`, OFF by default): only a currently authorized organization admin's run in an Admin-mode channel gets the daemon user's WHOLE home read-write at its identical path — other channel folders, repositories, gateway metadata and credential stores — with the container engine's storage masked. Non-admin members and guests use a separate project container, retain Worker with the selected Auto/Lean options, and cannot read the operator home, other channels or admin engine histories. The daemon checks the author's current role when resolving each foreground or background spawn; metadata, prompt text and a stored job's claimed role cannot grant a mount. Admins retain the legacy container/HOME/artifact lane; project authors use a fresh `cgp2-` container, HOME and temporary volumes and a separate `.runtime-project-v2//` artifact root. The project and clean workspace remain shared, so files deliberately stored there are visible to admitted authors. Lane changes start fresh engine sessions and do not transfer admin history. Memory reviews always use the project lane. The switch remains boolean, part of create-time reconciliation, and unavailable through MCP tools. - **Secrets never ride a listing response.** `/api/settings`, `/api/channels`, `/api/users` and the channel-meta PUT return `has*`/`last4` ONLY. A value is fetched one at a time from `POST /api/secrets/reveal`, which re-checks the admin password even for a valid session and audit-logs what was revealed (never the value). New secret fields go in the `src/web/secrets.js` allowlist — resolved by NAME with own-property checks, so it can never become "read any config key". The admin password is stored as a scrypt hash and is deliberately not revealable. - **A run is never killed for being quiet, and never capped by runtime.** Reaching the inactivity window makes the watchdog probe whether the engine PROCESS still exists and, if it does, REPORT the wait to the user and keep going. A turn ends only on a vanished process or the absolute `CG_MAX_SILENCE` budget. `COMMAND_TIMEOUT` = how often a quiet turn reports itself, NOT when it dies (both are read per turn in `run.js`). Every runner goes through `createStallWatchdog`; none keeps its own timer. Progress is STDOUT: engine stderr is commentary (retry/backoff/sign-in chatter) that records liveness but never resets the silence budget, or a wedged-but-chatty process becomes immortal — liveness is the pid probe's job. A turn that dies for ANY classifiable reason says which one: a wedged turn's buffered stderr is classified on the way out, and a credential failure it names before a tool has run is replay-safe, so it fails over instead of dead-ending. - **The user must always be able to tell a run is alive.** Long turns carry a ticking heartbeat row (elapsed + last activity + running subagents), quiet stretches say "still connected", and both queues (global run slots, per-thread) report position. Any new waiting state must announce itself — silence that looks like death is the bug this exists to prevent. - **A pinned runtime is never traded away.** Cross-engine failover exists so a DEFAULT never strands a thread. When the user pinned this thread's harness/model by hand (`/model` → "just this thread", a `claude`/`codex` directive, or a per-run API override), the turn fails with the harness's OWN error plus the manual-switch hint instead of being quietly answered by the other engine. Channel and gateway defaults are not pins. - **Composio identities stay distinct.** Inject the active author's personal token as `composio-user` and, independently, the channel token as `composio-agent`, falling back to the organization token (never the other way round; a DM gets only `composio-user`). A channel folder never stores either token in plain settings. "My account" means `composio-user`; "your/channel account" means `composio-agent`. Reads/searches may use either or both unless the user restricts the account or scope. Writes/sends/state changes require the intended identity and connected account; reuse established choices and clarify only unresolved mutation identity. Independent authorized reads can continue while that clarification is pending. - **Composio is the only external Slack MCP.** There is NO hosted per-user Slack MCP and no `connect_slack`/`xoxp` OAuth (removed). Slack actions beyond the gateway's own bot tools — cross-channel search, sending elsewhere, scheduled sends, canvases, reactions — go through the **Slack toolkit inside Composio** (acting as the explicitly selected personal or shared account). The gateway's own bot-token Slack tools (`slack_channel_history` / `slack_thread_replies`, `slack_download_file`, `slack_post_chart`, `slack_post_table`, Slack Lists, `slack_upload_snippet`) act as the BOT and MUST stay hard-scoped to the current channel id, never an arbitrary one. - **Platform capabilities are declared once and fail closed.** Every difference between chat surfaces (tables, headings, images, streaming, ephemeral messages, threads, native charts/Lists, attachment reach) lives in the adapter's capability descriptor in `src/platforms/`, and is read through `platformSupports()` / `capabilitiesFor()` — never by comparing a platform id to a literal. An undeclared capability resolves to the LEAST capable value, an unknown capability KEY throws, and an unknown/missing platform on a stored record resolves to Slack (every row written before multi-platform support is a Slack row). Reply formatting is degraded on the way OUT by `format/degrade.js`, not by teaching the model a per-surface dialect; each formatter re-proves the control-sequence escaping contract for its own platform. Unattended posts go through `postNotice`/`postDirectMessage`, and a platform whose transport is not wired gets a connector that THROWS on write — a silent no-op would look like a delivered answer to the scheduler. - **A channel's secrets are write-only and never leave the channel.** Per-channel environment secrets (`src/config/channel-env.js`) are what let one conversation act as its own Supabase or Vercel account instead of sharing the host's single login. Every surface returns name + provider + last4 + who/when and NOTHING else — no reveal path, not even for admins, and deliberately not in the `src/web/secrets.js` allowlist, whose named-getter shape is what stops it becoming "read any config key". Names are validated against the reserved prefixes and names on write AND re-filtered at the runner boundary (`safeSpawnEnv` from `channel-env.js`, applied inside `buildClaudeEnv`/`buildCodexEnv`), because an arbitrary name is code execution (`LD_PRELOAD`, `NODE_OPTIONS`, `PATH`) or identity hijack (`ANTHROPIC_BASE_URL`), and `extra` deliberately beats everything inherited. A rotated value must retire the warm process (the digest is in the pool fingerprint), a background job resolves at ITS own spawn because it outlives the run, and values are redacted out of replies, the live stream, and job output — write-only in the UI is not write-only at runtime: a run's process can read its own environment, and every attempt is told the injected NAMES so it can use them without printing them. In a proxy-mode container a secret with an egress rule (`src/gateway/egress/catalog-rules.js`, or the entry's own "used on hosts") is only its PLACEHOLDER — stable per scope/channel/owner/name, swapped for the live value by the proxy on the declared hosts — so rotation no longer needs to retire anything; the redactor still carries every REAL value. A secret with NO known or declared destination is decided by its kind (`secretExposure` in `catalog-rules.js`): a web-API-looking one is HIDDEN — a placeholder swapped in a credential-like header or query parameter, but only on servers an admin APPROVED for that secret (`approvedHosts` on its entry); its first request to any other server is refused with a 403 while a durable admin-only approval card is posted in the live thread (`src/gateway/secret-host-approvals.js`), never swapped into the engines' own APIs — while a password, connection string, signing key or configuration value stays READABLE: raw and listed unprotected (withheld under `containerEgressSecretsStrict` — on for new installs, pinned off at boot for installs that predate it; `list_secrets` reports each such name as a finding). `set_secret_mode` forces hidden or readable; `allow_secret_host` (admin) approves a server without a card. Every spawn site resolves through `resolveEgressRunEnv`, never `resolveRunEnv` directly. - **The gateway uses the operator's own Claude login, and never copies a credential file.** Which login answers a turn is decided ONLY by `src/gateway/claude-login.js`; no other module may stat, read, link or copy a `.credentials.json`. Claude Code writes that file by RENAME and rotates the refresh token on every refresh, so any second copy that refreshes logs the first one out — that is exactly how the gateway's own engine-home copy silently expired while the operator stayed signed in. A run receives a RELAY of the resolved login's ACCESS token instead, gateway-owned and applied last in the child env so a channel secret cannot displace it — and in a proxy-mode container not even that: the channel's relay PLACEHOLDER (`containerClaudeCredential`), which the egress proxy swaps for the current access token on `api.anthropic.com` only. A turn with no resolvable login fails closed with the remedy named when Claude is the selected engine; a missing optional child login does not block Codex or Qwen. Before every proxy-mode container turn, `nested-claude-login.js` prepares a private HOME env file containing only channel-bound relay placeholders and the nested CLI launcher. Qwen's main CLI bypasses that launcher; nested `claude` clears inherited provider settings before loading the Anthropic relay. API-key child logins also use destination-bound relay grants. A new consumer asks the resolver; it never adds a second notion of "the login". The ONE login file the gateway writes is an interactive SSH session's (`src/gateway/ssh-session.js`): an ACCESS-ONLY `.credentials.json` in the channel's config dir — the relayed token, its expiry and plan facts, never a refresh token, so it can rotate nothing — plus the operator's account record, both written through the relay, refreshed with it, and removed when the channel's last session ends. Claude Code labels a token in the environment "Claude API" and hides the plan, the usage windows and the plan's default model; only a file login shows them, which is what a developer in a terminal needs to see. Codex is the twin (`src/gateway/codex-token-relay.js`): behind the egress proxy a container never mounts the operator's `auth.json`; the runner writes an ACCESS-ONLY `auth.json` into the channel's HOME volume before every Codex run (and an SSH session) whose access token is the channel's relay placeholder in JWT shape — the real claims, `cgph_r…` as the signature — and whose refresh token is empty, the proxy swaps the WHOLE token on the OpenAI/ChatGPT hosts only, and the daemon renews the real login with a cheap ephemeral `codex exec` in the login's own `CODEX_HOME`. It is written by rename, never through a mount. Only the legacy bridge mode and an API-key login keep the shared read-write file mount. - **Only admins get `--dangerously-skip-permissions`**, and only in an Admin-mode channel (a live Slack turn by an admin author, or a live HTTP run API turn, whose key is an admin credential acting as the `api` principal — `src/config/api-principal.js`). Everyone else runs with the folder's `permissions.allow` allowlist and answers tool requests through the `permission_prompt` approval card (or Auto mode); headless can't answer interactive prompts. - **Authorization (who may talk)** is `isAuthorized()` in `src/gateway/modes.js`, checked before anything else runs on every surface: a user is allowed if they are an **admin** or **approved** (`approved` = approved by the deployment operator). Approved users may talk in their DM and in any channel whose access policy is the default `approved` (membership is implied by posting); a channel may narrow that to `admins` or to `none`. Unknown/un-approved users are denied **everywhere, including DMs** — the only exception is an explicit per-channel guest grant (`meta.allowedUsers`, channels only, constrained to current members). Who may change a channel's access settings is `canManage()` (`meta.manageAccess`: admins, members, or a named list). This is authorization only; dangerous permissions still require an admin author **and** an admin-mode channel, and the run-API caller's `author` is never trusted for either (the run API KEY is: its runs act as the admin `api` principal, never as the named author, and get no personal scope). - **Attachments:** image/file attachments are downloaded into the channel folder's `uploads/` (per-file cap in `src/util/bounded-bytes.js`, no-follow writes) and their paths handed to the engine as text (read via the Read tool — images render visually); a failed download is named, never pretended. Voice notes are transcribed locally (Whisper) when enabled. - **Assistant status:** in an assistant/AI-app thread the bot shows the native shimmering status (`assistant.threads.setStatus`) with phrases that track progress; it no-ops elsewhere, where the streamed placeholder message is the feedback. - **Mention gating:** respond in a DM without a mention; everywhere else require an explicit `<@BOT_ID>` mention. The only bypasses are the reaction trigger on a thread the bot already engaged and the scan for mentions posted before the bot joined; registered slash commands work at conversation top level. - **Linux only:** ChannelGate targets Linux with systemd and rootless Podman — no macOS/launchd branches, no BSD-tool assumptions; `src/start.js` (through `platform-gate.js`) refuses every other platform with one plain line. Still prefer portable Node APIs over shelling out (`child_process` with `detached:true` instead of `setsid`, `node:fs`/`node:path` instead of shell utils) and keep any shell POSIX-portable. - **Secrets** live in gitignored bootstrap/runtime configuration and the SQLite user store, outside published source. `users.json` is a legacy import only. Never hardcode or log tokens; `npm run secret-scan` must stay clean and CI runs it over candidate history too. - **`src/ee/` is proprietary and the enforcement is not optional.** The directory is source-visible under its own `LICENSE-EE.md`, not the root Sustainable Use License. Removing, disabling or circumventing the key verification, the usage limits or the usage reporting — including the `licenseAdmission()` call site in `src/gateway/run.js` and the `license_usage` schema — is a license violation and is sent back in review. Patches to that directory, new tiers or limits, and any licensing/CLA/trademark text change need a prior discussion. - **One worktree per task; never edit a shared checkout's working tree.** Several threads, schedules and background jobs run in the same folder, and two of them editing one working tree silently overwrite each other. Before the first edit of any task that touches a tracked file, open `.worktrees/` on its own branch from `origin/beta` and work only there; the shared checkout stays on `beta`, clean, and never changes branch. Uncommitted changes you did not write belong to another task — leave them and say so. Commands, landing and cleanup are under **One worktree per task** in Contributor workflow. - **Project records:** shipped behavior lives in `FEATURES.md`; the cumulative regression lives in `TEST-PLAN.md`. Keep both aligned with shipped code. Do not create, consult, or update `TASKS.md`, and do not make a brainstorming or standalone plan-writing phase a prerequisite for implementation. Use the active request, repository state, focused acceptance criteria, and tests as the working source of truth. - **Behavior changes need acceptance evidence.** Update `FEATURES.md` and `TEST-PLAN.md`, with regression tests and reproducible acceptance instructions for Claude and Codex whenever both can reach the behavior. State exact fixtures, setup, prompt/action, expected evidence and pass rules. Mark engine-independent cases only when the engine cannot affect them. Contributors need no private QA service access: include results and unexecuted live cases in the PR. Maintainers must complete the applicable live release gates before declaring a release ready. ## Contributor workflow ### One worktree per task — before your first edit **Never edit tracked files in a shared checkout's working tree.** Several threads, schedules and background jobs can run in the same folder at once, and two of them editing one working tree overwrite each other's uncommitted changes with no error and no trace. This is not a style preference and is not waived by a task being small, urgent, one file, or already started. The failure it prevents is real and has happened here: six unrelated features were built directly in the served checkout and left uncommitted, interleaved at hunk level across the same shared files, and could no longer be separated into the six commits they should have been. Run this before the first edit of any task that creates, modifies or deletes a tracked file (read-only questions, status checks and analysis need none of it): ```sh git rev-parse --is-inside-work-tree # confirm this is the repo git fetch origin grep -qx '.worktrees/' .git/info/exclude 2>/dev/null || echo '.worktrees/' >> .git/info/exclude git worktree add -b / .worktrees/ origin/beta ``` - Branch from `origin/beta` — never from `main`, and never from the shared checkout's current HEAD. - `` is `feat`, `fix`, `docs`, `chore` or `test`; `` names the work. If the branch already exists, another task owns it: pick a different slug. - Keep worktrees in `.worktrees/` **inside** the repo folder, so they stay within the channel's mounted working folder. The `info/exclude` line keeps them out of `git status` without touching the tracked `.gitignore`. - Do every edit, test run and commit under `.worktrees//…`, using paths inside the worktree rather than the matching path at the repo root. Commit early: only committed work survives a concurrent thread. Stage only your own files. - The shared checkout stays on `beta` and clean. Never switch its branch, and never edit its files while a task worktree is open. - One task, one worktree. Two features are two branches, even when they touch the same file — that is what keeps them separable later. Land in the shared checkout (under the deployment's landing lock where one applies): bring `beta` current, `git merge --no-ff /`, re-run a proportionate check after any conflict resolution, then push. Clean up only after `git merge-base --is-ancestor / beta` succeeds, with `git worktree remove` followed by `git branch -d` — never `rm -rf` the directory, which leaves git's entry `prunable` and then blocks the branch delete. If you find uncommitted changes in the shared checkout that you did not write, they are another task's work in progress. Leave them alone, say so in your reply, and open your own worktree. ### Beta development and stable promotion - `beta` is the integration branch for all new development, fixes, documentation and tests. Start each task from current `origin/beta` in its own branch/worktree; validate and land on `beta`, then push `origin beta`. Keep a served beta checkout on `beta` between tasks. - `main` is the stable release branch for downstream installations. Completing a task or receiving permission to push to `beta` never authorizes a merge or push to `main`. - Promote `beta` to `main` only after BOTH a full test pass and the user's explicit confirmation to release that exact candidate. Prepare a release PR and report its candidate commit, scope, test results and live acceptance evidence BEFORE asking for confirmation. A request to adopt this workflow, earlier general approval, silence, or a beta merge is not release approval. The maintainer procedure, gates and evidence rules are `docs/MAINTAINER-RELEASE.md`, `docs/RELEASE-CHECKLIST.md` and `docs/RELEASE-ACCEPTANCE.md`; the release workflow accepts only the package version's exact tag on `main` (`scripts/check-release-candidate.mjs`). - A full test pass means `npm run test:coverage` (the full regression suite with coverage floors), `npm run check:static`, `npm run secret-scan`, `npm run test:security-coverage`, `npm audit --omit=dev --audit-level=high`, and `npm run check:dco -- origin/main..HEAD` (`origin/beta..HEAD` for a development branch), plus passing CI and every applicable live release gate in `TEST-PLAN.md` for Claude and Codex. Include exact fixtures, prompts/actions and observed evidence; a skipped, blocked, failed or unexecuted required check is not a pass. Only cases unaffected by engine choice may be marked engine-independent. Maintainers complete private deployment acceptance where applicable. - Approval and evidence belong to the exact tested candidate. If its content changes, or integration with newer `main` changes the proposed release, rerun the applicable full checks and obtain fresh user confirmation before promotion. Release only the approved candidate; do not include later beta commits. Keep `beta` up to date with the approved stable history. - These branch and release rules apply to both Claude and Codex and override generic skill defaults that say to merge completed tasks into `main`. Use the deployment's serialized landing lock for integration; never switch a shared served checkout to `main` merely to publish a release. Open the task's own worktree first (see **One worktree per task** above; it is not optional). Target development pull requests at `beta`; completed development work is merged and pushed to `beta`. Keep shared integration checkouts clean, stage only your changes (never `git add -A` / `git commit -a`), write conventional imperative subjects, sign off every commit under `CLA.md` (`git commit -s`; the trailer is the CLA acceptance), and open a pull request with the design note and the actual check output. External contributors push to their own fork and never need the publisher's GitHub account or a production checkout. See [CONTRIBUTING.md](CONTRIBUTING.md) for local checks, what is accepted directly and what needs a prior discussion. For a checkout actually serving a daemon, follow its deployment-specific landing policy: refresh, verify and land under `npm run with-landing-lock -- `. Maintainers serialize production integration; this is not a requirement for outside contributors to deploy their own changes. Preserve both sides of shared documentation conflicts (`FEATURES.md`, `TEST-PLAN.md` and `CHANGELOG.md` are the usual hotspots). Delete task worktrees/branches only after confirming their commits are safely integrated, or after an explicit owner-authorized handoff that preserves the commits remotely. A deployment may keep additional private QA account, test-channel and production landing details in a gitignored `AGENTS.local.md`. Do not commit customer identities, credentials or private system links to public contributor instructions. Session-specific operator instructions remain binding for that session even when public documentation is revised.