# Refactor survey: features g–p (helper_worktrees through put_off_work)

The 19 folders hold about 12,000 lines and every file in them was read. They have no failing contracts, but there are about 20 places where the same logic is written two to six times. The worst cases are plan membership (written five times), the plugin install pipeline (twice), button pressing (lives in phone instead of message_buttons), "send text to the agent running in an environment" (three features) and the plugin manifest key checks (eight copies). Two god files stand out: `phone/controller.py` (768 lines) and `plugins/source.py` (six jobs). The plan is 9 batches plus an optional tail.

I read CLAUDE.md, AGENTS.md and the shared framework (`features/base.py`, `parts.py`, `journal.py`, `nudges.py`, `recital.py`, `shaping.py`, `format.py`, `command_line.py`, `__init__.py`). Line numbers are as of today.

## Files looked at

Every `.py` file in each folder:
- **helper_worktrees**: feature, interceptors, resource, details, controller, test
- **helpers**: feature, handlers, details, resource, controller, test
- **history_searches**: `__init__`, feature, details, handlers, test
- **hosting**: feature, files, handlers, details, commands, apps, test
- **journal_laws**: resource, feature, controller, handlers, interceptors, details, policy, test
- **kanban**: feature, commands, details, lanes, shifts, board, test
- **long_commands**: feature, details, handlers, test
- **memory_checkpoints**: commands, reread, feature, handlers, details, test
- **message_buttons**: feature, handlers, details, shaping, test
- **messages**: feature, formatters, answering, details, handlers, test
- **open_viewer**: `__init__`, feature, handlers, details, test
- **organization**: feature, handlers, details, agents, commands, delegation, test
- **permission_prompts**: handlers, details, feature, test
- **phone**: `__init__`, details, export, feature, resource, places, push, routes, controller, test
- **pinned_links**: details, feature, handlers, test
- **plans**: feature, interceptors, worker, resource, progress, details, handlers, controller, test
- **plugins**: feature, payload, queue, skills, lifecycle, dashboard, run, details, services, answer, commands, parts, host, declared, source, manifest, test
- **pull_requests**: details, feature, handlers, test
- **put_off_work**: details, feature, handlers, test

Test files were read by test name only, to check the 10-test cap (all are within it) and to spot tests that belong to another feature.

---

## Findings by feature

"No change" means no behaviour change.

### plans (most of the duplicated logic in this range)

- **PL1 – "Which phase holds row n" is written five times.**
  - Sites: `plans/controller.py:107`, `plans/handlers.py:89-90`, `plans/handlers.py:112`, `plans/progress.py:24`, `kanban/lanes.py:36-38`. Also `current_phase` at `progress.py:14-17`.
  - The todo/ticket-to-phase-field map is written twice (`handlers.py:107`, `controller.py:96/99`).
  - Refactor: add methods on the `Plan` resource (`plans/resource.py`): `phase_of(kind, n) -> int`, a `current_phase` property, and `placement(todo) -> Placement | None`. Add one `PHASE_FIELDS = {"todo": PHASE.todos, "ticket": PHASE.tickets}`.
  - Low risk, no change.
- **PL2 – kanban re-implements `held`.** `kanban/lanes.py:34-41` (`Sources.placement`) is the same walk as `plans/progress.py:20-32` (`held`): skip ended plans, find the phase, ask whether the current phase holds the row. Both should call `Plan.placement`. `held` keeps only its extra rule (a row outside an active plan, below critical). Low risk, no change.
- **PL3 – Phase members are listed three ways.** `resource.py:9-10` (`rows_of`, which hardcodes tickets), `progress.py:50-53` (`phase_rows`, through the `PHASE_ROWS` registry) and `progress.py:56-59` (`phase_complete`). Merge into one `members(record, phase)` and derive `phase_complete` from it. Low risk, no change.
- **PL4 – "Active plans" is filtered four times.** `handlers.py:125`, `handlers.py:146`, `kanban/board.py:113`, `progress.py:32`. Add `Plans._active()` beside `progress.running`. Low risk, no change.
- **PL5 – Dead branch.** `controller.py:205-211` (`_phase`) checks the range and then wraps the same index in a `try/except IndexError` that can never fire. Delete the try. No change.
- **PL6 – Wrong event type.** `handlers.py:81-84` listens to `AnyEvent` and filters `("todo","completed")` by hand. `engine.events.resources.TodoCompleted` already exists, so type the handler on it. No change.
- **PL7 – "Speak to the primary agent" written four times here.** `handlers.py:28/36/47/60` each do `agents.primary()` → `context.speaking_to(agent)`. The same shape appears in `nudges.py:55-58` and in checks, rules, source_links, boards, dumps and templates.
  - Refactor: add `Context.to_primary() -> AgentContext | None` in `features/parts.py` (batch B0) and use it here.
  - No change.
- **PL8 – Ticket orchestration lives in plans.**
  - `plans/worker.py` (whole file) and `controller.py:145-148` start a "worker agent" whose kickoff is entirely about board tickets, and which only starts when phases hold tickets. Refactor: move `start_worker`/`kickoff` into tickets and call them through a `PLAN_STARTS` registry next to `PHASE_STARTS`.
  - `tickets/feature.py:38-41` fills `PHASE_ROWS`/`PHASE_STARTS` by plain assignment, so `features.unload()` never clears them and they ignore the tickets switch. Refactor: use `register_always`/`register_global`.
  - Medium risk. Behaviour changes only when tickets is switched off: plans then stop starting ticket work.
- **PL9 – Layering.** `progress.py` holds Plans operations that take a `record` (`step`, `catch_up`, `phase_rows`). The controller imports them inside functions (`controller.py:145,167`) to break a cycle. After PL3/PL8, move them onto `Plans` as `_step`, `_catch_up` and `_members` ("controllers own their type's operations"). Medium risk, no change.
- **PL10 – Environment owner string parsed by hand.** `controller.py:217-222` reads `place.owner.startswith("ticket:")` and `.partition(":")`. The same parse is in `helpers/controller.py:88`, `tickets/controller.py:459/548/559` and `family_tree/tree.py:78`. Refactor: add `Environment.owned_by(kind) -> int` in `resources/types.py` next to `helping` (502), in batch B0. No change.

### kanban

- **K1** – Duplicates `held`: see PL2.
- **K2 – Wrong layer.** `board.py:14-65` (`Card`, `BoardLanes`) and `lanes.py:10-16` (`Lane`) are the shared board view used by `tickets/controller.py:24-25,145,188`. `Card` carries ticket-only fields (`actions`, `link`, `link_label`, `state`, `session`, `repositories`, `type`; `board.py:29-35`). Refactor: move these to `surfaces/board.py` and have both features import from there. Low risk, no change.
- **K3 – Refs parsed by hand.** `shifts.py:31-32,45`, `lanes.py:69` and `board.py:95` split and respeak `"todo:3"` by hand. The same happens in `messages/handlers.py:108` and `phone/controller.py:458,573,700`. Refactor: add a `Ref` value object in `resources/base.py` with `.parse(ref)` and `.spoken` (batch B0). No change.
- **K4 – Possible rule conflict.** kanban only adds commands, yet has a 9-test `test.py`; CLAUDE.md says such a feature has no test file. This is for the owner to decide, not a refactor.

### phone

- **P1 – God controller.**
  - `controller.py:297-765` mixes two things: operations on the Phone row itself (`connect`, `_pair`, `_missed`, `_active`, `_by_key`, `_live`, `_subscribe`, `_notify`, `_arrange`, `_switch`, `_start`), and about 30 operations on other types (feed, read, press, say, react, comment, answer, dismiss, approve, continue, close, attach, share, export, file, source, list, permit, pause, resume, stop, helpers). The second group all start with `self._home(phone)`, which is called 25 times.
  - Keep the Phone-row operations on `Phones`.
  - Move the actions to `phone/surface.py` as `PhoneSurface(home, phone)`.
  - Move the view-model (`_feed`, `_marks`, `Session`, `_said`, `_latest`, `_faces`, `_notices`, `_plan`, `_running`, `_helpers_of`, `_subagents_of`) to `phone/feed.py`.
  - `routes.py` then calls the surface.
  - Medium risk (large move; `phone/test.py` covers the routes), no change.
- **P2 – Button pressing lives in phone.** `controller.py:457-477` (`_press`: find the unspent button, run its say or action, record it as pressed) is message_buttons' operation. `controller.py:556-563` (`_owed`) uses `spent`. Refactor: add `message_buttons/pressing.py` with `press(record, row, label, actor, via)` and `unspent(row)`. Low-medium risk, no change.
- **P3 – "Waits on the user" defined twice, differently.**
  - `places.py:28-31` (`owed`) and `controller.py:556-563` (`_owed`) disagree: only the controller counts unspent buttons. Their kind lists are also duplicated (`places.py:16` WAITED = `controller.py:61` WAITING).
  - Refactor: one `owed(row)`.
  - **Behaviour change:** per-environment `waiting` counts rise for read rows that still have live buttons.
- **P4 – Status literal.** `"ready"` appears at `places.py:30` and `controller.py:560,750`. Use plans' `READY`. No change.
- **P5 – CLAUDE.md "controllers by reference" broken.**
  - `CONTROLLERS["agent"|"reaction"|"notice"|"share"|"question"|"plan"]` at lines 510, 519, 684, 687, 697, 723, 742, 748, 755. Use the classes from `controllers.types`.
  - `FEATURES["ask_questions"].values(home)["hold"]` is repeated at 507 and 543. Replace with one helper using `AskQuestionsDetails.values(home).hold`.
  - No change.
- **P6 – Same validation three times.** "Parse ref → readable → load → reaches" in `_press` (458-465), `_file` (573-580) and `_reached` (700-706). Make the first two call `_reached`. No change.
- **P7 – Behaviour switch read and written behind the controller's back.**
  - `controller.py:398-403` writes `settings["features"]["work_tracking.auto"]` directly, and 588 reads it raw. `work_tracking/auto.py:16` (`automatic`) is the existing read funnel.
  - Refactor: add `Feature.choose(record, key, on)` in `features/base.py` (B0, the counterpart of `chosen`) and use it for the write.
  - Small **behaviour change:** the displayed "auto" value becomes false when work_tracking itself is off.
- **P8 – "Session holder or refuse" repeated** at `_permit` (411-413) and `_holder` (658-660). Unify. No change.
- **P9 – Hand-written payload parsing.** `routes.py:33-189` has 14 `from_payload` dataclasses. Subclass `engine.fields.Loaded` and use `from_json`, as `Button` and `Hosting` already do. Keep `Switching` explicit because it tests `is True`. Low risk, no change.
- **P10 – Same error mapping five times.** `routes.py:330-361` repeats `try/except Refused → 422` and the 201 JSON reply. Replace with one action table and one error mapping, merged with `acts` (362-383). No change.
- **P11 – Body parsing duplicated across features.** `routes.py:388-390,426-435` duplicates `features/sharing/server.py:158-163`. Add one `json_body()` on the sharing handler. This touches sharing, which is outside this range.
- **P12 – Logic in the feature file.** `feature.py:12-23` (`phones_live`, `phones_told`, with local imports) should move to the controller; `feature.py` should only wire. No change.
- **P13 – Helper status lives in phone.** `controller.py:74-133` (`HelperSnapshot`, `helper_state`, `helper_reason`, `asked_permission`) is the helpers feature's domain. Move to `features/helpers/state.py`. No change.

### plugins

- **PG1 – Install pipeline written twice.**
  - `commands.py:41-72` (Install) and `75-108` (Upgrade) repeat ports → environment → checked → prepared → place → welcomed → restarted, with a `try/finally drop`.
  - `commands.py:18-26` (`welcomed`) and `125-132` (`allowed`) are logic sitting in the commands file.
  - Refactor: one `lifecycle.install_staged(...)`; `allowed` becomes `Setting.check(value)` on `declared.Setting`.
  - Medium risk, no change.
- **PG2 – "Plugin row by name" five times.** `answer.py:55`, `answer.py:81`, `commands.py:50`, `parts.py:142`, `parts.py:151-152`. Add `lifecycle.named(plugins, name)`. No change.
- **PG3 – Plugin settings rewritten three ways.** `lifecycle.py:68`, `answer.py:86-88`, `commands.py:146-147`. Add one `lifecycle.resettled(plugins, row, **changes)`. No change.
- **PG4 – Plugin placement/env built four ways.** `parts.py:70-72` (`placed`), `host.py:156,160` (same computation), `commands.py:58/88/98`, `services.py:76`. `host` should use `placed()`. No change.
- **PG5 – `ports.<service>` map built three times.** `source.py:80`, `services.py:77,84,87`, `host.py:180-182`. Add `port_values(ports)`. No change.
- **PG6 – Reply parsing twice.** `host.py:49-57` and `run.py:64-75`. Use `run.read` for both. Error wording changes slightly.
- **PG7 – Copy of a controller method.** `payload.py:24-27` re-implements `Agents._session_or_primary` (`controllers/agents.py:51-52`). Call the method. No change.
- **PG8 – Environment folders globbed by hand.**
  - `host.py:86-88` and `services.py:54-58` list environments themselves; `controllers.types.environment_records` already does this.
  - `services.plugins` picks the first folder, while `here()` in the same file (34-35) uses `default_env`. Use `here()`.
  - Low risk; the plugin row is project-scoped, so I expect no change. Check before relying on it.
- **PG9 – Two writers of `.git/info/exclude`.** `skills.py:42-50` writes it without a lock; `engine/worktree.py:320-334` (`excluded`) writes it with a lock. Generalise the engine writer and use it for both. Medium risk.
- **PG10 – Engine work duplicated.** `source.py:127-134` (`run`) copies `engine.proc.ran`. `run.py:78-88` (`stop`) copies `engine/keeper.py:115-125` (`teardown`). Move both to engine. Low risk.
- **PG11 – Wrong place.** `manifest.py:252-259` (`fill`) is a generic `{x}` filler used by hosting, services, host, commands, source and parts. Move it to `engine/wording.py`. No change.
- **PG12 – Unknown-key check copied eight times.**
  - `manifest.py:39-41,108-110,144-146,163-165,228-230,245-247` and `dashboard.py:57-59,77-79`. Add one `unknown_keys(given, known, where)`.
  - The `name` parameter is dead in `command()`, `texts()`, `shaped()` and `chat()`.
  - No change.
- **PG13 – `source.py` does six jobs.**
  - The jobs: paths (33-57, 72-73, 237-238), environment (76-124), setup (127-161), preview (164-200), staging (60-69, 213-234), lock and token (241-254).
  - Split into `paths.py`, `environment.py`, `setup.py`, `preview.py` and `staging.py`.
  - The remote-URL prefix tuple appears twice (65, 222); replace with `remote(address)`.
  - Callers to update: `commands/http.py:855,871`.
  - No change.
- **PG14 – Dead constant:** `source.py:24` `CHOSEN`.
- **PG15 – Log writing by hand.** `queue.py:69-72` duplicates `source.logged` (53-57). Use `logged`. **Behaviour change:** refused lines gain a timestamp in the log.
- **PG16 – Trivial wrappers and two thread conventions.**
  - `host.py:90-97`: `Host.installed`, `Host.name` and `Host.cursor` are one-line wrappers, and `cursor` ignores its `record` argument. Inline them.
  - `feature.py:30-32` starts a thread around `host.watch`, while `services.watch` starts its own. Pick one convention.
- **PG17 – Time budget formula twice.** `parts.py:89` and `parts.py:121`. Add `Manifest.refuse_budget`. No change.
- **PG18** – `run.py:52-61` (`PluginReply`) hand-parses JSON; use `Loaded`. Also move `lifecycle.called` (14-15) into `declared.py` beside `declared`, and update the import at `commands/http.py:50`.

### hosting

- **HO1 – Function named as a question writes rows.** `apps.py:58-65` (`idle()`) updates `idle_since` twice. Move the writes into `StopIdleApps.handle` (`handlers.py:12-19`) and keep a pure `idle_past(ticket, minutes, now)`. No change.
- **HO2 – `apps.py` does four jobs.**
  - Service specs (18-44), card extras (73-92) and the idle check (58-65) live next to the address queries.
  - Move service specs to `hosting/services.py` and card extras to `hosting/card.py`.
  - Keep `address`/`app_here` in `apps.py`, so `organization/commands.py:1` is untouched.
  - No change.
- **HO3 – Service spec building written three times.** `apps.py:37-43`, `plugins/services.py:79-89` and `plugins/source.py:83-92` each do allocate → `taken.add` → `ServiceSpec(**files_for)` with a `http://127.0.0.1:{port}` URL. Add one funnel in `engine/services.py`. No change.
- **HO4** – `apps.py:10` imports `fill` from the plugin manifest (see PG11).

### helpers / helper_worktrees

- **H1 – "Driver of the agent running in environment X" written three times.**
  - `helpers/controller.py:65-67`, `organization/agents.py:30-33`, `tickets/controller.py:230,241` all do `Sessions.holder` → `DRIVERS[p](Record(root, env), terminal_of(root, session))`.
  - "Stop that agent" is also repeated: `organization/agents.py:36-39`, `tickets/controller.py:510,562,638`.
  - Refactor: add `driver_in`, `tell_in` and `stop_in` in `features/agent_sessions/launch.py`. Tickets converts later.
  - No change.
- **H2** – `helpers/controller.py:88` parses the owner string (see PL10).
- **H3 – Look-up after create.** `helpers/controller.py:49-54` calls `Worktrees.cut()`, which returns a message string, and then reloads the row by title. Add `Worktrees._cut() -> row`; `cut` wraps it. No change.
- **HW1 – Hand-rolled plurals.** `helper_worktrees/controller.py:30,94` build `commit{'s' …}` by hand; use `engine.wording.plural`. No change.
- **HW2 – Interceptor asks four questions instead of telling.** `interceptors.py:17-25` calls `_touched`, `_has_new_tip`, `_drift` and `_told`. Replace with one `Worktrees._drift_to_tell(places, commands)` that also marks the row told. No change.
- **HW3** – `controller.py:33-34` (`listed`) is a git helper; move it to `engine/worktree.py`.

### organization

- **O1 – CLAUDE.md rule broken.** `handlers.py:13` uses `context.journal.of("todo")`, a string key. Use `context.journal.todos`. No change.
- **O2 – "Role of a todo" looked up twice.** `handlers.py:14` and `commands.py:54`. Add `role_of(record, todo)` in `delegation.py`. No change.
- **O3 – Label fallback repeated.** `role.title or role.name` / `domain.title or domain.name` at `agents.py:26`, `delegation.py:57`, `commands.py:37,42` and `tickets/controller.py:159,832`. Add a `label` property on `Role`/`Domain` in `engine/organization.py`. No change.
- **O4** – `agents.py:30-39` duplicates H1.
- **O5 – Environment folders globbed by hand.** `delegation.py:18-25` (`everywhere`) should use `environment_records`. No change.
- **O6 – Query with a side effect.** `delegation.py:38-43` (`next_in_line`) also unblocks rows. Split it into a query plus an unblock done by the handler. No change.
- **O7 – Interceptor registered too wide.** `ReportCoversOutputs` (`commands.py:46-58`) is registered on the generic `"update"` action (`feature.py:14`) and then filters `controller.type != "todo"`. `controllers/base.py:131` supports `"todo.update"`, so register on that and drop the check. No change.

### messages

- **M1 – "Written by the agent" tested by hand.** `answering.py:21,29,37`, `handlers.py:190` and `controllers/messages.py:28` all check `seen[:1] == [AGENT]`. Add a `Resource.author` property in `resources/base.py` (B0). No change.
- **M2 – `handlers.py` does six jobs.** Split into `saving.py` (33-40), `inbox.py` (22-74), `closing.py` (77-124), `linking.py` (117-136) and `prose.py` (139-196). Low risk, no change.
- **M3 – Etiquette checks in the wrong feature.**
  - `handlers.py:139-196` (`NameRunTogether`, `NameBareNumbers`) check the agent's prose. They are the same shape as `chat_etiquette/handlers.py` (`NameShopTalk`), so they belong there.
  - Settings keys `messages.paragraphs`/`messages.numbers` would move, which needs a migration.
  - Optional; medium-high risk.
- **M4 – Small repeats.** The count reset `trigger.write(..., count=0)` is repeated (57, 70); the unread filter is done by hand (54). Add `Messages._unread()`. Low risk.
- **M5 – Misplaced tests.** `test.py:121,135,183,188,219,251` test nudge routing, display hooks, reactions and event commands, not this feature.

### journal_laws

- **JL1 – Duplicates recital.**
  - `interceptors.py:22-41` re-implements `recital.py:56-87` (`WhisperOnKeyword`, `WhisperOnKeywordInChat`, `recite`) for laws instead of rows.
  - Refactor: give recital a "source" of recitable items, with rows and laws as two sources. Keep `register_recital` stable for rules, facts and reminders.
  - **Behaviour change** to confirm: law whispers would start appearing in the agent's `whispers` list.
- **JL2 – Same expression twice.** The briefing-file list is computed at `policy.py:120` and `policy.py:140`. Add `briefing_files()`. No change.
- **JL3 – `policy.py` does two jobs.**
  - Split into `laws.py` (law catalogue, `carry`, dispatch `refusal`) and `briefing.py` (`Markers`, `block`, `brief`, `leading`, `untouchable`, `instructions_hash`).
  - Move `handlers.py:27-42` (`long_briefings`, `read_on_the_way`) into `briefing.py`.
  - Update imports at `rules/handlers.py:5`, `rules/test.py:2` and `install.py:491`.
  - No change.
- **JL4 – Import cycle.** details → handlers → policy → details, which forces a function-local import at `policy.py:91`. Move the `LARGEST_RESULT`/`TOO_LONG` constants into `details.py`, as long_commands does. No change.
- **JL5 – Two jobs at feature level.** The Output resource and controller, `output_lines`, `RefuseWholeLongReads` and `NoticeLargestResult` are "read narrowly" enforcement, separate from shipping the laws. Splitting them into their own feature moves setting keys. Optional; high risk, low value.

### history_searches

- **HS1 – Second shell parser.** `handlers.py:7-38` (`said`, `asked`, `VALUED`) duplicates `status_bar/shell.py` (`pieces`, `verb_of`, `journal_words` at 101-109; `VALUED` at 9). Use those.
  - Medium risk: quoting differs (shlex strips quotes). Keep the existing test.

### permission_prompts

- **PP1 – "Set skip" written four times.** `feature.py:38`, `feature.py:46`, `agents/control.py:116`, `commands/queries.py:259`. It is also read raw at `feature.py:34` instead of through the Details values. Refactor: `skipped(record)` and `set_skipped(record, on)`. No change.
- **PP2 – Logic in the feature file.** `feature.py:22-47` should move to `permission_prompts/skipping.py`. Update the imports at `tickets/controller.py:15`, `agent_sessions/launch.py:5`, `commands/queries.py:250` and `tickets/test.py:118`.
- **PP3 – Two jobs in one function.** `launch_args` (41-47) both stores the typed flag and computes the launch args. Split. No change.
- **PP4 – Misplaced tests.** `test.py:115,137,149` test starting_agents, the record files and auto mode.

### long_commands, memory_checkpoints, open_viewer, the small three, and class names

- **LC1 – `handlers.py` does two jobs.** Moving a foreground command (20-61) and watching runs left in the background (65-101). Split into `move.py` and `watch.py`. No change.
- **LC2 – Background tasks read three ways.** "provider + transcript → background_tasks" at lines 36, 49-52 and 66-69. Add `background_tasks_of(agent)`. No change.
- **LC3 – Misplaced tests.** `test.py:22,57,144` test the engine clock, terminal typing and Codex message sending.
- **MC1 – Wrong layer.** `engine/record.py:65` stores `cleanup_read_at`, which is memory_checkpoints' state (read at `reread.py:14`, written at `commands.py:13`). Move it to feature state with a migration. **Behaviour change** if the migration is skipped: the reread becomes owed once.
- **OV1 – Trigger store used as a flag.** `open_viewer/handlers.py:12,17` keeps a one-time flag in the trigger store; `Context.once` is the existing funnel. Low risk; the viewer may open once more after the upgrade.
- **OV2 – Misplaced tests.** `open_viewer/test.py:69-161` holds four tests of the engine/viewer server's security, not this feature.
- **NM – Class names that don't match their feature.** `memory_checkpoints` `Context`/`ContextDetails` shadows `features.parts.Context`. Also `Deferral`, `Buttons`, `TabFocus`, `Permissions` and `Law`. Rename each to its folder's name. Nothing outside the folders imports them (checked). No change.
- **N1 – "Notices carrying key=value" filtered four times.** `pinned_links/handlers.py:13`, `pull_requests/handlers.py:18`, `permission_prompts/handlers.py:13`, `plugins/services.py:42`. Add `Notices._carrying(key, value)`. Low value.
- **No findings:** pinned_links, pull_requests and put_off_work have nothing beyond N1 and the rename.

---

## Batches

Each batch owns the files listed and touches no other batch's files. Most valuable first.

**B0 – Foundations (merge first; additive, low risk, no change).**
- Files: `resources/base.py` (`Resource.author`, `Ref`), `resources/types.py` (`Environment.owned_by`), `features/parts.py` (`Context.to_primary`), `features/base.py` (`Feature.choose`).
- B1, B2, B4 and B5 use these.

**B1 – Plans and kanban.**
- Covers: PL1–PL9, K1–K3, PL10 conversion.
- Files: `plans/*`, `kanban/*`, new `surfaces/board.py`, plus the import lines `tickets/controller.py:24-25` and `tickets/feature.py:38-41`.
- After B0. Medium risk overall; only PL8 changes behaviour.

**B2 – Plugins and hosting.**
- Covers: PG1–PG18, HO1–HO4.
- Files: `plugins/*`, `hosting/*`, `controllers/plugins.py`, `engine/wording.py`, `engine/services.py`, `engine/worktree.py`, `engine/proc.py`, `engine/keeper.py`, `commands/http.py` (import lines).
- Independent of B0. Medium risk; PG6 and PG15 change behaviour slightly.

**B3 – Phone and message_buttons.**
- Covers: P1–P13, the `Buttons` rename, MB press/unspent.
- Files: `phone/*`, `message_buttons/*`, new `helpers/state.py`, `features/sharing/server.py` (P11; coordinate with the q–z owner).
- After B0. Medium-high risk (largest move); P3 and P7 change behaviour.

**B4 – Agent environments.**
- Covers: H1–H3, HW1–HW3, O1–O7.
- Files: `helpers/*` except `state.py`, `helper_worktrees/*`, `organization/*`, `features/agent_sessions/launch.py`, `engine/organization.py`.
- After B0. Tickets adopts `tell_in`/`stop_in` in its own batch. Low-medium risk, no change.

**B5 – Messages.**
- Covers: M1, M2, M4, the M5 test moves.
- Files: `messages/*`, `controllers/messages.py`.
- After B0. Low risk.

**B6 – Laws and recital.**
- Covers: JL1–JL4, HS1, the `Law` rename.
- Files: `journal_laws/*`, `features/recital.py`, `history_searches/*`, and the import lines `rules/handlers.py:5`, `rules/test.py:2`, `install.py:491`.
- Medium risk; JL1 needs a decision first.

**B7 – Permission prompts.**
- Covers: PP1–PP4, the `Permissions` rename.
- Files: `permission_prompts/*`, `agents/control.py`, `commands/queries.py`, plus one import line each in `tickets/controller.py:15` and `agent_sessions/launch.py:5`.
- Run after B4, because both edit `launch.py`. Low risk.

**B8 – Housekeeping.**
- Covers: LC1–LC3, MC1, OV1–OV2, the remaining renames, N1.
- Files: `long_commands/*`, `memory_checkpoints/*`, `open_viewer/*`, `put_off_work/*`, `pinned_links/*`, `pull_requests/*`, `engine/record.py`, a new migration.
- Low-medium risk; MC1 needs the migration.

**Optional tail (higher risk, lower value; do last or drop):**
- M3: move the prose checks to chat_etiquette (settings migration).
- JL5: split the narrow-reads feature (settings migration).
- Message buttons: shape buttons before the write instead of the second update in `message_buttons/handlers.py:8-18`. One event instead of two, so behaviour changes.
- A `Record.project` funnel for the ~30 `record.root.parent` / `root.resolve().parent` sites. Resolved and unresolved paths differ, so this is a behaviour risk.
- K4: remove kanban's test file, if the owner agrees it breaks the test rule.

The misplaced tests (LC3, M5, OV2, PP4) have no obvious home. CLAUDE.md caps each feature at one 10-test file and `tests/` only holds the generated runs, so each move needs a target with room under the cap; `scripts/checks/test_shape.py` enforces that.

### Critical Files for Implementation
- /Users/jessegall/projects/agent-journal/src/features/phone/controller.py
- /Users/jessegall/projects/agent-journal/src/features/plugins/commands.py
- /Users/jessegall/projects/agent-journal/src/features/plans/progress.py
- /Users/jessegall/projects/agent-journal/src/features/plugins/source.py
- /Users/jessegall/projects/agent-journal/src/features/agent_sessions/launch.py