


Build an App That Gathers Activity Events from Many Sources and Shows Them in Several Hosts
For an app that gathers events from many tools and shows them in an editor extension and a standalone server. Keeps layers one-way, turns each source into one event type, lets new sources plug in without other changes, and saves state safely.
To use this AI rule
## Keep Dependencies Flowing One Way So Hosts Never Import Each Other
Keep dependencies flowing one way through named layers. A `core` layer holds only protocol and interface definitions with zero runtime side effects. A runtime layer depends only on `core`. Each host surface — for example an editor-extension adapter and a standalone command-line server — depends on `core` plus the runtime, but the surfaces never import each other. Declare the allowed direction explicitly and enforce it, because a single cross-boundary import quietly couples two surfaces that must stay independently swappable.
### Why it holds
A dependency-free contract layer lets every surface and every integration compile against the same types without pulling in host-specific code. New surfaces compose the shared pieces; they never reach sideways into another surface's internals. When you cannot add a surface without editing an existing one, the layering has already broken.
## Generate Typed Bindings from One Protocol File and Check Them in CI
Let one machine-readable contract be the single source of truth for the wire protocol, then generate the typed bindings from it instead of hand-writing them. Treat the generated file as read-only: give it an auto-generation banner and never edit it by hand. Add a continuous-integration step that regenerates the bindings and fails the build on any non-empty diff, so the contract and the code can never drift apart.
### Pin the generator
Pin the contract version to what your code generator actually supports. A newer schema version the generator cannot handle can silently degrade generated types to a permissive catch-all, erasing all type safety without an obvious error. Record why the version is pinned and revisit only when the tooling catches up.
## Turn Every Source into One Event Type in a Single Hub
Aggregate all activity through a single hub. Whatever the source — events pushed over HTTP, or lines parsed from log and transcript files — normalize each into one canonical event type before it enters the runtime. Downstream code dispatches on the event's kind, never on source-specific action or tool names, so adding a source cannot ripple into the runtime or the UI.
### Keep the event set small and closed
Define a small closed set of event kinds: session start and end, work start and end, sub-task lifecycle, progress, and permission request. Each ingestion path's only responsibility is to produce these canonical events. Everything past the hub is source-agnostic and stays stable as new sources arrive.
## Hide the Connection Behind One Transport Interface
Put the wire behind a single transport interface with a tiny surface: send a message, subscribe to incoming messages, expose a ready promise and a connection state, and dispose. Implement it once per environment — for example an in-process message channel for an embedded surface and a reconnecting socket for a standalone one — and branch between implementations in exactly one factory function. Everything downstream depends on the interface and never learns which transport is live.
### Resilience lives in the implementation
Give the networked implementation exponential backoff and a send queue that drains on reconnect. The in-process one can report connected for its whole lifetime. Callers observe identical behavior either way, so no feature code contains transport-specific branches.
## Add Each Integration as a Provider Without Touching Other Code
Model every external integration as a provider implementing one interface: normalize a raw event into a canonical one, install and uninstall and query its hooks, format status, and declare which of its action names are exempt from specific rules. Offer optional capabilities — a polling fallback, an extended multi-actor mode — as optional methods so simple integrations stay small.
### The test of a clean boundary
A new integration should be one new subdirectory implementing the interface, with zero changes to the runtime, the UI, or any existing provider. If adding a source forces edits elsewhere, the boundary is leaking and needs tightening before more integrations pile onto the crack.
## Share One Runtime Core and One Store Between Both Hosts
Let both host surfaces compose the same runtime core; only the persistence namespace, the transport, and any host-specific adapter differ. Route every state change through one store that owns the data, emits typed mutation events, and is the single source of truth. Subscribe one broadcast layer to those events and translate them into outbound protocol messages there — no other module calls a transport method directly.
### Keep the host seam narrow
Expose exactly one callback interface between the runtime and its host for the few things that are genuinely host-specific: launching work, opening folders, import and export. Keeping that seam narrow is precisely what lets two surfaces share the core unchanged, so widen it only when a real host difference demands it.
## Use Push Signals First and Keep File Polling as a Backup
When a fast, reliable signal exists, prefer it, and keep a slower fallback for when it is absent. A push channel of event callbacks over HTTP gives instant, ordered signals; polling files is the fallback when push is not installed. Gate the two so they never double-count: a per-item delivered flag plus a global toggle decide whether the fallback's timers run.
### Split reading content from deriving state
Some data may only come from the fallback path — fine-grained progress, status text — so keep polling running for that even in push mode, but suppress the fallback's derived timers (permission prompts, idle detection) once push is confirmed for that item. Separating "read content" from "derive state" is what lets both modes coexist without conflict.
## Save State Separately per Host and Check the Copy Before Removing the Old One
Namespace persisted state per surface so two surfaces running in parallel never clobber each other. Write files atomically — write a temporary file, then rename — and mark your own writes so a cross-process watcher does not re-read them as external changes. Pair file watching with a polling backup, because native file watchers are unreliable on some platforms.
### Verify before you clear
When migrating legacy state, verify before clearing: write the new location, read it back, and only then remove the old key. While anything remains unmigrated, surface a non-blocking warning instead of assuming success. Never delete the source of truth before the copy is confirmed on disk.
## Put Constants in One Place and Enforce It with Lint Rules
Centralize every magic number and string — timing constants, identifiers, protocol literals, style tokens — in a small number of constants modules, one per layer, and never inline them. Inlined values drift, duplicate, and hide coupling that later refactors trip over.
### Enforce with lint, not vigilance
Back the policy with custom lint rules set to error level so violations block merges: forbid inline color and style literals outside the token file, require the shared shadow and spacing tokens, require the shared font reference. A rule a machine enforces survives contributor turnover; a convention written only in a document does not.
## Test in Layers and Use End-to-End Tests for Code That Changes Often
Use a layered test strategy, each layer with the right tool. Fast unit and integration tests cover pure logic: state mutations, event routing, parsing, migration, installer atomicity. End-to-end tests drive the real surfaces and assert on user-visible outcomes rather than internals.
### Prefer stable seams for open contribution
For code that contributors change constantly, such as UI internals, prefer end-to-end tests that pin user-facing behavior over unit tests bound to internals, so refactors do not force contributors to rewrite internal tests on top of feature work. Keep every test isolated — its own temporary home, workspace, and data directory — so no state leaks between tests, and drift-check any auto-generated test inventory in CI exactly as you drift-check generated code.
## Skip Bad Input Instead of Crashing and Tag Every Log Line
Degrade gracefully: catch and log errors, but never let them crash the host. Silently skip a malformed input line rather than aborting the whole stream. When an asset or resource is missing, log a warning and continue with a null or fallback value instead of failing the entire operation.
### Make logs greppable
Prefix every log line with a bracketed context tag that names the subsystem, so a flat console stream stays searchable. Decide deliberately whether you need centralized error reporting or telemetry; if you choose not to, state that decision and keep the local logging consistent so future readers are not left guessing.
## Read Growing Log Files Safely and Leave Enough Time in Async Tests
These rules are cheap to state and expensive to relearn.
### Reading append-only files
Pair any native file watcher with periodic polling on a short interval, and carry unterminated partial lines in a buffer across reads, because a reader can catch a file mid-write. A cleared or reset session often starts a brand-new file while the old one simply stops, so detect the switch by content rather than assuming the same handle keeps growing.
### Timing budgets in tests
When you assert on an outcome at the end of an asynchronous pipeline, budget for every stage's latency — the gap between the triggering signal and the derived one, the timer delay, and inter-process plus render slop. A timeout shorter than the summed budget is flaky by construction.
### Rendering and batching
Keep high-frequency animation state in an imperative store rather than framework state, and delay "work done" transitions slightly so UI batching does not hide brief active states from the user.
