Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions docs/adr/0008-command-descriptor-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# ADR 0008: Command Descriptor Registry

## Status

Proposed

## Context

A command's identity is restated, by hand, across roughly ten tables that must stay aligned by
convention: `PUBLIC_COMMANDS` (`src/command-catalog.ts`), the per-command metadata and family facets
(`src/commands/**`), the capability matrix (`src/core/capabilities.ts`), the daemon command registry
(`src/daemon/daemon-command-registry.ts`, ADR 0003), the structured-batch allowlist
(`src/batch-policy.ts`), the MCP exposure sets, the Node client interface and impl (`src/client-types.ts`,
`src/client.ts`), and the generic-dispatch `switch` (`src/core/dispatch.ts`, whose `default: throw` makes a
missing or renamed command a runtime error, not a compile error). Adding one command touches ~24 files, the
argument shape is (de)serialized ~4 times, and the gesture set is retyped in three places.

The codebase already proves the cure works for part of this: the `CommandFamilyFacet`
(`src/commands/family/`) derives the MCP tools, the CLI schema, and the batch writer from a single array.
It simply stops at the command-surface boundary; everything past it is hand-maintained.

ADR 0003 deliberately separated daemon route/policy into its own internally-owned registry with a small
predicate interface, and its 2026-06 update set four invariants that any single-declaration/derivation
model must preserve. This ADR is that model.

## Decision

Introduce one `CommandDescriptor` per command that **composes facets owned by their domains** and from which
every consumer table is **derived** by pure, parity-tested projection:

- The descriptor composes a `surface` facet (owned by `src/commands/**`: identity, CLI schema/reader, MCP),
a `capability` facet (owned by `src/core/capabilities`), and a `daemon` facet (route + request-policy
traits, **owned under `src/daemon/`** per ADR 0003), plus a typed result.
- The public catalog, capability matrix, daemon command registry, batch allowlist, MCP tool list, CLI
schema, and the Node client surface become pure projections of the descriptor set. The
`src/core/dispatch.ts` `switch` is replaced by a total map keyed on the command-name union, so a missing
handler is a compile error.
- The cross-process `invoke` (client) and in-daemon `execute` seams stay distinct; the process boundary is
never collapsed.

This **composes with**, and is bound by, ADR 0003's four invariants: daemon-owned declaration (never inlined
into the public surface), the predicate interface unchanged, no leakage of daemon-only traits into public
projections, and one declaration per concern enforced by the type system.

## Alternatives Considered

- Keep the hand-synced tables: no migration risk, but it is the status quo this ADR exists to remove —
~24-file cost per command and drift kept in check only by convention and tests.
- A single flat public descriptor with daemon fields inlined: re-contaminates the public command surface
with daemon-only policy, which is exactly what ADR 0003 (and its update) forbid.
- Build-time code generation: a real option, but runtime derivation with `as const satisfies` keeps the
source of truth in type-checked TypeScript with no separate build step or generated artifacts to review.

## Consequences

Adding a plain command touches ~1–2 files; per-platform behavior remains N implementations behind the
descriptor's `execute`. The descriptor is the prerequisite for typed per-command results (ADR 0010, which
deletes the `src/client-types.ts` mirror) and supplies the capability facet the platform-plugin work
(ADR 0009) hooks into.

Migration is **strangler-fig and sequential** — never a big-bang:

1. Introduce the `commandRegistry` as the root and **invert the import graph** so `command-catalog`,
`capabilities`, `daemon-command-registry`, and `batch-policy` become leaves that derive from it.
2. Promote each family's facet to a full `CommandDescriptor`, family by family.
3. Replace the dispatch `switch` with the registry-driven total map, arm by arm.

Each derived table must be asserted **byte-for-byte equivalent** to the hand-authored table by a parity test
**before** the hand table is deleted. The principal risk is the import-cycle inversion: `command-catalog.ts`
has ~95 importers and the family facet currently imports `AgentDeviceClient`, so the descriptor module must
own the `Input`/`Result` types and the client must be derived as a view type, enforced by a lint boundary.
Until this lands and the registry tests pin it, the hand-authored tables remain the source of truth.

`plans/perfect-shape.md` (§5.2) holds the prototype; this ADR owns the decision and its constraints.
53 changes: 53 additions & 0 deletions docs/adr/0009-apple-platform-consolidation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ADR 0009: Apple Platform Consolidation (AppleOS leaf axis)

## Status

Accepted

## Context

Apple support is modeled asymmetrically and is physically smeared across an `ios` directory that is really
the shared Apple engine. `Platform` carries `ios` and `macos` as separate literals, but tvOS is not a
platform at all — it is `platform: 'ios' + target: 'tv'`, with the OS name reconstructed late and lossily by
`resolveApplePlatformName` (`src/utils/device.ts`). Meanwhile ~697 LOC of macOS code lives inside
`src/platforms/ios/`, `src/platforms/macos/devices.ts` is a 19-line stub that the iOS discovery imports (the
dependency arrow points backwards), and the Apple interactor in `src/core/interactors/apple.ts` reaches into
`platforms/ios`. A four-investigator survey found that ~85% of `platforms/ios` (the runner stack,
tool-provider, discovery, snapshot, screenshot, perf, debug-symbols) is already OS-agnostic, and the XCTest
runner already builds `ios | macos | tvos` from one Xcode project. Distinguishing or adding an Apple OS today
is therefore costly out of proportion to the actual work, and iPadOS/visionOS/watchOS are unmodeled.

## Decision

Model Apple OSes with an **`AppleOS` discriminant** (`ios | ipados | tvos | watchos | visionos | macos`)
under a single `apple` Platform — **not** six `Platform` literals. The OS-agnostic Apple engine consolidates
under `src/platforms/apple/core/`, with genuinely per-OS code in `src/platforms/apple/os/<os>/` leaves;
the Apple plugin is the first instance of the platform-plugin registry (the platform axis of the
`perfect-shape` plan). Per-OS capability differences become data keyed by `AppleOS`. The additive,
non-breaking `appleOs` discriminant — the groundwork for this — shipped in #896.

## Alternatives Considered

- Promote each Apple OS to its own `Platform` literal: rejected. `DeviceTarget` (`mobile | tv | desktop`) is
already cross-platform (Android TV uses `target: 'tv'`), so a `tvos` literal collides with the form-factor
axis; it would also force the ~15 `isApplePlatform` and ~52 `macos` branch sites to enumerate six literals
and break the single-bucket `apple` capability/selector model that already works.
- Keep the status quo: rejected — the tvOS/macOS asymmetry, the mislabeled macOS code, and the lossy
target→OS inference persist, with no path to iPadOS/visionOS.
- Exclude macOS from the consolidation: rejected. macOS is already entangled — it builds via the same XCUITest
project and ~697 LOC of its code already lives inside `platforms/ios`. Excluding it would leave the
mislabel in place; including it as a distinct AppKit leaf normalizes it without homogenizing it.

## Consequences

Adding a first-class Apple OS becomes cheap: a leaf module plus a runner-profile row. iOS/iPadOS/tvOS/macOS
are mostly relocate-and-rename (the engine never needed to know which Apple OS it drives); visionOS is scoped
net-new work (XCUITest supports it — a profile row, a build case, `#if os(visionOS)`, a widened discovery
filter, plus real spatial-input QA); watchOS is an explicit **unsupported sentinel** because XCUITest cannot
drive watchOS UI. macOS stays a distinct AppKit leaf (its helper binary and menubar/desktop surface model are
preserved). The tvOS focus-only interaction contract (no coordinate `tap`) must not be flattened across OSes,
and snapshot fidelity is uneven (the deep-RN AX-server fallback is iOS-simulator-only). The final
`Platform` collapse of `ios`+`macos` into `apple` is the last, highest-diff step.

This composes with ADR 0008 (the descriptor's capability facet) and ADR 0003. The phased sequencing and
per-OS readiness live in `plans/apple-platform-consolidation.md`; this ADR owns the decision.
7 changes: 7 additions & 0 deletions plans/perfect-shape.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ wiring cheap and make *half-wired* platforms a compile error — they do not mak
reduction is real but modest (~**-1k to -3k LOC**, dominated by deleting the `client-types.ts` mirror); the
*real* prize is **files-per-change** and **type safety**, not raw line count.

**Decision records:** the two axes are now ADRs — [ADR 0008](../docs/adr/0008-command-descriptor-registry.md)
(command descriptor, composing with ADR 0003) and
[ADR 0009](../docs/adr/0009-apple-platform-consolidation.md) (Apple / `AppleOS`).
**Status (2026-06):** Phase 0 (type-safety, parse-at-boundary, derived allow-lists, `AppleOS` groundwork,
replay derivation) and the Tier-A dedup sweep are merged. The next gateway is the command-descriptor spine
(§5.2, ADR 0008); everything substantive cascades from it.

---

## 1. Mind map — the codebase today
Expand Down
Loading