`, and Snap-volume / Burst buttons + a `#devices-ls-lastcap` card. Mirror the `.devices-camera-*` class structure for the image stage so the existing zoom/pan inline machinery can be reused or replicated.
+
+- [ ] **Step 2: JS — frame paint + controls**
+
+Add `'manual'` to `VIEWS`. Add a frame handler mirroring `handleCameraFrame` (separate DOM ids `_lsImg`/`_lsMeta`, its own FPS window) and subscribe `ClientEventBus.on('LIGHTSHEET_FRAME', handleLightsheetFrame)` in `setupCameraWiring` (or a new `setupManualWiring`). Live toggle hits `/api/devices/lightsheet/live/start|stop`. Galvo/piezo/exposure inputs: on `input`, **debounce ~120 ms**, then `fetch('/api/devices/lightsheet/live/params', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({galvo, piezo, exposure})})`. Illumination toggles POST their routes. Acquire buttons POST `/api/devices/acquire/burst|volume` with the current params, show "acquiring…" then render the result ref in `#devices-ls-lastcap`. Embed A's graph: `if (window.TemperatureGraph) TemperatureGraph.init(document.getElementById('devices-ls-tempgraph'), 'current')`. FPS readout from the frame handler's window (reuse the `computeCameraFps` approach).
+
+- [ ] **Step 3: `node --check`** — `node --check gently/ui/web/static/js/devices.js` (exit 0).
+
+- [ ] **Step 4: Chrome-MCP harness verification** — build a standalone harness (like A's): copy `event-bus.js`, `temperature-graph.js`, the new manual JS, and `main.css`; stub `fetch` for `live/params` + status + acquire; feed simulated `LIGHTSHEET_FRAME` events (a moving synthetic gradient that shifts when galvo/piezo "params" change) to demonstrate the slide-and-see; screenshot; run the alignment/spacing/contrast UI audit and fix flaws. Live in-app + FPS verification is deferred to the rig.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/templates/index.html gently/ui/web/static/js/devices.js gently/ui/web/static/css/main.css
+git commit -m "feat(manual-mode): Manual view — SPIM live canvas, galvo/piezo/exposure, illumination, acquire, temp"
+```
+
+---
+
+### Task 7: FPS measurement (rig) + conditional binary transport
+
+**Files:**
+- Create: `docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md` (record the numbers)
+- (Conditional) Modify: `gently/ui/web/connection_manager.py` + `agent_ws`/`server.py` + `websocket.js` for a binary frame path.
+
+**This task is a measurement + a gate, not unconditional code.**
+
+- [ ] **Step 1:** On the rig, start lightsheet live and record from the Manual-view FPS readout + device logs: **device grab rate**, **delivered rate**, **browser paint rate**, at 512 px/q70 and at a reduced 384 px/q60. Write them into the notes file with the exposure used.
+- [ ] **Step 2: Diagnose.** If device grab < ~15 fps → limiter is exposure/readout/rpyc, not transport — tune exposure/size/quality, stop here. If device grab ≥ target but browser paint < target → transport is the bottleneck → do Step 3.
+- [ ] **Step 3 (conditional): binary WebSocket path.** Add a `send_bytes`-based frame channel: device→agent SSE stays; on the agent→browser hop, push the raw JPEG via `websocket.send_bytes(prefix + jpeg)` (small type byte), bypassing base64 + the per-client `json.dumps` + the EventBus fan-out; browser `onmessage` binary → `createImageBitmap(new Blob([buf]))` → `ctx.drawImage`. Re-measure and record.
+- [ ] **Step 4: Commit** the notes (and any binary-path code, if built).
+
+```bash
+git add docs/superpowers/notes/2026-06-28-lightsheet-fps-measurement.md
+git commit -m "docs(manual-mode): lightsheet live FPS measurement + transport decision"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** §2.1 streamer → Task 2; §2.2 monitor → Task 4; §2.3 proxy routes → Task 5; §2.4 client methods → Task 3; §2.5 Manual view → Task 6; §2.6 concurrency (`_state_pause_counter` back-off) → Task 2 streamer loop; §2.7 brightfield laser-off → Tasks 2 & 5 (`laser/off`); §3 transport baseline + measurement + binary escalation → Tasks 1/4 (baseline) + Task 7 (measure/escalate); §4 error handling → Tasks 2/5 (try/except, 503/502); §5 testing → each task's tests + Task 6 harness; LIGHTSHEET_FRAME event → Task 1. Single-camera (no side A/B) reflected throughout. ✓
+
+**Open confirmations (explicit, not placeholders):** pymmcore sequence-acq method availability + core handle (Task 2); scanner/piezo park method names (Task 2); the SetupPanel scanner/beam question (Task 2); the client low-level POST helper name (Task 3); the `require_control` test-override mechanism (Task 5). Each names a concrete fallback.
+
+**Type consistency:** frame payload `{t, shape, downsample, mime, jpeg_b64}` (reused encoder) across Tasks 2/3/4/6; live params `{galvo, piezo, exposure}` across Tasks 2/3/5/6; event `LIGHTSHEET_FRAME` across Tasks 1/4/6; `lightsheet_monitor` attr across Tasks 4/5. Consistent.
diff --git a/docs/superpowers/plans/2026-06-28-operations-tab.md b/docs/superpowers/plans/2026-06-28-operations-tab.md
new file mode 100644
index 00000000..46625492
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-28-operations-tab.md
@@ -0,0 +1,76 @@
+# Operations — agent-authored Operation Plan (D v3) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
+
+**Goal:** The agent emits a typed Operation Plan (its tactics, planned/active/done); Operations renders it ⊕ live telemetry. Built backend-first (the user-directed, low-rework part), then the renderer.
+
+**Architecture:** A typed `OperationPlan` in `FileContextStore` (new domain), written by a forced-tool agent call, served on a route, rendered by a data-driven operation-spine generalized for tactic kinds, with live-telemetry binding + scenario test mode.
+
+**Tech Stack:** Python (FileContextStore YAML, the `@tool`/forced-`tool_choice` pattern, EventBus `CONTEXT_UPDATED`), FastAPI route, vanilla-JS + SVG renderer, pytest.
+
+## Global Constraints
+- Source of truth = the agent's typed plan (NOT a backend reconstruction). Live telemetry binds onto declared tactics; the agent declares tactic identity, the system supplies live values.
+- Plan schema (see spec §1): `{session_id,title,goal,tactics:[{id,name,kind,state,scope,rationale,structure,live_bind,relations}],updated_at,updated_reason}`. `kind∈{standing_timelapse,reactive_monitor,scripted_protocol,exclusive_burst,oneshot,custom}`, `state∈{planned,active,done}`. No round-robin.
+- Forced typed output mirrors `gently/harness/memory/notebook_ask.py` (ASK_TOOL + `tool_choice={'type':'tool',...}` → validated `block.input`) OR the `@tool` auto-schema (`harness/tools/registry.py`).
+- Store mirrors `session_intents`/`active/` domains; fire the existing `CONTEXT_UPDATED`.
+- Route mirrors `gently/ui/web/routes/context.py` (`/api/context`).
+- Renderer = the validated operation-spine (harness `scratchpad/opsdesign/harness.html`) with the audit fixes (queued reads cocked + "next" marker; active phase/status is the headline; flatten — no card-in-card; whole-left-column amber + colored left edge per state; copy "queued"; mono values). Generalize for the tactic kinds (standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phases).
+- Frontend: no JS harness → `node --check` + Chrome-MCP audit across scenario fixtures.
+- Git hygiene: stage only your files by explicit path; never `git add -A`.
+
+---
+
+### Task 1: OperationPlan model + FileContextStore domain
+**Files:** Create/extend `gently/harness/memory/model.py` (an `OperationPlan`/`Tactic` dataclass or a documented dict schema); Modify `gently/harness/memory/file_store.py` (`set_operation_plan(session_id, plan)` / `get_operation_plan(session_id)`, YAML under `agent/operation_plans/{session_id}.yaml`, fire `CONTEXT_UPDATED`). Test: `tests/test_operation_plan_store.py`.
+- [ ] Confirm the real `FileContextStore` domain pattern from `set_session_intent`/`create_session_intent` (~file_store.py:758) + the `_notify_*`/`CONTEXT_UPDATED` emit. Mirror it.
+- [ ] TDD: set→get round-trip preserves the tactics list + states; `CONTEXT_UPDATED` fired on set. `pytest tests/test_operation_plan_store.py -v`; `pytest -q` clean. Commit `feat(operations): OperationPlan store domain in FileContextStore`.
+
+### Task 2: `declare_operation_plan` agent tool (forced typed output)
+**Files:** Create `gently/app/tools/operation_plan_tools.py` (+ register in `tools/__init__`). Test: `tests/test_operation_plan_tool.py`.
+- [ ] Confirm the `@tool` decorator + the forced-tool pattern (registry.py auto-schema, or a literal schema like notebook_ask.ASK_TOOL). The tool accepts the typed plan (tactics list) and writes it via `store.set_operation_plan`. Resolve the store from context (mirror existing memory tools).
+- [ ] TDD: calling the tool with a plan persists it (get returns it) + returns a confirmation; missing store → error. `pytest tests/test_operation_plan_tool.py -v`; `pytest -q` clean. Commit `feat(operations): declare_operation_plan typed agent tool`.
+
+### Task 3: Route `GET /api/operation_plan/{session_id}`
+**Files:** Create `gently/ui/web/routes/operation_plan.py` (+ register in `routes/__init__.py`). Test: `tests/test_operation_plan_route.py`.
+- [ ] Mirror `routes/context.py` / `routes/temperature.py` `_resolve_session` + `server.context_store`/`gently_store` resolution. Return the stored plan; 404/empty handled; `session=current` resolves newest.
+- [ ] TDD (TestClient + mock store): returns the plan; empty when none. `pytest tests/test_operation_plan_route.py -v`; `pytest -q` clean. Commit `feat(operations): GET /api/operation_plan/{session} route`.
+
+### Task 4: The operation-spine renderer + scenario library (frontend)
+**Files:** Create `gently/ui/web/static/js/operations-scenarios.js` (plan fixtures: temp_strain, expression_onset, hatching_detect, transmission_survey, decided_plan, async_multi, idle); Rewrite the Overview path in `gently/ui/web/static/js/experiment-overview.js` to the data-driven operation-spine (generalized per tactic kind, audit fixes); port CSS to `experiment.css`. Reference: `scratchpad/opsdesign/harness.html`.
+- [ ] Port the renderer (renderOperation/renderTactic/renderReadout/renderPhase) + add per-kind rendering: standing→per-embryo cadence strip; reactive→watch/reaction/status; scripted→phase stepper; exclusive/oneshot→compact. Audit fixes baked in. `?scenario=` dev mode loads a fixture.
+- [ ] `node --check` both JS; build a Chrome-MCP harness at `scratchpad/opsv3/` (real repo files) for the controller to audit across fixtures. Commit `feat(operations): operation-spine renderer + plan scenario library (data-driven)`.
+
+### Task 5: Live binding + refresh
+**Files:** Modify `experiment-overview.js` (fetch `/api/operation_plan`, bind live telemetry from `/strategy`/get_status onto tactics by `live_bind`, subscribe `CONTEXT_UPDATED` + tactic events → debounced refetch).
+- [ ] Bind temperature/current-burst/cadence/signal onto declared tactics' readouts/progress; live refresh on plan-change + telemetry events (debounced). `node --check`; harness check. Commit `feat(operations): live telemetry binding + event-driven refresh`.
+
+## Self-Review
+- Store→Task1; tool→Task2; route→Task3; renderer+scenarios→Task4 (audit fixes); live binding→Task5. ✓
+- Open confirmations: FileContextStore domain pattern (T1), the forced-tool/@tool pattern (T2), the route store-resolution (T3), the Overview render seam + harness renderer (T4), the live telemetry sources (T5).
+- Type consistency: the plan/tactic schema is identical across store (T1), tool (T2), route (T3), fixtures+renderer (T4), binding (T5).
+
+---
+## Execution-linkage tasks (added after recon — close the planning→execution loop)
+
+### Task 6: `transition_tactic` store helper
+**Files:** Modify `gently/harness/memory/file_store.py` (add `transition_tactic(session_id, tactic_id, state=None, **bind)` next to `set/get_operation_plan` ~:818 — read the plan, find the tactic by `id`, set `state` and merge `bind` into its `live`/`structure`, write back, fire `CONTEXT_UPDATED`; no-op if plan/tactic absent). Test: `tests/test_transition_tactic.py`.
+- [ ] TDD: declare a plan, transition a tactic planned→active with a `request_id` bind → get shows the new state + bound value; unknown tactic_id → no-op (no crash). Commit `feat(operations): transition_tactic store helper`.
+
+### Task 7: tactic_id threading + start-edge marking in execution tools
+**Files:** Modify `gently/app/tools/timelapse_tools.py` (`enable_monitoring_mode`, `queue_burst`, stop/pause), `gently/app/tools/temperature_protocol_tools.py`, and the burst/protocol event payloads (`exclusive.py` BURST_*, `temperature_protocol.py` TEMP_PROTOCOL_*) to carry an optional `tactic_id`. On execute, the tool calls `cs.transition_tactic(session, tactic_id, 'active')`; on stop/pause → 'done'/'paused'. Test: `tests/test_tool_tactic_linkage.py`.
+- [ ] Add optional `tactic_id` param; thread into event `data`; flip the plan tactic active on execute (guard: only if a plan + tactic_id exist). TDD with a fake context store capturing transitions. Commit `feat(operations): link execution tools to plan tactics via tactic_id`.
+
+### Task 8: `OperationPlanUpdater` service (completion edges via the bus)
+**Files:** Create `gently/app/operation_plan_updater.py` (a `Service` modeled on `gently/app/temperature_sampler.py` / `TimelineManager`); wire in `gently/app/agent.py` beside the temperature sampler (~:838-849). Test: `tests/test_operation_plan_updater.py`.
+- [ ] Subscribe `BURST_COMPLETE`, `TEMP_PROTOCOL_COMPLETED`, `EMBRYO_CADENCE_CHANGED`, `TRIGGER_FIRED`; on each, resolve the tactic (by `tactic_id` in payload, else by kind+embryo) and `cs.transition_tactic(session, tactic_id, 'done', **bind)` binding live values (request_id/mp4_path/setpoint/cadence). Mirror the sampler's start/stop lifecycle + session_id getter. TDD against a fake bus + store. Commit `feat(operations): OperationPlanUpdater — execution events transition plan tactics`.
+
+---
+## Plan-item ↔ operation linkage tasks (added — tactics planned at plan time)
+
+### Task 9: PlanItem/ImagingSpec tactical outline
+**Files:** Modify `gently/harness/memory/model.py` (`ImagingSpec` ~:200 / `PlanItem` ~:265 — add optional `tactics: list[dict]` outline field: each entry a lightweight tactic `{kind, name, target?, scope?, structure?}`). Ensure the plan-mode planning tools (`gently/harness/plan_mode/tools/planning.py` create_plan_item/update_plan_item) accept/persist it. Test: extend the plan-item model/store tests.
+- [ ] Confirm the real ImagingSpec/PlanItem dataclass + how planning tools set the spec. Add the optional `tactics` outline (default empty), persisted in the campaign plan YAML. TDD: a plan item round-trips its tactics outline. Commit `feat(operations): plan-item tactical outline (plan tactics with the imaging spec)`.
+
+### Task 10: Operation Plan goal/plan_item linkage + seeding
+**Files:** Modify `gently/app/tools/operation_plan_tools.py` (or a small seeding helper / the agent): resolve the current session's `plan_item_id`/`campaign_id`/goal from the `session_intent` (`get_current_session_intent` ~file_store.py:788) → set them on the Operation Plan top-level; when a session linked to a plan item with a `tactics` outline begins (or on first declare), SEED the Operation Plan's `planned` tactics from the outline. Test: `tests/test_operation_plan_seeding.py`.
+- [ ] Confirm the session_intent→plan_item linkage accessors. On declare/seed, populate `plan_item_id`/`campaign_id`/`goal` from the linked plan item and seed `planned` tactics from its outline (idempotent — don't clobber tactics already active/done). TDD with a fake store: a session linked to a plan item with an outline produces a seeded Operation Plan. Commit `feat(operations): seed Operation Plan goal + planned tactics from the linked plan item`.
diff --git a/docs/superpowers/plans/2026-06-28-tactics-library.md b/docs/superpowers/plans/2026-06-28-tactics-library.md
new file mode 100644
index 00000000..a845bfa8
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-28-tactics-library.md
@@ -0,0 +1,35 @@
+# Tactics Library (G) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
+
+**Goal:** Save / list / apply typed tactics (D's tactic objects) as reusable templates — a near-copy of the plan-template pattern.
+
+**Architecture:** A tactic-library domain in `FileContextStore` (save/list/get/apply), agent tools, and a read route — mirroring `save_plan_template`/`apply_plan_template`.
+
+## Global Constraints
+- Mirror the plan-template pattern: `_plans.py:551-667` (`save_plan_template`/`list_plan_templates`/`get_plan_template`/`apply_plan_template`) + `plan_mode/tools/templates.py`.
+- A saved tactic is a TEMPLATE (planned form): `{id,name,slug,kind,structure,scope_hint?,description,params?,created_at,created_by}` — NO live state. `apply_tactic` returns a fresh planned tactic (new run id, state="planned", no `live`).
+- Store domain `agent/tactic_library/{id}_{slug}.yaml`; fire `CONTEXT_UPDATED`.
+- Git hygiene: stage only your files by explicit path; never `git add -A`.
+
+---
+
+### Task 1: Tactic-library store domain
+**Files:** Modify `gently/harness/memory/file_store.py` (or `_plans.py` if templates live there) — add `save_tactic(tactic, name=None) -> str`, `list_tactics() -> list[dict]`, `get_tactic(id_or_name) -> dict | None`, `apply_tactic(id_or_name) -> dict | None`. Test: `tests/test_tactic_library_store.py`.
+- [ ] Confirm the exact plan-template implementation (`_plans.py:551-667`) — the id/slug generation, the YAML write helper, the `_notify`/`CONTEXT_UPDATED` emit — and mirror it for tactics at `agent/tactic_library/`. `apply_tactic` returns a deep copy with a fresh `id` (e.g. `tac_<8hex>`), `state="planned"`, and `live`/run-state stripped.
+- [ ] TDD: save a tactic → list shows it → get returns it → apply returns a fresh planned tactic (new id, no live, state=planned); get/apply unknown → None. `pytest tests/test_tactic_library_store.py -v`; `pytest -q` clean. Commit `feat(tactics-library): tactic-library store domain (save/list/get/apply)`.
+
+### Task 2: Agent tools
+**Files:** Create `gently/app/tools/tactic_library_tools.py` (+ register in `tools/__init__`). Test: `tests/test_tactic_library_tools.py`.
+- [ ] Mirror `plan_mode/tools/templates.py` (the `@tool` usage + store resolution from context). Tools: `save_tactic(name, tactic, description="")` → `store.save_tactic`; `list_tactics()` → `store.list_tactics`; `apply_tactic(id_or_name)` → `store.apply_tactic` then append the planned tactic to the current Operation Plan (`get_operation_plan(session)` → append → `set_operation_plan`; create a minimal plan if none). Resolve the context store + session from the agent (as `declare_operation_plan` does). Register the module.
+- [ ] TDD (fake context store + session): save persists; apply adds a planned tactic to the current Operation Plan; list returns the library; missing store → error. `pytest tests/test_tactic_library_tools.py -v`; `pytest -q` clean. Commit `feat(tactics-library): save/list/apply_tactic agent tools`.
+
+### Task 3: Route `GET /api/tactic_library`
+**Files:** Create `gently/ui/web/routes/tactic_library.py` (+ register in `routes/__init__.py`). Test: `tests/test_tactic_library_route.py`.
+- [ ] Mirror `routes/operation_plan.py` — resolve `server.context_store`, return `{tactics: store.list_tactics()}`; empty list when none. Register the router.
+- [ ] TDD (TestClient + mock store): returns the library; empty when none. `pytest tests/test_tactic_library_route.py -v`; `pytest -q` clean. Commit `feat(tactics-library): GET /api/tactic_library route`.
+
+## Self-Review
+- Store→Task1; tools→Task2; route→Task3. ✓
+- Open confirmations: the plan-template implementation to mirror (T1), the `@tool` + store/session resolution (T2), the route store handle (T3).
+- Type consistency: the saved-tactic dict shape is identical across store (T1), tools (T2), route (T3); `apply_tactic`'s fresh planned tactic matches D's tactic schema.
diff --git a/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md
new file mode 100644
index 00000000..4d8b0bf4
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-28-temp-change-burst-tactic.md
@@ -0,0 +1,368 @@
+# Temp-Change Burst Tactic (C) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** A scripted temperature-change burst protocol — brightfield bursts before a setpoint change, during the ramp (until lock), and after — launchable as an agent tool, observable on the Experiment tab.
+
+**Architecture:** A thin async `TimelapseOrchestrator.run_temp_change_burst_protocol` driver composing existing `BurstAcquisition` (extended to force lasers off), `set_temperature`/`get_temperature`, and brightfield primitives; new timeline EventTypes so the tactic + setpoint changes render; an agent tool that launches the driver via `asyncio.create_task`.
+
+**Tech Stack:** Python asyncio, the gently EventBus + TimelineManager, pytest (`asyncio_mode=auto`).
+
+## Global Constraints
+- C composes A (temperature stamp/persistence) + B1 (`set_laser_config("ALL OFF")`). No new deps.
+- Brightfield every burst: `laser_config="ALL OFF"`; lasers never left on, even on error/cancel.
+- Lock contract: poll `client.get_temperature()["state"]` until `"LOCKED" in state` (the device reports `'[ SYSTEM LOCKED ]'`).
+- Bursts are temperature-stamped automatically (A) and emit `BURST_START/COMPLETE` (render for free).
+- New EventTypes use `auto()` (wire serializes `.name`).
+- Tests: fakes for client + burst; `asyncio_mode=auto` (no decorator). Rig-deferred: real ramp timing.
+- Git hygiene: stage only your files by explicit path; never `git add -A` (pre-existing untracked screenshots/mockups + uv.lock are not yours).
+
+---
+
+### Task 1: Timeline EventTypes for the tactic
+
+**Files:** Modify `gently/core/event_bus.py` (3 new `auto()` members near the other domain events); Modify `gently/harness/session/timeline.py` (add 3 entries to the EventType→subtype map, near the `BURST_*` entries ~line 213-225). Test: `tests/test_temp_protocol_events.py`.
+
+**Interfaces:** Produces `EventType.TEMPERATURE_SETPOINT_CHANGED`, `EventType.TEMP_PROTOCOL_STARTED`, `EventType.TEMP_PROTOCOL_COMPLETED`, each mapped to a timeline subtype (`setpoint_changed`, `temp_protocol_started`, `temp_protocol_completed`).
+
+- [ ] **Step 1: failing test**
+```python
+# tests/test_temp_protocol_events.py
+from gently.core.event_bus import EventType
+
+
+def test_new_event_types_exist():
+ for n in ("TEMPERATURE_SETPOINT_CHANGED", "TEMP_PROTOCOL_STARTED", "TEMP_PROTOCOL_COMPLETED"):
+ assert getattr(EventType, n).name == n
+
+
+def test_timeline_maps_the_subtypes():
+ from gently.harness.session import timeline as tl
+
+ src = open(tl.__file__, encoding="utf-8").read()
+ for sub in ("temp_protocol_started", "temp_protocol_completed", "setpoint_changed"):
+ assert sub in src
+```
+- [ ] **Step 2: run, expect FAIL** — `pytest tests/test_temp_protocol_events.py -v`
+- [ ] **Step 3: implement** — in `event_bus.py`, alongside the burst events:
+```python
+TEMPERATURE_SETPOINT_CHANGED = auto() # discrete setpoint change (timeline)
+TEMP_PROTOCOL_STARTED = auto() # temp-change burst protocol began
+TEMP_PROTOCOL_COMPLETED = auto() # protocol ended
+```
+In `timeline.py`'s map (mirror the `EventType.BURST_START: {...}` entries), add:
+```python
+ EventType.TEMPERATURE_SETPOINT_CHANGED: {"category": "temperature", "event_subtype": "setpoint_changed"},
+ EventType.TEMP_PROTOCOL_STARTED: {"category": "tactic", "event_subtype": "temp_protocol_started"},
+ EventType.TEMP_PROTOCOL_COMPLETED: {"category": "tactic", "event_subtype": "temp_protocol_completed"},
+```
+> Confirm the real map structure (keys/value shape) from the existing `BURST_START` entry and match it exactly.
+- [ ] **Step 4: run, expect PASS**; `pytest -q` no new failures.
+- [ ] **Step 5: commit** — `git add gently/core/event_bus.py gently/harness/session/timeline.py tests/test_temp_protocol_events.py && git commit -m "feat(tactic): timeline event types for temp-change burst protocol"`
+
+---
+
+### Task 2: Brightfield burst — thread `laser_config`
+
+**Files:** Modify `gently/app/orchestration/exclusive.py` (`BurstAcquisition.__init__` ~line 95 add param; its `client.acquire_burst(...)` call ~line 159-168 pass it); Modify `gently/app/orchestration/timelapse.py` (`queue_burst` ~line 2058 add `laser_config=None`, pass to `BurstAcquisition`). Test: `tests/test_burst_laser_config.py`.
+
+**Interfaces:** Consumes `client.acquire_burst(..., laser_config=...)` (already accepts it, `client.py:642`). Produces `BurstAcquisition(..., laser_config=None)` and `queue_burst(..., laser_config=None)`.
+
+- [ ] **Step 1: failing test**
+```python
+# tests/test_burst_laser_config.py
+import asyncio
+from gently.app.orchestration.exclusive import BurstAcquisition
+
+
+class FakeClient:
+ def __init__(self):
+ self.calls = []
+
+ async def acquire_burst(self, **kw):
+ self.calls.append(kw)
+ return {"success": True, "request_id": "b1", "frames": []}
+
+
+async def test_burst_passes_laser_config(monkeypatch):
+ b = BurstAcquisition("emb1", frames=3, mode="1hz", num_slices=1, laser_config="ALL OFF")
+ assert b._laser_config == "ALL OFF"
+ # the run() path forwards _laser_config into client.acquire_burst kwargs:
+ # (unit-level: assert the attribute + that run threads it — see note)
+```
+> NOTE: `BurstAcquisition.run` needs an orchestrator with a client + embryo lookup + persistence; a full run is heavy. Assert at minimum that `__init__` stores `_laser_config` and that the `acquire_burst` call site in `run` includes `laser_config=self._laser_config` (verify by reading; optionally add a focused test that monkeypatches the embryo/persistence to capture the `acquire_burst` kwargs). Keep the test honest — if a full run is impractical, assert the attribute and add an inline source check that `laser_config=self._laser_config` appears in the `acquire_burst(...)` call.
+- [ ] **Step 2: run, expect FAIL**
+- [ ] **Step 3: implement** — add `laser_config: str | None = None` to `__init__`, store `self._laser_config = laser_config`; in the `client.acquire_burst(...)` call add `laser_config=self._laser_config`. In `queue_burst`, add `laser_config: str | None = None` and pass `laser_config=laser_config` into the `BurstAcquisition(...)` construction.
+- [ ] **Step 4: run, expect PASS**; `pytest -q` clean.
+- [ ] **Step 5: commit** — `feat(tactic): thread laser_config through BurstAcquisition + queue_burst (brightfield bursts)`
+
+---
+
+### Task 3: `wait_for_temperature_lock` helper
+
+**Files:** Create `gently/app/orchestration/temperature_protocol.py` (module for the helper + later the driver). Test: `tests/test_wait_for_lock.py`.
+
+**Interfaces:** Produces `async def wait_for_temperature_lock(client, timeout_s, poll_s=2.0) -> bool`.
+
+- [ ] **Step 1: failing test**
+```python
+# tests/test_wait_for_lock.py
+from gently.app.orchestration.temperature_protocol import wait_for_temperature_lock
+
+
+class FakeClient:
+ def __init__(self, states):
+ self.states = list(states)
+ self.calls = 0
+
+ async def get_temperature(self):
+ i = min(self.calls, len(self.states) - 1)
+ self.calls += 1
+ return {"state": self.states[i]}
+
+
+async def test_returns_true_when_locked():
+ c = FakeClient(["[ IDLE ]", "[ HEATING ]", "[ SYSTEM LOCKED ]"])
+ assert await wait_for_temperature_lock(c, timeout_s=5.0, poll_s=0.001) is True
+
+
+async def test_returns_false_on_timeout():
+ c = FakeClient(["[ HEATING ]"])
+ assert await wait_for_temperature_lock(c, timeout_s=0.02, poll_s=0.001) is False
+```
+- [ ] **Step 2: run, expect FAIL**
+- [ ] **Step 3: implement**
+```python
+# gently/app/orchestration/temperature_protocol.py
+import asyncio, logging
+
+logger = logging.getLogger(__name__)
+
+
+async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.0) -> bool:
+ """Poll the controller until it reports a locked state, or timeout. Substring 'LOCKED'."""
+ loop = asyncio.get_event_loop()
+ t0 = loop.time()
+ while True:
+ try:
+ resp = await client.get_temperature()
+ except Exception as exc:
+ logger.warning("wait_for_temperature_lock poll failed: %s", exc)
+ resp = {}
+ if "LOCKED" in str(resp.get("state", "")):
+ return True
+ if loop.time() - t0 >= timeout_s:
+ return False
+ await asyncio.sleep(poll_s)
+```
+- [ ] **Step 4: run, expect PASS**
+- [ ] **Step 5: commit** — `feat(tactic): wait_for_temperature_lock poll helper`
+
+---
+
+### Task 4: The protocol driver
+
+**Files:** Modify `gently/app/orchestration/temperature_protocol.py` (add the driver fn that takes the orchestrator). Test: `tests/test_temp_protocol_driver.py`.
+
+**Interfaces:** Produces `async def run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, *, frames=60, mode="1hz", num_slices=1, bursts_before=1, bursts_after=1, lock_timeout_s=600.0, poll_s=2.0, burst_runner=None) -> dict`. `burst_runner` is an injectable `async (BurstAcquisition)->None` for tests (defaults to `lambda b: b.run(orchestrator)`).
+
+- [ ] **Step 1: failing test**
+```python
+# tests/test_temp_protocol_driver.py
+from gently.app.orchestration.temperature_protocol import run_temp_change_burst_protocol
+from gently.core.event_bus import EventType
+
+
+class FakeClient:
+ def __init__(self):
+ self.laser = None
+ self.led = None
+ self.setpoint = None
+ self._poll = 0
+
+ async def set_laser_config(self, c):
+ self.laser = c
+
+ async def set_led(self, s):
+ self.led = s
+
+ async def set_temperature(self, t):
+ self.setpoint = t
+
+ async def get_temperature(self):
+ self._poll += 1
+ return {"state": "[ SYSTEM LOCKED ]" if self._poll >= 2 else "[ HEATING ]"}
+
+
+class FakeOrch:
+ def __init__(self, client):
+ self._client = client
+ self._temperature_provider = lambda: None
+ self.events = []
+
+ @property
+ def client(self):
+ return self._client
+
+ def _emit_event(self, et, data):
+ self.events.append((et, data))
+
+
+async def test_phase_order_and_brightfield(monkeypatch):
+ client = FakeClient()
+ orch = FakeOrch(client)
+ bursts = []
+
+ async def runner(b):
+ bursts.append({"phase": getattr(b, "_phase", None), "laser": b._laser_config})
+
+ res = await run_temp_change_burst_protocol(
+ orch,
+ "emb1",
+ 25.0,
+ frames=3,
+ bursts_before=1,
+ bursts_after=1,
+ lock_timeout_s=5.0,
+ poll_s=0.001,
+ burst_runner=runner,
+ )
+ assert client.laser == "ALL OFF" and client.led == "Open"
+ assert client.setpoint == 25.0
+ assert all(b["laser"] == "ALL OFF" for b in bursts) # every burst brightfield
+ assert len(bursts) >= 3 # before + >=1 during + after
+ ets = [e[0] for e in orch.events]
+ assert EventType.TEMP_PROTOCOL_STARTED in ets
+ assert EventType.TEMPERATURE_SETPOINT_CHANGED in ets
+ assert EventType.TEMP_PROTOCOL_COMPLETED in ets
+ assert res["locked"] is True
+```
+- [ ] **Step 2: run, expect FAIL**
+- [ ] **Step 3: implement** — append to `temperature_protocol.py`:
+```python
+from gently.app.orchestration.exclusive import BurstAcquisition
+from gently.core.event_bus import EventType
+
+
+async def run_temp_change_burst_protocol(
+ orchestrator,
+ embryo_id,
+ target_setpoint_c,
+ *,
+ frames=60,
+ mode="1hz",
+ num_slices=1,
+ bursts_before=1,
+ bursts_after=1,
+ lock_timeout_s=600.0,
+ poll_s=2.0,
+ burst_runner=None,
+):
+ client = orchestrator.client
+ if burst_runner is None:
+
+ async def burst_runner(b):
+ await b.run(orchestrator)
+
+ async def one_burst(phase):
+ b = BurstAcquisition(
+ embryo_id,
+ frames=frames,
+ mode=mode,
+ num_slices=num_slices,
+ temperature_provider=getattr(orchestrator, "_temperature_provider", None),
+ laser_config="ALL OFF",
+ )
+ b._phase = phase
+ await burst_runner(b)
+
+ locked = False
+ error = None
+ cancelled = False
+ try:
+ await client.set_laser_config("ALL OFF")
+ await client.set_led("Open")
+ orchestrator._emit_event(
+ EventType.TEMP_PROTOCOL_STARTED,
+ {
+ "embryo_id": embryo_id,
+ "target_setpoint_c": target_setpoint_c,
+ "frames": frames,
+ "bursts_before": bursts_before,
+ "bursts_after": bursts_after,
+ },
+ )
+ for _ in range(bursts_before):
+ await one_burst("before")
+ await client.set_temperature(target_setpoint_c)
+ orchestrator._emit_event(
+ EventType.TEMPERATURE_SETPOINT_CHANGED,
+ {"embryo_id": embryo_id, "to": target_setpoint_c},
+ )
+ loop = asyncio.get_event_loop()
+ t0 = loop.time()
+ while True:
+ await one_burst("during")
+ try:
+ st = str((await client.get_temperature()).get("state", ""))
+ except Exception:
+ st = ""
+ if "LOCKED" in st:
+ locked = True
+ break
+ if loop.time() - t0 >= lock_timeout_s:
+ break
+ for _ in range(bursts_after):
+ await one_burst("after")
+ except asyncio.CancelledError:
+ cancelled = True
+ raise
+ except Exception as exc:
+ error = str(exc)
+ logger.exception("temp-change burst protocol failed")
+ finally:
+ orchestrator._emit_event(
+ EventType.TEMP_PROTOCOL_COMPLETED,
+ {"embryo_id": embryo_id, "locked": locked, "cancelled": cancelled, "error": error},
+ )
+ return {"locked": locked, "cancelled": cancelled, "error": error}
+```
+- [ ] **Step 4: run, expect PASS**; `pytest -q` clean.
+- [ ] **Step 5: commit** — `feat(tactic): temp-change burst protocol driver (brightfield before/during/after)`
+
+---
+
+### Task 5: Agent tool
+
+**Files:** Create `gently/app/tools/temperature_protocol_tools.py` (or add to an existing tools module — follow the `@tool` pattern). Test: `tests/test_temp_protocol_tool.py`.
+
+**Interfaces:** Produces a `run_temp_change_burst_protocol` agent tool that resolves orchestrator+client from `context`, launches the driver via `asyncio.create_task`, returns a started message; validates embryo/client presence.
+
+- [ ] **Step 1: failing test** — assert the tool, given a context with a fake orchestrator/client, creates a task and returns a "started" string; given no client, returns an error without creating a task.
+> Confirm the real `@tool` decorator + context helpers (`ctx_get(context,"client")`, `require_agent`, `require_timelapse_orchestrator`) from an existing tool (e.g. `gently/app/tools/temperature_tools.py`); mirror them. Write the test to the real registration shape.
+- [ ] **Step 2: run, expect FAIL**
+- [ ] **Step 3: implement** — mirror an existing tool: resolve `orchestrator` + `client`, guard None (return error dict/string), `asyncio.create_task(run_temp_change_burst_protocol(orchestrator, embryo_id, target_setpoint_c, frames=frames, bursts_before=bursts_before, bursts_after=bursts_after))`, return `f"Temp-change burst protocol started for {embryo_id} → {target_setpoint_c} C"`.
+- [ ] **Step 4: run, expect PASS**
+- [ ] **Step 5: commit** — `feat(tactic): run_temp_change_burst_protocol agent tool`
+
+---
+
+### Task 6: Observability in strategy_snapshot
+
+**Files:** Modify `gently/ui/web/strategy_snapshot.py` (`_replay_timeline` ~line 627; mirror the burst-phase handling at ~762/777). Test: `tests/test_temp_protocol_snapshot.py`.
+
+**Interfaces:** Consumes the timeline subtypes (`temp_protocol_started/completed`, `setpoint_changed`). Produces, in the snapshot: a `temp_protocol` band (open on started, close on completed) and a `setpoint_changes` list.
+
+- [ ] **Step 1: failing test** — feed `_replay_timeline` (or `build_strategy_snapshot` over a temp `timeline.jsonl`) a sequence: `temp_protocol_started`, `setpoint_changed(to=25)`, `burst_started/completed`, `temp_protocol_completed`; assert the snapshot exposes a temp_protocol span and a setpoint change of 25.
+> Confirm the real `_replay_timeline` input/output shape from the existing burst handling; match the snapshot dict structure it already produces.
+- [ ] **Step 2: run, expect FAIL**
+- [ ] **Step 3: implement** — in `_replay_timeline`, handle the three subtypes: on `temp_protocol_started` open a band (record start t + params); on `temp_protocol_completed` close it; on `setpoint_changed` append `{t, to}` to a `setpoint_changes` list in the snapshot. Surface them in the returned snapshot dict next to the existing phases.
+- [ ] **Step 4: run, expect PASS**; `pytest -q` clean.
+- [ ] **Step 5: commit** — `feat(tactic): surface temp-protocol band + setpoint changes in strategy snapshot`
+
+---
+
+## Self-Review
+- §2.1 brightfield burst → Task 2; §2.2 wait-for-lock → Task 3; §2.3 driver → Task 4; §2.4 events → Tasks 1 & 6; §2.5 tool → Task 5. ✓
+- Open confirmations (explicit): the timeline map value-shape (Task 1), the burst `run` acquire_burst call site (Task 2), the `@tool` + context helpers (Task 5), the `_replay_timeline` shape (Task 6). Each names a fallback.
+- Type consistency: `laser_config="ALL OFF"` everywhere; event names `TEMPERATURE_SETPOINT_CHANGED`/`TEMP_PROTOCOL_STARTED`/`TEMP_PROTOCOL_COMPLETED` across Tasks 1/4/6; driver returns `{locked,cancelled,error}`.
diff --git a/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md
new file mode 100644
index 00000000..cdbe76dc
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-embryo-roles-observability.md
@@ -0,0 +1,43 @@
+# Embryo roles/strain + multi-embryo Operations observability (D2) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
+
+**Goal:** Add a per-embryo `strain` field, refine roles to *use* (add lineaging + a subject/reference `class`), and build the Operations roster lens that reads existing roles + strain. Detector stays on role this pass (full detector→strain separation is tracked future work — spec §4).
+
+**Architecture:** Reuse gently's existing `role` (`roles.REGISTRY`, `EmbryoState.role`, `embryo.yaml`). Add `strain` alongside role; extend the registry; expose roles to the frontend; render a role+strain roster lens above the operation spine.
+
+## Global Constraints
+- REUSE the existing role concept — do NOT invent a new taxonomy. Extend `gently/harness/roles.py:REGISTRY` (`EmbryoRole` dataclass) and read role via the existing accessors (`EmbryoState.role`, `EmbryoInfo["role"]`, `get_role`/`REGISTRY.get`, `/api/embryos/positions`).
+- Strain is a FREE-FORM string per embryo (no registry this pass). Coexists with plan-level strain/genotype overrides.
+- Roles render with their REAL `REGISTRY` `ui_color`/`ui_icon` (magenta test, cyan calibration, lineaging's own) — never invented colors.
+- `class` ('subject'|'reference') is an attribute ON `EmbryoRole`, derived from role; test/unassigned→subject, calibration/lineaging→reference.
+- Backward compatible: embryos without strain → None; plans without role-scope render as D today.
+- Detector stays wired to role THIS PASS (spec §4 documents the full separation as future work — do not implement it here).
+- Git hygiene: stage only your files by explicit path; never `git add -A`.
+
+---
+
+### Task 1: Per-embryo `strain` field
+**Files:** Modify `gently/core/store_types.py` (`EmbryoInfo` — add `strain: str | None`); `gently/harness/state.py` (`EmbryoState` — add `strain: str | None = None`); `gently/core/file_store.py` (`register_embryo` — accept `strain=None`, write/coalesce it in embryo.yaml like `role`); `gently/ui/web/routes/data.py` (`/api/embryos/positions` ~:689 — add `"strain": emb.get("strain")`). Test: `tests/test_embryo_strain.py`.
+- [ ] Confirm how `role` is threaded through `register_embryo` (`file_store.py:507-580`, write at :576, coalesce at :564) and EmbryoState (`state.py:138`); mirror it for `strain`. Confirm the positions endpoint shape (`data.py:661-696`).
+- [ ] TDD: register an embryo with `strain="pan-nuclear GFP"` → get_embryo returns it; update coalesces; absent → None; positions endpoint includes strain. `pytest tests/test_embryo_strain.py -v`; `pytest -q` clean. Commit `feat(d2): per-embryo strain field`.
+
+### Task 2: Roles refined — lineaging + subject/reference class
+**Files:** Modify `gently/harness/roles.py` (add `class_: str = "subject"` — or `klass`/`role_class` to avoid the `class` keyword — to `EmbryoRole`; add a `lineaging` entry to `REGISTRY`; set `class_` on each role: test/unassigned→subject, calibration/lineaging→reference; give lineaging its own ui_color/ui_icon/default_cadence/detector kept None or nuclear like calibration). Test: `tests/test_roles_registry.py` (extend if exists).
+- [ ] Add the `class_` field (default "subject") to `EmbryoRole`; set it on all REGISTRY entries; add `lineaging` (reference, distinct ui_color e.g. a teal/green, ui_icon, cadence). Keep `detector_name` as-is on each role (staged). Confirm nothing else constructs EmbryoRole positionally in a way the new field breaks.
+- [ ] TDD: `REGISTRY["lineaging"].class_=="reference"`; `REGISTRY["test"].class_=="subject"`; `REGISTRY["calibration"].class_=="reference"`; `get_role("lineaging")` works; existing roles/fields unchanged. `pytest tests/test_roles_registry.py -v`; `pytest -q` clean. Commit `feat(d2): roles-as-use — add lineaging + subject/reference class`.
+
+### Task 3: `/api/roles` route + role-scoped tactic scope
+**Files:** Create `gently/ui/web/routes/roles.py` (`GET /api/roles` → `{roles:[{name,description,class_,ui_color,ui_icon,default_cadence_seconds}]}` from `list_roles()`/REGISTRY; register in `routes/__init__.py`). Modify `gently/app/tools/operation_plan_tools.py` (allow `scope.mode=='role'` + `scope.role` in validation — accept a REGISTRY key); add a pure resolver `resolve_scope_embryos(scope, roster_or_embryos) -> list[str]` (in a small module or operation_plan_tools) mapping mode=role→embryo_ids by role. Test: `tests/test_roles_route.py`, `tests/test_role_scope.py`.
+- [ ] `/api/roles` mirrors `routes/tactic_library.py` (simple list route, graceful). The resolver maps `{mode:'role',role:'test'}` against a list of embryos-with-roles → the matching ids; global→all, embryos→explicit. Validation accepts mode=role with a valid role key.
+- [ ] TDD: route returns the registry incl. lineaging + class_; resolver resolves role→ids, global→all, embryos→explicit, unknown role→[]. `pytest tests/test_roles_route.py tests/test_role_scope.py -v`; `pytest -q` clean. Commit `feat(d2): /api/roles route + role-scoped tactic scope resolver`.
+
+### Task 4: Operations roster lens (frontend)
+**Files:** Modify `gently/ui/web/static/js/experiment-overview.js` (add a roster lens above the operation spine: fetch `/api/embryos/positions` + `/api/roles`, group embryos by role `class_` (Subjects foregrounded, References compact) then by role, each row `id · role chip (REGISTRY ui_color/ui_icon) · strain · cadence-phase chip · current tactic (from the plan's role-scoped tactics) · state`; render tactic-node scope by role using the resolver/role labels); `gently/ui/web/static/css/experiment.css` (the `.ops-roster*` classes, using the role colors from the API, not hardcoded). Reference: the validated prototype `scratchpad/d2proto/index.html` (regrounded to real role colors).
+- [ ] Build the roster lens reading the real endpoints + role metadata (colors from `/api/roles`, not invented); class split → role groups → strain; spine nodes show role-scope ("→ test · E01.."). Backward compat: no embryos/roles → omit the lens, spine renders as D. `node --check`; build/refresh the opsv3 (or d2) Chrome harness with the real files for the controller to audit. Commit `feat(d2): Operations roster lens — embryos by role + strain`.
+
+## Self-Review
+- Strain→T1; roles/class→T2; roles route + role-scope→T3; roster lens→T4. ✓
+- Open confirmations: register_embryo/EmbryoState role threading (T1), EmbryoRole construction sites (T2), the route/resolver pattern (T3), the embryos+roles endpoints + plan cross-reference for current-tactic (T4).
+- Type consistency: `strain` str|None across model/store/endpoint; `class_` on EmbryoRole + in /api/roles + read by the renderer; role keys consistent across REGISTRY, scope.role, resolver, renderer.
+- Staged: detector stays on role (spec §4 future work referenced, not implemented).
diff --git a/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md
new file mode 100644
index 00000000..96e4c141
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-29-manual-mode-dual-camera.md
@@ -0,0 +1,38 @@
+# Manual mode B2 — dual-camera + laser-preset browser + timelapse form Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.
+
+**Goal:** Extend B1's single-camera manual mode with a laser-preset browser, dual-camera (side A/B) config, and a manual timelapse-config form — building the headless parts now (live dual-view + real acquisition are rig-deferred).
+
+**Architecture:** New `require_control` proxy routes wrapping existing client/device-layer + agent-tool paths; device_factory registers a second camera defensively; UI additions to `#devices-view-manual` / `devices.js`.
+
+## Global Constraints
+- HEADLESS-buildable parts only; mark RIG-DEFERRED parts (live dual view, real acquisition/timelapse) as noted in the spec — don't fake hardware.
+- Backward compatible: single-camera rigs must still start (defensive HamCam2 registration); the manual-view-entry laser-off safety (B1 I3) stays intact.
+- Laser preset list already exists: `GET /api/devices/laser/configs` (data.py:526). Reuse it; add only the set proxy + UI.
+- Proxy routes mirror the existing `routes/data.py` `require_control` pattern (e.g. `/api/devices/laser/off` :508). Device-layer/client calls mirror existing ones.
+- UI extends `#devices-view-manual` (index.html ~:720-835) + `DevicesManager` in `devices.js`.
+- Git hygiene: stage only your files by explicit path; never `git add -A`.
+
+---
+
+### Task 1: Laser-preset browser
+**Files:** Modify `gently/ui/web/routes/data.py` (add `POST /api/devices/laser/config` `require_control` → `client.set_laser_config(name)`, mirror `/laser/off` :508); `gently/ui/web/templates/index.html` (the Illumination group ~:800 — replace the static `#devices-ls-laser-status` indicator with a `
`;
+ // Run affordance — only for an actionable imaging item. Routes through the
+ // agent (it applies this item's spec via execute_plan_item), in keeping with
+ // the agent-first paradigm.
+ if (item.type === 'imaging' && item.status === 'planned') {
+ html += `