From d124743c9a903c3427509b915401315137de754f Mon Sep 17 00:00:00 2001 From: JonathanLab Date: Fri, 29 May 2026 17:17:58 +0200 Subject: [PATCH] refactor: update refactor doc and fix ci --- REFACTOR.md | 1108 +++++++++-- REFACTOR_PROGRESS.md | 47 + REFACTOR_SLICES.json | 1707 +++++++++++++++++ .../src/renderer/desktop-contributions.ts | 6 + apps/code/src/renderer/desktop-services.ts | 3 + apps/code/src/renderer/main.tsx | 16 +- packages/ui/package.json | 4 +- packages/ui/src/workbench/contribution.ts | 25 + packages/ui/src/workbench/service-context.tsx | 32 + pnpm-lock.yaml | 6 + scripts/refactor-init.sh | 122 ++ 11 files changed, 2861 insertions(+), 215 deletions(-) create mode 100644 REFACTOR_PROGRESS.md create mode 100644 REFACTOR_SLICES.json create mode 100644 apps/code/src/renderer/desktop-contributions.ts create mode 100644 apps/code/src/renderer/desktop-services.ts create mode 100644 packages/ui/src/workbench/contribution.ts create mode 100644 packages/ui/src/workbench/service-context.tsx create mode 100755 scripts/refactor-init.sh diff --git a/REFACTOR.md b/REFACTOR.md index aae71fabb..02d8cbf49 100644 --- a/REFACTOR.md +++ b/REFACTOR.md @@ -1,317 +1,1003 @@ -# REFACTOR.md — feature-by-feature migration guide +# REFACTOR.md - VS Code-style migration guide -This file is the **procedure** for porting an existing feature into the new package architecture. Read [AGENTS.md](./AGENTS.md) for the layering rules. Read this when you're about to move a feature across. +This file is the procedure for moving `apps/code` toward a VS Code-like architecture: +small host entrypoints, package-owned services, constructor injection, feature +contributions, and host-specific service implementations registered at startup. -[MIGRATION.md](./MIGRATION.md) is the running log of what landed and where — useful if you're tracking what's done vs. still to come. +Read [AGENTS.md](./AGENTS.md) for the layering rules. This guide explains how to +apply those rules during the package migration. + +[MIGRATION.md](./MIGRATION.md) is the running log of what landed, what still +bridges old code, and what unblocks each bridge's removal. + +For long-running or parallel agent work, use the coordination files described in +[Agent Harness](#agent-harness). They are the source of truth for what is +claimed, what is passing, and what the next agent should do. --- -## Target shape +## Target Shape -Three packages carry the work, organized by runtime. Each one is domain-folder-structured inside. +Runtime code moves out of `apps/code` into packages. `apps/code` becomes the +Electron host: process startup, windows, lifecycle, Electron adapters, and +registration of desktop-specific services. ``` packages/ -├── core/ # pure JS. All domain logic. Runs anywhere. -│ ├── sessions/ -│ ├── workspace/ -│ ├── auth/ -│ ├── tasks/ -│ └── ... -├── ui/ # React DOM. Mirrors core's domain folders. -│ ├── sessions/ -│ ├── workspace/ -│ ├── primitives/ # @posthog/quill wrappers, Button, Modal, Toast -│ └── ... -├── workspace-server/ # Node-only. Host syscalls. Organized by capability. -│ ├── git/ -│ ├── fs/ -│ ├── pty/ -│ ├── process/ -│ ├── watcher/ -│ └── ... -│ -├── platform/ # host-capability interfaces. Locked-down. -├── shared/ # zero-dep primitives, Saga, types. Locked-down. -├── workspace-client/ # TRPC client for workspace-server. -└── api-client/ # HTTP client for Django. +├── platform/ # service identifiers + host capability interfaces +├── core/ # host-agnostic business services and orchestration +├── ui/ # React DOM workbench, feature views, UI services +├── workspace-server/ # Node-only host syscall services and tRPC server +├── workspace-client/ # typed client for workspace-server +├── api-client/ # PostHog/Django HTTP client +└── shared/ # zero-dep primitives, types, utilities apps/ -├── web/ # mounts packages/ui. Provides platform-web adapters. -├── desktop/ # Electron shell. Spawns workspace-server. Provides Electron -│ platform-adapters. main = shell + adapters. NO business logic. -└── mobile/ # React Native. Imports core/* only. Writes its own RN UI. +├── code/ # current Electron desktop host +├── web/ # future web host +└── mobile/ # React Native host; imports core/platform/shared ``` -Per-domain folder shape, by package: +Real package paths live under `src/`: ``` -core/sessions/ ui/sessions/ workspace-server/services/git/ -├── sessions.ts ├── SessionList.tsx ├── git.ts -├── types.ts ├── SessionDetail.tsx └── schemas.ts -└── sessions.test.ts ├── useSession.ts - ├── store.ts (Zustand) - └── SessionList.test.tsx +packages/core/src/sessions/ +├── sessions.ts +├── sessions.module.ts +├── schemas.ts +└── sessions.test.ts + +packages/ui/src/features/sessions/ +├── SessionsView.tsx +├── sessions.contribution.ts +├── sessions.module.ts +├── store.ts +└── useSessions.ts + +packages/workspace-server/src/services/git/ +├── git.ts +├── git.module.ts +├── schemas.ts +└── git.test.ts + +apps/code/src/renderer/ +├── desktop-services.ts +├── desktop-contributions.ts +└── main.tsx +``` + +Use the bare layer names below (`core`, `ui`, `workspace-server`) as shorthand +for those package paths. + +--- + +## Architecture Model + +The model is VS Code-style, implemented with InversifyJS: + +- Packages define service identifiers, interfaces, implementations, and + registration modules. +- Host apps load package modules and bind host-specific implementations. +- Consumers receive dependencies through constructors. +- Feature startup happens through workbench contributions. +- `container.get(...)` is allowed only at startup boundaries, tests, and + framework adapters. It is not allowed inside service methods or components as a + service locator. + +There is no mega composition root that manually constructs every feature. The +desktop entrypoint should import registration modules and start the workbench. + +```ts +// apps/code/src/renderer/main.tsx +import "./desktop-services"; +import "./desktop-contributions"; + +await startWorkbench(); ``` -Naming: -- The main file is named after its domain (`sessions.ts`, `file-watcher.ts`, `git.ts`) — not `service.ts`, not `index.ts`. "Service" is DI culture; in core there's no DI and the suffix is meaningless. The repeated folder/file name is intentional: `file-watcher/file-watcher.ts` reads cleaner than `file-watcher/service.ts` and makes grep-by-filename land on the right file. -- `types.ts` — pure TS types, interfaces, enums, constants. No runtime cost. Use when the domain has internal-only types not crossing a tRPC boundary. -- `schemas.ts` — Zod schemas + types inferred from them (`z.infer`). Use when shapes cross a tRPC boundary (workspace-server procedures, anything validated at runtime). The schema is the source of truth; types are inferred from it, never declared separately. -- A domain can have both `types.ts` and `schemas.ts` when it has internal types AND boundary-validated shapes. Most have one or the other. -- Tests colocate next to the file under test (`sessions.test.ts`, `git.test.ts`). +```ts +// apps/code/src/renderer/desktop-services.ts +import { container } from "@renderer/di/container"; +import { NOTIFICATIONS_SERVICE } from "@posthog/platform/notifications"; +import { TrpcNotificationsService } from "@renderer/platform-adapters/notifications"; -Flat. No `internal/` folder. Split into more files when a single file gets too long to read, grouped by concept. +container + .bind(NOTIFICATIONS_SERVICE) + .to(TrpcNotificationsService) + .inSingletonScope(); +``` -**What each package owns:** +```ts +// apps/code/src/renderer/desktop-contributions.ts +import { container } from "@renderer/di/container"; +import { sessionsUiModule } from "@posthog/ui/features/sessions/sessions.module"; +import { notificationsUiModule } from "@posthog/ui/features/notifications/notifications.module"; -- **`core//`** — all business logic. Services, state machines, orchestration, retries, dedup, parsing, error normalization, typed events. Pure JS. Unit-testable with mocked clients. -- **`ui//`** — React components, hooks that wrap core service calls (`useQuery` over `core.sessions.list()`), and **thin** Zustand stores for pure UI state (selection, open/closed, scroll position, subscription-fed caches). **No business logic**, no multi-step flows, no retries, no orchestration, no `let inFlight: Promise` style dedup. If you find yourself writing those in `ui/`, the code belongs in `core/`. -- **`workspace-server//`** — host syscall procedures (git CLI, fs read/write, spawn, watcher). Dumb. No decisions. Called by core through `workspace-client`. +container.load(sessionsUiModule, notificationsUiModule); +``` -The desktop **main process is not the home of business logic anymore.** It does three things: spawn workspace-server, mount renderer, implement platform adapters. +The entrypoint chooses the runtime. Packages own the feature wiring. -**Import rules** (biome `noRestrictedImports`): +--- -- `core//` may import other `core//` (via their `index.ts`), `shared/`, `platform/`, `workspace-client`, `api-client`. **Never** `ui/*` or `workspace-server/*`. -- `ui//` may import `core/*`, `ui/primitives/`, `shared/`. **Never** `workspace-server/*` or other `ui//` internals. -- `workspace-server//` may import `shared/`, Node modules, and other `workspace-server//` via `index.ts`. **Never** `core/*` or `ui/*` — workspace-server is the host; it knows nothing about business domains. Domains live in `core/` and *call into* workspace-server through `workspace-client`. -- `shared/` and `platform/` import nothing else internal. +## Agent Harness + +This migration will be worked by many agents across many context windows. Treat +the repo like a handoff between engineers on shifts: every agent must be able to +arrive cold, understand what is already done, choose one slice, and leave the +next agent a clean state. + +Set up three coordination artifacts before broad parallel work starts: + +- `REFACTOR_SLICES.json` - structured inventory of migration slices and their + acceptance checks. +- `REFACTOR_PROGRESS.md` - append-only notes of what each agent changed, + validated, deferred, or broke. +- `scripts/refactor-init.sh` - one command that installs/starts/checks enough of + the app for a fresh agent to verify the baseline before doing new work. + +The JSON file is the anti-premature-victory device. Every slice starts as not +passing. Agents may claim a slice and later mark it passing only after the +acceptance checks and smoke test have actually run. + +Example slice: + +```json +{ + "id": "notifications-renderer-platform", + "category": "renderer-platform-capability", + "priority": 40, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/notification", + "apps/code/src/renderer/features/notifications", + "packages/platform/src/notifications.ts", + "packages/ui/src/features/notifications" + ], + "data": { + "model": "TaskNotification", + "sourceOfTruth": "TaskNotificationService decision inputs", + "derivedProjections": ["display title", "body text", "attention intent"] + }, + "acceptance": [ + "platform interface contains no Electron/macOS/Windows-specific terms", + "app adapter is a dumb tRPC/Electron wrapper", + "notification gating lives in package service", + "feature smoke test sends a prompt-complete notification" + ], + "passes": false +} +``` + +Use these statuses: + +- `todo` - unclaimed. +- `in_progress` - one agent owns it right now. +- `blocked` - cannot proceed without a named dependency or decision. +- `needs_validation` - code moved, but smoke test is not complete. +- `passing` - acceptance checks are verified and `passes` is true. + +Agents may update `status`, `claimedBy`, `notes`, validation evidence, and +`passes`. They must not delete slices or weaken acceptance criteria to make a +slice pass. If the criteria are wrong, add a note and get the criteria corrected +explicitly. + +### Agent Startup Protocol + +Every agent session starts the same way: + +1. Run `pwd`. +2. Read `REFACTOR.md`, `MIGRATION.md`, `REFACTOR_PROGRESS.md`, and + `REFACTOR_SLICES.json`. +3. Read recent git history: `git log --oneline -20`. +4. Check the worktree: `git status --short`. +5. Run `scripts/refactor-init.sh` if it exists. +6. Verify the baseline smoke test before implementing a new slice. If the + baseline is broken, fix or record that first; do not pile a new migration on + top of an unknown failure. +7. Claim exactly one `todo` slice by setting it to `in_progress` with your + agent/session id. + +### Agent Finish Protocol + +Every agent session ends by leaving the repo in a clean handoff state: + +1. Run focused tests/typecheck for the slice. +2. Run the relevant smoke test as a user would, not just a unit-level substitute. +3. Update `REFACTOR_SLICES.json`. + - Set `passes: true` only when acceptance checks actually passed. + - Use `needs_validation` if code is done but the feature was not exercised. + - Use `blocked` with a concrete reason if progress cannot continue. +4. Append a short `REFACTOR_PROGRESS.md` entry: slice id, changed paths, + validation run, remaining bridges, and next suggested slice. +5. Update `MIGRATION.md` for landed architectural movement. +6. Leave no unrelated edits in files outside the claimed slice. +7. Before committing, run `pnpm biome format --write .` and `pnpm typecheck`, + then stage the result. Biome owns formatting for every file including + `REFACTOR_SLICES.json` — commit the formatted version so CI does not bounce + it. Never bypass commit hooks with `--no-verify`. +8. If the harness expects commits, commit the slice with a descriptive message + only after the worktree is coherent and validation is recorded. + +### Parallel Work Rules + +- One agent owns one slice. Do not work a broad foundational refactor unless it + is explicitly assigned. +- Prefer separate git worktrees/branches per agent. Parallel edits to the same + package registration files, root DI files, or `REFACTOR_SLICES.json` will + conflict; keep those changes small and merge them deliberately. +- Do not mark the whole migration complete because several slices are passing. + Completion means every slice in `REFACTOR_SLICES.json` is passing or explicitly + retired with a reason. +- Do not start by making the architecture prettier. Pick the highest-priority + unclaimed slice and move it through the procedure. +- If a slice reveals a missing prerequisite, create or update a prerequisite + slice instead of doing untracked background work. --- -## Ground rules +## Service Rules + +### Data Is Destiny + +The data model is the contract that shapes the rest of the system. Treat model +choices as architectural choices, not incidental TypeScript cleanup. + +Use these rules when moving or defining data: + +- Model the domain object, not the first scalar you happen to need. A + `Statistic` with `label`, `value`, and `lastUpdatedAt` will evolve better than + passing `number` through five layers. +- Put runtime boundary shapes in Zod `schemas.ts`, infer TypeScript types from + those schemas, and make the schema the source of truth for tRPC/API boundaries. +- Store truth once. If two stores, caches, services, or persisted records can + disagree, name which one owns the truth and make the other a projection. +- Compute derived state. Counts, labels, filtered lists, permission display + state, and status summaries should be derived from the underlying facts unless + there is a measured reason to persist them. +- Keep hidden operational fields in the model when they are part of correctness: + timestamps, version ids, source ids, sync cursors, provenance, and invalidation + markers often matter even when they never render. +- Do not move a feature by copying its current state shape blindly. During the + audit, identify the model, the state, the owner of that state, and every + projection derived from it. + +Store truth once, then compute its consequences. + +### Service Identifiers + +Service identifiers live in the package that owns the contract. + +- Host capability contracts live in `packages/platform/src/.ts`. +- Core domain contracts live in `packages/core/src//.ts` + when other packages consume them. +- UI-only contracts live in `packages/ui/src/features//...` only when + they do not need to cross package boundaries. + +For existing platform capabilities, the interface files in `packages/platform` +are already the right home. The migration is to add package-owned service +identifiers beside those interfaces and gradually stop using app-local +`MAIN_TOKENS.` aliases. Do not create new platform identifiers in +`apps/code/src/main/di/tokens.ts`. + +Use symbols as Inversify identifiers: + +```ts +// packages/platform/src/notifications.ts +export const NOTIFICATIONS_SERVICE = Symbol.for("posthog.notifications"); + +export interface NotificationsService { + send(options: NotificationOptions): Promise; +} +``` + +Avoid global junk-drawer tokens. Prefer narrow feature or capability contracts. +Keep platform interfaces platform-agnostic: model the capability the app needs, +not the implementation detail a host happens to use. For example, expose +`notifyAttentionNeeded()` instead of `bounceDock()`, and let the Electron adapter +decide whether that means a dock bounce, taskbar flash, badge, sound, or no-op. + +Main-process migration shape: + +```ts +// packages/platform/src/clipboard.ts +export const CLIPBOARD_SERVICE = Symbol.for("posthog.platform.clipboard"); + +export interface ClipboardService { + writeText(text: string): Promise; +} +``` + +```ts +// apps/code/src/main/di/container.ts +container.bind(CLIPBOARD_SERVICE).to(ElectronClipboard); + +// Temporary bridge while old consumers still inject MAIN_TOKENS.Clipboard. +container.bind(MAIN_TOKENS.Clipboard).toService(CLIPBOARD_SERVICE); +``` + +```ts +constructor( + @inject(CLIPBOARD_SERVICE) + private readonly clipboard: ClipboardService, +) {} +``` + +Delete the `MAIN_TOKENS.*` bridge once all consumers inject the package-owned +token. + +### Constructor Injection + +Services use constructor injection. + +```ts +@injectable() +export class TaskNotificationService { + constructor( + @inject(NOTIFICATIONS_SERVICE) + private readonly notifications: NotificationsService, + @inject(SETTINGS_SERVICE) + private readonly settings: SettingsService, + ) {} + + async notifyPromptComplete(task: TaskSummary): Promise { + const settings = await this.settings.getNotificationSettings(); + if (!settings.promptComplete) { + return; + } + + await this.notifications.send({ + title: task.title, + body: "Prompt finished", + }); + } +} +``` + +Do not call `container.get(...)` inside service methods. That hides dependencies, +creates runtime ordering bugs, and makes web/mobile hosts impossible to reason +about. + +### Registration Modules + +Each package feature exports an Inversify `ContainerModule` for its services and +contributions. + +```ts +// packages/ui/src/features/notifications/notifications.module.ts +export const notificationsUiModule = new ContainerModule(({ bind }) => { + bind(TaskNotificationService).toSelf().inSingletonScope(); + bind(WORKBENCH_CONTRIBUTION) + .to(TaskNotificationContribution) + .inSingletonScope(); +}); +``` + +Modules may bind their own package's services. Host apps bind host-specific +implementations. A package module must never bind Electron implementations. + +### Contribution Startup + +Use contributions for startup side effects: subscriptions, route registration, +menus, keyboard commands, status items, global UI services, and feature boot. + +```ts +export interface WorkbenchContribution { + start(): void | Promise; +} + +export const WORKBENCH_CONTRIBUTION = Symbol.for("posthog.workbenchContribution"); +``` + +At startup, the workbench resolves all `WORKBENCH_CONTRIBUTION` bindings and +starts them. -- **Don't guess. Flag.** When you can't decide where a piece of code belongs, leave `// TODO(refactor): ` and move on. Wrong placement is worse than an open question. -- **Preserve structure during the move.** Same function names, same parameter order, same control flow. The move should diff against the old file cleanly. *Refactoring the logic* happens after, not during. -- **Don't invent new layouts.** Don't create new sibling packages, new abstractions, or new naming conventions mid-move. If the existing structure doesn't fit, raise it — don't bend the move around it. -- **Delete, don't deprecate.** When code moves, the old file is removed in the same change. No shims, no re-exports, no "deprecated" comments. -- **Banned imports in `packages/core`.** No `electron`, no `node:fs`, no `node:child_process`, no `node:net`, no `node:os`, no `node:path`. Pure JS only. Anything you'd reach for there is either a workspace-server procedure or a `@posthog/platform` interface. -- **Don't bundle other work.** Wire-format changes, algorithm rewrites, new features, cosmetic renames — keep them out of the move. They double review surface and obscure what's actually being relocated. -- **Not every feature needs a core module.** `core/` is for domain logic — state machines, retries, dedup, cross-feature coordination, business rules. Features that are pure data-piping (server → useQuery → component) skip `core/` entirely. Don't invent a core file for symmetry; let core stay empty for that feature. -- **Source-smoothing belongs with the source, not in core.** Debouncing a noisy event stream, dedup, bulk-threshold throttling, filtering source-specific noise (irrelevant git dir events, etc.) — these are properties of the *event source*, not domain decisions. They live in the workspace-server procedure that owns the source, so every client gets the smoothed stream for free. Don't put them in core just because they look like "orchestration." -- **Hooks are pure react-query idioms.** `useQuery`, `useMutation`, `useSubscription` over a tRPC procedure — that's the whole hook. No `useEffect` constructing services. No `for-await` over async iterables in a hook body. No imperative subscribe/unsubscribe ceremony with a wrappers map. If you find yourself reaching for those, the orchestration is in the wrong place — push it to wherever the tRPC procedure lives (typically workspace-server) and the hook collapses to 5 lines. -- **`useState`, `useRef`, `useEffect` in a hook are usually a smell.** They mean the hook is holding application state or subscription bookkeeping that should live elsewhere — react-query's cache, a Zustand store, a workspace-server procedure, or just derivation from existing query data. The legitimate uses are narrow: `useRef` for DOM refs (focus, scroll, measurement), `useEffect` for synchronizing imperative browser APIs (event listeners on `window`, `ResizeObserver`, etc.). Anything else — caching a previous value, holding a subscription handle, stashing a callback ref to avoid re-renders, building a wrappers map — means the hook is doing work that belongs upstream. -- **Try framework primitives before reaching for core.** Before extracting a forbidden pattern into a new core module, ask: does react-query / tRPC / Zustand already do this? `useMutation` dedups by mutation key. `useQuery` dedups by query key. `useSubscription` handles lifecycle. tRPC subscriptions invalidate caches. Most "I need a state machine for this" cases dissolve into a single mutation + its `onSuccess`. **Delete the forbidden pattern and use the framework primitive** is the first move. Only reach for a core module when you can't express the orchestration as a mutation/query/subscription — typically a Saga (multi-step with rollback), a long-running protocol (OAuth dance with redirects), or coordination that crosses multiple queries with invariants. -- **Smallest change first.** Try deleting the offending code before introducing a new abstraction. Try moving side effects into an existing `onSuccess` before writing an event bus. Try inlining at the call site before extracting a helper. The refactor PR should land *less* code than it deletes whenever possible. If your change adds a net new package, a new singleton, or a new abstraction layer, justify the line count. -- **Validate the app actually runs.** Typecheck and tests pass on incomplete work all the time. For any user-visible change, open the app and exercise the feature. For background changes, watch logs through one real usage cycle. CI green ≠ feature works. -- **Some main services stay in main forever.** Single-instance lock, window manager, deep-link router, crash reporter, auto-updater, app-lifecycle, anything that *is* the Electron shell. Don't try to migrate these. Mark them explicitly as "host-only" in code comments or a service-categorization doc so nobody wastes time auditing them for a slice. +```ts +export async function startWorkbench(): Promise { + const contributions = container.getAll( + WORKBENCH_CONTRIBUTION, + ); -## Comment markers + for (const contribution of contributions) { + await contribution.start(); + } -Use these consistently. Grep targets matter — follow-up passes hunt for each marker. + renderApp(); +} +``` -- `// TODO(refactor): ` — couldn't translate confidently. Flag and move on. -- `// PERF(refactor): ` — used to be in-process, now an RPC round-trip. Benchmark later. -- `// PORT NOTE: ` — the shape changed beyond a 1:1 move (split into two functions, async boundary moved, etc.). For readers comparing old vs. new. +Contributions are the place for "wire once at app boot" behavior. Components do +not start subscriptions ad hoc. --- -## What moves where +## Layer Ownership -| Today | New home | -|---|---| -| `apps/code/src/main/services//service.ts` — *business* orchestration: state machines, retries, OAuth flows, cross-feature coordination, business rules | `packages/core//.ts` | -| Same file — *source smoothing*: debounce, dedup, throttle, batch, source-noise filtering | `packages/workspace-server/services//` — alongside whatever procedure produces the noisy events. Don't route through core. | -| Same file — host syscalls (git CLI, fs, spawn, native modules) | `packages/workspace-server/services//` (git → `git/`, fs → `fs/`, spawn → `process/`, etc.) | -| `apps/code/src/main/trpc/routers/.ts` | Strict one-liner procedures, registered alongside their service in `workspace-server/services//`. Orchestrating procedures **disappear** — core (or the workspace-server procedure itself) does that work. | -| `apps/code/src/api/` (Django) | `packages/api-client/` | -| `apps/code/src/renderer/features//` (UI) | `packages/ui//` | -| `apps/code/src/renderer/stores/.ts` (thin UI state) | `packages/ui//store.ts` (still Zustand, still thin) | -| `apps/code/src/main/platform-adapters/.ts` | `apps/desktop/platform-adapters/.ts` | -| Renderer-consumed host capability (auth, notifications, integrations — anything in main that the renderer needs to query/mutate via electron-trpc) | `packages/platform/src/.ts` interface + `apps/code/src/renderer/platform-adapters/.ts` adapter that wraps `trpcClient.X.*` | +### `packages/platform` + +Owns host capability interfaces and service identifiers: + +- clipboard +- dialog +- notifications +- secure storage +- shell +- file picker +- app lifecycle hooks exposed to shared code +- renderer-consumed host capabilities implemented through tRPC adapters + +`platform` imports no internal packages. It is contracts only. + +Platform contracts must describe host-neutral capabilities. They should not +mention Electron, DOM, React Native, macOS, Windows, dock, taskbar, tray, or any +other host-specific surface. Those terms belong in adapters. The shared contract +should speak in product intent: notify, open external URL, pick file, write +clipboard text, request attention, store secret. + +Existing `apps/code/src/main/platform-adapters/*` classes already implement many +of these contracts. Keep them. The migration is not to rewrite adapters; it is to +bind them to package-owned platform tokens and move consumers off app-local +`MAIN_TOKENS` aliases. Renderer-consumed host capabilities follow the same +pattern with renderer adapters that wrap `trpcClient`. + +### `packages/core` + +Owns host-agnostic business logic: + +- state machines +- orchestration +- retries +- dedup +- batching with business meaning +- parsing and normalization +- typed domain events +- cross-feature business coordination + +Core services may depend on `platform`, `workspace-client`, `api-client`, +`shared`, and other core services. They never import `ui`, `workspace-server`, +Electron, or Node host syscalls. + +Core may use Inversify decorators and modules, but it must not import an app +container. It exports services and modules; hosts load them. + +### `packages/workspace-server` + +Owns Node-only host syscalls and the tRPC server: + +- git CLI +- fs reads/writes +- process spawn +- pty +- watchers +- shell execution +- Node-native capabilities + +Workspace-server services are capability-oriented and dumb. They do host work, +source smoothing, validation, and transport. They do not import `core` or `ui`. + +### `packages/ui` + +Owns React DOM workbench code: -If the migrated feature is pure data-piping (server → useQuery → component), there's no row to core — that's expected, not a missed step. +- feature views and components +- TanStack Router route contributions +- command/menu/status contributions +- UI services +- thin Zustand stores for UI state +- hooks wrapping one query/mutation/subscription -**Platform adapters apply in both directions.** The existing 15 interfaces in `packages/platform/src/` are all main-process-consumed (main service calls `IClipboard.write`). The same pattern works for renderer-consumed capabilities: interface in `packages/platform/`, adapter in `apps//src//platform-adapters/`, ui/core consume via the interface. This is the path for features that live in main and need to be reachable from ui — there's no separate "electron-trpc-client" package needed; the adapter IS the bridge. +UI imports `core`, `platform`, `shared`, and `ui/primitives`. UI never imports +`workspace-server` or app-specific code. + +Renderer stores remain thin: UI state, subscription-fed caches, and thin actions. +No business clients, retries, orchestration, cross-store reach-ins, or module +level promise dedup. + +### `apps/code` + +Owns Electron host code: + +- Electron main lifecycle +- window manager +- crash reporter +- updater +- deep links +- single instance lock +- Electron platform adapters +- desktop tRPC adapters +- desktop service registration files + +It can import package modules and bind concrete desktop implementations. It +should not contain business logic after migration. + +--- + +## Import Rules + +These should become `biome` restricted-import rules. + +- `platform` imports nothing internal. +- `shared` imports nothing internal. +- `workspace-server` may import `shared`, `platform` contracts when needed, + Node modules, and other workspace-server services by direct source path. +- `core` may import `shared`, `platform`, `workspace-client`, `api-client`, and + other core services by direct source path. +- `ui` may import `core`, `platform`, `shared`, `ui/primitives`, and other public + UI feature entry files. Avoid importing another feature's internals. +- `apps/code` may import all packages and its own host adapters. + +No barrel files. Import direct source files or explicit package export paths. --- -## Per-feature procedure +## Naming -Do these in order. One feature at a time. +- Main implementation file is named after the domain or capability: + `sessions.ts`, `file-watcher.ts`, `git.ts`. +- Registration file is `.module.ts`. +- Contribution file is `.contribution.ts`. +- Runtime boundary schemas live in `schemas.ts`. +- Type-only exports live in `types.ts`; runtime constants are allowed only when + deliberately shared. +- Tests colocate as `.test.ts` / `.test.tsx`. -1. **Audit.** Grep for the feature. List every file: main service, schemas, router, store, components, hooks, subscriptions, tests. **Also list fan-in**: which other main services consume this one's events or call its methods. The audit is for *you*, not a gate — most features in this codebase have fan-in, and that's not a reason to abandon the slice. See [Coexistence and bridges](#coexistence-and-bridges) for how to handle it. -2. **Identify host calls.** Anything touching git CLI, fs, child-process spawn, native modules, OS APIs. Those become workspace-server procedures. -3. **Sort the rest into one of three buckets:** - - **Source-smoothing** — debounce, dedup, throttle, batch, noise-filter events from a host source. Goes alongside the source's procedure in `workspace-server/services//`. Don't route through core. - - **Business orchestration** — state machines, retries, OAuth flows, cross-feature coordination, business rules, error normalization. Goes in `packages/core//`. - - **Neither** — pure data-piping from a server query to a component. There's no core module to write. Skip ahead to step 6. -4. **Define the workspace-server procedures first.** Strict one-liners over service methods. Zod input + output, schemas in `workspace-server/services//schemas.ts`. -5. **(If core applies)** Port business orchestration to `packages/core//.ts`. Pure JS. Constructor injection of `workspace-client` / `api-client` — **no Inversify in core.** Unit test the pure parts directly (extract pure functions for debouncing, drainage, predicates) — don't try to test the async iterable wiring. -6. **Wire the UI.** Hook in `packages/ui/features//` is a thin `useQuery` / `useMutation` / `useSubscription` over the tRPC procedure. No `useEffect` / `useRef` / `useState` ceremony. If you reach for those, the orchestration is in the wrong place — push it upstream and try again. -7. **Delete the old main service and router.** No shims, no re-exports — unless coexistence is genuinely needed for fan-in consumers ([Coexistence and bridges](#coexistence-and-bridges)). -8. **Apply in-slice cleanups.** See below. -9. **Add a MIGRATION.md entry.** What moved, what was cleaned, what was deliberately left, what the retirement condition is for any bridge. +No `service.ts` for new package code unless there is already a strong local +pattern in that folder. --- -## Canonical shape for features with real orchestration +## What Moves Where + +| Today | New home | +|---|---| +| `apps/code/src/main/services//service.ts` business orchestration | `packages/core/src//.ts` | +| `apps/code/src/main/services//service.ts` host syscalls | `packages/workspace-server/src/services//.ts` | +| Source smoothing for noisy host events | Same workspace-server service that owns the event source | +| `apps/code/src/main/trpc/routers/.ts` | `packages/workspace-server/src/services//` one-line router/procedure over service methods | +| `apps/code/src/api/` | `packages/api-client/src/` | +| `apps/code/src/renderer/features//` | `packages/ui/src/features//` | +| `apps/code/src/renderer/stores/.ts` thin UI store | `packages/ui/src/features//store.ts` | +| `apps/code/src/main/platform-adapters/.ts` | stays in `apps/code`; Electron adapter | +| renderer-consumed host capability | `platform` interface + app adapter + package service consuming the interface | + +Moving an interface alone is not a port. The implementation moves, or the old +code is clearly marked as a bridge with a retirement condition. + +--- + +## Per-Feature Procedure + +Work one feature or capability slice at a time. + +1. **Claim one slice.** Pick one `todo` item from `REFACTOR_SLICES.json`, set it + to `in_progress`, and stay inside that slice's paths unless a prerequisite + must be recorded. +2. **Audit.** List main services, routers, schemas, renderer stores, components, + hooks, subscriptions, tests, and fan-in consumers. +3. **Map the data.** Name the model, the source of truth, persisted state, + in-memory state, subscription-fed caches, and derived projections. If state is + duplicated, decide which copy owns truth before moving it. +4. **Identify host calls.** Git, fs, spawn, pty, Electron, OS APIs, native + modules, and watchers move to workspace-server or platform adapters. +5. **Sort logic.** + - Host syscall or source smoothing: `workspace-server`. + - Business orchestration: `core`. + - UI state/rendering: `ui`. + - Host capability contract: `platform`. +6. **Create or update service identifiers.** Put cross-package contracts in + `platform` or the owning package. For existing platform interfaces, add the + token beside the interface and bind the existing app adapter to it; keep + `MAIN_TOKENS.*` only as a temporary bridge for old consumers. +7. **Move workspace-server capability first.** Add Zod input/output schemas. + Routers/procedures remain one-line forwards over service methods. +8. **Move core orchestration if needed.** Use constructor injection. Add a module + binding the service. Unit test business behavior with mocked deps. +9. **Move UI.** Components, hooks, stores, routes, and contributions move to + `packages/ui/src/features//`. Register UI services/contributions in + `.module.ts`. Follow [Porting React UI](#porting-react-ui) for + component dependencies, route registration, stores, and tests. +10. **Bind host implementations in `apps/code`.** Desktop adapters wrap Electron + or `trpcClient`. Bind them in desktop registration files. +11. **Bridge only when fan-in requires it.** Keep old app services only as thin + delegation shims with `// PORT NOTE:` and a retirement condition. +12. **Delete old code when the bridge is gone.** +13. **Update `MIGRATION.md` and `REFACTOR_PROGRESS.md`.** +14. **Validate.** Typecheck, tests, app launch, and a real feature smoke test. +15. **Update `REFACTOR_SLICES.json`.** Mark `passing` / `passes: true` only when + validation and acceptance checks are complete. + +--- + +## Canonical Patterns + +### Host Capability Consumed by UI + +Notifications are a good example: the UI decides when a task notification should +be shown; the host knows how to show it. -When a feature genuinely needs core (multi-step Saga, OAuth dance, cross-query invariants — not just "we already had a forbidden pattern there"), use this shape. The **focus** port is the worked example. +```ts +// packages/platform/src/notifications.ts +export const NOTIFICATIONS_SERVICE = Symbol.for("posthog.notifications"); +export interface NotificationsService { + send(options: NotificationOptions): Promise; +} ``` -packages/core/src//.ts - └─ export interface ControllerDeps { ... } // narrow, feature-scoped - └─ export class Controller { - constructor(private deps: ControllerDeps, ...) {} - async enableX(input): Promise // methods DO things, RETURN results - async disableX(input): Promise // no internal state held about the domain - } - -apps/code/src/renderer/stores/Store.ts - └─ const controller = new Controller({ // module-scope singleton, OK because stateless - methodA: (...) => trpcClient.X.a.mutate(...), // each dep: one-line trpc wrap - methodB: (...) => trpcClient.X.b.query(...), - ... - }, logger); - └─ export const useStore = create<...>()((set, get) => ({ - session: null, // pure UI state - isLoading: false, - enableX: async (input) => { - set({ isLoading: true }); - const result = await controller.enableX(input); - set({ isLoading: false, session: result.success ? result.session : get().session }); - return result; - }, - // ... thin actions: call controller, set state from result - })); + +```ts +// apps/code/src/renderer/platform-adapters/notifications.ts +@injectable() +export class TrpcNotificationsService implements NotificationsService { + async send(options: NotificationOptions): Promise { + await trpcClient.notification.send.mutate(options); + } +} ``` -**Why this shape:** +```ts +// packages/ui/src/features/notifications/notifications.ts +@injectable() +export class TaskNotificationService { + constructor( + @inject(NOTIFICATIONS_SERVICE) + private readonly notifications: NotificationsService, + @inject(SETTINGS_SERVICE) + private readonly settings: SettingsService, + ) {} + + async notifyPromptComplete(task: TaskSummary): Promise { + const settings = await this.settings.getNotificationSettings(); + if (!settings.enabled) { + return; + } + + await this.notifications.send({ title: task.title }); + } +} +``` -- **Controller is stateless.** It orchestrates. Domain state lives where react can render it (store / react-query cache). The controller never holds `this.session` or `this.user` — those would be a second source of truth. -- **Module-scope `new Controller(...)` is fine** because the controller is stateless and its deps are trpc-bound (which is also a singleton). The forbidden "store owning a singleton with state" pattern doesn't apply. -- **Deps are feature-scoped, defined in core.** Not a global platform interface, not a re-export from the trpc client. ~20-30 narrow methods the controller actually uses. The renderer adapter is dumb one-line wraps over `trpcClient.X`. -- **Store actions are call-controller-then-set.** No multi-step flow in the store. No `let inFlight` dedup. No cross-store reach-ins (those move to the controller, or to mutation `onSuccess` if simple). -- **No event bus.** State changes via store updates after each action returns. React-query consumers react via cache invalidation (the store action can invalidate after success). +```ts +// packages/ui/src/features/notifications/notifications.module.ts +export const notificationsUiModule = new ContainerModule(({ bind }) => { + bind(TaskNotificationService).toSelf().inSingletonScope(); +}); +``` -**When this shape applies:** +```ts +// apps/code/src/renderer/desktop-services.ts +container + .bind(NOTIFICATIONS_SERVICE) + .to(TrpcNotificationsService) + .inSingletonScope(); +``` -The feature has at least one of: -- A Saga (multi-step with rollback) — e.g., focus enable: stash, checkout, save session, on failure unstash and restore -- A long-running protocol — OAuth dance with redirects, multi-round handshake -- An invariant that spans multiple queries — e.g., "if A is true, B must also be refreshed" -- A state machine genuinely complex enough that expressing it as one mutation `onSuccess` is hostile +No package imports `trpcClient`. No app file owns notification business logic. -If none of those apply — if the orchestration is "call endpoint, set state from result" — the feature **doesn't need core**. Use `useMutation`/`useQuery` directly. Don't invent a controller for symmetry. +### Feature Startup Subscription -**When this shape does NOT apply:** +Subscriptions are contributions, not component side effects. -- Pure data-piping (server query → useQuery → render). No core. The hook is 5 lines of `useQuery` over the tRPC procedure. -- Source-smoothing (debounce, dedup of noisy events). Goes in the workspace-server procedure that owns the source, not in core. -- Plain auth state that's already served by `trpc.X.getState`. React-query's cache IS the state. Don't shadow it with a stateful core class. +```ts +@injectable() +export class FileWatcherContribution implements WorkbenchContribution { + constructor( + @inject(FILE_WATCHER_SERVICE) + private readonly watcher: FileWatcherService, + @inject(FILE_WATCHER_STORE) + private readonly store: FileWatcherStore, + ) {} + + start(): void { + this.watcher.onDidChange((event) => { + this.store.applyChange(event); + }); + } +} +``` ---- +The contribution is registered once in the feature module. Components render the +store. Components do not subscribe directly. -## Coexistence and bridges +### Feature With Core Orchestration -This codebase is heavily inter-coupled — most main-process services consume events from, or call methods on, other main-process services. A pure "one feature, one slice, delete the old" port is the exception, not the rule. Expect coexistence; design for it. +Use core when there is real orchestration: Saga, rollback, long-running protocol, +multi-step invariant, or retry/dedup with business meaning. -**The default pattern: bridge the old module.** When you move a feature's guts into `packages/core//` + `packages/workspace-server//` + `packages/ui//`, the old `apps/code/src/main/services//service.ts` doesn't have to die in the same change. Keep it as a thin shim that: +```ts +// packages/core/src/focus/focus.ts +@injectable() +export class FocusService { + constructor( + @inject(GIT_SERVICE) + private readonly git: GitService, + @inject(WORKSPACE_SERVICE) + private readonly workspace: WorkspaceService, + ) {} + + async enableFocus(input: EnableFocusInput): Promise { + // multi-step business orchestration + } +} +``` -- constructs the new core module (or the workspace-server-backed client) at boot, -- forwards the events and methods its in-process consumers already depend on, -- holds no logic of its own — just delegation. +```ts +// packages/core/src/focus/focus.module.ts +export const focusCoreModule = new ContainerModule(({ bind }) => { + bind(FOCUS_SERVICE).to(FocusService).inSingletonScope(); +}); +``` -The shim is the seam. Other main services keep depending on it unchanged. As each of *those* services migrates later, they drop their dependency on the shim. When the last one is gone, delete the shim. +The UI store calls `FocusService.enableFocus` and updates UI state from the +result. The store does not own the flow. -Mark the shim file with a one-liner at the top: `// PORT NOTE: shim — delegates to . Delete when migrate.` That tells the next reader (or agent) exactly what's keeping it alive and what unblocks its removal. +### React Access to Services -**Skip the shim when the new class is signature-compatible with the old DI binding.** If the new `core//service.ts` already exposes the same methods and event API the old service did, you don't need a shim at all — just late-bind the new class to the existing DI token at bootstrap. The pattern (taken from the file-watcher migration): +React components may use a small boundary hook to access services from the +renderer container: ```ts -// In main bootstrap, after the new class's async prereqs are ready: -const connection = await wsServer.start(); -const workspaceClient = createWorkspaceClient(connection); -container - .bind(MAIN_TOKENS.FileWatcherService) - .toConstantValue(new CoreFileWatcherService({ workspace: workspaceClient })); +const focus = useService(FOCUS_SERVICE); +``` + +This hook is for component integration only. Do not use it as a replacement for +constructor injection in services, stores, or contributions. -await initializeServices(); // existing consumers resolve here, unchanged +For hooks wrapping server state, prefer TanStack Query: + +```ts +export function useSessions() { + const sessions = useService(SESSIONS_SERVICE); + + return useQuery({ + queryKey: ["sessions"], + queryFn: () => sessions.list(), + }); +} ``` -Remove the static `container.bind(...).to(OldClass)` from `container.ts`. Consumers keep `@inject(MAIN_TOKENS.X) private x: X` — only the *type import path* changes (from `../X/service` to `@posthog/core/X/service`). The DI token now points at the core class; no delegation layer, no event re-emission, no shim file to delete later. +Hooks should wrap one query, mutation, or subscription. If a hook coordinates +multiple async sources, move that coordination into a service. -This works when (a) the core class's public API is a strict superset of the old one, (b) there's a clean bootstrap point where the async prereqs are known to be ready, and (c) nothing tries to resolve the token earlier than that point. Verify (c) by grepping the token — `services/index.ts` side-effect imports, top-level `register*Handlers()` calls, etc. should not transitively `container.get` it before your bind runs. +### Porting React UI -**When the feature itself is too big to port in one slice** (the renderer-side `sessions` module is the canonical example — thousands of lines, owns state machines, holds subscriptions, reaches into other stores), carve it into smaller user-visible slices: "list sessions," "create session," "session detail view," "session permissions stream." Each slice is its own pass through the per-feature procedure, with its own MIGRATION.md entry. Pick read-only slices before mutations, mutations before subscriptions. +Feature UI moves to `packages/ui/src/features//`. The app host should +mount and register UI; it should not own feature rendering logic. -Rules that hold for both bridging and slicing: +Move these together when they belong to the same feature: -- **Don't add new code to the old module.** New logic goes in the new home. The old code is in maintenance mode for the duration. -- **Don't import across the seam in the wrong direction.** New `core/` code never imports from the old `apps/code/...` module — the dependency goes old → new, not new → old. If `core/` needs a helper that still only lives in the old module, copy it (mark with `// PORT NOTE: duplicated from , removed when lands`). -- **Track open coexistence in MIGRATION.md.** Each entry says what's still in the old location and what triggers its removal — fan-in waiting to migrate, shims keeping the boot path stable, helpers temporarily duplicated. -- **Coexistence is the cost, not the goal.** Every shim and duplicate is a debt with a known retirement condition. If you find one without a retirement condition, that's the layering problem — name it. +- route-level screens, +- child components, +- feature hooks, +- thin feature stores, +- route/menu/command/status contributions, +- colocated tests, +- small feature-local utilities. -If you genuinely can't find any tractable slice (the feature is so entangled that even a shim doesn't isolate the new code), that's a layering problem, not a porting problem. Raise it before starting. +Reusable visual building blocks move to `packages/ui/src/primitives/` only when +they are genuinely shared across features. Do not turn a one-feature component +into a primitive just because it moved. + +Component rules: + +- Components never import `trpcClient`, Electron APIs, `apps/code`, or + workspace-server code. +- Components use props for local parent-child data flow. +- Components use `useService(TOKEN)` only at React boundaries to access injected + services. +- Components use feature hooks for server state. A hook wraps one query, + mutation, or subscription. +- Components use thin Zustand stores only for UI state: selected id, open panel, + scroll state, draft text, local view mode. +- Components do not start global subscriptions. A contribution starts them once + and writes to a store/cache. +- Components do not coordinate cross-feature behavior. Put that in a service or + contribution. + +Route registration belongs to the feature module/contribution. Do not keep a +central app-local list of migrated feature routes. The host starts the workbench; +feature modules contribute their routes. + +When a component imports old renderer-only paths: + +| Old dependency | Migration move | +|---|---| +| `@renderer/trpc/client` or direct `trpcClient` | Wrap in a service/hook backed by `useService` + TanStack Query | +| `@stores/` global store | Move thin UI state to `packages/ui/src/features//store.ts`, or expose a temporary service bridge | +| `@renderer/*` utility | Move to `packages/ui` if host-agnostic; keep in app and wrap behind `platform` if host-specific | +| Electron/browser host API | Add or reuse a `platform` service and bind an app adapter | +| another feature's internals | depend on that feature's public service/model, or create a shared model in `core`/`shared` | +| multi-query derived view state | move merge/derivation to a service; hook exposes one query result | + +If a component depends on an unported store or utility, do not leave the +component in `apps/code` by default. Either port the dependency in the same +slice, or add a marked bridge with a retirement condition. The dependency +direction is old app code -> new package code, never the reverse. + +UI tests should move with the component. Prefer testing the package component +with fake services and explicit props. Use app/Electron tests only for host +adapter behavior or full smoke coverage. --- -## Resolving forbidden patterns +## Forbidden Patterns and Fixes + +### `container.get(...)` in Service Methods + +Wrong: + +```ts +async run() { + const settings = container.get(SETTINGS_SERVICE); +} +``` + +Fix: inject `SETTINGS_SERVICE` in the constructor. + +### Business Logic in Platform Adapters + +Adapters translate host calls. They do not decide. + +Wrong: notification adapter checks settings, truncates task names, and decides +whether to play a sound. + +Fix: UI/core service makes the decision; adapter sends the notification. + +Wrong: platform interface exposes `bounceDock()` or `flashTaskbar()`. + +Fix: platform interface exposes `requestUserAttention()` or +`notifyAttentionNeeded()`; each host adapter maps that intent to its local +surface. + +### Store Owning Multi-Step Flow + +Wrong: Zustand action performs OAuth, retries, token refresh, and cross-store +updates. + +Fix: service owns the flow. Store action calls one service method and sets UI +state from the returned result. + +### Duplicated Truth -When you encounter a forbidden pattern (see AGENTS.md) inside the code you're moving, fix it as part of the move. Don't extend the pattern, don't relocate it as-is. The technique for each: +Wrong: store persists both `sessions` and `sessionCount`, or separate stores keep +their own writable copies of the same task status. -**Multi-step flow in a store.** (OAuth dance, token refresh, polling, `let inFlightX: Promise | null` dedup.) Extract the flow as a class method on a new core module. Inject `workspace-client` / `api-client` via constructor. The class owns the dedup promise, the retry loop, the state machine. The store keeps a single `status` field and a thin action that calls the method. Test the core class with mocked clients. +Fix: one service/store owns the underlying facts. Counts, labels, status text, +filtered lists, and display summaries are computed projections. -**Cross-store reach-in.** (`useOtherStore.getState().something()` inside a store action.) Find the system event that triggered the reach-in. Make core emit a typed event for it. Each affected store subscribes via its feature's `subscriptions.ts` registrar and reacts independently. No store imports another. +### Cross-Store Reach-In -**Business client held in a store.** (`client: createClient(region, projectId)` field.) Construct the client in core, keyed by whatever id the store cared about. The store keeps the serializable id (`activeProjectId: string`). Components ask core for the client when they need it. +Wrong: -**Store owning a subscription.** (`let globalSubscription = trpcClient.X.subscribe(...)` at module scope.) Move the subscribe call into the feature's `subscriptions.ts` registrar, wired once at app boot. The store exposes a setter the registrar calls with each event. +```ts +useOtherStore.getState().clear(); +``` + +Fix: a service or contribution emits/handles a typed event; each feature reacts +through its own registered contribution. + +### Store Owning Subscriptions + +Wrong: module-level `let subscription`. + +Fix: a `WorkbenchContribution` starts the subscription once and writes events to +the store. + +### Custom Hook Orchestrating Multiple Queries + +Wrong: two `useQuery` calls plus custom merge/retry/state machine. + +Fix: expose one service method/procedure returning the merged shape; hook wraps +one query. + +### Renderer Service Fetching Domain Data + +Move domain fetching/orchestration to `core` or a UI service registered through +Inversify. Renderer services are only for renderer-only mechanics like focus +rings, drag-and-drop, measurement, and visual queues. -**Store owning a domain timer.** (`window.setTimeout(() => removeClone(id), 3000)`.) The lifecycle belongs in core. Core schedules the cleanup and emits a `Removed` event when it fires. Store reacts to the event like any other. +### Electron Import in Shared Service Code -**Custom hook orchestrating multiple queries.** (Two `useQuery` calls + a `useMemo` merge.) Replace with one core function that does the merge and exposes a single shape. Component uses one `useQuery` (or a derived hook over the single core call). +Define a platform interface. Implement it in `apps/code`. -**Imperative `trpcClient` from a component.** (`useEffect(() => trpcClient.X.query().then(setState))`.) Replace with `useQuery`. If the component needs the result imperatively for a side effect, use `queryClient.fetchQuery` rather than reaching past the cache. +--- -**tRPC router bypassing its service to call a repository.** Move every repository call into a service method. Router calls service. Never router → repository. +## Bridges and Coexistence -**tRPC router with inline business logic.** (Math, time arithmetic, conditional branching inside `.mutation`/`.query`.) Move the logic into a service method (workspace-server) or a core function. The router becomes a one-line forwarder. +This codebase is inter-coupled. Use bridges when fan-in makes direct deletion too +expensive. -**tRPC router with no backing service.** Create the service. Router shrinks to one-liners over it. If the existing router is a junk drawer (`os.ts`), split it: workspace-server procedures for host syscalls, `@posthog/platform` interfaces for host capabilities. +A bridge may stay in `apps/code` only when it: -**`container.get(X)` inside a service method.** That's a circular-dep dodge. Either: (a) split the service — the part X needs probably belongs in a third module both depend on, or (b) invert the relationship via events — X emits, the dependent listens. Never paper over with `container.get`. +- delegates to the new package service, +- holds no business logic, +- preserves an existing API for old consumers, +- has a `// PORT NOTE:` listing remaining consumers and the retirement condition. -**Renderer service fetching domain data or coordinating tRPC.** Move the whole module to `packages/core//`. If parts of it are genuinely UI mechanics (drag-and-drop, focus rings), split those off into a thin renderer-side helper. +Example: -**Platform adapter with business logic.** Strip the decisions out. Adapter does one syscall / one host-API call and returns. The decision lives in a service that depends on the adapter via its interface. +```ts +// PORT NOTE: bridge to @posthog/core/focus. Delete when SessionsService and +// TaskService consume FOCUS_SERVICE directly. +@injectable() +export class FocusServiceBridge { + constructor( + @inject(FOCUS_SERVICE) + private readonly focus: FocusService, + ) {} + + enableFocus(input: EnableFocusInput) { + return this.focus.enableFocus(input); + } +} +``` -**`import from "electron"` in service code.** Define the capability as an interface in `packages/platform` (`INotifier`, `IClipboard`, etc.). Service depends on the interface. Per-app adapter implements it. +The dependency direction is old app code -> new package code. New package code +must never import old app modules. -If you find debt that isn't a forbidden pattern and isn't a layering fix, **leave it.** Note it in MIGRATION.md and move on. +Track every bridge in `MIGRATION.md`. --- -## Recommended order +## Validation -1. **Read-only, no subscriptions** — done. diff-stats. -2. **Read-only, subscription-based** — done. file-watcher proved the SSE streaming transport (workspace-client `splitLink` + `httpSubscriptionLink`, hono server accepting `?secret=` query). Source-smoothing lives in workspace-server, hook is pure `useSubscription`. -3. **Write paths with Saga orchestration** — done. focus proved the [canonical core-bearing shape](#canonical-shape-for-features-with-real-orchestration): stateless `FocusController` in core with feature-scoped deps interface, thin store wraps `trpcClient.X.*` as deps adapter, store actions call controller and set state from result. This is the reference for any future feature that genuinely needs core. -4. **Renderer-side platform adapter** — next. Auth or notifications. Establishes the pattern for the ~25 host-capability services to follow: `packages/platform/src/.ts` interface + `apps/code/src/renderer/platform-adapters/.ts` adapter wrapping `trpcClient.X.*` + ui consumes via context. Unlocks the bulk of the remaining main services. -5. **Terminal / pty proxying.** Most ambitious. Tests the full pipeline including binary data. +For every slice: -Patterns now baked into the ground rules from prior slices: -- Source-smoothing belongs with the source (not core) — file-watcher. -- Hooks are pure react-query idioms — file-watcher. -- Stateless controller + thin store + dumb deps adapter for features that need core — focus. -- Try framework primitives before reaching for core; most "I need a state machine" cases dissolve into `useMutation` + `onSuccess`. -- Platform adapters apply in both directions; the existing 15 are main-consumed, the next ones are renderer-consumed. +- read the slice's acceptance criteria before changing code, +- run the relevant typecheck, +- run focused tests, +- start the app when user-visible behavior changed, +- smoke test the feature, +- watch logs for one real usage cycle when the change affects background work. -Apply these on every slice going forward. +Typecheck and tests are necessary but not sufficient. The app must actually run. +Do not set `passes: true` in `REFACTOR_SLICES.json` until the acceptance checks +and smoke test have passed. --- -## MIGRATION.md format +## Recommended Order -Add an entry as each feature lands. Ten lines max: +0. Run an initializer pass: create `REFACTOR_SLICES.json`, + `REFACTOR_PROGRESS.md`, and `scripts/refactor-init.sh`; populate slices from + the current `apps/code` audit with all `passes` values false. +1. Establish shared DI primitives: service identifiers, contribution token, + `useService`, and workbench startup that starts contributions. +2. Move read-only data-piping UI features. +3. Move source subscriptions into workspace-server services plus UI + contributions. +4. Move write paths with Saga/core orchestration. +5. Move renderer-consumed platform capabilities such as notifications, auth, and + integrations. +6. Move large entangled surfaces last: sessions, terminal, pty. + +Keep each slice behavior-preserving unless the migration exposes a forbidden +pattern that must be fixed to make the move valid. + +--- +## MIGRATION.md Format + +Keep entries short and operational: + +```md +## 2026-MM-DD - + +- Moved: `` -> `` +- Registered: `` +- Data: source of truth is ``; derived projections are `` +- Cleaned: +- Bridge: `` remains until +- Validation: ``` -## 2026-MM-DD — -- Moved: `apps/code/src/main/services//` → `packages///` -- Cleaned: -- Left as-is: -- New import path: `` (was ``) +`REFACTOR_PROGRESS.md` is append-only and more tactical: + +```md +## 2026-MM-DD HH:MM - - + +- Changed: `` +- Validated: `` +- Slice status: `` +- Next: `` ``` diff --git a/REFACTOR_PROGRESS.md b/REFACTOR_PROGRESS.md new file mode 100644 index 000000000..6f13eaa2b --- /dev/null +++ b/REFACTOR_PROGRESS.md @@ -0,0 +1,47 @@ +# REFACTOR_PROGRESS.md — append-only agent log + +Tactical, append-only record of what each agent session changed, validated, +deferred, or broke during the `apps/code` -> packages migration. + +- Newest entries at the bottom. +- One entry per session, even short ones. +- Format below. Keep entries short and operational. +- Source of truth for slice status is [REFACTOR_SLICES.json](./REFACTOR_SLICES.json). +- Landed architectural movement is summarized in [MIGRATION.md](./MIGRATION.md). +- The procedure is in [REFACTOR.md](./REFACTOR.md); the layering rules are in [AGENTS.md](./AGENTS.md). + +## Entry format + +```md +## YYYY-MM-DD HH:MM — + +- Changed: `` +- Validated: `` +- Slice status: `` +- Next: `` +``` + +--- + +## 2026-05-29 — initializer — harness setup (no slice claimed) + +- Changed: created `REFACTOR_SLICES.json`, `REFACTOR_PROGRESS.md`, `scripts/refactor-init.sh`. +- Audit: enumerated 57 slices from `apps/code` (43 main services, 39 tRPC routers, 52 renderer features, 15 platform interfaces). 4 slices recorded as already `passing` from prior landed work (`diff-stats`, `file-watcher`, `focus`, `api-client` — see MIGRATION.md); the remaining 53 are `todo`. +- Key audit findings recorded as the highest-priority slices: + - **Foundation is not built yet.** `packages/di` is empty (no package.json/src). No `useService`, `WORKBENCH_CONTRIBUTION`, `WorkbenchContribution`, `startWorkbench`, or `ContainerModule` exist in source. → slice `di-foundation` (priority 100), REFACTOR.md Recommended Order step 1. Most other slices depend on it. + - **Platform interfaces lack Symbol identifiers.** `packages/platform/src/*.ts` define interfaces only; they are bound today via legacy `MAIN_TOKENS` in `apps/code/src/main/di/tokens.ts`. → slice `platform-identifiers` (priority 90). + - Forbidden patterns confirmed present and tagged on their slices: `os.ts` is a 401-line router with no backing service (`dialog-capability`, `misc-host-capabilities`); `WorkspaceService` uses `container.get(FileWatcherService)` and a router-bypasses-service-to-repository pattern (`workspace`); `TaskService` is a renderer DI service that fetches domain data (`ui-task-detail`); the ~3796-line renderer sessions service (`sessions`). + - `FileWatcherBridge` retirement is gated on four consumers: `fs-capability`, `archive`, `suspension`, `workspace`. +- Validated: `node -e` JSON parse of `REFACTOR_SLICES.json` (57 slices, no duplicate ids, all required fields present); `bash -n scripts/refactor-init.sh`. No application code changed, so no app smoke test was run. +- Slice status: n/a (no slice claimed; this was the initializer pass, REFACTOR.md Recommended Order step 0). +- Next: an agent should claim `di-foundation` (priority 100) and establish the shared DI primitives before broad parallel feature work begins. `connectivity` (82) and `projects` (81) are good first read-only feature slices to exercise the foundation once it lands. + +## 2026-05-29 — initializer — coverage gap closure (no slice claimed) + +- Triggered by review: first audit covered services/routers/features/stores/platform but missed (a) non-feature main surface and (b) the entire shared React surface. +- Added slices: `analytics`, `ui-event-bus` (UIService, uses container.get in router), `ui-app-shell` (themeStore + rendererWindowFocusStore); folded the host-only `workspace-server` child-process service into `app-lifecycle`. +- After REFACTOR.md gained the "Porting React UI" section, added the shared-React slices: `ui-primitives` (packages/ui/src/primitives — components/ui, shared visuals, action-selector, generic hooks), `ui-shell` (App.tsx/main.tsx/Providers/layout/styles + boot dismantled into contributions), `ui-permissions` (components/permissions, ACP-typed), `renderer-shared-hooks` (feature-coupled hooks in renderer/hooks redistributed to owning features), `renderer-shared-utils` (utils/types/assets split: host-agnostic->ui/shared, host-coupled->platform). +- Folded domain cross-cutting into owners (no double-ownership): sagas/task -> `ui-task-detail`, constants/keyboard-shortcuts -> `ui-command`, utils/analytics.* -> `analytics`. +- Coverage: wrote a scan over all 281 code items under apps/code/src + packages/platform/src. 281 mapped except 3 intentional non-slices, now recorded in REFACTOR_SLICES.json meta.deliberatelyNotSliced (main services/index.ts, main services/types.ts, renderer hooks/useFileWatcher.ts). +- Validated: JSON parses, 65 slices (61 todo, 4 passing), no duplicate ids, all required fields present. +- Slice status: n/a (initializer). Next unchanged: claim `di-foundation`. Note `ui-primitives` (priority 83) should land early because feature UI ports may not import apps/code, so they need primitives in @posthog/ui first. diff --git a/REFACTOR_SLICES.json b/REFACTOR_SLICES.json new file mode 100644 index 000000000..0c6fb0b8c --- /dev/null +++ b/REFACTOR_SLICES.json @@ -0,0 +1,1707 @@ +{ + "meta": { + "purpose": "Structured inventory of migration slices for the VS Code-style refactor described in REFACTOR.md. This file is the anti-premature-victory device: every slice starts not passing and only becomes passing after acceptance checks and a real smoke test have actually run.", + "generatedBy": "initializer pass (audit of apps/code @ 2026-05-29)", + "conventions": { + "priorityOrder": "Higher number = do sooner. Roughly follows REFACTOR.md 'Recommended Order': foundation (90-100) > platform identifiers (90) > read-only UI pipes (80) > workspace-server capabilities (70) > core write paths (55-65) > UI-consumed platform capabilities (50) > auth/integrations/mcp (40) > agent/llm/analytics (30) > large entangled surfaces sessions/terminal/inbox (10-20).", + "status": { + "todo": "unclaimed", + "in_progress": "one agent owns it right now (set claimedBy)", + "blocked": "cannot proceed without a named dependency or decision (record in notes)", + "needs_validation": "code moved, smoke test not complete", + "passing": "acceptance checks verified and passes:true" + }, + "rules": "Agents may update status, claimedBy, notes, validation evidence, and passes. Do NOT delete slices or weaken acceptance criteria to make a slice pass. If criteria are wrong, add a note and get them corrected explicitly. The `data` block for todo slices is a starting hint; the claiming agent performs the full data audit (model, source of truth, persisted/in-memory state, derived projections) per REFACTOR.md 'Per-Feature Procedure' step 3.", + "coverage": "Every code item under apps/code/src (main services, routers, renderer features/stores/components/hooks/utils/sagas/constants/types, top-level shell) and packages/platform/src maps to at least one slice's `paths`. Feature-local components/hooks move WITH their feature slice; shared React code is covered by ui-primitives, ui-shell, ui-permissions, renderer-shared-hooks, and renderer-shared-utils (REFACTOR.md 'Porting React UI').", + "deliberatelyNotSliced": [ + "apps/code/src/main/services/index.ts — DI composition root; host wiring that transforms under di-foundation, not a migratable feature", + "apps/code/src/main/services/types.ts — shared main type defs, no behavior to migrate", + "apps/code/src/renderer/hooks/useFileWatcher.ts — already migrated to packages/ui (file-watcher slice); renderer copy is a bridge leftover to delete, not a new slice" + ] + } + }, + "slices": [ + { + "id": "di-foundation", + "category": "foundation", + "priority": 100, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/di", + "apps/code/src/renderer/di/container.ts", + "apps/code/src/renderer/main.tsx", + "apps/code/src/renderer/desktop-services.ts", + "apps/code/src/renderer/desktop-contributions.ts" + ], + "data": { + "model": "shared DI primitives", + "sourceOfTruth": "packages/di owns useService, WORKBENCH_CONTRIBUTION token, WorkbenchContribution interface, startWorkbench", + "derivedProjections": [] + }, + "acceptance": [ + "packages/di exists with package.json, tsup/tsconfig, and exports: WORKBENCH_CONTRIBUTION symbol, WorkbenchContribution interface, startWorkbench(), useService() React hook", + "startWorkbench resolves all WORKBENCH_CONTRIBUTION bindings and awaits each contribution.start() before rendering", + "useService reads from the renderer container and is documented as component-boundary only (not a service-locator replacement for constructor injection)", + "apps/code/src/renderer/main.tsx imports desktop-services + desktop-contributions and calls startWorkbench()", + "at least one already-migrated feature (e.g. notifications or file-watcher) is wired through a ContainerModule + contribution to prove the path end to end", + "app boots and renders with the contribution-driven startup" + ], + "passes": false, + "notes": "Prerequisite for almost every other slice. REFACTOR.md Recommended Order step 1. packages/di is currently empty. No useService/WORKBENCH_CONTRIBUTION/startWorkbench/ContainerModule exist in source today." + }, + { + "id": "platform-identifiers", + "category": "foundation", + "priority": 90, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/platform/src", + "apps/code/src/main/di/tokens.ts", + "apps/code/src/main/platform-adapters" + ], + "data": { + "model": "host capability contracts", + "sourceOfTruth": "each packages/platform/src/.ts owns its Symbol identifier + interface", + "derivedProjections": [] + }, + "acceptance": [ + "every packages/platform/src/.ts exports a Symbol.for(...) service identifier alongside its interface", + "platform interfaces contain no Electron/DOM/React Native/macOS/Windows/dock/taskbar/tray terms (host-neutral product intent only)", + "apps/code binds each electron adapter to the platform-owned identifier; legacy MAIN_TOKENS platform entries become aliases or are removed", + "no platform package file imports anything internal", + "app boots with adapters resolved via platform identifiers" + ], + "passes": false, + "notes": "Interfaces already exist in packages/platform/src but have no Symbol identifiers; they are bound today via MAIN_TOKENS in apps/code/src/main/di/tokens.ts. Audit interface naming for host-specific leakage (e.g. notifier.requestAttention is good; check the rest)." + }, + + { + "id": "diff-stats", + "category": "ui-feature", + "priority": 80, + "status": "passing", + "claimedBy": null, + "paths": [ + "packages/workspace-server/src/services/git/service.ts", + "packages/ui/src/features/diff-stats", + "packages/workspace-client/src" + ], + "data": { + "model": "DiffStats", + "sourceOfTruth": "DiffStats zod schema in packages/workspace-server/src/services/git/schemas.ts (z.infer)", + "derivedProjections": ["DiffStatsBadge display"] + }, + "acceptance": [ + "getDiffStats lives in workspace-server git service behind a one-line procedure", + "PSK comparison uses timingSafeEqual", + "DiffStats schema is the source of truth, not a hand-declared type", + "useDiffStats hook wraps a single query" + ], + "passes": true, + "notes": "Landed 2026-05-27 (see MIGRATION.md). Bootstrapped @posthog/workspace-server, workspace-client, ui packages. Left as-is: useTaskDiffSummaryStats still has 4 modes (local/branch/PR/cloud) — collapses once relay protocol exists." + }, + { + "id": "file-watcher", + "category": "workspace-server-capability", + "priority": 70, + "status": "passing", + "claimedBy": null, + "paths": [ + "packages/workspace-server/src/services/watcher", + "packages/ui/src/features/file-watcher", + "apps/code/src/main/services/file-watcher/bridge.ts", + "apps/code/src/main/trpc/routers/file-watcher.ts" + ], + "data": { + "model": "FileWatcherEvent (discriminated union)", + "sourceOfTruth": "WatcherService in workspace-server (owns debounce, bulk threshold, git filtering = source smoothing)", + "derivedProjections": ["renderer caches keyed by repo"] + }, + "acceptance": [ + "all watcher orchestration + source-smoothing lives in workspace-server WatcherService.watchRepo()", + "useFileWatcher is a pure useSubscription wrapper (no useEffect/for-await/orchestration state)", + "fileWatcher.watch is a one-line subscription procedure", + "nothing for file-watcher lives in packages/core" + ], + "passes": true, + "notes": "Landed 2026-05-28. FileWatcherBridge in apps/code remains until fs/archive/suspension/workspace consumers migrate (see those slices). Two parallel watcher pipelines per repo remain (bridge + renderer); not yet deduped." + }, + { + "id": "focus", + "category": "core-orchestration", + "priority": 60, + "status": "passing", + "claimedBy": null, + "paths": [ + "packages/core/src/focus/service.ts", + "packages/workspace-server/src/services/focus", + "apps/code/src/main/services/focus/service.ts", + "apps/code/src/main/trpc/routers/focus.ts", + "apps/code/src/renderer/stores/focusStore.ts" + ], + "data": { + "model": "FocusSession", + "sourceOfTruth": "FocusController in packages/core owns enable/disable/restore flow; workspace-server owns git/worktree/watch host ops; main persists local snapshot for Electron restart", + "derivedProjections": ["focusStore UI state"] + }, + "acceptance": [ + "multi-step focus flow lives in core FocusController with injected dependency interface", + "git/worktree/watch host work lives in workspace-server focus service behind one-line focus.* procedures", + "focusStore is thin: UI state + one controller call per action, no flow graph", + "main FocusService is a documented bridge, not the source of truth" + ], + "passes": true, + "notes": "Landed 2026-05-28. Bridge: main FocusService shim persists focus-session for restore + re-emits events to legacy main-router subscribers. Retire when session restore/subscribers read from workspace-server (or shared persistence). Restore still re-saves validated session to repopulate server in-memory map." + }, + { + "id": "api-client", + "category": "core-orchestration", + "priority": 75, + "status": "passing", + "claimedBy": null, + "paths": ["packages/api-client/src", "apps/code/src/api"], + "data": { + "model": "PostHog/Django HTTP transport", + "sourceOfTruth": "ApiFetcher in packages/api-client (config-driven, appVersion injected)", + "derivedProjections": [] + }, + "acceptance": [ + "fetcher + generated client + augmentation moved to @posthog/api-client", + "no __APP_VERSION__ Vite global in the fetcher (appVersion is a config field)", + "scripts/update-openapi-client.ts writes into the package", + "renderer imports @posthog/api-client" + ], + "passes": true, + "notes": "Landed 2026-05-28 (transport only). The 2929-line posthogClient.ts god-class is NOT moved — tagged PORT NOTE, to be sliced per feature into packages/core//service.ts. Those per-feature carves are tracked by the relevant feature slices below." + }, + + { + "id": "connectivity", + "category": "core-orchestration", + "priority": 82, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/connectivity", + "apps/code/src/main/trpc/routers/connectivity.ts", + "apps/code/src/renderer/features/connectivity", + "apps/code/src/renderer/stores/connectivityStore.ts" + ], + "data": { + "model": "ConnectivityState", + "sourceOfTruth": "audit: likely the main connectivity service polling network/online state", + "derivedProjections": ["connectivityStore UI flags"] + }, + "acceptance": [ + "connectivity polling/detection lives in a package service (core or workspace-server depending on whether it does host syscalls)", + "router is one-line forwards over the service", + "connectivityStore is thin: subscription cache + UI flags, no polling loop", + "feature smoke test: toggling network reflects in the UI" + ], + "passes": false, + "notes": "Small read-only pipe (~127 LOC main, ~52 LOC feature). Good early slice to exercise the foundation." + }, + { + "id": "projects", + "category": "ui-feature", + "priority": 81, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/features/projects"], + "data": { + "model": "Project", + "sourceOfTruth": "audit: PostHog API (carve from posthogClient.ts into packages/core or api-client consumer)", + "derivedProjections": ["project list view"] + }, + "acceptance": [ + "projects feature view + hooks move to packages/ui/src/features/projects", + "data access wraps a single query/procedure; no imperative trpcClient in components", + "any project fetching carved out of posthogClient.ts god-class into a core/api-client consumer", + "smoke test: project list renders" + ], + "passes": false, + "notes": "Small read-only UI feature (~133 LOC)." + }, + { + "id": "environments", + "category": "ui-feature", + "priority": 80, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/environment", + "apps/code/src/main/services/session-env", + "apps/code/src/main/trpc/routers/environment.ts", + "apps/code/src/renderer/features/environments" + ], + "data": { + "model": "Environment / SessionEnv", + "sourceOfTruth": "audit: environment + session-env main services", + "derivedProjections": ["environments list UI"] + }, + "acceptance": [ + "environment business logic moves to core; any host env reads (process env, files) to workspace-server", + "router one-line forwards", + "environments feature view moves to packages/ui/src/features/environments", + "smoke test: environments list renders/edits" + ], + "passes": false, + "notes": "Pairs main environment (~240) + session-env (~158) with renderer environments feature (~162)." + }, + { + "id": "folders", + "category": "core-orchestration", + "priority": 65, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/folders", + "apps/code/src/main/trpc/routers/folders.ts", + "apps/code/src/renderer/features/folders", + "apps/code/src/renderer/features/folder-picker" + ], + "data": { + "model": "Folder", + "sourceOfTruth": "audit: folders main service + folder repository", + "derivedProjections": ["folder tree UI", "folder-picker"] + }, + "acceptance": [ + "folder host ops (fs listing) live in workspace-server; folder business/persistence orchestration in core", + "router one-line forwards over service", + "folders + folder-picker UI move to packages/ui/src/features", + "smoke test: open folder picker, select a folder, it persists" + ], + "passes": false, + "notes": "main folders ~346 LOC; folders feature ~143; folder-picker ~583." + }, + { + "id": "workspace", + "category": "core-orchestration", + "priority": 62, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/workspace", + "apps/code/src/main/trpc/routers/workspace.ts", + "apps/code/src/main/trpc/routers/additional-directories.ts", + "apps/code/src/renderer/features/workspace", + "apps/code/src/renderer/stores/activeRepoStore.ts" + ], + "data": { + "model": "Workspace / Repository / Worktree", + "sourceOfTruth": "audit: WorkspaceService + Workspace/Worktree/Repository repositories", + "derivedProjections": ["activeRepoStore", "workspace UI"] + }, + "acceptance": [ + "workspace orchestration moves to core; git/worktree/fs host ops to workspace-server", + "router bypasses-service-to-repository anti-pattern is removed (workspace.ts does this today)", + "container.get(FileWatcherService) inside WorkspaceService is replaced by constructor injection or events", + "activeRepoStore stays thin; workspace UI moves to packages/ui", + "smoke test: switch active repo, worktree state updates" + ], + "passes": false, + "notes": "1610 LOC main service. Two named forbidden patterns live here today: router-bypasses-service-to-repository, and container.get(FileWatcherService) inside a method. Entangled with focus (already migrated) and file-watcher bridge retirement." + }, + { + "id": "archive", + "category": "core-orchestration", + "priority": 58, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/archive", + "apps/code/src/main/trpc/routers/archive.ts", + "apps/code/src/renderer/features/archive" + ], + "data": { + "model": "ArchiveEntry", + "sourceOfTruth": "audit: ArchiveService + ArchiveRepository", + "derivedProjections": ["archive list UI"] + }, + "acceptance": [ + "archive orchestration moves to core; fs/host ops to workspace-server", + "archive is a file-watcher consumer — wire it via useFileWatcher/workspace-client and help retire FileWatcherBridge", + "router one-line forwards", + "smoke test: archive a task, it appears in the archive view" + ], + "passes": false, + "notes": "main ~618 LOC, feature ~802. One of the four FileWatcherBridge consumers." + }, + { + "id": "suspension", + "category": "core-orchestration", + "priority": 57, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/suspension", + "apps/code/src/main/trpc/routers/suspension.ts", + "apps/code/src/main/services/sleep", + "apps/code/src/main/trpc/routers/sleep.ts", + "apps/code/src/renderer/features/suspension" + ], + "data": { + "model": "Suspension", + "sourceOfTruth": "audit: SuspensionService + SuspensionRepository", + "derivedProjections": ["suspension UI"] + }, + "acceptance": [ + "suspension orchestration moves to core; host sleep/power ops via platform power-manager", + "suspension is a file-watcher consumer — wire via workspace-client and help retire FileWatcherBridge", + "router one-line forwards", + "smoke test: suspend/resume a session" + ], + "passes": false, + "notes": "main suspension ~571 + sleep ~70; feature ~160. FileWatcherBridge consumer." + }, + { + "id": "handoff", + "category": "core-orchestration", + "priority": 55, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/handoff", + "apps/code/src/main/trpc/routers/handoff.ts" + ], + "data": { + "model": "Handoff", + "sourceOfTruth": "audit: HandoffService", + "derivedProjections": [] + }, + "acceptance": [ + "handoff orchestration moves to core", + "host syscalls (git/fs) move to workspace-server", + "router one-line forwards", + "smoke test: run a handoff end to end" + ], + "passes": false, + "notes": "main ~910 LOC. Likely entangled with sessions/cloud-task; audit fan-in before moving." + }, + { + "id": "usage-monitor", + "category": "core-orchestration", + "priority": 55, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/usage-monitor", + "apps/code/src/main/trpc/routers/usage-monitor.ts", + "apps/code/src/renderer/features/billing" + ], + "data": { + "model": "UsageStats / BillingState", + "sourceOfTruth": "audit: UsageMonitorService + PostHog billing API", + "derivedProjections": ["billing view"] + }, + "acceptance": [ + "usage polling/aggregation moves to core", + "billing API access carved from posthogClient.ts into core/api-client consumer", + "billing feature moves to packages/ui/src/features/billing", + "smoke test: billing/usage view renders live numbers" + ], + "passes": false, + "notes": "main usage-monitor ~314; billing feature ~1279." + }, + { + "id": "cloud-task", + "category": "core-orchestration", + "priority": 45, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/cloud-task", + "apps/code/src/main/trpc/routers/cloud-task.ts" + ], + "data": { + "model": "CloudTask", + "sourceOfTruth": "audit: CloudTaskService + PostHog cloud API", + "derivedProjections": ["cloud task status in sessions/tasks UI"] + }, + "acceptance": [ + "cloud task orchestration (polling, status machine, retries) moves to core", + "cloud API access carved from posthogClient.ts", + "router one-line forwards", + "smoke test: create/poll a cloud task to completion" + ], + "passes": false, + "notes": "main ~1496 LOC. Deeply tied to sessions + handoff + diff-stats 'cloud' mode. Audit fan-in carefully." + }, + { + "id": "provisioning", + "category": "core-orchestration", + "priority": 50, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/provisioning", + "apps/code/src/main/trpc/routers/provisioning.ts", + "apps/code/src/renderer/features/provisioning" + ], + "data": { + "model": "ProvisioningState", + "sourceOfTruth": "audit: ProvisioningService", + "derivedProjections": ["provisioning UI"] + }, + "acceptance": [ + "provisioning orchestration moves to core; host ops to workspace-server", + "router one-line forwards", + "provisioning feature moves to packages/ui", + "smoke test: provisioning flow completes" + ], + "passes": false, + "notes": "main ~22 LOC (thin); feature ~115." + }, + { + "id": "deep-links", + "category": "core-orchestration", + "priority": 48, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/deep-link", + "apps/code/src/main/services/inbox-link", + "apps/code/src/main/services/task-link", + "apps/code/src/main/services/new-task-link", + "apps/code/src/main/trpc/routers/deep-link.ts" + ], + "data": { + "model": "DeepLink", + "sourceOfTruth": "audit: deep-link parsing/routing in main", + "derivedProjections": ["navigation actions"] + }, + "acceptance": [ + "deep-link parsing/routing logic moves to core; OS protocol registration stays in apps/code (Electron deep link is host lifecycle)", + "inbox-link/task-link/new-task-link share the core link parser", + "router one-line forwards", + "smoke test: open a posthog:// deep link, app routes correctly" + ], + "passes": false, + "notes": "deep-link ~108, inbox-link ~77, task-link ~97, new-task-link ~197. OS-level protocol handler registration is genuine host code and stays in apps/code." + }, + { + "id": "app-lifecycle", + "category": "core-orchestration", + "priority": 50, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/app-lifecycle", + "apps/code/src/main/services/watcher-registry", + "apps/code/src/main/services/workspace-server", + "apps/code/src/main/trpc/routers/workspace-server.ts", + "packages/platform/src/app-lifecycle.ts" + ], + "data": { + "model": "AppLifecycle hooks + workspace-server child-process connection", + "sourceOfTruth": "audit: AppLifecycleService + watcher-registry; workspace-server child connection (url/secret) is host infra owned by apps/code", + "derivedProjections": [] + }, + "acceptance": [ + "host lifecycle (Electron app events) stays in apps/code behind platform app-lifecycle interface", + "any business reactions to lifecycle move to core contributions", + "watcher-registry role re-evaluated (still used by focus + app-lifecycle)", + "workspace-server child-process spawn/connect service stays in apps/code (genuine host infra) and is explicitly documented as such, not migrated", + "smoke test: app start/quit hooks fire correctly; workspace-server child connects on boot" + ], + "passes": false, + "notes": "app-lifecycle ~192, watcher-registry ~115. Mostly host code; carve out only business reactions. The main `workspace-server` service + router manage the Electron-spawned child process (ELECTRON_RUN_AS_NODE) and stay in apps/code by design — included here so the audit accounts for them rather than silently omitting them." + }, + { + "id": "analytics", + "category": "core-orchestration", + "priority": 33, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/posthog-analytics.ts", + "apps/code/src/main/services/posthog-analytics.test.ts", + "apps/code/src/main/trpc/routers/analytics.ts", + "apps/code/src/renderer/utils/analytics.ts", + "apps/code/src/renderer/utils/analytics.test.ts" + ], + "data": { + "model": "AnalyticsEvent / user identity", + "sourceOfTruth": "posthog-analytics service owns identify/reset/capture; current-user-id is the source of truth for attribution", + "derivedProjections": ["captured event properties"] + }, + "acceptance": [ + "system-event analytics (identify, reset, capture) lives in a package service, not in stores or components (AGENTS.md R2: no system-event analytics in stores)", + "analytics service consumes the PostHog API/transport, not Electron directly", + "router one-line forwards with zod input/output", + "existing posthog-analytics.test.ts is ported/kept green", + "smoke test: an identify + a captured event reach PostHog" + ], + "passes": false, + "notes": "main posthog-analytics ~? LOC (file, not dir) + analytics router + existing test. Genuine domain that was missing from the first audit pass. Decide core vs platform: capture transport is API, but the 'when to capture' gating is business logic for core." + }, + { + "id": "ui-event-bus", + "category": "foundation", + "priority": 49, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/ui", + "apps/code/src/main/trpc/routers/ui.ts" + ], + "data": { + "model": "UIServiceEvent (typed main->renderer UI event bus)", + "sourceOfTruth": "UIService emits typed UI events; renderer subscribes", + "derivedProjections": ["renderer reactions to UI events"] + }, + "acceptance": [ + "UIService typed event emitter moves to the appropriate package (core for cross-feature coordination, or stays as host wiring if purely Electron-window driven — decide during audit)", + "the ui.ts router stops using container.get(UIService) and forwards over an injected service (or becomes feature subscription contributions)", + "renderer consumers subscribe via contributions, not ad hoc", + "smoke test: a UI event emitted in main is received by the renderer" + ], + "passes": false, + "notes": "Cross-cutting UI event bus. router/ui.ts uses container.get inside the procedure (forbidden pattern). May fold into di-foundation's event/contribution model; kept separate so it's explicitly tracked." + }, + { + "id": "ui-app-shell", + "category": "ui-feature", + "priority": 21, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/stores/themeStore.ts", + "apps/code/src/renderer/stores/rendererWindowFocusStore.ts" + ], + "data": { + "model": "app-shell UI state (theme preference, window focus/visibility)", + "sourceOfTruth": "themeStore owns theme pref (persisted); rendererWindowFocusStore derives window-focused from document visibility + OS focus", + "derivedProjections": [ + "isDarkMode", + "windowFocused (gates inbox polling)" + ] + }, + "acceptance": [ + "themeStore + rendererWindowFocusStore move to packages/ui as thin pure-UI stores", + "theme persistence uses electronStorage / platform storage, not ad hoc", + "window-focus signal is exposed cleanly for consumers (e.g. inbox polling pause) without cross-store reach-ins", + "smoke test: toggle theme persists across restart; backgrounding the window pauses inbox polling" + ], + "passes": false, + "notes": "Two app-shell stores that did not belong to any feature slice. rendererWindowFocusStore is consumed by inbox polling — coordinate with the inbox slice. Pure UI state; safe once di-foundation lands." + }, + { + "id": "llm-gateway", + "category": "core-orchestration", + "priority": 35, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/llm-gateway", + "apps/code/src/main/trpc/routers/llm-gateway.ts" + ], + "data": { + "model": "LlmGateway request/response", + "sourceOfTruth": "audit: LlmGatewayService", + "derivedProjections": [] + }, + "acceptance": [ + "gateway orchestration moves to core", + "router one-line forwards with zod input/output", + "smoke test: a gateway call round-trips" + ], + "passes": false, + "notes": "main ~299 LOC." + }, + { + "id": "posthog-plugin", + "category": "core-orchestration", + "priority": 32, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/main/services/posthog-plugin"], + "data": { + "model": "PostHog plugin integration", + "sourceOfTruth": "audit: posthog-plugin service", + "derivedProjections": [] + }, + "acceptance": [ + "plugin orchestration moves to core; host ops to workspace-server", + "no Electron imports in moved code", + "smoke test: plugin feature works end to end" + ], + "passes": false, + "notes": "main ~530 LOC." + }, + { + "id": "enrichment", + "category": "core-orchestration", + "priority": 34, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/enrichment", + "apps/code/src/main/trpc/routers/enrichment.ts", + "packages/enricher" + ], + "data": { + "model": "EnrichmentResult (flag detection)", + "sourceOfTruth": "packages/enricher owns AST detection; enrichment service orchestrates", + "derivedProjections": ["flag annotations in UI"] + }, + "acceptance": [ + "enrichment orchestration moves to core consuming @posthog/enricher", + "fs reads move to workspace-server", + "router one-line forwards", + "smoke test: flag detection annotates a file" + ], + "passes": false, + "notes": "main ~423 LOC; packages/enricher already exists as the AST engine." + }, + { + "id": "agent", + "category": "core-orchestration", + "priority": 30, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/agent", + "apps/code/src/main/trpc/routers/agent.ts", + "packages/agent" + ], + "data": { + "model": "AgentSession / AgentMessage (use ACP SDK types)", + "sourceOfTruth": "packages/agent framework; agent service orchestrates lifecycle", + "derivedProjections": ["session messages in sessions UI"] + }, + "acceptance": [ + "agent orchestration moves to core consuming @posthog/agent", + "ACP SDK types used, no hand-rolled agent/tool/permission types", + "no rawInput usage; zod-validated meta fields only", + "permissions implemented as tool calls, not custom methods", + "smoke test: start an agent session, exchange a prompt + permission" + ], + "passes": false, + "notes": "main ~2791 LOC. Deeply tied to sessions. Audit fan-in; likely sequenced near sessions." + }, + + { + "id": "git-core", + "category": "workspace-server-capability", + "priority": 70, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/git", + "apps/code/src/main/trpc/routers/git.ts", + "packages/workspace-server/src/services/git", + "apps/code/src/renderer/features/git-interaction" + ], + "data": { + "model": "Git CLI capability (status, diff, branch, commit, worktree, etc.)", + "sourceOfTruth": "packages/workspace-server git service (diff-stats already there); packages/git holds saga ops + gh client", + "derivedProjections": ["git-interaction UI"] + }, + "acceptance": [ + "remaining git CLI ops move into workspace-server git service with zod schemas", + "routers are one-line forwards; no inline git logic in router", + "git-interaction UI moves to packages/ui consuming workspace-client", + "no Electron imports; capability is dumb (host work + validation + transport)", + "smoke test: status/diff/commit flow through the migrated path" + ], + "passes": false, + "notes": "main git ~2878 LOC; git-interaction feature ~4921. diff-stats already carved. packages/git (sagas + gh CLI + locks) already exists — reconcile ownership. Large; consider sub-slices per command group during claim." + }, + { + "id": "fs-capability", + "category": "workspace-server-capability", + "priority": 68, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/fs", + "apps/code/src/main/trpc/routers/fs.ts", + "packages/workspace-server/src/services/fs" + ], + "data": { + "model": "Filesystem capability (read/write/list/watch-invalidate)", + "sourceOfTruth": "packages/workspace-server fs service", + "derivedProjections": ["file caches in renderer"] + }, + "acceptance": [ + "remaining fs syscalls move into workspace-server fs service (partial scaffold exists)", + "file-cache invalidation reconciled with WatcherService (fs is a FileWatcherBridge consumer today)", + "router one-line forwards; zod schemas", + "smoke test: read/write/list a file through the migrated path" + ], + "passes": false, + "notes": "main fs ~377; workspace-server fs service already scaffolded. fs is one of the four FileWatcherBridge consumers (helps retire the bridge)." + }, + { + "id": "shell-capability", + "category": "workspace-server-capability", + "priority": 66, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/shell", + "apps/code/src/main/trpc/routers/shell.ts" + ], + "data": { + "model": "Shell exec capability", + "sourceOfTruth": "audit: ShellService (process spawn)", + "derivedProjections": [] + }, + "acceptance": [ + "shell/process-spawn moves to workspace-server shell service", + "router one-line forwards; zod schemas", + "no Electron imports", + "smoke test: run a shell command through the migrated path" + ], + "passes": false, + "notes": "main ~472 LOC. Likely shared by terminal/pty + agent." + }, + { + "id": "process-tracking-capability", + "category": "workspace-server-capability", + "priority": 64, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/process-tracking", + "apps/code/src/main/trpc/routers/process-tracking.ts" + ], + "data": { + "model": "TrackedProcess", + "sourceOfTruth": "audit: ProcessTrackingService", + "derivedProjections": [] + }, + "acceptance": [ + "process tracking moves to workspace-server", + "router one-line forwards", + "smoke test: a tracked process is reported correctly" + ], + "passes": false, + "notes": "main ~249 LOC." + }, + { + "id": "local-logs-capability", + "category": "workspace-server-capability", + "priority": 60, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/local-logs", + "apps/code/src/main/trpc/routers/logs.ts" + ], + "data": { + "model": "LogEntry", + "sourceOfTruth": "audit: local-logs service (fs-backed)", + "derivedProjections": ["log viewer UI"] + }, + "acceptance": [ + "log file reading/tailing moves to workspace-server", + "router one-line forwards (logs.ts)", + "smoke test: logs stream/render" + ], + "passes": false, + "notes": "main ~108 LOC." + }, + { + "id": "terminal-pty", + "category": "workspace-server-capability", + "priority": 18, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/terminal", + "apps/code/src/main/services/shell" + ], + "data": { + "model": "PtySession", + "sourceOfTruth": "audit: pty spawn/IO (host) + terminal UI (xterm.js)", + "derivedProjections": ["terminal panes"] + }, + "acceptance": [ + "pty spawn + IO streaming move to workspace-server", + "terminal UI (xterm.js) moves to packages/ui consuming a streaming subscription", + "no orchestration in the store; subscription via contribution", + "smoke test: open a terminal, run a command, see output, resize works" + ], + "passes": false, + "notes": "feature ~937. Large entangled surface (REFACTOR.md Recommended Order step 6). Depends on shell-capability." + }, + + { + "id": "notifications", + "category": "renderer-platform-capability", + "priority": 52, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/notification", + "apps/code/src/main/trpc/routers/notification.ts", + "packages/platform/src/notifier.ts", + "packages/ui/src/features/notifications", + "apps/code/src/main/platform-adapters/electron-notifier.ts" + ], + "data": { + "model": "TaskNotification", + "sourceOfTruth": "notification decision inputs (task state + settings) in a UI/core service", + "derivedProjections": ["display title", "body text", "attention intent"] + }, + "acceptance": [ + "platform interface contains no Electron/macOS/Windows-specific terms", + "electron adapter is a dumb tRPC/Electron wrapper", + "notification gating (settings check, truncation, sound decision) lives in the package service not the adapter", + "feature smoke test sends a prompt-complete notification" + ], + "passes": false, + "notes": "packages/ui/src/features/notifications already partially scaffolded (canonical example in REFACTOR.md). main notification ~72; INotifier interface already exists. Verify gating moved out of adapter." + }, + { + "id": "clipboard-capability", + "category": "renderer-platform-capability", + "priority": 50, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/platform/src/clipboard.ts", + "apps/code/src/main/platform-adapters/electron-clipboard.ts" + ], + "data": { + "model": "clipboard text/image capability", + "sourceOfTruth": "platform clipboard interface; electron adapter implements", + "derivedProjections": [] + }, + "acceptance": [ + "clipboard interface gets a Symbol identifier (covered by platform-identifiers slice)", + "any clipboard business logic moves out of the adapter into the consuming UI/core service", + "renderer consumers use the platform service via DI / a thin tRPC adapter, not direct trpcClient", + "smoke test: copy/paste text and image work" + ], + "passes": false, + "notes": "Interface + electron adapter exist. Slice covers carving any logic out of adapter + wiring UI consumers via DI. Depends on platform-identifiers + di-foundation." + }, + { + "id": "dialog-capability", + "category": "renderer-platform-capability", + "priority": 50, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/platform/src/dialog.ts", + "apps/code/src/main/platform-adapters/electron-dialog.ts", + "apps/code/src/main/trpc/routers/os.ts" + ], + "data": { + "model": "dialog/message-box/file-picker capability", + "sourceOfTruth": "platform dialog interface", + "derivedProjections": [] + }, + "acceptance": [ + "dialog interface host-neutral with Symbol identifier", + "os.ts router (396 lines, no backing service today) is split: dialog/file-picker concerns go behind the platform service", + "no business logic in the dialog adapter", + "smoke test: open file picker + message box" + ], + "passes": false, + "notes": "os.ts is a 401-line router with NO backing service (named forbidden pattern). This slice addresses the dialog/file-icon/image-processor/app-meta portions of os.ts." + }, + { + "id": "secure-storage-capability", + "category": "renderer-platform-capability", + "priority": 50, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/platform/src/secure-storage.ts", + "apps/code/src/main/platform-adapters/electron-secure-storage.ts", + "apps/code/src/main/trpc/routers/secure-store.ts", + "apps/code/src/main/trpc/routers/encryption.ts" + ], + "data": { + "model": "secret store capability", + "sourceOfTruth": "platform secure-storage interface (Electron safeStorage adapter)", + "derivedProjections": [] + }, + "acceptance": [ + "secure-storage interface host-neutral with Symbol identifier", + "secret read/write decisions live in consuming services, not the adapter", + "secure-store/encryption routers one-line forward over the service", + "smoke test: store + retrieve a secret survives restart" + ], + "passes": false, + "notes": "Backs auth/integrations token storage; sequence before/with auth slice." + }, + { + "id": "context-menu-capability", + "category": "renderer-platform-capability", + "priority": 46, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/context-menu", + "apps/code/src/main/trpc/routers/context-menu.ts", + "packages/platform/src/context-menu.ts", + "apps/code/src/main/platform-adapters/electron-context-menu.ts" + ], + "data": { + "model": "ContextMenu spec", + "sourceOfTruth": "menu content decided by UI/core service; host renders native menu", + "derivedProjections": [] + }, + "acceptance": [ + "menu item construction/business logic lives in a package service, adapter just shows the native menu", + "platform interface host-neutral with Symbol identifier", + "router one-line forwards", + "smoke test: right-click menu shows correct items and actions fire" + ], + "passes": false, + "notes": "main context-menu ~595 LOC — significant logic to carve out of what should be a dumb adapter." + }, + { + "id": "updater-capability", + "category": "renderer-platform-capability", + "priority": 44, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/updates", + "apps/code/src/main/trpc/routers/updates.ts", + "packages/platform/src/updater.ts", + "apps/code/src/main/platform-adapters/electron-updater.ts", + "apps/code/src/renderer/stores/updateStore.ts" + ], + "data": { + "model": "UpdateState", + "sourceOfTruth": "update check/download orchestration in core; host download/install via platform updater", + "derivedProjections": ["updateStore UI", "update banner"] + }, + "acceptance": [ + "update orchestration (check cadence, state machine) moves to core", + "platform updater interface host-neutral with Symbol identifier; adapter is dumb", + "updateStore stays thin (subscription cache + UI flags)", + "smoke test: update check reflects available/not-available in UI" + ], + "passes": false, + "notes": "main updates ~521; updateStore + updateStore.test exist. Has existing tests to preserve." + }, + { + "id": "power-manager-capability", + "category": "renderer-platform-capability", + "priority": 42, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/platform/src/power-manager.ts", + "apps/code/src/main/platform-adapters/electron-power-manager.ts" + ], + "data": { + "model": "power/sleep-blocker capability", + "sourceOfTruth": "platform power-manager interface", + "derivedProjections": [] + }, + "acceptance": [ + "power-manager interface host-neutral with Symbol identifier", + "sleep-blocking decisions live in consuming service (e.g. suspension), adapter is dumb", + "smoke test: power/sleep blocking toggles correctly during a long task" + ], + "passes": false, + "notes": "Consumed by suspension/sleep; coordinate with that slice." + }, + { + "id": "misc-host-capabilities", + "category": "renderer-platform-capability", + "priority": 40, + "status": "todo", + "claimedBy": null, + "paths": [ + "packages/platform/src/url-launcher.ts", + "packages/platform/src/file-icon.ts", + "packages/platform/src/image-processor.ts", + "packages/platform/src/app-meta.ts", + "packages/platform/src/storage-paths.ts", + "packages/platform/src/main-window.ts", + "packages/platform/src/bundled-resources.ts", + "apps/code/src/main/trpc/routers/os.ts" + ], + "data": { + "model": "assorted host capabilities (open URL, file icon, image processing, app meta, storage paths, window, bundled resources)", + "sourceOfTruth": "respective platform interfaces", + "derivedProjections": [] + }, + "acceptance": [ + "each interface gets a Symbol identifier and is host-neutral", + "the remaining portions of os.ts (the 401-line, service-less router) are split behind these capabilities or backing services", + "no business logic in any adapter", + "smoke test: open-external-url, file icon render, image paste each work" + ], + "passes": false, + "notes": "Catch-all for the smaller platform adapters and the rest of os.ts. May be split into per-capability slices if a claimant prefers." + }, + + { + "id": "auth", + "category": "core-orchestration", + "priority": 40, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/auth", + "apps/code/src/main/services/auth-proxy", + "apps/code/src/main/services/oauth", + "apps/code/src/main/trpc/routers/auth.ts", + "apps/code/src/main/trpc/routers/oauth.ts", + "apps/code/src/renderer/features/auth" + ], + "data": { + "model": "AuthSession", + "sourceOfTruth": "AuthService owns OAuth dance, token refresh, session; AuthSessionRepository persists", + "derivedProjections": ["auth UI state", "seats", "settings gating"] + }, + "acceptance": [ + "OAuth dance, token refresh, session-sync all live in a core service (no multi-step flow in any store)", + "token persistence via platform secure-storage", + "logout fans out via a typed event; each store reacts in its contribution (no cross-store reach-ins)", + "auth feature moves to packages/ui; store is thin", + "smoke test: full login -> token refresh -> logout cycle" + ], + "passes": false, + "notes": "auth ~722, auth-proxy ~210, oauth ~624; feature ~1151. Canonical multi-step-flow case. Depends on secure-storage-capability. Logout is the cross-store-coordination example in REFACTOR.md." + }, + { + "id": "github-integration", + "category": "core-orchestration", + "priority": 38, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/github-integration", + "apps/code/src/main/trpc/routers/github-integration.ts", + "apps/code/src/main/services/integration-flow-schemas.ts", + "apps/code/src/renderer/features/integrations" + ], + "data": { + "model": "GithubIntegration", + "sourceOfTruth": "GithubIntegrationService + token in secure-storage", + "derivedProjections": ["integrations UI"] + }, + "acceptance": [ + "github OAuth/integration flow moves to core; gh CLI host ops via packages/git or workspace-server", + "token storage via platform secure-storage", + "integrations UI moves to packages/ui; store thin", + "smoke test: connect github, list repos" + ], + "passes": false, + "notes": "main ~154; shares integration-flow-schemas with linear/slack; integrations feature ~697 (shared with linear/slack)." + }, + { + "id": "linear-integration", + "category": "core-orchestration", + "priority": 37, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/linear-integration", + "apps/code/src/main/trpc/routers/linear-integration.ts", + "apps/code/src/renderer/features/integrations" + ], + "data": { + "model": "LinearIntegration", + "sourceOfTruth": "LinearIntegrationService + token in secure-storage", + "derivedProjections": ["integrations UI"] + }, + "acceptance": [ + "linear integration flow moves to core", + "token storage via platform secure-storage", + "shares the integration UI slice in packages/ui", + "smoke test: connect linear, list issues" + ], + "passes": false, + "notes": "main ~45 (thin). Sequence with github/slack as one 'integrations' wave." + }, + { + "id": "slack-integration", + "category": "core-orchestration", + "priority": 37, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/slack-integration", + "apps/code/src/main/trpc/routers/slack-integration.ts", + "apps/code/src/renderer/features/integrations" + ], + "data": { + "model": "SlackIntegration", + "sourceOfTruth": "SlackIntegrationService + token in secure-storage", + "derivedProjections": ["integrations UI"] + }, + "acceptance": [ + "slack integration flow moves to core", + "token storage via platform secure-storage", + "shares the integration UI slice in packages/ui", + "smoke test: connect slack, post a message" + ], + "passes": false, + "notes": "main ~170. Sequence with github/linear." + }, + { + "id": "external-apps", + "category": "core-orchestration", + "priority": 36, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/external-apps", + "apps/code/src/main/trpc/routers/external-apps.ts", + "apps/code/src/renderer/features/external-apps" + ], + "data": { + "model": "ExternalApp", + "sourceOfTruth": "ExternalAppsService (detect/launch external editors/apps)", + "derivedProjections": ["external-apps UI"] + }, + "acceptance": [ + "external app detection/launch: host detection to workspace-server, launch via platform url-launcher/shell", + "orchestration in core if multi-step", + "external-apps feature moves to packages/ui", + "smoke test: detect + open an external app" + ], + "passes": false, + "notes": "main ~733; feature ~71." + }, + { + "id": "mcp-apps", + "category": "core-orchestration", + "priority": 35, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/main/services/mcp-apps", + "apps/code/src/main/services/mcp-proxy", + "apps/code/src/main/services/mcp-callback", + "apps/code/src/main/trpc/routers/mcp-apps.ts", + "apps/code/src/main/trpc/routers/mcp-callback.ts", + "apps/code/src/renderer/features/mcp-apps", + "apps/code/src/renderer/features/mcp-servers", + "apps/code/src/renderer/features/posthog-mcp" + ], + "data": { + "model": "McpApp / McpServer connection", + "sourceOfTruth": "McpAppsService + McpProxyService (process spawn, proxy, oauth callback)", + "derivedProjections": ["mcp-apps/mcp-servers/posthog-mcp UI"] + }, + "acceptance": [ + "mcp process spawn/proxy host ops move to workspace-server; connection orchestration to core", + "mcp-callback oauth handling joins the auth/oauth pattern", + "mcp UI features move to packages/ui; stores thin", + "smoke test: add an MCP server, connect, list tools" + ], + "passes": false, + "notes": "mcp-apps ~480, mcp-proxy ~303, mcp-callback ~327; features mcp-servers ~2380 + mcp-apps ~1114 + posthog-mcp ~130. Sizeable; may sub-slice." + }, + + { + "id": "ui-settings", + "category": "ui-feature", + "priority": 25, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/settings", + "apps/code/src/renderer/stores/settingsStore.ts", + "apps/code/src/main/services/settingsStore.ts" + ], + "data": { + "model": "Settings", + "sourceOfTruth": "main SettingsStore persists; SETTINGS_SERVICE interface consumed by core/ui", + "derivedProjections": ["settings UI", "per-feature settings gates"] + }, + "acceptance": [ + "settings persistence stays main behind a SETTINGS_SERVICE interface consumed via DI", + "settings feature moves to packages/ui; settingsStore stays thin", + "no cross-store reach-ins for settings; consumers inject SETTINGS_SERVICE", + "smoke test: change a setting, it persists and gates the relevant feature" + ], + "passes": false, + "notes": "feature ~6019. settingsStore has existing tests. Many features depend on SETTINGS_SERVICE (notifications already references it) — define the interface early even if the big UI move comes later." + }, + { + "id": "ui-sidebar", + "category": "ui-feature", + "priority": 22, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/sidebar", + "apps/code/src/renderer/features/right-sidebar", + "apps/code/src/renderer/features/panels", + "apps/code/src/renderer/stores/createSidebarStore.ts", + "apps/code/src/renderer/stores/headerStore.ts" + ], + "data": { + "model": "layout/panel UI state", + "sourceOfTruth": "sidebar/panel stores (pure UI state)", + "derivedProjections": ["sidebar/panel layout"] + }, + "acceptance": [ + "sidebar/right-sidebar/panels move to packages/ui", + "stores remain pure UI state", + "route/panel registration via contributions where applicable", + "smoke test: open/close/resize panels and sidebars" + ], + "passes": false, + "notes": "sidebar ~3827, panels ~3396, right-sidebar ~61. Mostly pure UI; good candidates once foundation lands." + }, + { + "id": "ui-command", + "category": "ui-feature", + "priority": 23, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/command", + "apps/code/src/renderer/features/command-center", + "apps/code/src/renderer/features/actions", + "apps/code/src/renderer/stores/commandMenuStore.ts", + "apps/code/src/renderer/stores/shortcutsSheetStore.ts", + "apps/code/src/renderer/constants/keyboard-shortcuts.ts" + ], + "data": { + "model": "Command / Action", + "sourceOfTruth": "command registry (candidate for command contributions)", + "derivedProjections": ["command palette", "shortcuts sheet"] + }, + "acceptance": [ + "commands register via WORKBENCH_CONTRIBUTION command contributions, not ad hoc", + "command/command-center/actions move to packages/ui", + "stores stay thin", + "smoke test: command palette opens and runs a command" + ], + "passes": false, + "notes": "command ~536, command-center ~1328, actions ~140. Natural fit for the contribution model." + }, + { + "id": "ui-onboarding", + "category": "ui-feature", + "priority": 20, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/onboarding", + "apps/code/src/renderer/features/setup", + "apps/code/src/renderer/features/tour" + ], + "data": { + "model": "OnboardingState", + "sourceOfTruth": "audit: setup run service + onboarding state", + "derivedProjections": ["onboarding/setup/tour UI"] + }, + "acceptance": [ + "onboarding/setup/tour move to packages/ui", + "SetupRunService (currently a renderer DI service) re-evaluated: any data fetching/orchestration moves to core", + "smoke test: first-run onboarding completes" + ], + "passes": false, + "notes": "onboarding ~2976, setup ~1848, tour ~804. setup has a renderer SetupRunService bound in renderer DI today." + }, + { + "id": "ui-skills", + "category": "ui-feature", + "priority": 26, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/skills", + "apps/code/src/renderer/features/skill-buttons", + "apps/code/src/main/trpc/routers/skills.ts" + ], + "data": { + "model": "Skill", + "sourceOfTruth": "skills router (no backing service today — add one)", + "derivedProjections": ["skills/skill-buttons UI"] + }, + "acceptance": [ + "skills router gets a backing service; host ops (fs/skill pull) to workspace-server", + "skills/skill-buttons move to packages/ui", + "smoke test: list skills, trigger a skill button" + ], + "passes": false, + "notes": "skills ~366, skill-buttons ~395. Check whether skills.ts has a backing service." + }, + { + "id": "ui-folder-picker", + "category": "ui-feature", + "priority": 24, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/features/folder-picker"], + "data": { + "model": "folder picker UI", + "sourceOfTruth": "platform dialog/file-picker + folders service", + "derivedProjections": ["folder picker dialog"] + }, + "acceptance": [ + "folder-picker moves to packages/ui", + "uses platform dialog/file-picker capability, not direct trpcClient", + "smoke test: pick a folder via the picker" + ], + "passes": false, + "notes": "feature ~583. Pairs with folders + dialog-capability slices. May be folded into folders." + }, + { + "id": "ui-ai-approval", + "category": "ui-feature", + "priority": 28, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/features/ai-approval"], + "data": { + "model": "ApprovalRequest (permission via tool call)", + "sourceOfTruth": "agent permission tool calls (ACP types)", + "derivedProjections": ["approval prompts"] + }, + "acceptance": [ + "ai-approval moves to packages/ui", + "approvals are driven by agent permission tool calls using ACP SDK types, not hand-rolled permission_request patterns", + "smoke test: an agent tool permission prompt appears and approve/deny works" + ], + "passes": false, + "notes": "feature ~169. Tied to agent slice." + }, + { + "id": "ui-code-editor", + "category": "ui-feature", + "priority": 16, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/code-editor", + "apps/code/src/renderer/features/editor" + ], + "data": { + "model": "EditorDocument", + "sourceOfTruth": "fs capability (file contents) + CodeMirror UI state", + "derivedProjections": ["editor panes"] + }, + "acceptance": [ + "code-editor/editor move to packages/ui consuming fs capability via workspace-client", + "file read/write through workspace-server fs, not direct main calls", + "smoke test: open a file, edit, save" + ], + "passes": false, + "notes": "code-editor ~1581, editor ~492. Depends on fs-capability." + }, + { + "id": "ui-code-review", + "category": "ui-feature", + "priority": 14, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/features/code-review"], + "data": { + "model": "ReviewDiff / ReviewComment", + "sourceOfTruth": "git capability (diffs) + gh client (PR data)", + "derivedProjections": ["code-review UI"] + }, + "acceptance": [ + "code-review moves to packages/ui consuming git/diff + gh data via workspace-client/core", + "no multi-query orchestration hooks — merged shape comes from a procedure", + "smoke test: open a PR/diff, view + comment" + ], + "passes": false, + "notes": "feature ~4243. Depends on git-core + diff-stats. Entangled." + }, + { + "id": "ui-git-interaction", + "category": "ui-feature", + "priority": 15, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/features/git-interaction"], + "data": { + "model": "git working-tree interaction (stage/commit/branch UI)", + "sourceOfTruth": "git capability in workspace-server", + "derivedProjections": ["git-interaction UI"] + }, + "acceptance": [ + "git-interaction moves to packages/ui consuming workspace-client git procedures", + "no git logic in the store/components", + "smoke test: stage, commit, switch branch from the UI" + ], + "passes": false, + "notes": "feature ~4921. Depends on git-core. Bundle with or after git-core." + }, + { + "id": "ui-message-editor", + "category": "ui-feature", + "priority": 13, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/features/message-editor"], + "data": { + "model": "DraftMessage", + "sourceOfTruth": "Tiptap editor state (UI) + cloud-prompt encoding (@posthog/shared)", + "derivedProjections": ["composed prompt"] + }, + "acceptance": [ + "message-editor moves to packages/ui", + "prompt encoding uses @posthog/shared cloud-prompt, not inline logic", + "smoke test: compose a message with attachments/mentions and send" + ], + "passes": false, + "notes": "feature ~4715. Tied to sessions + agent." + }, + { + "id": "ui-task-detail", + "category": "ui-feature", + "priority": 12, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/task-detail", + "apps/code/src/renderer/features/tasks", + "apps/code/src/renderer/sagas/task" + ], + "data": { + "model": "Task / TaskDetail", + "sourceOfTruth": "audit: TaskService (currently a renderer DI service) — move data/orchestration to core", + "derivedProjections": ["task-detail + tasks UI"] + }, + "acceptance": [ + "TaskService data fetching/orchestration moves to core (it is a renderer service today, bound in renderer DI)", + "task-detail + tasks move to packages/ui; stores thin", + "no renderer service fetching domain data", + "smoke test: open a task, view detail, perform a task action" + ], + "passes": false, + "notes": "task-detail ~5228, tasks ~822. TaskService is bound in renderer DI container today (renderer-service-fetching-domain-data forbidden pattern). Tied to sessions/cloud-task." + }, + { + "id": "ui-inbox", + "category": "ui-feature", + "priority": 11, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/inbox", + "apps/code/src/renderer/stores/pendingTaskPromptStore.ts" + ], + "data": { + "model": "InboxItem", + "sourceOfTruth": "audit: inbox data source (likely PostHog API + local) — carve into core", + "derivedProjections": ["inbox list/detail UI"] + }, + "acceptance": [ + "inbox data/orchestration moves to core; inbox-prompts uses @posthog/shared", + "inbox feature moves to packages/ui; stores thin", + "smoke test: inbox loads items, open + act on one" + ], + "passes": false, + "notes": "feature ~10417 (second largest). Tied to inbox-link deep link + sessions. Sub-slice during claim." + }, + { + "id": "sessions", + "category": "ui-feature", + "priority": 10, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/features/sessions", + "apps/code/src/renderer/stores/cloneStore.ts", + "apps/code/src/renderer/stores/navigationStore.ts" + ], + "data": { + "model": "Session / Clone", + "sourceOfTruth": "audit: the 3796-line renderer sessions service (named canonical forbidden example) — move to core/workspace-server", + "derivedProjections": ["sessions UI", "cloneStore", "navigation"] + }, + "acceptance": [ + "the large renderer sessions service is dismantled: host work to workspace-server, orchestration to core, UI to packages/ui", + "cloneStore stops owning timers for domain cleanup (host emits Removed events)", + "no module-level subscriptions; subscriptions via contributions", + "stores become thin; no cross-store reach-ins", + "smoke test: create a session/clone, run an agent turn, clean up" + ], + "passes": false, + "notes": "feature ~15718 (largest). The canonical 'move large entangled surface last' slice (REFACTOR.md Recommended Order step 6). Depends on agent, git-core, fs, terminal-pty, cloud-task. MUST be sub-sliced before work; do not claim as one unit." + }, + + { + "id": "ui-primitives", + "category": "ui-shared", + "priority": 83, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/components/ui", + "apps/code/src/renderer/components/action-selector", + "apps/code/src/renderer/components/ActionSelector.tsx", + "apps/code/src/renderer/components/CodeBlock.tsx", + "apps/code/src/renderer/components/HighlightedCode.tsx", + "apps/code/src/renderer/components/List.tsx", + "apps/code/src/renderer/components/Divider.tsx", + "apps/code/src/renderer/components/DotsCircleSpinner.tsx", + "apps/code/src/renderer/components/DotPatternBackground.tsx", + "apps/code/src/renderer/components/TreeDirectoryRow.tsx", + "apps/code/src/renderer/components/HeaderRow.tsx", + "apps/code/src/renderer/components/HedgehogMode.tsx", + "apps/code/src/renderer/components/ZenHedgehog.tsx", + "apps/code/src/renderer/hooks/useDebounce.ts", + "apps/code/src/renderer/hooks/useDebouncedValue.ts", + "apps/code/src/renderer/hooks/useInView.ts", + "apps/code/src/renderer/hooks/useBlurOnEscape.ts", + "apps/code/src/renderer/hooks/useAutoFocusOnTyping.ts", + "apps/code/src/renderer/hooks/useImagePanAndZoom.ts", + "apps/code/src/renderer/utils/toast.tsx", + "apps/code/src/renderer/utils/focusToast.tsx", + "apps/code/src/renderer/utils/confetti.ts", + "apps/code/src/renderer/utils/syntax-highlight.ts", + "packages/ui/src/primitives" + ], + "data": { + "model": "shared visual building blocks + generic UI hooks", + "sourceOfTruth": "packages/ui/src/primitives owns reusable, host-agnostic components and hooks shared across features", + "derivedProjections": [] + }, + "acceptance": [ + "genuinely cross-feature primitives move to packages/ui/src/primitives (REFACTOR.md 'Porting React UI'): components/ui/*, shared visuals, action-selector, generic hooks (useDebounce, useInView, useBlurOnEscape, useAutoFocusOnTyping, useImagePanAndZoom)", + "no primitive imports trpcClient, Electron, apps/code, or workspace-server code", + "a one-feature component is NOT promoted to a primitive just because it moved", + "Quill (@posthog/quill) is preferred where it has an equivalent; raw primitives only fill genuine gaps (AGENTS.md R11)", + "colocated tests/stories move with the component", + "smoke test: a feature renders using the migrated primitives with no app-path imports" + ], + "passes": false, + "notes": "Should land EARLY: feature UI slices import primitives, and the new rule forbids feature components in packages/ui from importing apps/code. components/ ~7038 LOC total (subset is primitives; the rest is shell/permissions/feature). Reconcile against @posthog/quill before recreating primitives." + }, + { + "id": "ui-shell", + "category": "ui-shared", + "priority": 19, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/App.tsx", + "apps/code/src/renderer/main.tsx", + "apps/code/src/renderer/components/Providers.tsx", + "apps/code/src/renderer/components/MainLayout.tsx", + "apps/code/src/renderer/components/FullScreenLayout.tsx", + "apps/code/src/renderer/components/ThemeWrapper.tsx", + "apps/code/src/renderer/components/BackgroundWrapper.tsx", + "apps/code/src/renderer/components/GlobalEventHandlers.tsx", + "apps/code/src/renderer/components/ErrorBoundary.tsx", + "apps/code/src/renderer/components/DraggableTitleBar.tsx", + "apps/code/src/renderer/components/ResizableSidebar.tsx", + "apps/code/src/renderer/components/SpaceSwitcher.tsx", + "apps/code/src/renderer/components/LoginTransition.tsx", + "apps/code/src/renderer/components/ScopeReauthPrompt.tsx", + "apps/code/src/renderer/components/KeyboardShortcutsSheet.tsx", + "apps/code/src/renderer/styles", + "apps/code/src/renderer/utils/queryClient.ts" + ], + "data": { + "model": "workbench shell (app root, providers, layout, boot)", + "sourceOfTruth": "startWorkbench (di package) owns boot; App.tsx auth-gating + ad-hoc subscription registration get dismantled into contributions", + "derivedProjections": ["rendered app frame"] + }, + "acceptance": [ + "App.tsx stops registering subscriptions/initializers inline (initialize*Store, registerBillingSubscriptions, useSubscription side effects) — these become WORKBENCH_CONTRIBUTIONs started by startWorkbench", + "layout/shell components move to packages/ui (shell), importing no trpcClient/Electron directly", + "auth-gate routing (AuthScreen vs MainLayout) is driven by injected auth service state, not cross-store reach-ins", + "route registration is owned by feature modules/contributions, not a central app list", + "smoke test: app boots through startWorkbench, renders the authed shell, and a contributed route loads" + ], + "passes": false, + "notes": "App.tsx is the boot/auth-gate/subscription hub (imports ~15 feature initializers today). Heavily depends on di-foundation and auth. main.tsx also referenced by di-foundation — coordinate. Low priority (entangled, late) but it is where the contribution model proves out end to end." + }, + { + "id": "ui-permissions", + "category": "ui-feature", + "priority": 29, + "status": "todo", + "claimedBy": null, + "paths": ["apps/code/src/renderer/components/permissions"], + "data": { + "model": "Permission request (ACP tool-call permission)", + "sourceOfTruth": "agent permission tool calls using @anthropic-ai/claude-agent-sdk (ACP) types", + "derivedProjections": [ + "per-permission UI (read/edit/execute/fetch/move/delete/mcp/...)" + ] + }, + "acceptance": [ + "permission components move to packages/ui (likely under the agent/ai-approval feature)", + "permission types come from the ACP SDK, not hand-rolled types (AGENTS.md + global rule)", + "permissions are rendered from agent tool-call permission requests, not a custom permission_request channel", + "smoke test: each permission type renders and approve/deny round-trips to the agent" + ], + "passes": false, + "notes": "14 permission components in components/permissions/. Tightly coupled to agent + ai-approval slices; sequence together." + }, + { + "id": "renderer-shared-hooks", + "category": "ui-shared", + "priority": 27, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/hooks/useAuthenticatedClient.ts", + "apps/code/src/renderer/hooks/useAuthenticatedQuery.ts", + "apps/code/src/renderer/hooks/useAuthenticatedMutation.ts", + "apps/code/src/renderer/hooks/useAuthenticatedInfiniteQuery.ts", + "apps/code/src/renderer/hooks/useConnectivity.ts", + "apps/code/src/renderer/hooks/useIntegrations.ts", + "apps/code/src/renderer/hooks/useMeQuery.ts", + "apps/code/src/renderer/hooks/useSeat.ts", + "apps/code/src/renderer/hooks/useFeatureFlag.ts", + "apps/code/src/renderer/hooks/useProjectQuery.ts", + "apps/code/src/renderer/hooks/useRepoFiles.ts", + "apps/code/src/renderer/hooks/useRepositoryDirectory.ts", + "apps/code/src/renderer/hooks/useDetectedCloudRepository.ts", + "apps/code/src/renderer/hooks/useTaskContextMenu.ts", + "apps/code/src/renderer/hooks/useTaskDeepLink.ts", + "apps/code/src/renderer/hooks/useNewTaskDeepLink.ts", + "apps/code/src/renderer/hooks/useSetHeaderContent.ts" + ], + "data": { + "model": "feature-coupled renderer hooks", + "sourceOfTruth": "each hook wraps one query/mutation/subscription for a specific feature", + "derivedProjections": [] + }, + "acceptance": [ + "each hook moves to its owning feature in packages/ui (e.g. useMeQuery/useSeat/useAuthenticated*->auth, useConnectivity->connectivity, useIntegrations->integrations, useProjectQuery->projects, useRepoFiles/useRepositoryDirectory/useDetectedCloudRepository->workspace, useTask*DeepLink->deep-links)", + "any hook that orchestrates multiple queries is collapsed into a single service procedure (AGENTS.md R4)", + "no hook imports trpcClient directly — they wrap useService + TanStack Query", + "this slice is a tracking/redistribution slice: it passes when every listed hook has a home or is consumed via its feature slice" + ], + "passes": false, + "notes": "These hooks live in renderer/hooks/ (not feature dirs) so they were not caught by feature-dir paths. Most should migrate WITH their owning feature slice; this slice exists so they are explicitly accounted for and not orphaned. useFileWatcher.ts already migrated to packages/ui (file-watcher slice)." + }, + { + "id": "renderer-shared-utils", + "category": "ui-shared", + "priority": 31, + "status": "todo", + "claimedBy": null, + "paths": [ + "apps/code/src/renderer/utils", + "apps/code/src/renderer/types", + "apps/code/src/renderer/assets" + ], + "data": { + "model": "shared renderer utilities + types + assets", + "sourceOfTruth": "split by dependency: host-agnostic -> @posthog/ui or @posthog/shared; host-coupled -> platform adapter", + "derivedProjections": [] + }, + "acceptance": [ + "host-agnostic utils (object, path, time, random, xml, urls, posthogLinks, links, generateTitle, promptContent, sendMessageKey, agentVersion, session, repository, getFilePath) move to @posthog/ui or @posthog/shared", + "host-coupled utils (electronStorage, dialog, notifications, sounds, browser, platform, clearStorage, handleExternalAppAction, overlay) move behind a @posthog/platform interface + app adapter — no Electron import left in shared code", + "logger.ts uses the scoped logger pattern; queryClient.ts handled by ui-shell", + "renderer/types: electron.d.ts stays in apps/code (host ambient types); rehype.d.ts moves to @posthog/ui", + "assets referenced by package UI move to packages/ui/src/assets; app-only assets stay", + "colocated util tests move with their util and stay green" + ], + "passes": false, + "notes": "utils ~2052 LOC, the biggest cross-cutting cleanup. Sub-slice during claim by destination (ui vs shared vs platform). utils/analytics.* is owned by the `analytics` slice; constants/keyboard-shortcuts by `ui-command`; sagas/task by `ui-task-detail` — excluded here to avoid double-ownership." + } + ] +} diff --git a/apps/code/src/renderer/desktop-contributions.ts b/apps/code/src/renderer/desktop-contributions.ts new file mode 100644 index 000000000..a83ae6d48 --- /dev/null +++ b/apps/code/src/renderer/desktop-contributions.ts @@ -0,0 +1,6 @@ +import { container } from "@renderer/di/container"; + +export function registerDesktopContributions(): void { + // Feature modules will be loaded here as UI migrates to packages/ui. + void container; +} diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts new file mode 100644 index 000000000..cad5c501d --- /dev/null +++ b/apps/code/src/renderer/desktop-services.ts @@ -0,0 +1,3 @@ +// Desktop host service bindings live here as features move into packages. +// Importing the renderer container performs today's existing bindings. +import "@renderer/di/container"; diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index c3a7fce92..05caaf556 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -3,7 +3,12 @@ import "reflect-metadata"; import "@stores/rendererWindowFocusStore"; import { Providers } from "@components/Providers"; import { preloadHighlighter } from "@pierre/diffs"; +import { ServiceProvider } from "@posthog/ui/workbench/service-context"; import App from "@renderer/App"; +import { registerDesktopContributions } from "@renderer/desktop-contributions"; +import { container } from "@renderer/di/container"; +import "@renderer/desktop-services"; +import { startWorkbenchContributions } from "@posthog/ui/workbench/contribution"; import React from "react"; import ReactDOM from "react-dom/client"; import "./styles/globals.css"; @@ -59,13 +64,18 @@ document.title = import.meta.env.DEV ? "PostHog Code (Development)" : "PostHog Code"; +registerDesktopContributions(); +void startWorkbenchContributions(container); + const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Root element not found"); ReactDOM.createRoot(rootElement).render( - - - + + + + + , ); diff --git a/packages/ui/package.json b/packages/ui/package.json index 1d9291a00..e04adb1d5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,7 +18,9 @@ "@posthog/api-client": "workspace:*", "@posthog/core": "workspace:*", "@posthog/platform": "workspace:*", - "@posthog/workspace-client": "workspace:*" + "@posthog/workspace-client": "workspace:*", + "inversify": "catalog:", + "reflect-metadata": "catalog:" }, "peerDependencies": { "@phosphor-icons/react": "catalog:", diff --git a/packages/ui/src/workbench/contribution.ts b/packages/ui/src/workbench/contribution.ts new file mode 100644 index 000000000..89ad053e3 --- /dev/null +++ b/packages/ui/src/workbench/contribution.ts @@ -0,0 +1,25 @@ +import type { Container } from "inversify"; + +export interface WorkbenchContribution { + start(): void | Promise; +} + +export const WORKBENCH_CONTRIBUTION = Symbol.for( + "posthog.workbenchContribution", +); + +export async function startWorkbenchContributions( + container: Container, +): Promise { + if (!container.isBound(WORKBENCH_CONTRIBUTION)) { + return; + } + + const contributions = container.getAll( + WORKBENCH_CONTRIBUTION, + ); + + for (const contribution of contributions) { + await contribution.start(); + } +} diff --git a/packages/ui/src/workbench/service-context.tsx b/packages/ui/src/workbench/service-context.tsx new file mode 100644 index 000000000..b484a4b7e --- /dev/null +++ b/packages/ui/src/workbench/service-context.tsx @@ -0,0 +1,32 @@ +import type { ServiceIdentifier } from "inversify"; +import type { ReactNode } from "react"; +import { createContext, useContext, useMemo } from "react"; + +interface ServiceContainer { + get(serviceIdentifier: ServiceIdentifier): T; +} + +const ServiceContext = createContext(null); + +export function ServiceProvider({ + children, + container, +}: { + children: ReactNode; + container: ServiceContainer; +}) { + const value = useMemo(() => container, [container]); + + return ( + {children} + ); +} + +export function useService(serviceIdentifier: ServiceIdentifier): T { + const container = useContext(ServiceContext); + if (!container) { + throw new Error("useService must be used within a ServiceProvider"); + } + + return container.get(serviceIdentifier); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fae275e8..e5eff7b28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1021,6 +1021,12 @@ importers: '@posthog/workspace-client': specifier: workspace:* version: link:../workspace-client + inversify: + specifier: 'catalog:' + version: 7.11.0(reflect-metadata@0.2.2) + reflect-metadata: + specifier: 'catalog:' + version: 0.2.2 devDependencies: '@phosphor-icons/react': specifier: 'catalog:' diff --git a/scripts/refactor-init.sh b/scripts/refactor-init.sh new file mode 100755 index 000000000..fd05a8be2 --- /dev/null +++ b/scripts/refactor-init.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# refactor-init.sh — baseline check for a fresh refactor agent. +# +# Run this at the start of every refactor session (REFACTOR.md "Agent Startup +# Protocol" step 5). It installs deps, builds the package graph, and typechecks +# so you can confirm the baseline is green BEFORE piling a new migration slice +# on top of an unknown failure. +# +# It deliberately does NOT launch the Electron app — the app smoke test is +# manual and per-slice (see REFACTOR.md "Validation"). This script prints the +# commands for that at the end. +# +# Usage: +# bash scripts/refactor-init.sh # full baseline (install, build deps, typecheck) +# SKIP_INSTALL=1 bash scripts/refactor-init.sh # skip pnpm install (deps already current) +# WITH_TESTS=1 bash scripts/refactor-init.sh # also run the unit test suites +# FAST=1 bash scripts/refactor-init.sh # skip install AND build deps (typecheck only) + +set -euo pipefail + +cd "$(dirname "$0")/.." +ROOT="$(pwd)" + +bold() { printf "\033[1m%s\033[0m\n" "$1"; } +step() { printf "\n\033[1;36m==> %s\033[0m\n" "$1"; } +ok() { printf "\033[1;32m✓ %s\033[0m\n" "$1"; } +warn() { printf "\033[1;33m! %s\033[0m\n" "$1"; } + +SKIP_INSTALL="${SKIP_INSTALL:-0}" +WITH_TESTS="${WITH_TESTS:-0}" +FAST="${FAST:-0}" +if [ "$FAST" = "1" ]; then + SKIP_INSTALL=1 +fi + +step "Context" +bold "pwd: $ROOT" +if command -v node >/dev/null 2>&1; then bold "node: $(node -v)"; fi +if command -v pnpm >/dev/null 2>&1; then bold "pnpm: $(pnpm -v)"; else warn "pnpm not found — run 'corepack enable' or install pnpm"; fi +bold "branch: $(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + +step "Recent history (git log --oneline -10)" +git log --oneline -10 || true + +step "Worktree status (git status --short)" +if [ -n "$(git status --short)" ]; then + git status --short +else + ok "clean" +fi + +step "Coordination files" +for f in REFACTOR.md MIGRATION.md REFACTOR_PROGRESS.md REFACTOR_SLICES.json AGENTS.md; do + if [ -f "$f" ]; then ok "$f present"; else warn "$f MISSING"; fi +done + +if [ -f REFACTOR_SLICES.json ] && command -v node >/dev/null 2>&1; then + step "Slice summary (REFACTOR_SLICES.json)" + node -e ' + const j = require("./REFACTOR_SLICES.json"); + const s = j.slices || []; + const by = {}; + for (const x of s) by[x.status] = (by[x.status] || 0) + 1; + console.log("total slices:", s.length); + for (const k of Object.keys(by).sort()) console.log(" " + k + ": " + by[k]); + const inprog = s.filter(x => x.status === "in_progress"); + if (inprog.length) { + console.log("\nin_progress (claimed — do not take these):"); + for (const x of inprog) console.log(" - " + x.id + " (claimedBy: " + (x.claimedBy || "?") + ")"); + } + const todo = s.filter(x => x.status === "todo").sort((a, b) => b.priority - a.priority).slice(0, 5); + console.log("\ntop unclaimed todo by priority:"); + for (const x of todo) console.log(" - [" + x.priority + "] " + x.id + " (" + x.category + ")"); + ' +fi + +if [ "$SKIP_INSTALL" != "1" ]; then + step "Install dependencies (pnpm install)" + pnpm install + ok "dependencies installed" +else + warn "skipping pnpm install (SKIP_INSTALL=1)" +fi + +if [ "$FAST" != "1" ]; then + step "Build package graph (pnpm build:deps)" + pnpm build:deps + ok "package deps built" +else + warn "skipping build:deps (FAST=1)" +fi + +step "Typecheck (pnpm typecheck)" +pnpm typecheck +ok "typecheck passed" + +if [ "$WITH_TESTS" = "1" ]; then + step "Unit tests (pnpm test)" + pnpm test + ok "tests passed" +else + warn "skipping tests (set WITH_TESTS=1 to run 'pnpm test')" +fi + +step "Baseline OK" +cat <<'EOF' +Next steps: + 1. Read REFACTOR.md, MIGRATION.md, REFACTOR_PROGRESS.md, REFACTOR_SLICES.json. + 2. Claim ONE todo slice: set status -> in_progress and claimedBy -> your id. + 3. Verify the app actually runs before changing code (manual smoke test): + pnpm dev # both workspace-server (agent) + code app + # or just the desktop app: + pnpm dev:code + 4. Work the slice per REFACTOR.md "Per-Feature Procedure". + 5. Finish per REFACTOR.md "Agent Finish Protocol": focused tests, real smoke + test, update REFACTOR_SLICES.json + REFACTOR_PROGRESS.md + MIGRATION.md. + 6. Before committing: pnpm biome format --write . && pnpm typecheck + (Biome formats REFACTOR_SLICES.json too; commit the formatted version.) + +Do NOT set passes:true until acceptance checks AND a real smoke test pass. +EOF