summaryrefslogtreecommitdiffhomepage
path: root/.dispatch
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-06 22:08:16 +0900
committerAdam Malczewski <[email protected]>2026-06-06 22:08:16 +0900
commite1c8cf3257cb33457aa882c548f5195ecc0f9854 (patch)
treed355147cdab8eb77917ad02caedf26b3d8d0be57 /.dispatch
downloaddispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.tar.gz
dispatch-web-e1c8cf3257cb33457aa882c548f5195ecc0f9854.zip
Slice 1: surface system + WS transport + composition root
Pure-core feature libraries assembled at the composition root: - core/protocol: pure reducer over surface catalog/spec/error messages - features/surface-host: generic field-kind interpreter (toggle/progress/ selector/stat/button) + pure plan logic; no surface-id special-casing - adapters/ws: injected WebSocket client (effects at the edge) - app: composition root store (Svelte 5 runes over the pure reducer), host-relative surface WS URL resolution (resolveWsUrl), root App.svelte Verified green: svelte-check 0/0, vitest 84 passed, biome clean, vite build ok.
Diffstat (limited to '.dispatch')
-rw-r--r--.dispatch/package-agent.md69
-rw-r--r--.dispatch/rules/frontend-inject-transport.md7
-rw-r--r--.dispatch/rules/frontend-interpreter-generic.md8
-rw-r--r--.dispatch/rules/frontend-no-ambient-state.md7
-rw-r--r--.dispatch/rules/frontend-pure-core.md7
-rw-r--r--.dispatch/ui-contract.reference.md169
6 files changed, 267 insertions, 0 deletions
diff --git a/.dispatch/package-agent.md b/.dispatch/package-agent.md
new file mode 100644
index 0000000..a5666e4
--- /dev/null
+++ b/.dispatch/package-agent.md
@@ -0,0 +1,69 @@
+<!-- ORCHESTRATOR-ONLY meta (see ORCHESTRATOR.md): every FE summon is assembled as
+ package-agent.md + the inlined .dispatch/rules/* + the per-summon TASK block.
+ This is the base for ALL FE owner-agents; nothing here is restated per summon. -->
+
+# Frontend Owner-Agent — Brief
+
+You are the **sole owner-agent for exactly ONE unit** — a single feature module /
+directory under `src/`. Your unit + job are in the **TASK** at the end. You build
+it, test it, and write a report — nothing else. If no single unit is named, stop.
+
+## Hard guardrails (NON-NEGOTIABLE)
+- **Single-writer, directory-scoped.** Read/create/edit any file inside your unit's
+ directory. Never create or edit anything OUTSIDE it — not another feature, not
+ `src/app/` (the composition root), not root config (`package.json`,
+ `tsconfig.json`, `vite.config.ts`, `biome.json`), not the harness, not the
+ backend repo (`../arch-rewrite`).
+- **Need a change outside your unit?** Do NOT make it — write a CHANGE-REQUEST in
+ your report (a sibling's public export, a backend contract, root config,
+ composition wiring). The orchestrator dispatches it.
+- **No workspace/dep wiring.** Don't `bun install` or edit root config; list a new
+ dep / wiring need as a CR.
+- **No git** (no commits, branches, pushes, resets).
+
+## What you may read (visibility)
+- **Your own unit:** every file, freely.
+- **The contract you consume:** reproduced IN-REPO at
+ `.dispatch/ui-contract.reference.md` — read THAT. Your code imports
+ `@dispatch/ui-contract` normally, but **do NOT read `node_modules/@dispatch/*`** — it
+ symlinks to the backend repo (OUTSIDE this repo) and a headless permission prompt will
+ HANG the run (see "Headless read boundary").
+- **Sibling units — PUBLIC SURFACE only:** their `index.ts` exports. Don't read
+ their internals (needing them ⇒ the contract is incomplete → report a CR).
+
+## Headless read boundary (you run non-interactively)
+You run HEADLESS: a Read of any file OUTSIDE this repo (`dispatch-web/`) triggers a
+permission prompt that CANNOT be answered → the run HANGS until aborted. Use Read/Edit ONLY
+within `dispatch-web/`. If you believe you need a file outside your scope, do NOT attempt
+the read — STOP and write the need in your report, then end.
+
+## Engineering standard (the inlined `.dispatch/rules/*` govern; in brief)
+- **Pure core / injected shell.** Decision logic is `input → output`: zero DOM,
+ zero `fetch`/WS, zero Svelte. Put it in a `.ts` module (e.g. `logic/`) that tests
+ with NO mounting and NO mocks. Effects are INJECTED (props or an `adapter/`).
+- **Svelte-thin.** `.svelte` files wire props/events to pure logic + render; no
+ business logic. (biome lints `.ts`/`.js` only; `svelte-check` owns `.svelte`.)
+- **No ambient state.** Own state explicitly; runes wrap the pure reducer;
+ subscriptions are disposed on unmount.
+- **Tests, asymmetric.** Pure logic → vitest with ZERO internal mocks (never
+ `vi.mock` of our own modules). Components → a few `@testing-library/svelte`
+ tests; don't chase coverage there, don't mock siblings. Faking the OUTERMOST
+ edge (a fake socket/fetch/clock) is the only allowed mock.
+- **Isolation over DRY.** Self-contained over a shared helper wired between
+ features. The only shared surfaces are the imported contracts.
+- **Strict TS:** respect `exactOptionalPropertyTypes` + `noUncheckedIndexedAccess`.
+
+## Verify before finishing — YOUR UNIT IN ISOLATION
+Run, and paste the output into your report:
+- `bunx svelte-check --tsconfig ./tsconfig.json` → 0 errors
+- `bunx vitest run src/<your-dir>` → all pass (count goes up)
+- `bunx biome check src/<your-dir>` → clean
+The orchestrator runs the authoritative full `typecheck`/`test`/`check`/`build`.
+
+## Report (REQUIRED) → `reports/<your-unit>.md`
+1. Files created/changed. 2. Public surface you expose (exported types/functions/
+components). 3. New test names + the isolated-verify output. 4. Change-requests
+(sibling export, backend contract, root config, composition wiring) — explicit and
+actionable.
+
+Your specific **TASK** follows at the end of this prompt.
diff --git a/.dispatch/rules/frontend-inject-transport.md b/.dispatch/rules/frontend-inject-transport.md
new file mode 100644
index 0000000..fbe83d7
--- /dev/null
+++ b/.dispatch/rules/frontend-inject-transport.md
@@ -0,0 +1,7 @@
+# Rule: inject the transport; parsers are pure
+
+The WS/NDJSON framing + parsing is a PURE function (bytes/messages → typed events);
+the socket/fetch is INJECTED. Test the parser with crafted chunk inputs (and
+trace-replay-style fixtures), never a live connection. The op-protocol core is a
+pure state machine: `reduce(intent, incoming) → { viewModel, outgoingCommands }`;
+the carrier (WebSocket) is the injected shell.
diff --git a/.dispatch/rules/frontend-interpreter-generic.md b/.dispatch/rules/frontend-interpreter-generic.md
new file mode 100644
index 0000000..557f2d4
--- /dev/null
+++ b/.dispatch/rules/frontend-interpreter-generic.md
@@ -0,0 +1,8 @@
+# Rule: the surface interpreter is generic
+
+The surface interpreter switches on field KINDS (toggle/progress/selector/stat/
+button/custom), NEVER on a surface id. An `if (surface.id === "...")` imports a
+feature's identity into the platform and breaks isolation (guardrail 1). An
+unknown field `kind` or a `custom` `rendererId` with no registered renderer →
+GRACEFUL SKIP, never a crash. Render from the spec; the backend owns what a
+surface contains.
diff --git a/.dispatch/rules/frontend-no-ambient-state.md b/.dispatch/rules/frontend-no-ambient-state.md
new file mode 100644
index 0000000..663bf1a
--- /dev/null
+++ b/.dispatch/rules/frontend-no-ambient-state.md
@@ -0,0 +1,7 @@
+# Rule: no ambient state (frontend)
+
+State is owned per-unit and passed explicitly. NO module-global mutable store
+reached from everywhere — that is the old FE's "tools leak across tabs" /
+"model resets on tab switch" bug class. Svelte runes (`$state`) are a THIN
+reactive wrapper over a pure reducer, never the home of logic. Subscriptions are
+owned and disposed on unmount (no orphaned or duplicate subscriptions).
diff --git a/.dispatch/rules/frontend-pure-core.md b/.dispatch/rules/frontend-pure-core.md
new file mode 100644
index 0000000..fa7bc2e
--- /dev/null
+++ b/.dispatch/rules/frontend-pure-core.md
@@ -0,0 +1,7 @@
+# Rule: pure core / injected shell (frontend)
+
+Decision logic — reducers, view-models, formatters, parsers — is pure
+(input → output): NO DOM, NO `fetch`/WebSocket, NO Svelte import. Put it in a
+`.ts` module that tests with zero mounting and zero mocks. Effects (socket, fetch,
+IndexedDB, clock) are INJECTED at the edges (props or an adapter). This is for
+testability, not purity dogma — stop where it would only add ceremony.
diff --git a/.dispatch/ui-contract.reference.md b/.dispatch/ui-contract.reference.md
new file mode 100644
index 0000000..3962fc1
--- /dev/null
+++ b/.dispatch/ui-contract.reference.md
@@ -0,0 +1,169 @@
+# `@dispatch/ui-contract` — in-repo reference (read THIS, not node_modules)
+
+> This MIRRORS the backend's `@dispatch/ui-contract` package source so headless FE agents can
+> read the surface contract WITHOUT following the `file:` dep symlink out of this repo (which
+> hangs on a permission prompt). Your CODE still imports `@dispatch/ui-contract` normally — this
+> file is for READING only.
+>
+> **Orchestrator:** this is a SNAPSHOT — regenerate it whenever `ui-contract` changes.
+
+```ts
+/**
+ * UI contract — the frontend-agnostic vocabulary for backend-declared "surfaces".
+ *
+ * A SURFACE is a "data transportation surface": a typed description of what data an
+ * extension exposes, its semantics, and the actions that can act on it — NOT UI.
+ * Any client renders a surface in its own idiom (web/Svelte, CLI, future TUI/mobile).
+ * Types-only, zero runtime, zero `@dispatch/*` deps.
+ */
+
+/** Where a surface mounts — a coarse, semantic placement hint, NOT layout/CSS. Open string. */
+export type Region = string;
+
+/** A typed reference to a backend action a field can invoke (client posts payload back). */
+export interface ActionRef {
+ readonly actionId: string;
+}
+
+/** One selectable option in a `selector` field. */
+export interface SurfaceOption {
+ readonly value: string;
+ readonly label: string;
+}
+
+/** A field within a surface — a SEMANTIC value, not a widget. `kind` is the discriminant. */
+export type SurfaceField =
+ | ToggleField
+ | ProgressField
+ | SelectorField
+ | StatField
+ | ButtonField
+ | CustomField;
+
+/** A boolean setting plus the action that flips it. */
+export interface ToggleField {
+ readonly kind: "toggle";
+ readonly label: string;
+ readonly value: boolean;
+ readonly action: ActionRef;
+}
+
+/** A bounded ratio in [0, 1] with a label. Read-only. */
+export interface ProgressField {
+ readonly kind: "progress";
+ readonly label: string;
+ readonly value: number;
+}
+
+/** An enum choice: the current value, the options, and the action that sets it. */
+export interface SelectorField {
+ readonly kind: "selector";
+ readonly label: string;
+ readonly value: string;
+ readonly options: readonly SurfaceOption[];
+ readonly action: ActionRef;
+}
+
+/** A read-only labelled scalar readout. */
+export interface StatField {
+ readonly kind: "stat";
+ readonly label: string;
+ readonly value: string;
+}
+
+/** A labelled action trigger. */
+export interface ButtonField {
+ readonly kind: "button";
+ readonly label: string;
+ readonly action: ActionRef;
+}
+
+/**
+ * The escape hatch: data that fits no semantic field kind. Opaque `payload` + a
+ * `rendererId`; clients WITH a renderer for that id show it, others GRACEFULLY SKIP.
+ */
+export interface CustomField {
+ readonly kind: "custom";
+ readonly rendererId: string;
+ readonly payload: unknown;
+}
+
+/** A surface: an ordered set of fields mounted in a region, with a title. */
+export interface SurfaceSpec {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+ readonly fields: readonly SurfaceField[];
+}
+
+/** A surface-catalog entry — discovery metadata only (no field data). */
+export interface SurfaceCatalogEntry {
+ readonly id: string;
+ readonly region: Region;
+ readonly title: string;
+}
+
+/** The surface catalog: the list of available surfaces a client can choose to show. */
+export type SurfaceCatalog = readonly SurfaceCatalogEntry[];
+
+/** A live update for a subscribed surface. v1 carries the full new spec. */
+export interface SurfaceUpdate {
+ readonly surfaceId: string;
+ readonly spec: SurfaceSpec;
+}
+
+// ── Surface WebSocket protocol (slice 1: surfaces only) ──────────────────────
+
+/** A client → server message on the surface channel. */
+export type SurfaceClientMessage = SubscribeMessage | UnsubscribeMessage | InvokeMessage;
+
+export interface SubscribeMessage {
+ readonly type: "subscribe";
+ readonly surfaceId: string;
+}
+
+export interface UnsubscribeMessage {
+ readonly type: "unsubscribe";
+ readonly surfaceId: string;
+}
+
+/** Invoke a field's action; `payload` is the new value (e.g. a toggle's boolean). */
+export interface InvokeMessage {
+ readonly type: "invoke";
+ readonly surfaceId: string;
+ readonly actionId: string;
+ readonly payload?: unknown;
+}
+
+/** A server → client message on the surface channel. */
+export type SurfaceServerMessage =
+ | CatalogMessage
+ | SurfaceMessage
+ | SurfaceUpdateMessage
+ | SurfaceErrorMessage;
+
+/** The current surface catalog (sent on connect and whenever it changes). */
+export interface CatalogMessage {
+ readonly type: "catalog";
+ readonly catalog: SurfaceCatalog;
+}
+
+/** The full current spec for a surface the client just subscribed to. */
+export interface SurfaceMessage {
+ readonly type: "surface";
+ readonly spec: SurfaceSpec;
+}
+
+/** A live update for a subscribed surface. */
+export interface SurfaceUpdateMessage {
+ readonly type: "update";
+ readonly update: SurfaceUpdate;
+}
+
+/** A surface-scoped error. */
+export interface SurfaceErrorMessage {
+ readonly type: "error";
+ readonly surfaceId?: string;
+ readonly message: string;
+}
+```