Stoat — AI Agent Coordination design notes
A planning and progress surface for AI-agent-driven development across the Critter Stack repositories.
The goal: see what work is at play, how it relates, what has already happened, and what is next — as a dependency DAG spanning GitHub issues, pull requests, NuGet publishing, and Bobcat-monitored test runs, across upstream and downstream repositories.
Decisions of record (2026-08-01), amended by the split (2026-08-09) and by the fold (2026-09-18).
Read this first — what the fold changed
On 2026-09-18 the run console moved out of Bobcat and into this repository, and the two applications became one host. Three things below were written against the old arrangement and are kept for their reasoning, not as a description of today:
- Stoat no longer observes runs over HTTP. The console is composed into this process, so runs are read from the registry in memory (
LocalRunsClient) and observation follows ingestion rather than a clock — a box with no suite running used to sweep 5,760 times a day to discover nothing. A slow backstop tick remains, and covers only the one thing a signal cannot see: a run going orphaned, which is the absence of events rather than the arrival of one.BobcatRunsClientis still registered whenStoat:BobcatUrlnames a remote console. - Drilling into a run no longer leaves for Bobcat. The run board is a route in this application (
/runs/:runId). - The add-on shape is retired. See the storage section below.
What did not change is the founding rule: Stoat references nothing in Bobcat and Bobcat references nothing here — zero, not one-way. Runs arrive as HTTP posts to /api/ingest, on the same terms GitHub and nuget.org are observed. That is exactly what let a BSL console absorb an MIT viewer without either side acquiring a reference to the other.
Split from Bobcat (2026-08-09)
This context began as Bobcat.Monitor's second bounded context and moved to its own repository. The line: Bobcat is the MIT integration testing framework — Gherkin runner, supervisor, and the publisher side of the run wire. Stoat is the BSL AI coordination tool. (The viewer was Bobcat's at the time of writing; it moved here on 2026-09-18.)
Three consequences that changed the design, not just its address:
- The free/paid question answers itself. Test-run monitoring was free because Bobcat is MIT, full stop — no carve-out to document, no exception to defend. Every Stoat MCP tool is gated, and that is the whole point of the boundary. Amended 2026-09-18: the line moved rather than blurred. What is free is what a run publishes — the wire, the env vars, the format, and port 5525 — all still MIT and still in Bobcat. What is licensed is everything that remembers: the board, the archive, the exports and the nine run tools, all now gated here like every other tool.
test-run-gatebecame an observation. Stoat could no longer read a run registry in process, soBobcatPollerread Bobcat's publicGET /api/runs— the third poller beside GitHub and NuGet, same observed-provenance rules. Stoat holds no reference to Bobcat at all, in either direction, and that is unchanged. A gate whose evidence source is unreachable isunknown, neverwaiting. Amended 2026-09-18: it is an observation still, but of an in-process registry that signals on ingestion — the provenance rules and theunknownstate are untouched, only the transport and the clock are gone.BOBCAT_PLAN_NODEbecameBOBCAT_RUN_TAG. Bobcat now stamps an opaque correlation tag it never interprets — coordination vocabulary does not belong in the MIT repo, and the generalized mechanism is more useful anyway (a ticket id, a build number).
The parked "monitor becomes Bobcat, library gets renamed" idea is dead: Bobcat keeps its name and its meaning, and the coordination half got a new name instead.
Prior art, and the gap this fills
Surveyed 2026-08-01: Beads (Yegge's agent-native graph issue tracker — validates dependency DAG + agent-first JSON, but local/repo-scoped, no GitHub substrate, no publishing), Task Master (PRD → task DAG + "next task" MCP tools — same validation, same limits), Vibe Kanban and Conductor (worktree execution boards — "what are agents doing now", not "what blocks what"), GitHub Agent HQ / Mission Control (GitHub-native agent session list — real and shipping, but a flat list with no dependency model at all).
Nobody models the cross-repo release train. Issue B in Wolverine cannot start until JasperFx merges, publishes 2.x.y, and Wolverine consumes it — and a test run is the evidence gate at each hop. Publish/consume/upgrade as first-class DAG nodes with monitored test runs as drill-in evidence is the differentiator, and it is precisely the Critter Stack's daily coordination problem, so the dogfood is real work from day one.
GitHub native primitives were checked and do not cover the model: issue dependencies (GA 2025-08) are same-repo only, sub-issues are org-scoped, and publish steps have no GitHub identity of any kind. GitHub-native features are mirrors, never the model.
Core stance: GitHub is the system of record, the monitor is a read model
Same posture as test runs. The monitor never owns issue or PR state — it folds observations into projections. Two event provenances, kept distinct on every event:
- Observed — emitted by pollers (GitHub, NuGet feeds). Idempotent by construction: change-detection against the last-observed state, so a re-poll never appends a duplicate.
- Asserted — emitted by agents via MCP (claims, decisions, session metadata). Append-once by nature.
The dashboard can always answer "does the plan believe this because GitHub said so or because an agent said so." Anything a poll can observe comes from the poll — a crashed agent's issue must not render "in progress" forever (the orphaned-run rule, new hat).
Polling, not webhooks: this is a localhost dev tool. REST/GraphQL with etags, authenticated (the repos are private). NuGet observation polls the nuget.org flat container index and authenticated private feeds and local folder feeds — required in phase 2, not deferred, because Bobcat's own packages may live on a private feed while the repo is private, and the self-hosted dogfood plan (below) crosses exactly that hop.
The plan model
Amended 2026-08-21 (issue #5): plans are event-sourced in the coordination store, not YAML files in a repository. The original rationale for YAML-in-git — versioned, PR-able, diff-able — is met natively by the store and improved on: one stream per plan (plan/{slug}), fine-grained events (PlanCreated, PlanHeaderChanged, NodeAdded, NodeChanged, NodeRemoved, DependencyAdded, DependencyRemoved, NodeBound, PlanArchived), every one carrying WHO (an agent's name over MCP, a user string over REST, file:<name> from the feeder) and WHEN, so a plan's history reads as a record of decisions and plan_history is a query rather than an archaeology of commits. The decisive property is that agents can edit plans while the work is under way — discovering an upstream issue, or materializing an unrealized node into a real one (NodeBound) — through the same tool surface they work from. Every planning step is an MCP tool and a REST endpoint; nobody edits YAML by hand. PlanHandler decides every command against the stream under [WriteModel], validates the would-be document with the parser's own rules, and appends only if valid — the store never holds a plan the derivation cannot read, and a refused edit gets the same message a bad document would. YAML survives only as an import/export format: import_plan / PUT /api/plans/{slug} parse a document and DIFF it into the existing plan (importing the same document twice records nothing), and a plans directory (Stoat:PlansPath) is fed through the same import at startup — a way to author in bulk, never the record. A planning repository is therefore unnecessary; if one is ever wanted it is a drafting surface whose documents get imported, not a second source of truth. The anchor issue lives wherever the epic's discussion already does (anchor: is cross-repo by design).
Dependencies cross plans, and every plan has a colour (decided 2026-08-21). A depends_on entry is a bare node id in the same plan or a slug-qualified plan/node reference to a node another plan OWNS — a JasperFx publish is a junction several plans wait on, and it lives in exactly one of them; the others borrow it. The status derivation runs over every active plan in one global dependency order (PlanStatus.ForAll); ready = every dependency done, wherever it lives; and a node's transitive dependents across plans are its leverage (unblocks, unblocksPlans). The handler validates qualified references against the other active plans (the node exists; no cycle across plans; a borrowed node cannot be removed from under its dependents). Each plan carries a server-assigned colour (PlanColors, first unused of a palette, settable by change_plan) — the board and the DAG paint every node by its home plan, and a plan's page draws borrowed nodes as ghost cards in their owner's colour. The cross-plan ready board is the landing page: every ready node across every plan, coloured by plan, sorted by leverage, with the not-done junctions the most work is waiting on beneath — derived client-side from the pushed per-plan statuses, so it is live for free.
The model below stands; read "plan document" as "the plan's projected document".
A plan document is the DAG. Node kinds:
issue—org/repo#n, cross-repo. Status derived: open → claimed (agent-asserted, plus anagent:workinglabel mirror) → PR-open (closes-linkage) → merged → closed.pr-gate— merge policy is metadata:merge-on-greenuses GitHub's native auto-merge (the monitor visualizes policy + status; no merge bot until native auto-merge demonstrably cannot express a need — the ITestHostLauncher rule),needs-human-reviewrenders as a human-shaped node.publish— package + feed + bump kind (Fix/Minor/Major). The bump kind is consumed by whatever executes the publish (Nuke target, CI). Done means the version was observed on the feed, never that something claimed it published. Declared bump vs. actually-observed version mismatch renders as a fault, not silently reconciled.consume/upgrade— downstream repo takes the new version. Done when the version bump is observed in the downstream repo's committed package config.test-run-gate— a Bobcat-monitored suite as evidence. See drill-in below.
Edges are plain depends-on. Where GitHub can natively hold an edge (same-repo blocked-by), the monitor may mirror it for people living in the GitHub UI — mirror only, phase 2+, and the monitor stays read-only against GitHub in phase 1.
Superseded 2026-08-21 (see the amendment above): plans live in the coordination store, not a planning repo. What survives of this paragraph is the anchor — one GitHub issue per plan as the epic's face for discussion, linkage and closure, living wherever the epic does — and the stance on GitHub Projects v2: at most an optional human-facing mirror, never a store.
Event-sourced on the Critter Stack, SQLite first
The coordination context is event-sourced from day one. The monitor's NDJSON + fold + replay was already folk event sourcing; this is the same architecture with a real engine.
Amended 2026-08-09 — the engine has landed. Restated 2026-08-17 with the real numbers. This section was written expecting a SQLite-backed store "~mid-August 2026". Fisher passes all 32 suites and 272 tests of JasperFx.Events.ComplianceTests — the shared cross-store suite Marten and Polecat also enroll in — as 28 event suites plus the four document suites that arrived with JasperFx 2.47.0, and it has no open issues of its own. It has both things listed below as consumer requirements to feed upstream: the async projection daemon and subscriptions. Both packages Stoat needs are published — Fisher (0.8.0 at time of writing) and WolverineFx.Fisher (6.28.2), the latter carrying subscriptions, the aggregate-handler strategy, envelope transaction, and ancillary typed stores. The version numbers move weekly and should not be read as pins; the durable fact is that CritterWatch already ships a Fisher flavour as its third store on exactly these packages. Nothing here is blocked on the store arriving; the work is to write against it.
Storage gradient — a compile target, not a connection string (corrected 2026-08-17; the original wording promised more than the mechanism delivers). SQLite embedded default (zero config, a file beside today's archives) → Marten/Postgres (shared team box) → Polecat/SQL Server. What genuinely spans the three is the
JasperFx.Eventsseam and, since wolverine#3914, the store-agnostic Wolverine handler surface:[DeciderFunction]/[WriteModel]/[ReadModel]resolve the owning store from the registered persistence strategies, andUnitOfWork<T>/Storage.Store<T>replaceMartenOps/PolecatOps, so a handler names no store at all. What does not span them is the document session: there is no runtimeIDocumentSessionacross Marten, Polecat and Fisher. CritterWatch swaps stores with "Option B" — one source tree compiled twice (<Compile Include="..\CritterWatch.Services\**\*.cs">into a sibling project), a per-projectGlobalUsings.csaliasing the store namespace, and a handful of per-store twin files for projection bases, serializers and hosting.Stoat's decision: Fisher only, written to Option B discipline from day one. No store-named type in shared source, projections deriving from a thin per-store base twin rather than Marten's or Fisher's directly, handlers on the store-agnostic attributes. The second store is then a csproj plus a
GlobalUsings.cs, not a rewrite — and nobody writes code against a connection-string promise that was never true.The coordination store is the host's MAIN store (decided 2026-08-21, superseding the 2026-08-17 "ancillary typed store" decision). There is one store, and Stoat's streams, projections and documents live in it; Stoat registers it with
AddStoatStore. The ancillary shape was built first and dropped the same day: it needs a primary store beside it anyway (Wolverine.Fisherwires its codegen strategies from the primaryIntegrateWithWolverine()), which for a standalone tool is an empty store carrying nothing but Wolverine's tables.⚠️ The add-on half of that decision is retired (2026-09-18). It read "Stoat is either a standalone application or an add-on to CritterWatch", the add-on contributing
StoatStorage.ConfigureProjectionsto CritterWatch's store. Stoat is a standalone tool. Bundling it with CritterWatch is a commercial arrangement, not a technical one — and the technical path was never as close as that sentence implied. Three things make it a dead end, and all three would have to be undone deliberately rather than discovered:src/StoatisPackAsTool. There is no library package for another host to reference.ConfigureProjectionstakes Fisher'sStoreOptions, so it could only ever have joined CritterWatch's SQLite flavour — not the Marten/Postgres one that is its primary.- Since the console folded in, this host binds two ports, serves a SPA, runs the async daemon and hosts 52 MCP tools. That is an application, not a module.
What stays true, and is the reason the two stores still read alike: both use string stream identity and the same serialization choices. That is worth keeping for a human comparing them side by side, and for any future migration between them.
Built 2026-08-21 (issue #2). String stream keys (
{plan}/{nodeId},github/{org/repo#n},nuget/{feed}/{package},pin/{repo}|{package},bobcat/runs,baseline/{plan}/{nodeId}); observed events emitted from the pollers' change-detection seam, now inside a recording handler ([Entity]current snapshot → append only if news) so idempotency is the store's property; claims asNodeClaimed/ClaimRenewed/ClaimReleaseddecided by a[WriteModel]handler, with expiry derived at read time, never asserted; inline self-aggregatingSnapshot<T>documents as the read models; and the status derivation unchanged as a pure function over a per-request snapshot of those documents. TheNuGetBaselineStoreJSON file is gone — a baseline is the first event on its stream.Session streams built 2026-08-28 (issue #10) — the OBSERVED half:
session/{sessionId}carryingAgentSessionObserved, from a fourth poller that reads~/.claude. The asserted half (#4) is still open, and lands on these same streams.Streams: per plan, per agent session, per publish node, per observed issue. Projections:
PlanProjection(current DAG state),AgentSessionProjection, and the cross-cutting reads flat files made painful ("every issue this agent touched, any plan").Wolverine subscriptions are the signaling mechanism.
await_*MCP tools park on subscriptions, not polling loops — aPackageVersionObservedevent wakes every awaiter. The store is the coordination bus, not just memory. (Capacitor records; this orchestrates.) Built 2026-08-21 (issue #3): one Fisher subscription (stoat-live, on the Solo async daemon) relays every coordination event to Wolverine asIEvent<T>; a handler pulses an in-processCoordinationChangeSignal; the awaits park on it with a 5s backstop tick, and a broadcaster re-derives plan status on each pulse (250ms debounce) and pushesplan_status_updated— the same view the status endpoint returns — over the existing SignalR batch so the browser replaces rather than polls (a 60s backstop poll remains, because a lease expiring is time, not an event).Consumer requirements fed upstream early: the embedded provider needs the async projection daemon (live projections, not inline-only) and Wolverine subscription support. Graceful degradation exists (inline projections, in-process waiters) but the store should know its first consumer wants both.
End state for storage: the event store becomes Stoat's system of record. The old plan said "for BOTH contexts", demoting Bobcat's NDJSON to an export format — the split cancelled that. Bobcat's run archive stays NDJSON in the MIT repo, and its rationale ("one store, not two") no longer applies now that there legitimately are two tools. Nothing new here gets built on a hand-rolled fold.
The never-slow-a-run invariant is untouched: it lives on Bobcat's publisher side (fire-and-forget HTTP, bounded channel, drop on backpressure), not in any storage here.
Two existing Bobcat threads land on this substrate:
- The issue #44 layer-2 failure ledger moves into this epic as an early projection on the shared substrate. Its open questions (on-disk format, failure-class keying, concurrent CI append merging, aging) are what an event store answers by existing: append-only events, a projection keyed by
FailureSignature, native concurrent appends, stream archival. The DECIDED fork survives intact as an event pair —HintProposed/HintAccepted— with the human-accepts audit trail built in. SQLite removes the old entry bar (CI appending to a shared Postgres). KnownTestDurationsstops being caller-supplied: a projection over run events feeds the supervisor's LPT balancing from real history.
MCP tools for agents
Extends the existing /api/mcp surface (same stateless streamable-HTTP shape): get_plan, next_ready_nodes (dependency-ordered ready work — the Task Master lesson), claim_issue, report_status, await_dependencies(nodeId), and await_package_version(feed, package, minVersion). The await pair is the token-optimization mechanism — the await_run_completion pattern, already proven, generalized: agents block on upstream instead of burning tokens polling GitHub and feeds themselves.
ai-skills content ships with the tools: the claim → work → PR → signal-downstream workflow, written for agents that have never seen this conversation. (Mind the repo-visibility line — ai-skills is public-facing; it documents the wire surface, not private internals.)
Test-run drill-in
Rewritten by the split (2026-08-09). A test-run-gate node correlates to live runs via BOBCAT_RUN_TAG={plan}/{nodeId} — an opaque string Bobcat stamps on a run and never interprets, one more variable in the BOBCAT_RUN_ID family, injected and inherited by a supervisor's workers the same way.
Stoat observed those runs over HTTP (BobcatPoller → GET /api/runs), so the gate rendered running/passed/failed from a polled snapshot, and drilling in left for Bobcat's own run card at ${bobcatUrl}/runs/{runId}. Linking out beat rendering a second, staler copy of a view that already existed — and it was what kept Stoat free of any Bobcat reference.
Amended by the fold (2026-09-18). The view no longer exists twice, so there is nothing to link out to: the run board is a route in this application (/runs/:runId) and the gate reads the registry in memory. The reference count is still zero — it was never the linking-out that kept it there, it was the absence of an assembly reference, and that is unchanged.
The node-state visual grammar extends the existing color-token grammar (running blue / passed green / failed red / retrying orange, --bm- tokens) rather than inventing a second one.
Two states the original in-process version did not need, and which survive the fold because a remote console is still a supported deployment: a gate is unknown before the first sweep, and unknown when the console is unreachable. "No run yet" and "we cannot see whether there was a run" are different facts, and only the first means the gate is genuinely outstanding.
Agent session memory (Layer B) — the Capacitor answer, scoped honestly
Kurrent's Capacitor (launched 2026-06, on KurrentDB) validated the thesis: event sourcing is the right substrate for agent memory. The Critter Stack counter-story is the storage gradient — local-first SQLite file in a repo → team memory on Postgres/SQL Server — plus user-defined C# projections and reactive memory (subscriptions can wake downstream agents; Capacitor records, it doesn't orchestrate). That product does not live in Bobcat: it is a JasperFx thing, its own repo when extracted, and the clearest commercial candidate in this whole epic. This context is its proving ground — the session-event schema, capture path, and recall tools get proven here first.
Capture depth: coordination metadata first. Session started/finished, issue claimed, decision summaries, hypothesis notes an agent explicitly commits, links to commits/PRs/runs. Capture is a hook posting to /api/ingest — dependency-free, fire-and-forget, never slows the agent (the MonitorPublisher contract). Full turn-by-turn transcript capture is a later opt-in, once a projection actually wants it: transcripts are huge, and giant payloads faulting wires is a fresh scar (the MTP ValueTooLarge lesson).
Memories (decided 2026-09-11, stoat#35)
The general shape the repository knowledge base was the first instance of. One stream per memory, memory/{id}, string identity; asserted events with By/At on every one (MemoryRecorded, MemoryRevised, MemoryRefiled, MemoryLinked, MemoryArchived, MemoryRestored); an Inline MemoryRecord snapshot with an FTS5 index over title, body and tags. Scope is a field — Global, Repository(slug), Plan(slug), Session(id) — so re-filing is an event, and the scopes are the coordination model's own nouns rather than a generic workspace/project pair. Kind is the filing vocabulary: decision, gotcha, convention, how-to, reference, todo. An immutable memory (memorizer's "record" archetype) can be archived or superseded, never revised.
Version history, diff and revert fall out of the stream. Recall is IMemorySearch, the one memory read that needs a store feature, so its Fisher half lives under Persistence/ and the second store's search is a sibling class there. Everything else — handler, reader, tools — sees the interface. The embedding leg (an async vector projection, provider optional) is a later node; recall is lexical-only until it lands and stays lexical-only when no provider is configured, which is what local-first requires.
Deliberately: a repository note is ALSO a memory. AddRepositoryNote still appends to the repository's stream, so RepositoryRecord.Notes is unchanged, and the same transaction starts a memory stream with Repository scope. One note system, not two.
Reactive (stoat#39): memory events ride the live relay on their own list and wake their own signal, never the coordination one — a memory changes no plan's status. await_memories is the wake primitive: an agent holding a node parks on its repository's scope and hears what another session recorded there, which is the thing a memory that only records cannot do.
Repositories as first-class memory (decided 2026-09-04, dogfood)
Dogfooding surfaced that "repository" existed only as fragments: the repo half of an issue ref, per-plan repo aliases, the git tree roots the roster groups by. Decided: a first-class Repository in the coordination context — and it is deliberately the first formal agent-memory shape, ahead of session memory (Layer B above): facts asserted once, shared by every plan, carrying provenance.
- One stream per repository (
repo/{owner}/{name}), fine-grained ASSERTED events with By/At, folded inline intoRepositoryRecord(Coordination/Repositories/). The slug is the natural key — the sameowner/nameshape refs already use;Hostis recorded from day one ("github.com" default) so GitLab later is a value, not a key migration. - What it carries: description/default branch, NuGet packages published and consumed, CI workflow (check) names that gate the repo, coarse repo-level dependencies with reasons, and append-only markdown notes — the memory: a gotcha, a convention, a decision, with who and when. Notes are never edited in place.
- Same discipline as plans: every assertion is a command through
RepositoryHandler([WriteModel], store-agnostic), sent by license-gated MCP tools (RepositoryTools: register/describe/declare/retract/note/archive + list/get); re-asserting a known fact appends nothing, so agents assert idempotently without reading first. REST is reads only for now (/api/repositories), for the console. - Deliberately NOT: cycle-checking repo dependencies (planning context, not a derivation input — refusing an assertion about the world would be Stoat deciding something it was told); relaying repository events on the live bus (they change no plan's derived status; a repositories UI that wants live updates adds them to
CoordinationEvents.Allthen). - What it unlocks (planning-ui-notes 2026-09-04): ready-board filtering by repository, the by-repository swim-lane view, and the per-repo expected-check list the PR/CI status work needs.
Commercial boundary discipline
The repo is private (2026-08-01) while the commercial split is decided; the likely shape is the CritterWatch precedent — libraries as the adoption funnel, consoles as the product. This design must not foreclose any line:
- Wire contracts are the seam. The plan schema is a documented wire format; agents talk HTTP/MCP; Stoat references no
Bobcat.*assembly at all (the founding rule, hardened by the split from "one-way" to "zero"). - Memory contracts ride
JasperFx.Events(OSS, JasperFx-owned), never a Bobcat or Stoat assembly — theDispositionKindreasoning again. A future extraction moves deployables, never breaks contracts. - Private-feed NuGet observation (above) is partly a consequence of this decision.
Build order
- Plan schema + read-only DAG view — plan document contract, GitHub poller (auth'd, etags, observation events), Vue DAG surface with drill-in navigation. Storage-agnostic work that fills the window until the SQLite store lands; the first stored projection is written directly against that store, pre-release included.
- NuGet observation — nuget.org + private/local feeds; publish and consume nodes flip on observed evidence.
- MCP tools + ai-skills content — the claim/report/await surface, subscription-backed.
- Test-run gate linkage (
BOBCAT_RUN_TAG) — done; see Test-run drill-in. - Session-memory capture — metadata-depth, hook-based, into session streams. The OBSERVED half landed first (#10, 2026-08-28): a fourth poller over
~/.claude— session files and transcript tails, no cooperation from the agent — feedingsession/{sessionId}streams and an agent roster that joins sessions to claims and working trees. It answers the two supervision failures of 2026-08-10 (the wait-trap, and silent user-stop casualties) by making a claim's health derivable:held/stalled/orphaned, with the cause the session stopped for as the reason. It never releases a claim — the lease still does that; marking is the intervention. The asserted half (#4, an agent's hook posting what only it knows) is still open. - Failure-ledger projection (#44 layer 2) on the shared substrate.
The first real plan document is this epic itself: SQLite event store in JasperFx → publish → Bobcat.Monitor consumes → phases above, with the cross-repo publish/consume hop as real nodes. Self-hosting the plan is the fastest way to find the schema's gaps and the best possible demo.
Parked, explicitly
- Spinning up isolated agent sessions. The mental model is noted for later — the monitor becomes to agents what
Supervisoris to workers (lanes, purposes, environment injection) — but it is a separate epic, and the worktree-isolation execution layer is well-trodden ground elsewhere (Vibe Kanban, Conductor) that could be integrated rather than built. - Product extraction of Layer B. Unscheduled until the shape is proven here.
- Run-context storage migration to the event store (known follow-on; NDJSON keeps working until then).
- GitHub write-back mirrors (labels, same-repo blocked-by edges) — phase 2+ at the earliest; phase 1 is strictly read-only against GitHub.