diff --git a/docs/plans/2026-06-26-import-offer.md b/docs/plans/2026-06-26-import-offer.md
new file mode 100644
index 000000000..078e0fa59
--- /dev/null
+++ b/docs/plans/2026-06-26-import-offer.md
@@ -0,0 +1,364 @@
+# Dashboard-Surfaced Legacy Import Offer — Implementation Plan
+
+> **For agentic workers:** implement task-by-task; each task ends with a green test + a commit. Steps use `- [ ]`.
+
+**Goal:** Replace the first-boot CLI import prompt with a daemon API (`GET`/`POST /api/v1/import`) and a dashboard banner. **Scope: projects + per-project settings only** (no orchestrator sessions, no transcript relocation), a faithful port of `aoagents/ReverbCode` PR #320.
+
+**Architecture:** `ao start` becomes headless. The `legacyimport` engine is simplified to import projects only. A new `service/importer` wraps it with a detection probe (`Status`) and a trigger (`Run`) that writes through the daemon's shared store. An HTTP controller exposes both; the Electron renderer polls status and shows an `ImportOffer` banner. Because startup is now headless, the Electron main process also gains always-on discovery of a daemon it didn't spawn.
+
+**Tech Stack:** Go (chi, sqlc, code-first OpenAPI via `cmd/genspec`), React + @tanstack/react-query + openapi-fetch, Electron, vitest.
+
+## Global Constraints
+
+- Module path: `github.com/aoagents/agent-orchestrator/backend`.
+- **No em dashes** anywhere (prose, comments, copy). Use `.`/`,`/`(...)`.
+- `openapi.yaml` and `frontend/src/api/schema.ts` are **generated** (never hand-edit); change the Go reflection source then run `npm run api:spec && npm run api:ts`.
+- All app state under `~/.ao` only (already enforced; don't regress).
+- Branch: `ao/agent-orchestrator-3/import-offer` (sibling of `…/root`). PR target `main` on `AgentWrapper/agent-orchestrator`.
+- **Reference:** this repo sits exactly at PR #320's base, so the change set lines up 1:1 with that PR. Where a step says "PR-verbatim," copy the PR's content for that file.
+- Commit immediately after each task (AO worktrees can be force-removed).
+
+## File Structure
+
+| File | Status | Responsibility |
+| --- | --- | --- |
+| `backend/internal/legacyimport/orchestrator.go` (+`_test.go`) | delete | orchestrator-session mapping/import (out of scope) |
+| `backend/internal/legacyimport/claude.go` (+`_test.go`) | delete | Claude transcript relocation (out of scope) |
+| `backend/internal/storage/sqlite/store/session_import_store.go` (+`_test.go`) | delete | `ImportSession` (only used by orchestrator import) |
+| `backend/internal/legacyimport/importer.go` | modify | trim `Store`/`Options`/`Report`; drop orchestrator loop; add `quote()` |
+| `backend/internal/legacyimport/config.go` | modify | `Repo` -> `*yaml.Node`; tolerate `*yaml.TypeError` |
+| `backend/internal/legacyimport/paths.go` | modify | drop `defaultClaudeProjectsDir` + `projectSessionsDir` |
+| `backend/internal/legacyimport/{importer,config,project}_test.go` | modify | projects-only assertions |
+| `backend/internal/cli/import.go` | modify | drop orchestrator/transcript copy + summary lines |
+| `backend/internal/service/importer/importer.go` (+`_test.go`) | create | `Status`/`Run` over the daemon store |
+| `backend/internal/httpd/controllers/imports.go` (+`_test.go`) | create | `GET`/`POST /import`, 501 on nil svc |
+| `backend/internal/httpd/controllers/dto.go` | modify | `ImportStatusResponse`, `ImportRunResponse` |
+| `backend/internal/httpd/apispec/specgen/build.go` | modify | `import` tag + ops + schemaNames |
+| `backend/internal/httpd/api.go` | modify | `APIDeps.Import`, `API.imports`, wire + Register |
+| `backend/internal/daemon/daemon.go` | modify | `Import: importsvc.New(...)` |
+| `backend/internal/cli/start.go` | modify | delete `maybeFirstBootImport` + 2 imports |
+| `backend/internal/httpd/apispec/openapi.yaml` | regenerate | — |
+| `frontend/src/api/schema.ts` | regenerate | — |
+| `frontend/src/shared/daemon-discovery.ts` (+`.test.ts`) | modify | `shouldAdoptDiscoveredPort` |
+| `frontend/src/main.ts` | modify | external daemon discovery loop |
+| `frontend/src/renderer/hooks/useImportStatus.ts` | create | `useImportStatus`/`useRunImport` |
+| `frontend/src/renderer/components/ImportOffer.tsx` (+`.test.tsx`) | create | the banner |
+| `frontend/src/renderer/components/SessionsBoard.tsx` | modify | render `` |
+
+---
+
+## Task 1: Simplify the import engine to projects-only
+
+**Files:** Delete `legacyimport/orchestrator.go`, `orchestrator_test.go`, `claude.go`, `claude_test.go`, `storage/sqlite/store/session_import_store.go`, `session_import_store_test.go`. Modify `legacyimport/importer.go`, `config.go`, `paths.go`, and their tests; `cli/import.go`.
+
+**Interfaces:**
+- Produces: trimmed `legacyimport.Store` (`GetProject`, `UpsertProject` only), `legacyimport.Options{Root, DryRun, Now, RepoOriginURL}`, `legacyimport.Report{DryRun, ProjectsImported, ProjectsSkipped, Notes}`. **Tasks 2 and 3 depend on this `Report` shape.**
+
+- [ ] **Step 1: Delete the orchestrator/transcript files.**
+
+```bash
+git rm backend/internal/legacyimport/orchestrator.go backend/internal/legacyimport/orchestrator_test.go \
+ backend/internal/legacyimport/claude.go backend/internal/legacyimport/claude_test.go \
+ backend/internal/storage/sqlite/store/session_import_store.go \
+ backend/internal/storage/sqlite/store/session_import_store_test.go
+```
+
+- [ ] **Step 2: Trim `legacyimport/importer.go`.**
+ - `Store` interface: keep only `GetProject` and `UpsertProject` (drop `GetSession`, `ImportSession`).
+ - `Options`: drop `DataDir` and `ClaudeProjectsDir`; keep `Root`, `DryRun`, `Now`, `RepoOriginURL`.
+ - `Report`: reduce to `{DryRun bool, ProjectsImported int, ProjectsSkipped int, Notes []string}`.
+ - In `Run`, delete the orchestrator block (the `sessionsDir`/`readOrchestratorMapping`/`switch mapping.status` that follows `importProject`), so the loop body ends after `importProject`.
+ - Delete the `importOrchestrator` function.
+ - Add the `quote()` helper used by the project-id note:
+
+```go
+// quote wraps s in double quotes for note messages, rendering an empty string as
+// "?" so a missing value is still legible.
+func quote(s string) string {
+ if s == "" {
+ return `"?"`
+ }
+ return `"` + s + `"`
+}
+```
+
+- [ ] **Step 3: `legacyimport/config.go` parsing-robustness fix (PR-bundled, improves project import).**
+ - Add `"errors"` import.
+ - Change the project config's `Repo string \`yaml:"repo"\`` field to `Repo *yaml.Node \`yaml:"repo"\`` with a comment that it is captured but never consumed (origin is re-resolved from the repo path).
+ - In `loadLegacyConfig`, when `yaml.Unmarshal` errors, keep the partial decode on a `*yaml.TypeError` instead of failing the whole registry:
+
+```go
+ var typeErr *yaml.TypeError
+ if !errors.As(err, &typeErr) {
+ return legacyConfig{}, fmt.Errorf("parse legacy config.yaml: %w", err)
+ }
+```
+
+- [ ] **Step 4: `legacyimport/paths.go`.** Delete `defaultClaudeProjectsDir` and `projectSessionsDir`; drop the now-unused `"strings"` import; update the package doc to "maps the legacy project registry and per-project settings."
+- [ ] **Step 5: Update tests.** Rewrite `importer_test.go` (drop `sessions` from `fakeStore`, drop orchestrator/transcript assertions, `writeLegacyRoot`/`runOpts` take only `root`), `config_test.go`, and `project_test.go` (add the `nonNilNode()` helper returning a populated `*yaml.Node`) per the PR.
+- [ ] **Step 6: `cli/import.go`.** Update the command `Short`/`Long` to say "projects" only, the confirm prompt to "Import projects from %s?", and delete the `Orchestrators:`/`Transcripts:` lines from `writeImportSummary`. Remove the `DataDir` field from the `legacyimport.Options{...}` literal it builds.
+- [ ] **Step 7:** `cd backend && go build ./... && go test ./internal/legacyimport/... ./internal/cli/... ./internal/storage/sqlite/...` → expect PASS.
+- [ ] **Step 8:** Commit: `refactor(legacyimport): scope import to projects + settings only`
+
+---
+
+## Task 2: Import service (`service/importer`)
+
+**Files:** Create `backend/internal/service/importer/importer.go`, `…/importer_test.go`
+
+**Interfaces:**
+- Consumes: trimmed `legacyimport.Store`, `legacyimport.Run`, `legacyimport.HasLegacyData`, `legacyimport.DefaultLegacyRootDir`, `legacyimport.Report`, `legacyimport.Options{Root}`; `domain.ProjectRecord`.
+- Produces: `importer.Status{Available bool; LegacyRoot string}`, `importer.Service` (`Status(ctx)`, `Run(ctx)`), `importer.Deps{Store, Root}`, `importer.New(Deps) *Manager`. **Controller (Task 3) and daemon (Task 5) depend on these exact names.**
+
+- [ ] **Step 1: Write `importer.go`** (projects-only: no `DataDir`):
+
+```go
+// Package importer is the controller-facing service for the legacy-AO import.
+// It wraps the internal/legacyimport engine with the two operations the
+// dashboard needs: a detection probe ("is a legacy install available?") and a
+// trigger that runs the import through the live daemon's store, so the daemon
+// stays the sole writer. The engine is reused verbatim; this package adds no
+// import logic of its own, only the daemon-side detection and the store wiring.
+package importer
+
+import (
+ "context"
+
+ "github.com/aoagents/agent-orchestrator/backend/internal/domain"
+ "github.com/aoagents/agent-orchestrator/backend/internal/legacyimport"
+)
+
+// Store is the storage slice the import service needs: the legacy importer's
+// write surface plus a project listing for the "already imported" check.
+// *sqlite.Store satisfies it, so the daemon passes its single shared store and
+// the import runs through the same write path as every other mutation.
+type Store interface {
+ legacyimport.Store
+ ListProjects(ctx context.Context) ([]domain.ProjectRecord, error)
+}
+
+// Status reports whether a legacy AO install is available to import. Available
+// is true only when legacy data is present AND the rewrite database holds no
+// projects yet (the first-boot condition); a populated database is assumed
+// already imported (or started fresh on purpose), so the offer is not surfaced.
+type Status struct {
+ Available bool `json:"available"`
+ LegacyRoot string `json:"legacyRoot"`
+}
+
+// Service is the controller-facing import contract.
+type Service interface {
+ Status(ctx context.Context) (Status, error)
+ Run(ctx context.Context) (legacyimport.Report, error)
+}
+
+// Deps bundles the import service's dependencies.
+type Deps struct {
+ // Store is the rewrite's durable store (the daemon's shared *sqlite.Store).
+ Store Store
+ // Root overrides the legacy AO root to read. Empty -> the default.
+ Root string
+}
+
+// Manager implements Service over the daemon's store and config.
+type Manager struct {
+ store Store
+ root string
+}
+
+var _ Service = (*Manager)(nil)
+
+// New constructs the import service. An empty Root falls back to the default
+// legacy root so callers that don't override it get the standard location.
+func New(deps Deps) *Manager {
+ root := deps.Root
+ if root == "" {
+ root = legacyimport.DefaultLegacyRootDir()
+ }
+ return &Manager{store: deps.Store, root: root}
+}
+
+// Status reports import availability without touching legacy or rewrite data
+// beyond a project count. It never errors on a missing legacy store; that is
+// simply "not available".
+func (m *Manager) Status(ctx context.Context) (Status, error) {
+ st := Status{LegacyRoot: m.root}
+ if !legacyimport.HasLegacyData(m.root) {
+ return st, nil
+ }
+ projects, err := m.store.ListProjects(ctx)
+ if err != nil {
+ return Status{}, err
+ }
+ st.Available = len(projects) == 0
+ return st, nil
+}
+
+// Run executes the import through the daemon's store. It is idempotent: the
+// engine skips rows that already exist, so a re-run (or a run against a
+// partially-populated database) is safe and never overwrites. Legacy files are
+// never modified.
+func (m *Manager) Run(ctx context.Context) (legacyimport.Report, error) {
+ return legacyimport.Run(ctx, m.store, legacyimport.Options{Root: m.root})
+}
+```
+
+- [ ] **Step 2: Write `importer_test.go`** PR-verbatim: `fakeStore` implements only `GetProject`/`UpsertProject`/`ListProjects` (the trimmed `Store` needs nothing else); tests `TestStatus_NoLegacyData`, `TestStatus_LegacyPresentEmptyDB`, `TestStatus_AlreadyPopulated`, `TestStatus_ListError`, `TestRun_ImportsThenStatusFlipsUnavailable`, `TestNew_DefaultsRoot`.
+- [ ] **Step 3:** `cd backend && go test ./internal/service/importer/...` → expect PASS.
+- [ ] **Step 4:** Commit: `feat(importer): import service over daemon store (status + run)`
+
+---
+
+## Task 3: HTTP controller + DTOs
+
+**Files:** Create `backend/internal/httpd/controllers/imports.go`, `…/imports_test.go`; Modify `…/controllers/dto.go`
+
+**Interfaces:**
+- Consumes: `importsvc.Status`, `legacyimport.Report`, `apispec.NotImplemented`, `envelope.WriteJSON`/`WriteError`, `httpd.NewRouterWithControl(cfg, log, termMgr, APIDeps, ControlDeps)`.
+- Produces: `controllers.ImportService`, `controllers.ImportController{Svc}`, `controllers.ImportStatusResponse`, `controllers.ImportRunResponse`. **api.go (Task 5) and specgen (Task 4) depend on these.**
+
+- [ ] **Step 1:** Add to `dto.go` (add the `legacyimport` import):
+
+```go
+// ImportStatusResponse is the body of GET /api/v1/import: whether a legacy AO
+// install is available to import, and the root the daemon would read from.
+type ImportStatusResponse struct {
+ Available bool `json:"available"`
+ LegacyRoot string `json:"legacyRoot"`
+}
+
+// ImportRunResponse is the body of POST /api/v1/import: the structured outcome
+// of the import run (counts + notes), reused verbatim from the import engine.
+type ImportRunResponse struct {
+ Report legacyimport.Report `json:"report"`
+}
+```
+
+- [ ] **Step 2:** Create `imports.go` PR-verbatim (`ImportService` interface, `ImportController`, `Register`, `status`, `run`; nil `Svc` answers `apispec.NotImplemented`).
+- [ ] **Step 3:** Create `imports_test.go` PR-verbatim (`fakeImportService`, `newImportTestServer` using `httpd.NewRouterWithControl(config.Config{}, log, nil, httpd.APIDeps{Import: svc}, httpd.ControlDeps{})`, tests `TestImportAPI_Status/StatusError/Run/RunError`). The `doRequest` helper already exists in the `controllers_test` package.
+- [ ] **Step 4:** `go build ./...` fails until Task 5 wires `APIDeps.Import` (expected). Parse-check now: `gofmt -l internal/httpd/controllers/imports.go`. Test gate runs at end of Task 5.
+- [ ] **Step 5:** Commit: `feat(httpd): import controller + DTOs (GET/POST /api/v1/import)`
+
+---
+
+## Task 4: OpenAPI spec generation
+
+**Files:** Modify `backend/internal/httpd/apispec/specgen/build.go`; regenerate `openapi.yaml` + `frontend/src/api/schema.ts`
+
+- [ ] **Step 1:** In `build.go` add, PR-verbatim: the `import` tag (in `Tags`), the schemaName mappings (`ControllersImportStatusResponse`->`ImportStatusResponse`, `ControllersImportRunResponse`->`ImportRunResponse`, `LegacyimportReport`->`ImportReport`), the `importOperations()` func (2 ops), and `ops = append(ops, importOperations()...)`.
+- [ ] **Step 2:** Regenerate: `npm run api:spec` then `npm run api:ts`.
+- [ ] **Step 3:** Inspect the diff. The generated `ImportReport` schema must be the projects-only 4-field shape (`dryRun`, `projectsImported`, `projectsSkipped`, `notes`), matching the PR's `schema.ts` verbatim. If orchestrator fields appear, Task 1's `Report` trim was incomplete; fix it before continuing.
+- [ ] **Step 4:** `cd backend && go test ./internal/httpd/apispec/...` (route<->spec parity) → expect PASS.
+- [ ] **Step 5:** Commit: `feat(apispec): describe /api/v1/import; regenerate openapi + schema.ts`
+
+---
+
+## Task 5: Wire controller into API + daemon
+
+**Files:** Modify `backend/internal/httpd/api.go`, `backend/internal/daemon/daemon.go`
+
+- [ ] **Step 1:** `api.go` edits:
+ - `APIDeps`: add `Import controllers.ImportService` (after `NotificationStream`).
+ - `API` struct: add `imports *controllers.ImportController` (after `notifications`).
+ - `NewAPI`: add `imports: &controllers.ImportController{Svc: deps.Import},` (after the `notifications:` line).
+ - `Register` timeout group: add `a.imports.Register(r)` (after `a.notifications.Register(r)`).
+- [ ] **Step 2:** `daemon.go` edits:
+ - Add import: `importsvc "github.com/aoagents/agent-orchestrator/backend/internal/service/importer"`.
+ - In the `httpd.APIDeps{...}` literal, add: `Import: importsvc.New(importsvc.Deps{Store: store}),` (projects-only: **no** `DataDir`).
+- [ ] **Step 3:** `cd backend && go build ./... && go test ./internal/httpd/... ./internal/service/importer/...` → expect PASS (gate for Task 3's tests too).
+- [ ] **Step 4:** Commit: `feat(daemon): mount import service on the API`
+
+---
+
+## Task 6: Make `ao start` headless
+
+**Files:** Modify `backend/internal/cli/start.go`
+
+- [ ] **Step 1:** Delete the `maybeFirstBootImport` method and its call. Replace the call + the comment above it with:
+
+```go
+ // `ao start` is headless: it only launches the daemon. Detecting a legacy AO
+ // install and offering to import it is the dashboard's job (it polls
+ // GET /api/v1/import and POSTs to run the import through the live daemon).
+ // `ao import` remains for explicit offline imports.
+```
+
+- [ ] **Step 2:** Remove now-unused imports `…/internal/legacyimport` and `…/internal/storage/sqlite`. Keep `config`. Leave `cli/import.go`, `confirm`, `stdinIsInteractive`, `writeImportSummary` untouched.
+- [ ] **Step 3:** `cd backend && go build ./... && go test ./internal/cli/...` → expect PASS.
+- [ ] **Step 4:** Commit: `refactor(cli): ao start no longer prompts; import moves to the dashboard`
+
+---
+
+## Task 7: External daemon discovery (Electron main)
+
+**Files:** Modify `frontend/src/shared/daemon-discovery.ts`, `…/daemon-discovery.test.ts`, `frontend/src/main.ts`
+
+**Interfaces:** Produces `shouldAdoptDiscoveredPort(current: DaemonStatus, discoveredPort: number): boolean`.
+
+- [ ] **Step 1 (TDD):** Add the `shouldAdoptDiscoveredPort` describe block to `daemon-discovery.test.ts` (PR-verbatim). Run `npm --prefix frontend run test -- daemon-discovery` → expect FAIL (not exported).
+- [ ] **Step 2:** Add `shouldAdoptDiscoveredPort` + the `DaemonStatus` type import to `daemon-discovery.ts` (PR-verbatim). Re-run → expect PASS.
+- [ ] **Step 3:** In `main.ts`: import `shouldAdoptDiscoveredPort`, add `EXTERNAL_DISCOVERY_POLL_MS = 1_000`, `externalDiscoveryTimer`, `discoverExternalDaemonOnce()`, `startExternalDaemonDiscovery()` (PR-verbatim); call `startExternalDaemonDiscovery()` in `app.whenReady().then(...)` and clear the timer in `before-quit`. Skip the prettier-only reflows the PR carries.
+- [ ] **Step 4:** `npm --prefix frontend run typecheck` → expect PASS.
+- [ ] **Step 5:** Commit: `feat(electron): discover an externally-started daemon from running.json`
+
+---
+
+## Task 8: Import status hooks
+
+**Files:** Create `frontend/src/renderer/hooks/useImportStatus.ts`
+
+**Interfaces:** Consumes `apiClient`/`apiErrorMessage` (`renderer/lib/api-client`), `workspaceQueryKey` (`renderer/hooks/useWorkspaceQuery`, value `["workspaces"]`). Produces `useImportStatus()`, `useRunImport()`, `importStatusQueryKey`, types `ImportStatus`/`ImportReport`.
+
+- [ ] **Step 1:** Create the file PR-verbatim (`ImportReport` TS type = `{projectsImported, projectsSkipped, notes?}`). `useImportStatus` polls every 30s with `throwOnError: false`; `useRunImport` invalidates `importStatusQueryKey` + `workspaceQueryKey` on success.
+- [ ] **Step 2:** `npm --prefix frontend run typecheck` → expect PASS.
+- [ ] **Step 3:** Commit: `feat(renderer): useImportStatus / useRunImport hooks`
+
+---
+
+## Task 9: ImportOffer banner
+
+**Files:** Create `frontend/src/renderer/components/ImportOffer.tsx`, `…/ImportOffer.test.tsx`
+
+- [ ] **Step 1 (TDD):** Create `ImportOffer.test.tsx` PR-verbatim (mocks `../lib/api-client`; asserts offer-shown/hidden/accept/decline/error). Run `npm --prefix frontend run test -- ImportOffer` → expect FAIL (component missing).
+- [ ] **Step 2:** Create `ImportOffer.tsx` PR-verbatim. Heading "Import projects from your earlier AO?"; body copy "Importing brings in your projects. Your old files are never modified, and you can do this later instead." (projects-only, no orchestrator mention). Built from `ui/button` (`primary`/`ghost`, size `sm`). Re-run → expect PASS.
+- [ ] **Step 3:** Commit: `feat(renderer): ImportOffer dashboard banner`
+
+---
+
+## Task 10: Render the banner on the board
+
+**Files:** Modify `frontend/src/renderer/components/SessionsBoard.tsx`
+
+- [ ] **Step 1:** Add `import { ImportOffer } from "./ImportOffer";` and, directly under the `` line, insert:
+
+```tsx
+ {/* First-run legacy-AO import opt-in. Renders only when the daemon
+ reports an importable install, and only on the top-level board. */}
+ {!projectId && }
+```
+
+- [ ] **Step 2:** `npm --prefix frontend run typecheck && npm --prefix frontend run test` → expect PASS.
+- [ ] **Step 3:** Commit: `feat(renderer): surface ImportOffer on the dashboard board`
+
+---
+
+## Task 11: Full verification
+
+- [ ] `cd backend && go build ./... && go test -race ./...` → green.
+- [ ] `golangci-lint run` on touched packages → clean.
+- [ ] `npm --prefix frontend run typecheck && npm --prefix frontend run test` → green.
+- [ ] Confirm `git status` shows **no** uncommitted drift in `openapi.yaml`/`schema.ts` after a fresh `npm run api:spec && npm run api:ts`.
+- [ ] **Full build** (per the build-verification rule; rollup tree-shaking can hide missing emits): run the frontend production build.
+- [ ] `ao preview` the dashboard against a daemon pointed at a seeded `~/.agent-orchestrator` legacy root with an empty rewrite DB; verify the banner appears, Import imports the projects + retires the banner, Not now dismisses.
+
+---
+
+## Self-Review
+
+**Spec coverage:** ✅ engine simplified to projects-only (T1); `service/importer` status+run (T2); `GET`/`POST /api/v1/import` + 501 (T3); OpenAPI + schema.ts regen (T4); api/daemon wiring (T5); headless `ao start` (T6); external daemon discovery (T7); `useImportStatus`/`useRunImport` (T8); `ImportOffer` + board render (T9/T10). Matches PR #320 1:1.
+
+**Placeholder scan:** none — every new file has full source or is "PR-verbatim" for an existing PR file; every edit names the symbol and location.
+
+**Type consistency:** `importer.Deps{Store, Root}` (T2) ↔ daemon `importsvc.New(importsvc.Deps{Store: store})` (T5) ✅ (no `DataDir` anywhere). `legacyimport.Report` 4-field shape (T1) ↔ `ImportRunResponse` (T3) ↔ generated `ImportReport` (T4) ↔ `ImportReport` TS type (T8) ✅. `controllers.ImportService` (T3) ↔ `APIDeps.Import` (T5) ✅. `importStatusQueryKey`/`workspaceQueryKey` (T8) ↔ `ImportOffer` (T9) ✅. `Status{Available, LegacyRoot}` consistent across service/controller/DTO/schema ✅.
+
+**Cross-task coupling to watch:** (a) Task 3's controller test only goes green after Task 5 wires `APIDeps.Import` (gate = end of Task 5); (b) Task 4 must run after `dto.go` (Task 3) exists so the reflector sees the new types; (c) Task 4 Step 3 is the guard that Task 1's `Report` trim actually took.
diff --git a/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md b/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md
new file mode 100644
index 000000000..bff6c8ba8
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-26-legacy-migration-popup-design.md
@@ -0,0 +1,260 @@
+# Dashboard Legacy-Migration Popup + `app-state.json` Marker: Design
+
+> **Status:** ready for plan. Grounded against `upstream/main` @ `514946fd8`
+> (`feat(cli): ao start fetches + opens the desktop app … (#2201)`) on 2026-06-26.
+> Every "current state" claim carries a `file:line` reference.
+>
+> Builds on the marker concept from the `ao start` bootstrapper spec
+> (`docs/ao-start-bootstrapper-and-npm-deprecation.md`, §5). Deferred Settings
+> work is tracked in AgentWrapper/agent-orchestrator#2205.
+
+---
+
+## 0. Goal
+
+`ao start` no longer runs the legacy import (it now fetches+opens the desktop
+app; the daemon-spawn path and `maybeFirstBootImport` are gone). The spec's §6.4
+left an open decision: where does the legacy first-boot import go? This design
+answers it: **the desktop app offers the import on launch via a popup, gated on a
+persisted `migration` marker in `~/.ao/app-state.json`.** If the user hasn't
+migrated (and legacy data exists), the app prompts. The import runs through the
+app-owned daemon and is idempotent; legacy files are never modified, so a failed
+or declined import loses nothing.
+
+---
+
+## 1. Ground truth (what the code is today)
+
+| Fact | Value | Source |
+| --- | --- | --- |
+| Marker file | `~/.ao/app-state.json`, **app is sole writer**, written every launch | `frontend/src/main/app-state.ts`; spec §5, invariant 3 |
+| Marker fields today | `schemaVersion, appPath, version, installedAt, lastReconciledAt, installSource` | `frontend/src/main/app-state.ts` `AppStateMarker` |
+| Marker write call site | `app.whenReady()` before `createWindow()` | `frontend/src/main.ts:859` (inside `whenReady`, `:868`) |
+| Go reader of marker | read-only, ignores unknown JSON fields, does NOT gate on `schemaVersion` | `backend/internal/cli/start.go` `appState` struct (~`:38`) |
+| Import engine | `internal/legacyimport` (projects + per-project settings + orchestrator + transcripts) + `ao import` CLI | present from #314 |
+| Import **daemon API** | **does not exist on main** (no `service/importer`, no `httpd/controllers/imports.go`) | verified |
+| Daemon ownership | the app spawns + owns the daemon | `frontend/src/main.ts` `startDaemon` |
+| HTTP client (renderer) | `apiClient` over the daemon loopback API | `frontend/src/renderer/lib/api-client.ts:81` |
+| IPC bridge | `contextBridge.exposeInMainWorld("ao", api)`; renderer calls `window.ao..*` | `frontend/src/preload.ts:73` |
+| IPC handler pattern | `ipcMain.handle(":", …)` in main | `frontend/src/main.ts:773-796` |
+| Dashboard route | `_shell.index.tsx` (the board) | `frontend/src/renderer/routes/` |
+| **Global Settings page** | **none** — only per-project `_shell.projects.$projectId_.settings.tsx` | verified |
+
+---
+
+## 2. Decisions locked
+
+1. **Approach A:** daemon import API (detect + run) + app-side marker. The
+ renderer uses `apiClient`; the app stores only the decision in
+ `app-state.json`. (User-approved.)
+2. **App is the sole writer of `app-state.json`** (invariant 3); the **daemon is
+ the sole writer of the DB**. The import runs through the daemon; the app
+ records the marker.
+3. **Import scope = projects + per-project settings only** (no orchestrator
+ sessions, no transcripts). The daemon API is the projects-only engine from the
+ import-offer plan (`docs/plans/2026-06-26-import-offer.md`).
+4. **Popup actions:** **Proceed** (run), **Skip** (re-prompt next launch),
+ **Don't Migrate** (red; permanently declines). Small print points to a future
+ Settings redo path.
+5. **Settings "Migration" section is deferred** to AgentWrapper/agent-orchestrator#2205.
+ v1 ships the popup only. Until #2205 lands, `declined` is effectively
+ permanent, so v1 copy must NOT promise a working Settings path (see §6).
+
+---
+
+## 3. Scope
+
+**In scope:**
+- Backend: the projects-only import daemon API: `GET /api/v1/import` (availability)
+ and `POST /api/v1/import` (run), plus `service/importer`, DTOs, OpenAPI regen.
+ (= the import-offer plan, with the status-semantics tweak in §5.2.)
+- App marker: add a `migration` block to `app-state.json` (`schemaVersion` → 2),
+ preserved across launches; an IPC getter/setter.
+- Renderer: a launch-time `MigrationPopup` gated on (marker not terminal) AND
+ (daemon reports legacy data available); the three-action UX; failure
+ reassurance.
+
+**Out of scope (deferred / separate):**
+- Global Settings page + the Migration section / redo entry point → #2205.
+- The engine simplification details themselves live in the import-offer plan; this
+ design consumes that API, it does not re-specify the engine internals.
+- Track B (signing/auto-update) and anything in the bootstrapper spec's out-of-scope.
+
+---
+
+## 4. Invariants (load-bearing)
+
+1. **Filesystem/DB is the source of truth; the marker is a hint.** A `completed`
+ marker is never trusted to mean the rows exist; re-running is always safe
+ (idempotent engine). (Mirrors bootstrapper invariant 2.)
+2. **App is the sole writer of `app-state.json`.** The renderer never writes it
+ directly; it goes through the main process over IPC.
+3. **The import never deletes or modifies legacy files.** This is what makes a
+ failed/declined import lossless, and it is the promise the failure copy makes.
+4. **The import is idempotent.** Existing rows are skipped, so Skip-then-Proceed,
+ double-clicks, and Settings re-runs never duplicate or clobber.
+
+---
+
+## 5. Backend: the import daemon API
+
+### 5.1 Surface (from the import-offer plan)
+- `GET /api/v1/import` → `{ available: bool, legacyRoot: string }`.
+- `POST /api/v1/import` → `{ report: { dryRun, projectsImported, projectsSkipped, notes? } }`.
+- `internal/service/importer` wraps `legacyimport` (projects-only); wired into
+ `daemon.go` as `Import: importsvc.New(importsvc.Deps{Store: store})`.
+- A nil service answers OpenAPI-backed `501`. OpenAPI + `schema.ts` regenerated.
+
+### 5.2 Status semantics (the one change vs the import-offer plan)
+The import-offer plan computed `available = HasLegacyData(root) && len(projects)==0`
+(the empty-DB heuristic). **Here the marker governs whether to prompt**, so the DB
+heuristic is redundant and can be wrong (a user who added projects but never
+imported legacy still has legacy data). Define:
+
+```
+available = legacyimport.HasLegacyData(root)
+```
+
+The "already decided / don't nag" logic moves entirely to the app marker;
+idempotency keeps a re-run safe regardless of DB contents.
+
+---
+
+## 6. App marker: `migration` block in `app-state.json`
+
+Bump `SCHEMA_VERSION` to `2` and extend `AppStateMarker`
+(`frontend/src/main/app-state.ts`):
+
+```ts
+export type MigrationStatus = "pending" | "completed" | "declined" | "failed";
+
+export interface MigrationState {
+ status: MigrationStatus; // absent file => treated as "pending"
+ lastAttemptAt?: string; // last Proceed attempt (success or failure)
+ completedAt?: string; // set when status -> completed
+ report?: { projectsImported: number; projectsSkipped: number };
+ error?: string; // last failure message (status === "failed")
+}
+
+export interface AppStateMarker {
+ schemaVersion: number;
+ appPath: string;
+ version: string;
+ installedAt: string;
+ lastReconciledAt: string;
+ installSource: string;
+ migration?: MigrationState; // new; preserved across launches
+}
+```
+
+- **Preservation:** the launch-time `writeAppStateMarker` must carry an existing
+ `migration` block through untouched (the same way it preserves `installedAt` /
+ `installSource`). It never sets `migration` itself.
+- **Setter:** a new `updateMigration(stateDir, partial, now)` does an atomic
+ read-modify-write (temp + rename, same as `writeAppStateMarker`) so the marker
+ is updated without clobbering the launch-written fields.
+- **Go reader:** unchanged. `start.go`'s `appState` ignores the unknown
+ `migration` field and does not gate on `schemaVersion`, so bumping to 2 is safe
+ (verified). Adding a mirrored `Migration` field there is optional and not needed
+ for v1.
+
+Terminal states (never auto-prompt): `completed`, `declined`. Prompting states:
+`pending`, `failed`.
+
+---
+
+## 7. Renderer: detection, gate, popup
+
+### 7.1 IPC additions
+- main: `ipcMain.handle("appState:getMigration", …)` → returns `MigrationState`
+ (or `{status:"pending"}` when absent); `ipcMain.handle("appState:setMigration",
+ (_e, partial) => updateMigration(...))`.
+- preload: extend the `ao` bridge with
+ `appState: { getMigration, setMigration }`.
+
+### 7.2 Gate (where the popup decides to show)
+A `useMigrationOffer()` hook, consumed on the dashboard (`_shell.index.tsx`):
+1. read `window.ao.appState.getMigration()`,
+2. if `status ∈ {completed, declined}` → render nothing,
+3. else `GET /api/v1/import`; show the popup only when `available === true`
+ (a 501 / unreachable daemon resolves to "not available", never an error).
+
+`Skip` sets an in-memory dismissed flag for the session (no marker write) so the
+popup returns next launch. This matches "re-prompt until complete" for Skip while
+keeping `Don't Migrate` permanent.
+
+### 7.3 Popup actions / data flow
+
+| Action | Effect |
+| --- | --- |
+| **Proceed** | `POST /api/v1/import`. On success: `setMigration({status:"completed", completedAt, report})`, then invalidate the workspace query so projects appear. On failure: `setMigration({status:"failed", lastAttemptAt, error})` and show the reassurance inline. |
+| **Skip** | session-only dismiss; no marker write; re-prompts next launch. |
+| **Don't Migrate** (red) | `setMigration({status:"declined", lastAttemptAt})`; never auto-prompts again. |
+
+### 7.4 Copy
+- Heading: "Import projects from your earlier AO?"
+- Body: names the legacy root; "Importing brings in your projects. Your old files
+ are never modified, and you can do this later." (projects-only; **no orchestrator
+ mention**.)
+- Failure: "Migration failed: ``. Your legacy projects are untouched
+ (nothing is ever deleted). You can retry." with a Retry that re-POSTs.
+- Small print: a neutral "You can run this again later." **v1 must not promise a
+ Settings location** (Settings is deferred to #2205); the Settings wording lands
+ with that issue.
+
+---
+
+## 8. Components / files
+
+**Backend** (per the import-offer plan, projects-only; status tweak §5.2):
+`internal/service/importer/*`, `internal/httpd/controllers/imports.go`,
+`dto.go`, `apispec/specgen/build.go`, `api.go`, `daemon.go`, regenerated
+`openapi.yaml` + `frontend/src/api/schema.ts`.
+
+**App / renderer (new in this design):**
+- `frontend/src/main/app-state.ts` — `MigrationState`, schema v2, preserve +
+ `updateMigration`.
+- `frontend/src/main.ts` — `appState:getMigration` / `appState:setMigration`
+ handlers.
+- `frontend/src/preload.ts` — `ao.appState` bridge methods.
+- `frontend/src/renderer/hooks/useMigrationOffer.ts` — gate (IPC marker + daemon
+ GET).
+- `frontend/src/renderer/components/MigrationPopup.tsx` — the three-action dialog
+ (built from `components/ui/*`).
+- `frontend/src/renderer/routes/_shell.index.tsx` — mount the popup on the board.
+
+---
+
+## 9. Error handling
+
+- Daemon unreachable / 501 on `GET` → "not available" → no popup (never blocks).
+- `POST` failure → `failed` marker + lossless-reassurance copy + Retry.
+- Corrupt/missing marker → treated as `pending` (self-healing, like the existing
+ reader).
+- Atomic temp+rename for every marker write → a concurrent `ao start` reader never
+ sees a partial file.
+
+---
+
+## 10. Testing
+
+- **`app-state.ts`:** v2 round-trip; launch write preserves an existing
+ `migration` block; `updateMigration` merges without clobbering `appPath`/etc.;
+ corrupt file → `pending`.
+- **Backend:** import service status (`available` = `HasLegacyData` only) + run +
+ idempotency; controller GET/POST + 501; route↔spec parity. (From the plan.)
+- **`useMigrationOffer` / `MigrationPopup`:** popup shows only when not-terminal +
+ available; Proceed success → completed + workspace invalidated + popup retires;
+ Proceed failure → reassurance + Retry; Skip → no marker write; Don't Migrate →
+ `declined` and stays hidden.
+
+---
+
+## 11. Open decisions
+
+1. **Settings redo path** → deferred to #2205 (locked: not in v1).
+2. **Mirror `migration` into the Go `appState` struct?** Not needed for v1; add
+ only if a CLI surface ever needs to read migration status.
+3. **Where exactly to mount the popup** — board (`_shell.index.tsx`) vs the shell
+ layout (`_shell.tsx`) so it shows on any route. Default: board, since that is
+ the first-run landing surface; revisit if it should be shell-wide.