I've surveyed the whole of my part. Paths below are relative to `/Users/jessegall/projects/agent-journal/src/`.

The most important finding is a real bug. A cadence the user saves in `settings.triggers` for a feature behaviour is ignored by every nudge and by six call sites that read cadences directly; only `Feature.due` honours it. The rest are duplicated logic, dead code and logic in the wrong layer, grouped into 12 batches.

**Files I looked at**
- **Top-level, read in full:** `features/__init__.py`, `base.py`, `parts.py`, `journal.py`, `nudges.py`, `trigger.py`, `recital.py`, `renames.py`, `format.py`, `shaping.py`, `settings.py`, `command_line.py`.
- **Feature folders, every non-test `.py` read in full:** acknowledgements, agent_sessions (`feature`, `details`, `handlers`, `launch`, `history`), ask_questions (`feature`, `details`, `choices`, `interceptors`, `handlers`), attachment_descriptions (`feature`, `details`, `video`, `handlers`), auto_archive, auto_update (`feature`, `details`, `check`, `launch`), awaited, boards (`feature`, `details`, `resource`, `limits`, `orchestration`, `agent_types`, `handlers`, `controller`), branch_switches, browser_control, chat_etiquette, checks (`feature`, `details`, `resource`, `touched`, `handlers`, `controller`), clean_slate (`feature`, `details`, `slate`), close_from_commits, collections, command_tags (`feature`, `details`, `formatters`, `reading`, `handlers`), critique (`feature`, `details`, `resource`, `lenses`, `controller`), dev_faults (`feature`, `details`, `counting`, `developing`, `reports`), dumps (`feature`, `details`, `resource`, `handlers`, `controller`), facts, family_tree (`feature`, `details`, `tree`), file_feed (`feature`, `details`, `feed`), form_of_address (`feature`, `details`, `address`).
- **Tests:** I read `acknowledgements/test.py`, `awaited/test.py` and `branch_switches/test.py` in full. I only looked at `dev_faults/test.py` where it uses `counting.py`. The other `test.py` files were not reviewed.
- **Nothing to fix:** chat_etiquette, facts, `command_line.py`, `settings.py`.
- **Every usage claim below was checked with grep across `src/`.**

**Order of work**
- **Wave 1:** batches B1 and B2. They add the shared helpers, so they go first.
- **Wave 2:** B3 to B12 can run in parallel; their file sets don't overlap. Items marked "after B1" or "after B2" use helpers from those batches.
- **Out of my part:** where a fix touches a file another surveyor owns, it is marked **[other part]**. Those owners should make that change.

---

## B1 — Feature core (biggest payoff: the bug fix and the helpers everything else uses)
Files: `features/base.py`, `features/__init__.py`, `features/trigger.py`, `features/renames.py`, `features/format.py`, `features/shaping.py`, new `features/switches.py`.

1. **Saved cadences are ignored — `base.py:260-261`.** `Feature.cadence(record, key)` never uses `record`; it returns the declared trigger. `Feature.due` still honours the user's saved cadence, because `trigger.due` looks it up by name. But `Nudge.due` (`nudges.py:35`) and `recital.whisper_due` (`recital.py:89`) go through `Context.every` → `trigger.claimed`, under a name like `boards.ideas.<digest>`. No saved cadence is ever found under that name, so they fall back to the declared one.
   - The same happens at the six places that compute `float(cadence(...).every) * MINUTE` by hand: `boards/handlers.py:61`, `auto_update/check.py:85`, `tickets/handlers.py:119`, `plans/handlers.py:123`, `work_tracking/handlers.py:245`, `sequences/handlers.py:217`.
   - **Fix:** `cadence()` looks up the saved trigger under `keyed(key)`, falling back to the declared one. Add `Feature.interval(record, key) -> seconds` as the one way to get "how often, in seconds", and use it at all of those sites.
   - **Behaviour changes:** saved cadences start being honoured (a bug fix). Medium risk.
2. **One name for two things — `trigger.py:50` vs `trigger.py:26`.** The module function `spec(record, name, default)` returns the saved trigger; the method `Trigger.spec()` returns a dict description. Rename the function to `saved` and the method to `described`. Update the callers: `base.py:52,308` and `auto_update/check.py:18,85` (B9). No behaviour change.
3. **Settings read written twice — `base.py:148-150` and `273-274`.** `FeatureDetails.values` and `Feature.values` have the same body. Keep the classmethod on `FeatureDetails`, which twelve call sites use, and make `Feature.values` delegate to it.
   - `Feature.setting` (276-277) has one caller (`dev_faults/reports.py:32`); remove it once B10 switches that call to `values(record).get(...)`.
   - No behaviour change.
4. **Dead code in `base.py`:**
   - `agent` and `agent_due` (239-244) have no callers.
   - `enable` and `disable` (233-237) have no callers.
   - `plural` (302-303) just wraps `engine.wording.plural`. Its five callers should import that directly: `dumps/handlers.py:52,81` (B6), `dev_faults/reports.py:42` (B10), `recital.py:102` (B4) and `record_audit/handlers.py:12` **[other part]**.
   - `standing` (246-247) has one caller, `plugins/services.py:42` **[other part]**.
   - No behaviour change.
5. **`base.py` does two jobs.** Lines 79-127 are a cache of which features are switched on (`SWITCHES`, `GENERATION`, `CHANGE_LOGS`, `written`, `booted`, `switches`, `rebooted`, `environments_changed`, `generation`), separate from the `Feature` class.
   - Move them to `features/switches.py`.
   - Update the importers: `features/__init__.py`, `shaping.py:2`, `commands/parser.py:82` **[other part]** and `hosting/test.py:52` **[other part]**.
   - No behaviour change.
6. **`register_global` picks one of three behaviours by argument type — `base.py:202-217`.** A non-callable is wrapped in `Switched`, a list is appended to, a dict is keyed into.
   - Split it into `register_global` (callables) and `register_switched(container, key, target)` (objects).
   - The one caller of the object mode is `phone/feature.py:30` **[other part]**.
   - No behaviour change.
7. **Alias parsing written twice.** `__init__.py:29-31` and `commands/http.py:61-64` both unpack a feature's `aliases` into old name → new name. Add `Feature.renamed_from() -> dict` and use it in both places. No behaviour change.
8. **`load()` does five jobs — `__init__.py:21-44`.** It imports features, renames aliased state, builds and wires each feature, subscribes to switch changes, and creates feature rows.
   - Split it into one step per job.
   - The row sync in `seat()` (47-64) is an operation on the Features type, so it belongs on the `Features` controller (`controllers/features.py`) **[other part]**. While there, check whether `Features.on` (`controllers/features.py:12`) has any callers; I found none.
   - No behaviour change.
9. **Features looked up by string key.** `FEATURES.get("auto_update")` (`auto_update/check.py:84`, `launch.py:46`) and `FEATURES.get("dev_faults")` (`commands/dispatch.py:126,155`, `commands/cli.py:164`, `commands/http.py:255`).
   - Add `features.running(FeatureClass)`, matching the CLAUDE.md rule that controllers are reached by class.
   - Callers switch in B9, B10 and **[other part]**.
10. **`renames.py:26-33` and `69-77`** both read, change and rewrite `settings.json` for every environment. Fold them into one pass, and use the `engine.record.SETTINGS` path constant.
    - All of `rename()` runs on every process start, for every alias, globbing `runtime/sessions`. It is really a one-time migration and belongs in `migrations/`.
    - Medium risk; the end state on disk is the same.
11. **Row formatting written twice — `format.py:24-29`.** `markdown()` formats title, abstract, brief and sections field by field, which `shaping.py:30-43` already does.
    - Render `markdown` from the output of `shaping(row, record, DOWNLOAD)`.
    - The cached `shaped` and the uncached `shaping` differ only by a suffix. Merge `shaping.py` into `format.py`, which holds the other half of the same job, and give the uncached function a clearer name.
    - Note: `commands/http.py` imports `KEEP_SHAPED` and `settled` to run a second cache of the same thing **[other part]**.
    - No behaviour change.
12. **Shared "say a line as one text" helper.** Add `Feature.spoken(line, **values) -> "title - brief"`. The same composition appears at `ask_questions/interceptors.py:25-26`, `auto_update/check.py:119-120`, `sharing/guard.py:50`, `plans/interceptors.py:12` and `skill_loading/interceptors.py:47`.
13. **`trigger.py:32-44` holds other features' counters.** The `Mark` record carries `count`, `edits`, `since`, `notified` and `viewer_opened`, which are counters for messages, work_tracking, skill_loading and open_viewer, not trigger bookkeeping.
    - Those features should keep them in `context.state` **[other part]**; then remove the fields here.
    - Behaviour change: the counters reset once.

## B1b — Feature parts and speech
Files: `features/parts.py`, `features/journal.py`, `features/nudges.py`, new `features/wiring.py`.

1. **`parts.py` does two jobs.** It defines the part types and context classes, and it also holds the code that registers parts (`Events`, `Client`, `AgentHooks`, `Commands`, `wanted`, `limited`, `refusals`, `agent_row`, lines 123-313).
   - Move the registration code to `features/wiring.py`. Only `journal.py` imports it, plus `refusals` from `skill_loading/handlers.py:6` **[other part, one line]**.
   - No behaviour change.
2. **Same lookup in two places — `parts.py:265-273` and `284-288`.** The policy and the canceler both look up the agent row by session and build an `AgentContext`. Make one helper. No behaviour change.
3. **Dead code:**
   - `Speaker.command` (`parts.py:44-47`) has no callers.
   - The `if spec` branches in `journal.py:106-112` can never be taken: `feature.line()` already refuses a missing line.
4. **"Speak to the primary agent" written many times.** Add `Context.primary() -> AgentContext | None`, wrapping `agents.primary()` plus `speaking_to`.
   - Use it here in `nudges.py:55-58`.
   - Then in `boards/handlers.py:26-29` (B5), `checks/handlers.py:79-81` (B7) and `dumps/handlers.py:25-27,52` (B6).
   - About 15 more sites **[other part]**: plans, work_tracking, sequences, rules, source_links, sharing.
5. **Wrong layer — `journal.py:22-24`.** `waiting()` scans `Works` rows from the features layer; the same question is asked again at `tickets/controller.py:398` and `work_tracking/handlers.py:170`. Make it a `Works` controller method **[other part]**.
6. **Time units live in the wrong module — `nudges.py:9-10`.** `MINUTE` and `DAY` are imported by eight modules that have nothing to do with nudges. Move them to `engine.wording` or a small clock module. Do this as a final sweep after B1, because `interval()` removes most `MINUTE` uses.

## B2 — Shared helpers for refs and capped lists
Files: `resources/base.py`, `controllers/base.py` (both **[other part]**, kept small), `features/collections/controller.py`, `features/acknowledgements/handlers.py`.

1. **"type:n" refs are parsed by hand in about 25 places.** In my part:
   - `collections/controller.py:41-47`
   - `dumps/controller.py:48,59`
   - `boards/controller.py:200`
   - `acknowledgements/handlers.py:21,25`
   - `ask_questions/handlers.py:59`

   Elsewhere: sharing, phone, tickets, kanban, messages, `agents/actors.py:18`, `controllers/todos.py:88`.
   - Add a `Ref` value object (`Ref.parse`, `str()`), plus one way to load the row a ref points at. `Collections._member` is already most of that.
   - Adopt it here in collections and acknowledgements; the other batches adopt it afterwards.
   - No behaviour change.
2. **"Append to a list field and keep the last N" written about 10 times.** Add `Controller._appended(n, field, entry, keep)`, which `controllers/agents.py:44` (`card`) already does for its own field. It is adopted in B4, B5 and B6.
3. **`acknowledgements/handlers.py:20-27`:** `reply_kept` loads nudges by ref inside a `try`. Once refs are parsed by `Ref`, this becomes one call.

## B3 — Git facts
Files: `engine/worktree.py`, `features/branch_switches/*`, `features/close_from_commits/*`, `features/clean_slate/*`, `features/form_of_address/*`.

1. **Finding the git folder written twice.** `branch_switches/handlers.py:10-17` and `close_from_commits/handlers.py:75-82` both walk up to `.git` and follow a `gitdir:` file; `engine/worktree.py:18-28` walks the same markers.
   - Add `engine.worktree.git_dir(folder)` and `head_branch(git_dir)`.
   - `close_from_commits/handlers.py:31` (a `git branch --show-current` subprocess) and its "a detached head" text, also written as `branch_switches DETACHED`, switch to `head_branch`.
   - No behaviour change.
2. **The agent's working folder is worked out two ways.** `branch_switches/handlers.py:31` and `close_from_commits/handlers.py:38-40` differ only in an `is_dir` check. Make one helper. Low risk.
3. **`clean_slate/slate.py:43-45`** has its own git subprocess wrapper. Use `engine.proc.git`, which behaves the same (stdout, or "" on failure).
4. **Dead code:** `clean_slate/feature.py:9-10` overrides `register` with `pass`, which is what the base class already does.
5. **Old storage shim — `slate.py:28-36`.** It still reads the old `settings["clean_slate"]["moved"]` list alongside the file. Move that to a migration, then read only the file. Low to medium risk.
6. **`form_of_address/address.py:9-17`** reads `git config user.name` itself. So do `tickets/controller.py:485` and `session_recording/scrub.py:44` **[other part]**. Add one cached `engine.worktree.git_user_name(project)`.
7. **`form_of_address/feature.py:15-16`:** `addressed` only calls `address`; register `address` directly.
8. **Unclear names:** rename `AddressDetails` to `FormOfAddressDetails`, and `Commits` / `CommitsDetails` to `CloseFromCommits` / `CloseFromCommitsDetails`.
9. **`close_from_commits/handlers.py:54`:** "open works for to-do n" is also computed at `work_tracking/handlers.py:106`. Make it a `Works` controller method **[other part]**.

## B4 — Agent row facts
Files: `resources/types.py` (the `Agent` class), `controllers/agents.py`, `features/agent_sessions/*`, `features/awaited/*`, `features/attachment_descriptions/*`, `features/recital.py`, `features/family_tree/tree.py`.

1. **Constants and checks duplicated.**
   - `agent_sessions/handlers.py:14,19` declare `STOPPED` and `COMPACTING` again; they already exist at `resources/types.py:278`.
   - `attachment_descriptions/handlers.py:41` compares against the literal `"stopped"`, and `agent_sessions/handlers.py:71` makes the same check. Add an `Agent.live` property.
   - The list of run kinds (subagent, shell, monitor) is written at `awaited/handlers.py:8` and again at `resources/types.py:364`. Add an `Agent.runs` property and have `background_run` use it.
   - No behaviour change.
2. **Capped lists on the agent row.** `agent_sessions/handlers.py:45-48` (compactions) and `recital.py:62-63` (whispers) keep these by hand. Add Agents controller operations next to `card`, using the B2 helper.
3. **Logic in the wrong feature, and a verbatim copy.** `agent_sessions/launch.py` launches agents; it has nothing to do with tracking sessions.
   - Its `prepared()` (lines 23-31) is copied line for line in `tickets/controller.py:436-446` (`QUIET_IN_TICKETS` equals `QUIET`).
   - Move the file to `features/starting_agents/launch.py`.
   - Update the importers **[other part]**: `organization/agents.py`, `plans/worker.py` and `helpers/controller.py`. Have tickets call `prepared()`.
   - Low risk.
4. **Wrong layer — `agent_sessions/history.py:15-22`.** Importing a transcript writes message files directly (`messages.resource(...)` plus `write_text(messages.path(n))`), bypassing the controller.
   - Make it a `Messages` controller operation (`controllers/messages.py`) **[other part]**.
   - Medium risk: the imported rows must keep their timestamps.
5. **One handler doing two jobs — `attachment_descriptions/handlers.py:55-86`.** `SampleVideoFrames` runs ffmpeg itself and also does the row bookkeeping.
   - Move the extraction into `video.py`, which already holds `probe`.
   - The `"video; "` tag prefix is written at 66 and 83 and read at 50; make it one constant.
   - Line 60 repeats `media()` just for video.
   - No behaviour change.
6. **Unclear names:** rename `AgentsFeature` / `AgentsDetails` to `AgentSessions` / `AgentSessionsDetails`, and `Attachments` to `AttachmentDescriptions`.
7. **Small cleanup in `recital.py`:** inline `whisper_due` (88-89), which has one caller and is defined after it. Low value.
8. **Low priority — `family_tree/tree.py:92-117`.** It reads transcripts incrementally, cached by file size; so do `runner/engine.py:134,189` and `providers/turns.py:25` **[other part]**. That suggests one helper at the provider level.

## B5 — Boards
Files: `features/boards/*`.

1. **`controller.py` is 384 lines doing four jobs:** stages; drafting a request (request, hand, outline, progress, score, expect, say, stall, retry, wait, group, pick, revise, follow-up); building from a document (build, log, built, keep, discard); and running a board (start, pause, resume, orchestrate).
   - Split it into `drafting.py`, `building.py` and `running.py` as mixins that `Boards` combines.
   - Medium risk: first check that commands still find methods that come from a mixin.
2. **Callers dig into `board.drafting` themselves.** Over 15 places call `board.drafting.get("since"/"phase", ...)`. Add `Board` properties for these, alongside the existing `asked`.
3. **Four helpers where one will do — lines 140-150.** `_update_drafting`, `_replace_drafting`, `_update_building` and `_replace_building`; the `_replace_*` ones are one-line `update` wrappers. Make one merge helper.
4. **Ticket numbers parsed from text twice** (184-190 and 203-207). Make one `numbers_in(text)`.
5. **Workaround for an import cycle — lines 35, 137-138.** `CARD_ROWS` is a global list that `tickets/feature.py:39` fills in. Import `Tickets` lazily inside `_cards` instead, and drop the assignment in tickets **[other part]**.
6. **Boards edits the sequences feature's data.** `retry()` (276-295) rewrites sequence runs and their `env|about` keys; `orchestrate("off")` (128-134) finishes runs by key prefix. These should be Sequences controller operations **[other part]**.
7. **Logic in a handler — `handlers.py:35-43`.** `uncovered()` works out which goal clauses no ticket covers; that belongs on the Boards controller as `_uncovered(board)`. Line 38 also uses `journal.of("ticket")`, against the CLAUDE.md rule.
8. **`feature.py:28-29`:** `filler_model` is logic sitting in `feature.py`; move it to `orchestration.py`, which reads the same settings.
9. **After B1:** `handlers.py:61` uses `interval()`.
10. **After B2:** the ref parse at 200 and the capped logs at 350-351.

## B6 — Dumps
Files: `features/dumps/*`.

1. **Controllers reached by string key**, against the CLAUDE.md rule: `controller.py:45` (`CONTROLLERS["collection"]`) and `controller.py:138` (`types_module.CONTROLLERS["message"]`). Use the `Collections` and `Messages` classes.
2. **An item's status is worked out in five places:** `handlers.py:50`, `handlers.py:78`, `controller.py:180-181`, `controller.py:209-210` and `standing()` at 272-277.
   - `_names` (168-169) is derived from the row, and handlers call it even though it is private.
   - Add a `DumpItem` value object and an item-names property on the `Dump` resource.
   - No behaviour change.
3. **Capped lists:** 140, 228 and 237 trim by hand, and `log` and `say` build the same entry. Use the B2 helper plus one entry builder.
4. **One handler doing four jobs — `handlers.py:23-52`.** `PromptFiling` handles a choice being made, the dump being filed, an answer arriving and new items arriving, separated by early returns. Split it into one handler per moment. Medium risk: keep each guard exactly.
5. **`controller.py:21-28`:** `Offer` is a value object defined in the controller; move it to `resource.py`.
6. **Needs a decision.** Some refusals use `_refuse` (which `--force` can override) and others use `raise Refused` (which it can't), with no pattern (e.g. 142/149 vs 160/190). Choosing one way is a behaviour change.

## B7 — Checks
Files: `features/checks/*`, plus a new engine lock helper.

1. **Reading command output is split across two files.** `controller.py:37-62` (`tail`, `steps`, `progress` and their regexes) sits inside the controller, and `handlers.py:84-86` (`summary`, `ANSI`) is in the handlers. Move both to `checks/output.py`.
2. **Same shell call three times.** `"/bin/sh -c"` + `streamed` + `TIMEOUT` appears at 90, 132 and 158. Make one `_shell(...)`.
3. **Same refusal twice.** "has no command" is written at 75 and 97. Make one `_runnable(n)`.
4. **Project folder computed two ways.** `root.resolve().parent` at 86 and 123, `root.parent` at 158. Make one property. Low risk (symlinks).
5. **Two ways to run a check in the background.** The scheduled run (`handlers.py:27-59`) takes a cross-process lock for the check first; a manual `run(wait=False)` (`controller.py:72-79`) starts its own thread without the lock.
   - Make one controller method that takes the lock, and have the handler call it.
   - Behaviour change: a manual run while another process holds that check's lock is skipped.
   - Medium risk.
6. **Lock-or-skip written six times.** `handlers.py:50-59` is one of six copies of "take this lock file without waiting, or give up"; the others are `runner/engines.py:69`, `plugins/host.py:65`, `plugins/source.py:248`, `engine/keeper.py:137` and `install.py:301`. Add one engine helper and adopt it here.
7. **`handlers.py:69-72`:** building the failure title (`{summary}` substitution, 80-character cut) belongs on the `Check` resource.
8. **Probably redundant — `handlers.py:19-21`.** `CheckUpdated.read` repeats `ResourceEvent.read`, which already reads `ran` from the data. Delete it after confirming the bool conversion behaves the same.

## B8 — Ask questions
Files: `features/ask_questions/*`.

1. **Duplicates an existing helper.** `interceptors.py:14-15, 29-32, 39-41` (`Option`, `option_title`, the JSON parse) repeat `resources/shapes.py:58-75` (`normalize_options`, which also reads title-or-label). Use `normalize_options`.
2. **`handlers.py:46-47`** use `journal.of("question")` and `of("agent")`, against CLAUDE.md. Use the classes. The same fix is needed in `tickets/handlers.py:68`, `sequences/handlers.py:283`, `organization/handlers.py:13` and `sharing/guard.py:58` **[other part]**.
3. **`handlers.py:59-61`** builds a ref by hand and filters standing questions itself. Use `Ref` (after B2) and the existing `Questions.about(ref)` (`controllers/questions.py:36`). Check first whether `about` includes closed questions.
4. **After B1:** `interceptors.py:25-26` uses `spoken()`.
5. **Name clash:** the feature class is called `Questions` (`feature.py:9`), the same name as the controller `controllers.types.Questions` that the handlers use. Rename it to `AskQuestions` / `AskQuestionsDetails`.

## B9 — Auto-update and auto-archive
Files: `features/auto_update/*`, `features/auto_archive/*`, and the import in `runner/worker.py:21`.

1. **Same failure scan written twice.** `check.py:31,74` and `launch.py:60` both scan `journal upgrade` output for failure words, but `launch.py` misses "not built". Make one `failure_in(lines)`. Behaviour change: the launch path now catches "not built" (a fix).
2. **`check.py:123-137`:** `Relaunch` is a second job (restarting the agent after a release that changes how it launches). Move it to `relaunch.py`.
3. **After B1:**
   - `check.py:84` and `launch.py:46` use `features.running`.
   - `check.py:85` uses `interval()`.
   - `check.py:119` uses `spoken()`.
4. **Wrong layer — `launch.py:14-41`.** `lost()` and `repaired()` (finishing a half-done install, and the notice about data lost in 2.84.0) belong to `install.py` or a migration, not the update check. `check.py:69` also deletes install's own `runtime/upgrading` marker. Medium risk.
5. **One handler doing three jobs — `auto_archive/handlers.py:23-42`.** `ExpireOldRows` expires reports and to-dos, prunes capped types, and packs closed rows. Split it into three.
   - Lines 27-34 build controller and method names from strings (`f"{type_}s"`, `f"expire_{type_}"`). Use an explicit table instead.
6. **Pruning belongs to controllers — `auto_archive/handlers.py:14-20`.** `prune` and `PRUNABLE` are keyed by `resource.pruned_when` strings. Make pruning a controller operation with a predicate on the resource, and turn `pruned_when` into an enum (`resources/base.py:153-154`) **[other part]**.
7. **Unclear names:** rename `Retention` to `AutoArchive`, and `Updates` to `AutoUpdate`.

## B10 — Developer fault reports
Files: `features/dev_faults/*`, plus new test-only files.

1. **Test-only code in production — `counting.py`.** Only `dev_faults/test.py` (lines 137 and 164) imports it. Move it to `tests/kit.py`. The two tests that use it check engine request and settings budgets, not this feature, so move them under `tests/`. No behaviour change.
2. **`reports.py:78-86`:** `profiler` and `kept` (writing cProfile dumps) are a second job. Move them to `profiling.py`; `commands/dispatch.py:156` adopts that.
3. **Same list written twice.** The viewer budget kinds ("overlap", "page", "refetch") appear at 66-67 and again at 104. Make one table.
4. **After B1:**
   - Line 32 uses `values(...)` instead of `Feature.setting`.
   - Line 42 uses `engine.wording.plural` directly.
5. **Re-implements trigger logic — `reports.py:51-54`.** `told_uses` and `TOLD_EVERY` re-implement the "every N tool uses" cadence that `Context.every` already provides. Switch to that. Behaviour change: one repeated report may be said again right after deploy.
6. **Same file cache three times.** `developing.py:13-21`, `trigger.py:59-72` and `base.py:84-91` each hand-roll "re-read the file only when it changed". Add one engine helper.
7. **Unclear name:** rename `Faults` to `DevFaults`. Its only external import is `tickets/test.py:56`.

## B11 — Command tags
Files: `features/command_tags/*`.

1. **`reading.py:87-101`:** `answered()` and `tagged()` give the agent advice on answering messages, which is not tag reading. Move them to `command_tags/answering.py`.
2. **Two near-identical strip functions.** `visible()` (54-55) and `stripped()` (66-67) have the same body; `visible` is just `stripped` with the default tag table. Make one function.
   - `handlers.py:21` removes tags outright rather than keeping the quote marker. Keep that as a separate, named function.
3. **Unclear name:** rename `Tags` / `TagsDetails` to `CommandTags`.

## B12 — Small features
Files: `features/file_feed/*`, `features/critique/*`, `features/browser_control/feature.py` and `details.py`.

1. **`file_feed/feed.py` does two jobs:** keeping the feed (111-158) and computing diffs (17-108, 161-198). Move the diff code to `diff.py`.
   - `feature.py:9-11` defines the `KeepEdits` handler; move it to `handlers.py`.
2. **`critique/controller.py:36-65`:** `round()` validates lenses, runs the seed command, probes the app, creates the report, writes the briefs, picks the model and writes the instructions.
   - Split it into helpers.
   - Line 46 runs a shell command; use the same shell helper as B7.
   - Line 12 imports `answers` from `features/sharing/controller.py`, i.e. an HTTP probe taken from another feature's controller. Move the probe to the engine **[other part]**.
3. **Unclear name:** rename `Browser` / `BrowserDetails` to `BrowserControl`.

---

### Critical Files for Implementation
- /Users/jessegall/projects/agent-journal/src/features/base.py
- /Users/jessegall/projects/agent-journal/src/features/parts.py
- /Users/jessegall/projects/agent-journal/src/features/trigger.py
- /Users/jessegall/projects/agent-journal/src/features/boards/controller.py
- /Users/jessegall/projects/agent-journal/src/features/dumps/controller.py