# CLAUDE.md — loomcycle project guide

This file is loaded by Claude Code on every session in this repo. Read it cold; act from it without re-discovery.

## Project context

**loomcycle** is a high-load agentic runtime: one Go binary that owns the LLM tool-use loop end-to-end (model → tool_use → tool_result → model), runs as a sidecar, and is consumed by application servers over HTTP+SSE / gRPC / MCP / a TS adapter. Multi-provider (Anthropic, OpenAI, DeepSeek, Gemini, Ollama; + a synthetic `code-js` provider). Multi-tenant (per-principal tokens, tenant-isolated state + definition planes). Multi-agent (parent agents spawn sub-agents via the built-in `Agent` tool). Apache-2.0.

It exists because vendor SDKs (`@anthropic-ai/claude-agent-sdk`, OpenAI Agents SDK) bundle a binary, hide the loop, and lock you into one provider. Spawning that binary per call is 20–30 s cold-start, leaks memory under load, and pins a fleet to one model. loomcycle replaces the SDK with a pure HTTP-only loop, exposes native cache-control where the provider supports it, and stays single-tenant-friendly while being shaped to grow into multi-tenant production.

**Where it sits in the stack:**
```
   App / n8n / LangChain  ──HTTP+SSE · gRPC · MCP · TS──▶  loomcycle (this repo)  ──HTTP──▶  Anthropic / OpenAI / DeepSeek / Gemini / Ollama  (+ code-js, mock)
                                  │
                                  ├─ built-in tools (Read/Write/Edit/Grep/Glob/NotebookEdit/HTTP/WebFetch/WebSearch/Bash/Agent/Skill/Memory/Channel/AgentDef/SkillDef/Evaluation/Interruption/Context)
                                  ├─ substrate Defs (Agent/Skill/MCPServer/Schedule/Webhook/MemoryBackend/A2A — runtime-mutable, tenant-scoped)
                                  ├─ MCP servers (stdio pool + HTTP)   ·   MCP server surface (`loomcycle mcp [--upstream]`)
                                  ├─ triggers: Scheduler · inbound Webhooks · A2A peers
                                  └─ LLM Gateway (OpenAI-compatible) · LocalAPI gateway (OpenAPI → tools)
```

**Currently shipped (latest tag v1.12.0 — the post-1.0 line: Volumes, SQL Memory, Bashbox, Path + Document, per-tenant MCP + config layering, embedded presets + the TrueNAS app, the tenant-operator Web UI, multimodal image input, the routing view, a security + robustness hardening pass, tenant credentials + per-scope usage/cost attribution, per-scope token budgets, the operator-key scope):** The core primitives stabilised across the v0.8 → v0.23 line; v0.24.0 was an architecture-review hardening pass; v0.25.0 added the manual-management Web UI console + the RFC S ensemble-synchronization primitives (`Context op=time` agent clock, `Channel.await` / `Channel.broadcast` fan-in/out — in-band + MCP + REST/gRPC/TS client twins, `max_fires` self-retiring schedules); v0.25.1 is a patch — the scheduler's `on_complete: channel.publish` hook now honors the channel's declared scope (F37/RFC T; it was always user-scoped, so a global fan-in channel's pings were invisible to a global reader), plus a Web UI catch-up (collapsible left-sidebar nav; `max_fires` + channel broadcast/await surfaced); v0.25.2 is a patch — a scheduled run now resolves a runtime-created (substrate `AgentDef`) agent in the def's tenant (F38/RFC U; the fire path stamped the owning tenant on the row but not the def body, so it resolved at the shared "" tenant and failed `unknown agent`), and an agent-resolution failure no longer burns the `max_fires` budget; v0.25.3 is a patch — a dynamic stdio MCPServerDef's `${ENV}` env is now interpolated at spawn (F39/RFC V; static yaml interpolated at config-load but the dynamic def path passed `${...}` to the child literally); **v0.26.0 added the interactive terminal** — inline interruptions, mid-run **steering** (`POST /v1/runs/{run_id}/input` + `internal/steer` + a `steer` SSE event), **persistent** `interactive` runs that park at end_turn (`awaiting_input`) waiting for operator input, and a per-agent `unbounded_iterations` flag (LLM iteration-cap lift); the `/run` Web UI terminal drives it; v0.26.1 made steering **cross-replica** (a `coord.SteerCoordinator` routes `POST /v1/runs/{run_id}/input` to the owning replica by `runs.replica_id`, mirroring the cancel coordinator) + added an interactive-terminal end-to-end test (`MAX_INTERACTIVE_RUNS` remains deferred); v0.26.2 is a patch — a runtime-authored meta-agent's `*_def_scopes` capability gates (agent / skill / schedule / a2a-server-card / a2a-agent) now round-trip the `AgentDef` create/fork overlay (F40/RFC W; they had no field in the persisted overlay shape `mergedDef` or the read adapter `lookup.SubstrateAgentDef`, so a breeder/scheduler-of-agents came back default-deny on reload and could author nothing), mirrored like the F14 channels/evaluation_scopes/interruption closure and deliberately excluded from `content_sha256` (authority, not content — existing agent rows stay byte-stable); **v0.27.0 made interactive runs survive leaving the terminal** — an interactive run executes in a background goroutine under `context.WithoutCancel` (keeps request ctx values, not cancelled on client disconnect; stops only via the cancel registry) and is **re-attachable** via `GET /v1/runs/{run_id}/stream` (replay-from-`?from_seq` + store-tail, re-emitting persisted events as SSE frames; backed by a new run-scoped `GetRunEventsSince`), with a "resume in terminal" link in the runs list; also `Context op=self` now returns the resolved `provider`+`model` (non-secret), and the `/run` terminal caps its height (inner-scroll) + collapses tool results (note: the steer echo frame is no longer on the live wire for interactive runs; steering itself is unchanged); v0.27.1 is a patch — interactive-terminal Web UI polish (auto-scroll follows the live stream via a stick-to-bottom ref; `tool_call` collapses to a summary + expand like `tool_result`; the continue/steer box is a multi-line textarea — Enter sends, Shift+Enter newline); v0.27.2 is a patch — the collapsed `tool_result` summary is now truncated (it used whitespace-only `oneLine`, so large results showed in full whether folded or not); **v0.28.0 added two features** — (1) per-agent LLM **`sampling`** params (temperature/top_p/top_k/frequency_penalty/presence_penalty/seed/stop) settable via static yaml, AgentDef create/fork overlay, and per-instance on `POST /v1/runs` (merged per field, per-run > per-agent > provider default; `temperature:0.0` is deterministic ≠ unset; each driver maps what it supports, Anthropic drops temperature when `effort` engages thinking; content-identifying; reported via `Context op=self`); (2) **`pause` cooperative quiesce** (F41/RFC X) — the loop parks at an iteration boundary, `Pause()` waits (up to `timeout_ms`) for in-flight runs to park so `paused_runs_count` is accurate, new runs are 503-gated (`/v1/runs`+gRPC+webhook/A2A), making snapshot-a-mid-run reliable (a fan-out parent mid-`parallel_spawn` is the documented Phase-2 deferral); **v0.29.0 is a Web UI + operability release** (no new runtime primitives) — (1) the agent create/fork modal gained dedicated **sampling** controls + a collapsible **advanced JSON/YAML overlay** (`js-yaml`) for overlay keys without a dedicated control, shallow-merged over the structured fields, empty-box-never-blocks (#449); (2) the interactive terminal now **echoes the operator's own messages** (`❯ …` — initial prompt/steer/continue, client-side; they're filtered from the live SSE tail) + renders a **context-size gauge** backed by an additive `max_context_tokens` on the usage event from `Provider.Capabilities()` (`@loomcycle/client` 0.26.0; gRPC parity deferred) (#450); (3) **soft reclaim** — `AgentDefSetRetired` now clears the active pointer when retiring the active def (transactional, both backends) so a retired name is reclaimable (recreate to grant more tools — fork can't widen the `tools` ceiling), also fixing a latent bug where a retired-but-active def was still served to runs (`lookup.resolveDynamic`), plus `live_version_count`/`active_retired` on the list + Library badges (no hard delete — `delete` op deferred) (#452); plus a bundled `examples/exp6-self-evolving-agents/` (#451); v0.29.1 is a patch — adapter lockstep publish (the `max_context_tokens` field shipped in the runtime but `@loomcycle/client`'s npm publish skipped because the adapter `package.json` was at `0.26.0` not the tag's `0.29.0`; v0.29.1 realigns the adapter version so `@loomcycle/client@0.29.1` publishes — binaries identical to v0.29.0); **v0.30.0 closed F42 / RFC X Phase 2 — cross-instance resume of a snapshotted mid-run**: a `pause_state='paused'` run restored on another instance was data-only (nothing relaunched its loop; `/v1/_resume`→409, restart didn't relaunch), so v0.30.0 re-dispatches paused runs by reconstructing the loop from the transcript (`ResumePausedRuns` in `internal/api/http/resume.go`: re-resolve agent→provider/model/tools/system-prompt, `replayTranscript`→`PriorMessages`, flip pause_state→running, re-register cancel/pause/runstate/steer, re-enter `loop.Run` under the existing run_id in a detached goroutine) — fired after a snapshot restore (reports `paused_runs_resumed`) AND at boot (crash recovery; cluster-gated by `coord.LockKeyResumePausedRuns`); added an additive `runs.interactive` column (captured+restored, park-vs-complete semantics) + the stale-sweeper now skips `pause_state IN (paused,pausing)`; per-run secrets (user_bearer/credentials) + call-time overrides (allowed_hosts/sampling/metadata) are NOT snapshotted (re-derived from the agent def), and an idle-awaiting-input run is flagged for manual re-attach (the documented carve-outs); **v0.31.0 closed F42 / RFC X Phase 3 — park + resume a fan-out PARENT blocked in `Agent.parallel_spawn`**: a pause-watcher goroutine parks the parent on pause without touching `wg.Wait`/result-collection, a no-schema-change two-event spawn ledger (`spawn_child_started` index→run_id, `spawn_child_result`) on the parent transcript makes in-flight children durable, and `resumePausedRun` reconciles each child (ledger result, else awaits the re-dispatched child to terminal) + synthesizes the byte-compatible `{"results":[…]}` envelope into `PriorMessages` — all gated behind **`LOOMCYCLE_RESUME_FANOUT`** (default OFF → existing pause/snapshot/resume byte-identical until opt-in); **v0.32.0 shipped the full context-compaction subsystem** — a compaction replaces the loop's in-memory history with `[pinned task? + summary, ack] ++ last-N kept verbatim` (snapped to a clean user-turn boundary so a tool_use/tool_result pair never splits; system prompt re-derived; a `context_compaction` transcript marker makes `replayTranscript` rebuild the identical form on resume — full transcript retained), four triggers on one shared `loop.Summarize` (**manual** `POST /v1/runs/{id}/compact`, **auto** when `compaction.enabled` + footprint crosses `autocompact_at_pct` — autonomous runs too, off by default, self-debouncing, **self** `Context op=compact` which also reports `compaction`+`context` via `Context op=self`), and a per-agent **`compaction`** block (`enabled`/`target_percentage` 10–50/`keep_last_n`/`keep_first`/`autocompact_at_pct` 50–95/optional cheaper same-provider `model`; mirrors `sampling`: yaml/overlay/per-run/content-identifying) that **flows down the spawn tree**; **v0.33.0 lifted external fan-out + the run-mutation surface to every transport** — RFC Y external fan-out (**`POST /v1/runs:batch`** + a **`spawn_runs`** MCP tool + a **`SpawnRunBatch`** gRPC RPC: up to 32 fresh runs server-side-concurrent under the per-user admission gate, index-aligned envelope, per-child failures in-envelope, caller's authoritative tenant on each child, `mode:detach` awaits RFC P), a **`compact_run`** MCP tool + a **`CompactRun`** gRPC RPC (the compact op lifted to a shared `connector.CompactRun`; HTTP byte-identical; the parked-boundary 409 → gRPC `FailedPrecondition`), per-run **`sampling`** + **`compaction`** on MCP `spawn_run`/`spawn_runs` + the gRPC `RunRequest`/`ContinueRequest` (proto3 `optional` per field, so `temperature:0` stays deterministic — also added `Sampling` to `connector.SpawnRunRequest`, a latent gap) + `@loomcycle/client` **0.33.0** (`spawnRunBatch()`/`compactRun()`, 52→54 methods), and an **exp7** self-review hardening pass (the runtime reviewed its own repo via a 10-agent fan-out): tenant-scope `DynamicAgentDelete` (a cross-tenant delete), never interpolate loomcycle's own infra secrets (`PG_DSN`/`LOOMCYCLE_PG_DSN`/`LOOMCYCLE_AUTH_TOKEN`) into a YAML/MCP field + reject newline-bearing env values, scope-gate `POST /v1/runs/{id}/input` at `runs:create`, scheduler logs a dropped `RecordResult` error, an O(1) `ChannelGet` point-lookup that propagates store faults, an additive verify-if-present snapshot SHA-256 checksum, + `idleReadCloser` timer serialization / `Refresher.Start` idempotency / an `a2aServer.RegisterGRPC` nil-guard; the **v0.34 → v0.37** line was a hardening + operability run (context-transform plugins incl. the `redact` secret-scrubber, exp7 self-review, Web UI design system + theming, the interactive-sessions switcher + run-page polish, model aliases usable in tier candidates, sandbox introspection via `Context op=self`, and a slow-local-model robustness pass — heartbeat-during-call, compaction window cap, aliases-first config + a local-model guide); and **v1.0.0 is the stable, feature-complete tag** on that runtime — no new primitives, distribution wired (Homebrew / multi-arch Docker / Claude Code plugin / TS + Python adapters), validated by an 8-hour soak (1.27M circuits, 3.8M agent runs, zero leaks); **the post-1.0 line then continued**: v1.0.1 the `substrate:tenant` scope (RFC AF — full power within one's own tenant, not cross-tenant admin), v1.0.2 permitted host-widen with no static allowlist, **v1.1.0 Filesystem Volumes** (RFC AH; ⚠️ retired the legacy single `READ_ROOT`/`WRITE_ROOT`/`BASH_CWD` jail — an agent now gets FS access only via a `volumes:` binding), v1.1.1 interactive agentic sessions over gRPC + TS (RFC AI), **v1.2.0 SQL Memory** (RFC AA — a per-scope SQL database as a third `Memory` facet, sqlite + postgres tiers), **v1.3.0 Bashbox** (RFC AJ — a TRUE in-process gbash sandbox + operator host-command fallback), and **v1.4.0 the Path VFS** (RFC AL — a Unix-like `dirents` naming layer) **+ chunked-graph Documents** (RFC AK — chunk bodies in Memory, structure in SQL Memory), both lifted to every transport (HTTP / gRPC / MCP / TS / Python); and **v1.5.0** made the loomcycle-as-MCP-server transport (`/v1/_mcp`) **per-tenant** (RFC AG — `mcpPrincipalCtx` stamps the bearer's subject+tenant on every builtin dispatch, `applyPrincipal` on spawn, a per-tool gate withholds the admin-only meta-tools from a non-admin session, and the route flips `substrate:admin`→`substrate:tenant`; a `substrate:tenant` token now drives a fully tenant-confined MCP session, and an MCP agent's user-scoped docs/memory align with the Web UI), added **config-declared `principals:`** (RFC AO — static `(tenant,subject)` logins bound to `.env.local` secrets via `token_env`; one token serves the Web UI login AND an MCP thin client) and **config layering** (RFC AN — repeatable `--config` + `LOOMCYCLE_CONFIG_FILES`, deep-merged left→right last-wins, `LOOMCYCLE_CONFIG_STRICT` for fatal-on-conflict; stack a bundle onto a local config without copy-paste); and **v1.6.0** bundles config INTO the binary and makes loomcycle deployable as a TrueNAS app: **embedded config presets + the `document-agent` bundle** (RFC AQ — `go:embed` provider/tier presets `base`/`oauth`/`local` + agent bundles with inline `skills:`, selected via `LOOMCYCLE_PRESETS=base,document-agent`, layered as the config base; `loomcycle presets`/`env-template` introspect them; opt-in), **inline `skills:` + the `!prepend`/`!append` sequence-merge + `LOOMCYCLE_CONFIG_DIR`** (one-provider-per-file presets with no restatement; precedence presets→CONFIG_DIR→CONFIG_FILES→--config), a **Web UI Settings hub** (admin-only gear → tenant-token generation + presets viewer + runtime pause/resume + health, RFC L token minting web-reachable for no-shell deploys; + topbar sign-out via `/ui/logout`), and **loomcycle on TrueNAS SCALE** (RFC AR — `deploy/truenas/` ships a validated paste compose + `INSTALL.md`, a catalog-app source with an install wizard whose env coverage is generated by `loomcycle truenas-questions`, + `docs/TRUENAS.md`); **v1.6.1 is a patch** — the `loomcycle mcp --upstream` THIN CLIENT now self-heals on upstream session expiry (the upstream's 30-min MCP-session TTL left the proxy's cached `Mcp-Session-Id` dead → `404`/`-32001` → it never refreshed → only `/exit`-relaunch recovered; the proxy now caches `initialize` and transparently re-handshakes + retries once on `-32001`, single-flighted + one-retry-bounded; `internal/api/mcp/proxy.go`, runtime-only); **v1.6.2** adds the **`chat` bundle** (two conversational agents for the interactive `/run` terminal — `chat` tier:middle + `chat-local` pinned `model: local-medium` ollama-local, broad toolset, `LOOMCYCLE_PRESETS=base,chat`) and **`LOOMCYCLE_OLLAMA_LOCAL_NUM_GPU`** (a global `ollama-local` `options.num_gpu` knob to force GPU offload where Ollama auto-detection falls back to CPU — sibling of `NUM_CTX`, model-LOAD scope), plus a Web-UI run-row fix (agent name beside the status badges) and a docs pass (Postgres `≥ 14` wording, not a `16` pin; TrueNAS secrets via `env_file`); **v1.6.3 shipped the tenant-operator Web UI (RFC AS Phases 1+2)** — the def-management list endpoints (`/v1/_library/{agents,skills,mcp-servers}`, `/v1/_*def/names`) were tenant-BLIND (any token enumerated every tenant's def names); each is now scoped to the caller via `principalTenantScope` (admin/legacy/open see all + the `?tenant=` focus; a `substrate:tenant` operator sees only its own tenant's rows, statics excluded) AND the Library routes' gate was opened to `substrate:tenant` (#577 — they were pinned to `substrate:admin` by the `/v1/_*` catch-all, so a tenant token 403'd before the #575 scoped handler ran; the gap was that #575 scoped the handler but not the route), #575/#576/#577; and the Web UI left-nav swapped its binary `adminOnly` for an `all`/`tenant`/`admin` visibility class (#578) — **library/integrations/volumes/paths/schedules/interrupts** show for a tenant operator, **channels + memory deliberately stay admin-only** (their store rows have no `tenant_id` column → routes stay `substrate:admin` → a tenant token would 403; a deliberate narrowing of RFC AS §4, pending a tenant-axis schema migration); plus a token-mint lockout fix (#574 — the UI no longer offers `substrate:admin`, whose minting disables the legacy `LOOMCYCLE_AUTH_TOKEN` login); **v1.6.4 completed the tenant-operator Web UI (RFC AS)** — extends v1.6.3 to the remaining surfaces (same posture: admin sees all + `?tenant=` focus, a `substrate:tenant` operator confined to its own tenant): bundled/static agents now shown to tenant operators as the shared catalog floor (#580 — they previously saw zero, the list skipped operator-global statics), the `/v1/_schedules/*` surface tenant-scoped with per-def opaque-404 via `ScheduleDefGet` (#581), off-run Path/Document browse-by-subject (`substrateBrowseCtxFn` adds an authorized `?scope_id=`/`?tenant=`; the Web UI drives it from the topbar user-picker's manual subject entry → reach a doc an MCP agent created under another subject; #582 backend + #583 UI), and the audit log `/v1/_events` tenant-scoped via the event's owning session (`ListEvents` JOINs `sessions` when `EventFilter.TenantID` set — no schema migration; nav un-gated) (#585); plus a TrueNAS deploy-doc fix — SQL Memory's Postgres role needs `CREATEROLE` (per-scope login-role isolation; without it Documents fail `permission denied to create role`), #584; **v1.6.5 is a patch** — inline bundled skills (RFC AQ bundles' top-level `skills:` map = `cfg.Skills`) now show in the Library for admin AND tenant operators (the skills handler read only the `SkillsRoot` set, never `cfg.Skills` — `resolveSkills` bakes inline bodies into agent prompts then discards its set — so bundled skills showed `0` for everyone; the handler now merges `cfg.Skills`), #587; and a Document is never orphaned from the Path tree — `create_document` now ALWAYS registers a dirent (default `/documents/<title-slug>`; was skipped when `path` omitted → reachable only by id, invisible in the dirent-listing Path/Library browser) + a new `Document op=set_path` (`id`+`path`) attaches/re-homes a Path-tree name for an existing document (idempotent, own-scope, a new `op` on the existing dispatch → all transports), #588; **v1.6.6 is a patch** — a sub-agent's SESSION now inherits the parent's tenant. `runSubAgent` created the sub-run under the parent's tenant (subIdentity.TenantID) but the SESSION with an EMPTY tenant_id (passed `""` to `openOrCreateSessionAndRun` → `CreateSession`); once transcript/continuation reads moved onto the tenant-gated `s.tenantStore(...).GetSession`, a `substrate:tenant` viewer got `404 session not found` opening a sub-agent run (admin/legacy/open see all tenants → unaffected; also a latent cross-tenant-visibility gap pre-gate). Fix: pass `parentIdentity.TenantID` so session+run share the parent's tenant (mirrors `POST /v1/runs`), #590; **v1.6.7 is a patch** — `Context op=self` now also reports the agent's identity + server so an agent (esp. over MCP) can identify its tenant/credential/instance: `tenant_id` (from RunIdentity), a `principal` block from the auth.Principal when present (subject/tenant/scopes/is_admin/legacy/token_def_id/token_suffix — non-secret, NEVER the bearer; omitted in open mode), and a `server` block (`listen_addr` + `url` from a new `LOOMCYCLE_PUBLIC_URL`, falling back to `A2APublicBaseURL`); additive tool output across all transports, no wire/schema change; the TrueNAS deploy compose + INSTALL.md surface the new knob, #592/#593; **v1.7.0 added multimodal image / vision input across every provider and transport (RFC AT)** — one `image` prompt content block (user-segment only; `media_type` whitelist png/jpeg/gif/webp; inline bytes, no URL form — SSRF) serialized natively by Anthropic (`source` base64 block) / OpenAI (data-URI `image_url` content-array; DeepSeek text models gated `SupportsVision=false`) / Gemini (`inlineData`) / Ollama (message `images:[]`), with a loop-level validate + `Capabilities.SupportsVision` gate that refuses an image to a text-only model BEFORE the call (per-model nuance in the driver — legacy `claude-2`/`claude-instant`, `gpt-3.5*`, original `gpt-4`/`gpt-4-32k` snapshots; unknown defaults to supported), lifted to HTTP + MCP (`spawn_run`/`spawn_runs` `segments` schema) + gRPC (`PromptContentBlock.media_type`+`data` **bytes**, native — base64-encoded at the boundary) + the TS adapter (`PromptContent` `image` variant + `ImageMediaType`, `@loomcycle/client` 1.7.0) + the Python adapter (raw-bytes segment dict), plus a configurable run-ingest body cap **`LOOMCYCLE_MAX_REQUEST_BYTES`** (default 16 MiB, was 1 MiB; over-cap → 413), #595/#596; **v1.7.1 is a patch** — the RFC AT vision gate now also covers the provider-FALLBACK target: the v1.7.0 gate checked only the initially-resolved provider, so a vision-bearing run that failed over mid-flight to a text-only provider (e.g. DeepSeek, whose driver delegates `Call` to the OpenAI driver where `openaiSupportsVision("deepseek-*")` defaults to supported) leaked the `image_url` and got a raw 400; `tryProviderFallback` now re-checks `SupportsVision` against the re-resolved `(provider,model)` and refuses the swap (no attempt consumed, no `EventProviderFallback`, an `EventFallbackSuppressed` carries the RFC AT §4.4 cite + failed/refused pair, original error propagates — fails loudly, never leaks; refusing terminates the cascade rather than skipping to a later vision-capable candidate), #598; **v1.8.0 wired the `effort` hint to Ollama's `think` flag** — Ollama's `/api/chat` top-level `think` boolean (qwen3/deepseek-r1/gemma-thinking) was never sent, so a local reasoning model ran on its default and `effort` on an Ollama agent logged as "dropped"; `buildRequestBody` now maps `effort` medium/high→`think:true`, low→`think:false`, unset→omit (trace returns as `EventThinking`, already parsed from `message.thinking`), and `Capabilities()` flips `SupportsThinking`/`SupportsEffort`→true (the only consumer of `SupportsEffort` is the loop's drop-log). Behavior change: `effort` on a non-thinking Ollama model now errors (was a silent no-op) — consistent with Anthropic/OpenAI/Gemini; applies to `ollama` + `ollama-local`, #600; **v1.8.1 is a patch** — the CLI introspection tools (`validate`/`agents list`/`doctor`) loaded a single file via `config.Load` and ignored `LOOMCYCLE_PRESETS`/`CONFIG_DIR`/`CONFIG_FILES`, so an agent whose `model:` is a preset-defined alias (e.g. `deepseek-pro`) reported a false `no provider resolved` while the running server resolved it fine; a shared `loadLayeredConfig` (internal/cli) now assembles the same layered stack the server does (presets→CONFIG_DIR→CONFIG_FILES→--config, last wins), and an opt-in `LOOMCYCLE_OLLAMA_DEBUG_THINK=1` logs each Ollama request's model/effort/think for debugging local thinking traces, #602; **v1.8.2 is a patch** — the loop's per-iteration event switch had NO case for `EventThinking` (and no default), so every driver's streamed reasoning trace (Ollama message.thinking / Anthropic thinking_delta / OpenAI-DeepSeek reasoning_content — all emitted on the event channel) was silently dropped at the loop and never reached `OnEvent` → no client (SSE/gRPC/adapters) could render "thinking…" for ANY provider (the diagnostic proved loomcycle DID send Ollama think:true and Ollama DID generate the trace — the loop just ate it); fix is `case providers.EventThinking: emit(ev)` (forwarded live, still not accumulated into content nor echoed into the next request — the full trace stays on `EventDone.Reasoning`; `replayTranscript` skips thinking rows, `eventToProto` maps it so gRPC clients get it too), #604; **v1.9.0 added a routing view + a model-alias API and fixed a fallback-downgrade bug** — (1) **`GET /v1/_routing`** + a Web UI **routing** page surface, for each **user_tier × tier**, the ordered provider/model cascade a consumer resolves to right now (top → fallbacks) from a new lock-free `resolve.Resolver.Cascade` (reuses `candidatesFor`/`priorityFor` so it can't drift from `Resolve`'s order); an **admin** sees live availability per candidate + which entry is **selected** (first available = what runs now) + an active-providers header, a **`substrate:tenant`** operator sees the config cascade only (the handler strips availability/infra; route gated `ScopeTenant`; the Web UI page is data-driven — dots/badges/header render only when present), #607; (2) **`GET /v1/_models`** exposes the configured `models:` alias map (tenant-readable, non-secret) so a fork can store an alias and track the operator's local override rather than pinning a concrete model, #606; (3) **fix** — the R2 thinking-model fallback downgrade now also **drops the effort hint**: `deepseek-v4-pro`→`deepseek-v4-flash` still 400'd with "reasoning_content … must be passed back" because the surviving `effort=high` maps to `reasoning_effort`, which re-enables thinking mode on the hybrid V4 flash model regardless of the `-flash`/`-pro` name — `tryProviderFallback` now sets `newEffort=""` when the `ThinkingDowngrader` fires so the sibling actually stays non-thinking (provider-agnostic; scoped to the downgrade branch; regression `TestFallback_DowngradeDropsEffortHint`), #608; **v1.9.1 is a security + robustness hardening patch** — a whole-repo security review (gRPC tenant/subject isolation on read+channel RPCs #611, A2A peers authenticated via the operator-token substrate not just legacy #612, mem9 SSRF + embedder-key exfil blocked on a model-authored `base_url` #613, run cancel + interrupt-resolve tenant-gated on every transport #614, a `runstate` send-on-closed-channel panic #615, a `Grep` symlink-escape re-checked before opening walked files #616, four secret-exposure gaps #620, a per-user-cap read lock + terminal-error channel drain #621, an opt-in MCP-HTTP DNS-rebinding guard default-allow #622, + a #610 `_ = 0 // FIX DISABLED FOR TEST` debug-artifact removal that slipped into v1.9.0) plus provider-driver fixes (Anthropic replays the thinking block on tool-use continuations #617, OpenAI reasoning models use `max_completion_tokens` #618, Ollama surfaces in-stream error frames instead of a silent success #619, DeepSeek thinking-model fallback downgrades to the always-non-thinking `deepseek-chat` — `deepseek-v4-flash` is itself a hybrid thinking model so `-pro`→`-flash` never escaped thinking mode #624) and robustness fixes (webhook `user_tier` pinned to the def not the payload #623, `ollama-local` default header/idle timeout 300s→600s for slow 128K local-model cold-loads #625, and the **`loomcycle mcp --upstream` thin client self-recovering from dropped upstream connections** — it previously self-healed only a `404/-32001` session expiry, so a transport error from the server's 120s `IdleTimeout`-reaped keep-alive socket or a restart dead-ended with no retry and, since Claude Code never auto-reconnects a stdio server, every later tool call failed until a plugin reload; `forward()` now owns a bounded-backoff reconnect loop that composes with the re-handshake, a sub-120s `IdleConnTimeout` reopens before the server severs the socket, and a `ResponseHeaderTimeout` stall guard carves out agent-run/LLM tools — `spawn_run`/`spawn_runs`/`compact_run`/`evaluation` — through a separate no-header-timeout client so a slow run isn't retried into a double-execution; env knobs `LOOMCYCLE_MCP_UPSTREAM_HEADER_TIMEOUT_MS`/`LOOMCYCLE_MCP_UPSTREAM_RECONNECT_ATTEMPTS`, thin-client binary so reinstall the Homebrew `loomcycle` #626); and **v1.10.0 adds two feature lines** — **RFC AR tenant credentials** (the encrypted per-tenant/user `CredentialDef` store #630 with AES-256-GCM + per-tenant HKDF from `LOOMCYCLE_SECRET_KEY`; `$cred:` http-MCP consumption for per-user Telegram/Slack #631; and a provider/tool key override by env-var name so a tenant's own `ANTHROPIC_API_KEY`/`BRAVE_API_KEY` overrides the operator's host key #632) and **RFC AV per-scope token-usage & cost attribution** (a per-call `token_usage` ledger tagged with which key paid + a per-run cost summary #633; `GET /v1/_usage` grouped tenant-scoped report #634; a rollup-and-prune retention sweeper + compact `usage_archive` #635; a Web UI Usage page #636; an opt-in prune-by-session old-run archiver #637; gRPC/TS/Python adapter parity #638) — loomcycle owns a `pricing:` table, provider-reported cost wins; plus RFC AU Web-UI import of Claude Code skills+MCP servers #628, a Path one-level `ls` implicit-dir fix #629, and a 15-finding post-merge code review that hardened the line #639/#641 (per-tenant ledger identity, UTC archive bucketing, prune-by-session, redactor registration of resolved secrets); and **v1.11.0 adds RFC AW per-scope token budgets** — dynamically-configured soft+hard token limits per operator/tenant/user (calendar-month UTC, no row = unlimited, counters seeded from the RFC AV ledger, advisory/per-replica/fail-open), enforced at run admission (hard = refuse NEW runs → 429 / gRPC ResourceExhausted, universal because every trigger surface routes through `RunOnce`; an in-flight run that crosses hard warns-but-finishes; most-restrictive-wins), a new server-generated **`EventLimit`** warn event carried across SSE + gRPC + TS + Python + MCP `spawn_run` + the run transcript, a tenant-scoped **`GET/PUT/DELETE /v1/_limits`** (+ a gRPC `TokenLimit` RPC + `listLimits`/`setLimit`/`deleteLimit` adapter twins) with a Web UI Limits console (#642 Phase 1, #643 Phase 2), plus a post-merge review (#644) that redacted a cross-tenant operator-budget oracle, replaced the ceiling-cache reload with an O(1) update so a persisted budget is never left unenforced, and gave the webhook path 429 parity; **v1.11.1 is a Web-UI patch** — the Usage page no longer blanks on an empty report (`/v1/_usage` returned a Go nil slice → JSON `null` → the UI's `resp.rows.length` crashed the view; now returns `[]` + a defensive guard, #647), and the RFC AW token-limit editors accept K/M/G shorthand with a live recognition hint (#648), plus the Python adapter realigned to 1.11.0 for its `python-v1.11.0` PyPI publish (#646); and **v1.12.0 adds RFC AX the `providers:operator-key` scope** — gating the operator-API-key fallback for cost-isolated bring-your-own-key tenants: behind a default-off gate `LOOMCYCLE_OPERATOR_KEY_RESTRICTION` (gate-off ⇒ byte-identical), a run lacking the tenant-implied scope is restricted → Layer-1 credential-aware routing (skip un-keyable providers, 403 `operator_key_restricted`/gRPC PermissionDenied if none) + a mandatory Layer-2 driver backstop (`ErrOperatorKeyForbidden`, covers pinned agents); a negative fail-open `OperatorKeyRestricted` bit + a `runs` column (migration 0055) + trigger-def capture + A2A-peer-scope derivation carry it across every run path (incl. resume/sub-agent/gateway/compaction), granted at token mint (#650). This enumeration drifts — treat **README.md "What's shipped"** + **`REVISIONS.md`** as authoritative for per-feature detail, and **`docs/PLAN.md`** for the path forward. The shape today:

- **Six providers, native HTTP, no vendor SDK** — Anthropic, OpenAI, DeepSeek, Gemini, `ollama` (cloud/Bearer), `ollama-local` — behind one `Provider` interface with resolver-based per-tier/effort routing, user-tier fallback cascade, and `PinAfterSuccess`. Plus a synthetic **`code-js`** provider (goja, stateless replay, zero token cost) and a **mock** provider for cost-free load tests.
- **Built-in tools** — Read/Write/Edit/Grep/Glob/NotebookEdit/HTTP/WebFetch/WebSearch/Bash/Agent/Skill/Memory/Channel/AgentDef/SkillDef/Evaluation/Interruption/Context. **Vector Memory** (semantic search; sqlite-vec / pgvector; embedder substrate) and a pluggable **MemoryBackend** substrate whose default in-process backend is a native memory layer (`add` enqueues for background consolidation, `recall` is hybrid retrieval).
- **Substrate Defs** — content-addressed (SHA-256), runtime-mutable, tenant-scoped, push-at-boot: Agent / Skill / MCPServer / Schedule / Webhook / MemoryBackend / A2A. Authored over HTTP / gRPC / MCP / the TS adapter; verify-or-fork across deployments.
- **MCP on both sides** — client (mount external servers as tools) and server (`loomcycle mcp [--upstream]` thin client; concurrent stdio dispatch; bounded `spawn_run`; meta-tools).
- **Triggers** — Scheduler (`scheduled_runs`), inbound **Webhooks** (HMAC verify-before-parse), and **A2A** server/client interop. Non-secret run-metadata channel + per-run named credentials across all three trigger surfaces.
- **Multi-tenant authz (RFC L/N)** — `OperatorTokenDef` mints per-principal bearers bound to an authoritative `(tenant, subject, scopes)` resolved from the token; per-route HTTP + per-RPC gRPC scope gates; tenant isolation across BOTH the state plane and the definition plane; role-aware Web UI. `LOOMCYCLE_AUTH_TOKEN` still works — multi-tenancy is available, never required.
- **HA + ops** — Pause/Resume/Snapshot (cross-version-portable JSON), multi-replica (Redis cancel pubsub, cluster pause/resume + bus fanout, DB-backed session locks, singleton sweepers), OTEL + Prometheus + bundled observability profiles, per-tenant fairness, tool-use hooks.
- **Wire surfaces** — HTTP+SSE, gRPC, the `loomcycle mcp` server, the TS adapter (`@loomcycle/client`), an n8n community package, and an LLM Gateway + OpenAI-compatible shims (`POST /v1/_llm/chat`, `/v1/chat/completions`, `/v1/embeddings`).
- **Distribution** — Homebrew + multi-arch Docker; `init` / `doctor` first-run; `loomcycle import claude-code` + the Claude Code plugin; embedded React Web UI at `/ui` (Library, Activity, Channels, Audit, Schedules).

Internal packages added since v0.9.0: `a2a`, `audit`, `auth`, `claudeimport`, `coord`, `hooks`, `recipes`, `resolve`, `runner`, `runstate`, `scheduler`, `snapshot`, `otel`, `pause`.

**LocalAPI gateway** stays scaffolded as a future-consumer convenience for "OpenAPI without an MCP server." The integration vehicle for jobs-search-agent has been the MCP-server pattern since v0.4.0 — jobs-search-agent runs its own `/api/mcp` route exposing typed tools (e.g., `mcp__jobs__getAgentContext`, `mcp__jobs__patchApplication`), and loomcycle consumes it via the existing HTTP MCP transport. Loomcycle stays domain-agnostic; the consumer owns its own surface.

**MCP HTTP-transport hardening (cumulative through v0.8.1)** — surfaced by the jobs-search-agent integration and fixed with regression tests:
1. Operator yaml env-var allowlist must be `LOOMCYCLE_*`-prefixed (or one of the documented third-party names) for `${...}` expansion.
2. `Accept: application/json, text/event-stream` per the Streamable HTTP spec — strict servers return 406 otherwise.
3. SSE-framed responses parsed correctly (the SDK's transport defaults to SSE when both media types are advertised).
4. Pool-startup retry with exponential backoff so a peer compiling its `/api/mcp` route on first request doesn't get marked "skipped" indefinitely.
5. **(v0.8.1)** Lazy retry of "skipped" servers on the first agent call that needs a tool from them — peer restarts no longer require a loomcycle restart.

**Shipped — v1.0.0:** With the multi-tenant-auth capstone (v0.17.0, RFC L) and the substrate-completeness line (v0.18–v0.23) shipped, **v1.0 was a pure hardening + distribution milestone — no new primitives**: a hardened first-run install story (Homebrew / Docker wired to `init` / `doctor`), a Claude Code plugin robustness pass, and a security + runtime-QA sweep across the v0.13–v0.17 surfaces (A2A, inbound webhooks, pluggable memory, the `code-js` provider, the auth substrate) — all shipped, then the v1.0 tag. **Beyond v1.0** (unscheduled): a settings UI, an operator cookbook of deployment postures, Helm; plus the open RFC backlog (RFC AD compaction-tool-chain follow-up, RFC O/P/R MCP-server hardening, RFC AA SQL memory, RFC Z Phase 2 `compress` plugin). See `docs/PLAN.md` for the public roadmap; decision history lives in the separate operator-side `loomcycle-internal/doc-internal/PLAN.md` (not in this tree).

**Key consumers of loomcycle:**
- `jobs-search-agent` (separate repo; this machine: `/home/denn/workspace/jobs_search_agent`) — first production user. Adopts loomcycle as the only `Runner` backend; CLI / SDK backends were retired in May 2026 after a $80 cost incident traced to subprocess auth inheritance.
- TypeScript client at `adapters/ts/` → `npm: @loomcycle/client`. Python client at `adapters/python/` → `pip install loomcycle` (published on PyPI from v0.8.0 under the `denn-gubsky` account via the OIDC Trusted Publisher; versioned independently on `python-v*` tags. A `loomcycle` PyPI Organization is pending — the project transfers to it once approved, install name unchanged).

## Development workflow

This is the chain you follow for every non-trivial change. Don't skip stages; the discipline is what keeps the codebase small and reviewable.

1. **Architect** — read code first. Understand the package layout, the existing patterns, the call graph for the area you're touching. If you can't articulate the seam where your change goes, you're not ready to plan.
2. **Plan** — for anything beyond a one-line fix, write a plan. Include critical files, the change, and a verification step. Validate scope with the user when the change touches the wire protocol, security posture, or storage schema.
3. **Feature branch** — `feature-<short-description>` off `main`. Never commit to `main` directly.
4. **Code** — small focused commits. Each commit should be reviewable in isolation. No "and also fixed this" omnibus commits.
5. **Tests** — unit tests for new code, regression tests for fixes. A regression test is one that *fails on the unfixed code* — verify this by reverting your fix and seeing the test fail before you commit it.
6. **Code review** — self-review pass: read your own diff cold. Look for accidentally-committed secrets, dead code, debug logs, comments that say "TODO" without a follow-up issue.
7. **Regression test** — `go test ./...` from the repo root. Must pass clean. Don't commit "with one known failing test."
8. **Manual tests** — `go build -o bin/loomcycle ./cmd/loomcycle && ./bin/loomcycle --config <yaml>`, then `curl` the affected endpoint. For loop / provider / tool changes, run an actual end-to-end agent run against a real provider key. **For a deployable build (or any Web-UI change) use `make build-all`** — it runs `make build-ui` (embeds the latest SPA into `internal/webui/dist/`) then `make build` (`go build -o bin/loomcycle ./cmd/loomcycle`). Note: plain `go build ./...` is only a compile check — it writes **no** binary, so never use it to produce a deployable `./bin/loomcycle`.
9. **PR** — one branch, one PR. Title says what the change does in one line. Body explains *why* (the user-visible problem), *what* (the technical change), and *what was tested* (the test names + the manual verification steps).
10. **Human review** — wait for the user. Do not self-merge. Address review comments in additional commits on the same branch (don't force-push unless the user asks).
11. **Merge** — squash to `main` after approval, with the PR title as the commit subject and the PR body as the commit body. Tag if it's a release.
12. **CI** - keep CI always actual, update tests and make sure all CI tests are passed on every PR.

Skip the chain only for trivial fixes (typos, stale comment lines, obviously-correct one-liners). When in doubt, follow the chain.

## Karpathy guidelines (coding discipline)

These come from the `andrej-karpathy-skills:karpathy-guidelines` skill. Embed the gist here so they're available without invoking the skill.

- **Surgical changes.** Modify the minimum surface needed to fix or add what's asked. Don't rewrite adjacent code "while you're there." Don't refactor on the same commit as a behavior change. The smaller the diff, the easier the review.
- **Surface assumptions.** When you make a non-obvious choice, write it down in a comment or the PR body. "I assumed N is always small enough that O(N²) is fine" is the kind of statement that prevents future surprises.
- **Verifiable success criteria.** Before writing code, write down how you'll know it works. A regression test, a curl + expected output, a metric — something concrete. "It compiles" is not a success criterion.
- **Avoid overcomplication.** Prefer the boring solution. A 30-line stdlib implementation beats a 3-line dependency. Don't introduce abstractions speculatively.
- **Don't trust your own first draft.** After writing a function, read it as if someone else wrote it. Most bugs are obvious on the second read.

## Security rules — non-negotiable

These rules apply to every interaction with this repo. Violations are bugs even when they don't produce visible failure.

1. **Never share secrets.** API keys, bearer tokens, OAuth tokens, signing keys — never paste them into responses, tool output, log lines, or commit messages. If you need to refer to one, reference the env var name (e.g. `ANTHROPIC_API_KEY`) without the value.

2. **Never open or edit `.env.local`.** This file holds API keys and is git-ignored. Reading it surfaces the secrets into your context where they could be accidentally echoed. Editing it could clobber the user's working config. **Exception:** the user explicitly asks you to edit a specific line. In all other cases, when the user asks "set X env var", suggest the line they should add manually (e.g. "Add `LOOMCYCLE_FOO=bar` to `.env.local`") rather than reading or writing the file yourself. The non-secret companion **`.env.insecure`** (the two-file split — secrets vs. operational config, documented in `docs/CONFIGURATION.md` §9c) carries no credentials and *is* safe to read and edit; only `.env.local` is off-limits.

3. **Ignore API keys you happen to see.** If a key appears in a log line, a tool result, a stack trace, a stash diff — do not echo it back, do not include it in a commit, do not reference it. Pretend you didn't see it. If you must reference it (e.g. "the OPENAI_API_KEY env var is missing"), reference by name only.

4. **Treat these env-var name patterns as secrets:** anything matching `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_AUTH`, `*_PASSWORD`, `*_CREDENTIAL`. The `LOOMCYCLE_AUTH_TOKEN` env var is the bearer used by all `/v1/*` endpoints — never log or echo its value.

5. **Targeted git adds, never `git add .` or `git add -A`** when you have any uncertainty about what's staged. `.env*`, `*.pem`, `*.key` — these can sweep into a commit if you stage with a wildcard. Use `git add <specific paths>` and check `git status --short` before commit. (Internal design docs used to live at `doc-internal/` and were gitignored; v0.8.15 migrated them to `~/work/loomcycle-internal/doc-internal/` outside this repo.)

6. **Bearer-token check is constant-time.** If you touch the auth middleware in `internal/api/http/`, preserve the constant-time `subtle.ConstantTimeCompare` pattern. Variable-time comparison is a side-channel.

7. **The Bash tool is NOT a sandbox.** Even with `LOOMCYCLE_BASH_ENABLED=1`, the tool can reach arbitrary files via absolute paths and make network calls. Operators wanting real isolation must run loomcycle inside a container or VM. If you're modifying Bash, do not weaken its existing protections (cwd-binding, env-scrubbing, output bounds, timeouts) without an explicit user request.

8. **Caller-authoritative `allowed_hosts` is a trust boundary.** It must come from the upstream caller, never from the model. Don't pipe model-generated text into the policy. If you change `internal/tools/builtin/narrowing.go` or `internal/api/http/server.go runRequest`, preserve the "operator's static list is the floor" invariant.

## Code conventions

- **Go style:** `gofmt` clean. `errcheck` clean. No package-level mutable globals (config struct passed in, not read from a singleton). Errors-as-values; no panics outside `main` and intentional `panic("unreachable")` sites. Logging via `log` package (structured logging is a v1.x consideration).
- **Test naming:** `Test<Subject>_<Behaviour>`. e.g. `TestSubAgent_InheritsParentCallerHostAllowlist`. The behaviour describes the postcondition the test asserts, not the input.
- **Commit messages:** subject ≤ 72 chars in imperative mood ("fix(api): propagate host policy to sub-agents"). Body explains *why* before *what*. Reference commits via short SHA when calling out a regression. Always close with the `Co-Authored-By` line if Claude wrote substantial code.
- **Comments explain WHY, not WHAT.** Code says what; comments add the missing context (the constraint, the past incident, the surprising-but-correct invariant). Don't write comments that summarize the next line.
- **No backward-compat shims for unused code.** When deleting a function, delete it; don't keep a stub. When deprecating an env var, delete the read; don't add a "deprecated, ignored" branch.

## Cross-repo notes

loomcycle and `jobs-search-agent` (its primary downstream consumer) are separate repos with linked release cadences:

- **Wire-protocol changes** (HTTP request/response shape, SSE event types, YAML schema) require updating both repos in lockstep. Land in loomcycle first; bump the dependency in jobs-search-agent only after the loomcycle change is on `main`.
- **Runtime-only changes** (provider drivers, internal pkgs, performance fixes that don't change the wire) ship from loomcycle alone; jobs-search-agent picks them up on the next loomcycle bump.
- **Agent prompt changes** (`.claude/agents/*.md` files in jobs-search-agent) are a jobs-search-agent concern; loomcycle is agent-agnostic.
- **The TS adapter** (`adapters/ts/`) is published as `@loomcycle/client`. Changes to it require a version bump on the npm package. jobs-search-agent depends on a pinned version.

## Project documentation & RFCs

**The loomcycle document storage (MCP `document` / `path` tools, scope=user) is the PRIMARY store for ALL loomcycle project documentation — RFCs, design notes, PLANs, AND the product guides.** Create, edit, and extend docs THERE via MCP; do not treat a repo `.md` file as the source of truth. Repo markdown (`README.md`, `docs/*.md`) is an **EXPORT for external / GitHub use**: when a document changes and an external copy is needed, **MD-export it from the store** (`document op=export_md`) and write that into the repo — the store is authoritative, the repo file is the rendered copy. (Supersedes the earlier RFC-only rule and the retired `~/work/loomcycle-internal/doc-internal/rfcs/` flow: as of 2026-07-21 ALL project docs, not just RFCs, live in the store.)

**Layout (Path tree, scope=user):** `/loomcycle/rfcs/<slug>` (RFCs) · `/loomcycle/docs/<slug>` (product guides + design notes + research) · `/loomcycle/marketing/*`. Each document's ROOT chunk carries a `type` (`rfc` | `document` | `research` | `publication`) + a `status` (`draft` | `review` | `confirmed` | `done`); cross-document relations via `document op=link_chunks` (e.g. an RFC `documented_by` its guide, a research `related` to a guide, PLAN `references` an RFC).

- **Create / author:** `document op=import_md markdown:<full markdown>` (headings → chunk hierarchy) → `document op=set_path id:<document_id> path:/loomcycle/{rfcs,docs}/<slug>` → set the root chunk's fields (`op=update_chunk id:<root_chunk_id> type:… status:… revision:…` — `revision` is REQUIRED, get it from `op=get_chunk`). `import_md` auto-creates a `/documents/<title>` dirent — `path op=rm` it after `set_path` so the doc has one canonical path.
- **Edit / extend:** mutate chunks IN THE STORE (`op=update_chunk` / `create_chunk` / `move_chunk` / `link_chunks`) — never hand-edit the repo `.md` as the primary change. Re-export to the repo only when the file is externally consumed.
- **Read:** `document op=get_document path:<path>` (metadata) or `op=export_md` (full markdown); browse with `path op=ls path:/loomcycle/...`.
- **Still RFC-first:** a new wire / cross-transport / new-primitive feature needs an RFC authored (in the store) **and approved before implementation** — draft, confirm decisions with the user, write the RFC Document, gate the build on approval.
- Legacy RFCs under `loomcycle-internal/doc-internal/rfcs/` stay there for history; migrate opportunistically, don't bulk-move.

## When in doubt

- Look at recent commits on `main` (`git log --oneline -20`) for the style of recent changes.
- Before introducing a new architectural pattern, read the relevant RFC/decision docs — now loomcycle Documents via the MCP `document` tool (`op=get_document path:/loomcycle/rfcs/<slug>`; `path op=ls path:/loomcycle/rfcs`). The legacy `~/work/loomcycle-internal/doc-internal/PLAN.md` still holds older decision history.
- Ask the user. A 30-second clarification beats an hour of speculative refactor.
