Skip to content
4 changes: 3 additions & 1 deletion docs/superpowers/PR-PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ Each PR diffs cleanly against its parent in the stack; integrate the chain when
| 3 | `feature/temp-change-tactic` (C) | Automated temp-change burst protocol (`wait_for_temperature_lock` + driver), burst-acquisition wiring, protocol events + agent tool | B1 | per-task + whole-branch review + fixes |
| 4 | `feature/operations-tab` (D) | **Operations: the agent-authored Operation Plan** — typed declare tool, store, route, execution-linkage (tactic_id + updater), plan-item seeding, operation-spine renderer + live binding | C | 10 tasks + whole-branch (opus) review + 6 fixes; 105 tests; Chrome-audited |
| 5 | `feature/tactics-library` (G) | Save / list / apply reusable typed tactics (on D's substrate), mirroring plan-templates; apply→Operation Plan | D | 3 tasks + whole-branch review + fixes; ~64 tests |
| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | TBD (SDD) |
| 6 | `feature/embryo-roles-observability` (D2) | Per-embryo **strain** field + roles-as-**use** (lineaging + subject/reference) + multi-embryo Operations roster lens (role + strain) | G | 4 tasks + whole-branch review + fix; 54 tests; Chrome-audited |
| 7 | `feature/session-plan-linking` (F) | Session↔**plans** link/delink: multi-plan model (`unlink_plan_item_session` + reverse-query), link/delink endpoints, Plans-tab controls + session Linked-plans panel | D2 | 4 tasks + whole-branch review + fix; ~70 tests; both surfaces Chrome-audited |
| 8 | `feature/manual-mode-dual-camera` (B2) | Dual-camera config + laser-preset browser + timelapse config form (extends B1 manual mode) | F | TBD (SDD) |

Notes:
- Each branch is the natural unit of "distinct enough to own a PR." Sub-parts (e.g. B1's safety fixes)
Expand Down
39 changes: 39 additions & 0 deletions docs/superpowers/plans/2026-06-29-session-plan-linking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Session ↔ plans link/delink (F) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use `- [ ]`.

**Goal:** Let a session link to multiple plan items, with link/delink from both the Plans tab and the session view. Source of truth = `PlanItem.session_ids` (reverse-query for a session's plans). Base plans deferred.

**Architecture:** Data-layer `unlink_plan_item_session` + `get_plan_items_for_session` (reverse query); link/delink HTTP endpoints; link/delink UI on the Plans-tab item detail + a "Linked plans" panel on the session/Operations view.

## Global Constraints
- Source of truth for session↔plan-item linkage = `PlanItem.session_ids` (a list; `link_plan_item_session` appends — `file_store.py:1415`). A session's plans = reverse query. Do NOT add a plan_item list to SessionIntent.
- Campaign edge stays `SessionIntent.campaign_ids` (`link/unlink_session_campaign`). Linking a session to a plan item also `link_session_campaign(item.campaign_id)`. Delink (v1) only removes the plan-item edge.
- Endpoints mirror `gently/ui/web/routes/campaigns.py` (the item-detail route at :212, PATCH at :324). UI mirrors `campaigns.js` item-detail rendering (~:789-802 Sessions section).
- Repo/base plans DEFERRED (spec §4) — do not build.
- Git hygiene: stage only your files by explicit path; never `git add -A`.

---

### Task 1: Data layer — unlink + reverse-query
**Files:** Modify `gently/harness/memory/file_store.py` — add `unlink_plan_item_session(item_id, session_id)` (remove session from the item's `session_ids`, clear back-compat `session_id` if equal, persist to the campaign plan, fire `_notify_plan_change`; idempotent if absent) and `get_plan_items_for_session(session_id) -> list[PlanItem]` (iterate `get_active_campaigns()` → `get_plan_items(campaign_id)` → filter `session_id in (item.session_ids or [])`). Test: `tests/test_session_plan_linking_store.py`.
- [ ] Confirm `link_plan_item_session` (:1415) — how it appends to session_ids + persists the plan item to `plan/current.yaml` — and mirror the persistence for unlink. Confirm `get_active_campaigns`/`get_plan_items`.
- [ ] TDD: link a session to 2 items (different campaigns) → `get_plan_items_for_session` returns both; `unlink_plan_item_session` removes it from one (other remains); unlink absent → no-op; back-compat `session_id` cleared when it matched. `pytest tests/test_session_plan_linking_store.py -v`; `pytest -q` clean. Commit `feat(f): unlink_plan_item_session + get_plan_items_for_session`.

### Task 2: Link/delink endpoints
**Files:** Modify `gently/ui/web/routes/campaigns.py` — `POST /api/campaigns/{cid}/items/{item_id}/sessions` (body `{session_id}` → `link_plan_item_session` + `link_session_campaign`; return updated sessions) and `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` (→ `unlink_plan_item_session`); add `GET /api/sessions/{id}/plans` in `gently/ui/web/routes/sessions.py` (→ `get_plan_items_for_session`, return `[{id,title,campaign_id,status}]`). Test: `tests/test_session_plan_linking_routes.py`.
- [ ] Mirror the campaigns route store resolution (`server.context_store`/`gently_store`) + the item-detail route (:212). The session-plans route mirrors `sessions.py` resolution. Graceful (404 item/session; empty list).
- [ ] TDD (TestClient + mock store): POST links (store.link_plan_item_session + link_session_campaign called); DELETE delinks (unlink called); GET returns the session's plans; bad ids handled. `pytest tests/test_session_plan_linking_routes.py -v`; `pytest -q` clean. Commit `feat(f): session↔plan link/delink endpoints`.

### Task 3: Plans-tab link/delink UI
**Files:** Modify `gently/ui/web/static/js/campaigns.js` (the item-detail Sessions section ~:789-802 — add a `[+ link session]` picker (sessions from `/api/sessions`) + a `[delink]` button per session, calling the POST/DELETE endpoints then re-rendering the item) + `gently/ui/web/static/css/campaigns.css` (the control styles).
- [ ] Render the link control + per-session delink in the item-detail Sessions list; wire to the endpoints; optimistic re-render / refetch the item on success; keep the "No linked sessions" empty state with the link control. `node --check`; build a Chrome-MCP harness (real campaigns.js + a stubbed item-detail + /api/sessions) for the controller to audit. Commit `feat(f): Plans-tab session link/delink controls`.

### Task 4: Session "Linked plans" panel
**Files:** Modify the session/Operations view JS (`gently/ui/web/static/js/experiment-overview.js` or the session view `review.js` — whichever renders the active session header; CONFIRM which surface) — add a "Linked plans" panel listing `/api/sessions/{id}/plans` rows (title · campaign · status · `[delink]`) + a `[+ link to a plan]` picker (plan items from active campaigns via `/api/campaigns` tree); wire to the same endpoints; CSS as needed.
- [ ] Confirm the session-scoped render surface + how it knows the current session_id. Render the panel reading `/api/sessions/{id}/plans`; link/delink hit the same endpoints + refresh; symmetric with Task 3. Backward compat: no linked plans → a tidy empty state, no crash. `node --check`; Chrome-MCP harness audit. Commit `feat(f): session Linked-plans panel (link/delink)`.

## Self-Review
- Data→T1; endpoints→T2; Plans-tab UI→T3; session panel→T4. ✓
- Open confirmations: link_plan_item_session persistence (T1), campaigns/sessions route patterns (T2), the campaigns.js item-detail render seam (T3), the session-scoped render surface + current session_id (T4).
- Type consistency: plan-item linkage via `PlanItem.session_ids`; the endpoints + both UIs hit the same POST/DELETE/GET; a link from one surface appears on the other after refresh.
64 changes: 64 additions & 0 deletions docs/superpowers/specs/2026-06-29-session-plan-linking-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Design: Session ↔ plans link/delink (sub-project F)

Status: design 2026-06-29 (after recon + user steering). Branch `feature/session-plan-linking` (off D2).
Lets a session link to MULTIPLE plan items, with link/delink from BOTH the Plans tab and the session
view. "Repo/base plans" is DEFERRED (user has ideas — separate follow-on).

## 0. What exists (recon)
- Session↔**campaign**: many-to-many (`SessionIntent.campaign_ids` list; `link/unlink_session_campaign`).
- Session↔**plan-item**: `PlanItem.session_ids` is a LIST (a session can already appear under multiple
items in storage) — but a session's *own* notion of "its plans" is a single `active_plan_item_id`
pointer, and `SessionIntent` stores no plan-item ids.
- `attach_session_to_plan` appends to `item.session_ids` (via `link_plan_item_session`) but overwrites
the single active pointer; `detach_session_from_plan` only clears the pointer (NOT a data delink).
- Plans tab (`campaigns.js:789-802`) shows a per-item read-only Sessions list ("No linked sessions").
- NO link/delink endpoint or UI anywhere; NO `unlink_plan_item_session`; session endpoint returns no linkage.

## 1. The model — source of truth = `PlanItem.session_ids`
A session's linked plan items = the reverse query over plan items whose `session_ids` includes the
session. No new field on SessionIntent (avoids dual source of truth). Multi-plan falls out naturally
(a session can be in many items' `session_ids`). The campaign edge stays on `SessionIntent.campaign_ids`.

- **Link** session↔plan-item: `link_plan_item_session(item_id, session_id)` (exists, appends) +
`link_session_campaign(session_id, item.campaign_id)` (exists).
- **Delink**: NEW `unlink_plan_item_session(item_id, session_id)` — remove the session from
`item.session_ids` (+ clear the back-compat `session_id` if it pointed there); fire `_notify_plan_change`.
Campaign edge: leave it unless no other item of that campaign links the session (refinement — for v1,
delink only touches the plan-item edge; campaign delink stays the existing separate control).
- **Session's plans**: NEW `get_plan_items_for_session(session_id) -> list[PlanItem]` (reverse query
across `get_active_campaigns` → `get_plan_items` → filter `session_id in item.session_ids`).

## 2. Endpoints (mirror `routes/campaigns.py`)
- `POST /api/campaigns/{cid}/items/{item_id}/sessions` body `{session_id}` → link (link_plan_item_session
+ link_session_campaign). Returns the updated item sessions.
- `DELETE /api/campaigns/{cid}/items/{item_id}/sessions/{session_id}` → delink (unlink_plan_item_session).
- `GET /api/sessions/{id}/plans` → the session's linked plan items (id, title, campaign_id, status) via
`get_plan_items_for_session`. (A new sub-route; leaves the existing session payload untouched.)

## 3. UI — both surfaces
### 3.1 Plans tab item-detail (`campaigns.js` ~:789-802)
The existing per-item Sessions list gains: a **[+ link session]** control (a picker of recent sessions
from `/api/sessions`) and a **[delink]** button per listed session. Calls the POST/DELETE endpoints,
re-renders the item detail. Empty state keeps "No linked sessions" + the link control.

### 3.2 Session / Operations view — "Linked plans" panel
A panel (in the Operations/experiment view header or a session detail strip) listing the session's
linked plan items (from `/api/sessions/{id}/plans`): each row `plan item title · campaign · status ·
[delink]`, plus **[+ link to a plan]** (a picker of plan items from the active campaigns). Symmetric
with 3.1 — link/delink from either side; both hit the same endpoints + refresh.

## 4. Out of scope (deferred)
- **Repo/base plans** — the user has ideas; a separate follow-on (a repo plans library / seed). Noted,
not built here.
- Campaign-edge auto-cleanup on plan delink (v1 leaves the campaign link; refine later).
- Reworking `attach_session_to_plan`/`detach` agent tools beyond what's needed — the data-layer
delink (`unlink_plan_item_session`) is added; wiring a `detach` that calls it is a small optional add.

## 5. Testing
- Data layer: `unlink_plan_item_session` removes the session (+ back-compat session_id); idempotent on
absent; `get_plan_items_for_session` reverse-query returns the right items across campaigns; multi-plan
(a session under 2 items) round-trips.
- Endpoints: POST links (item.session_ids gains it + campaign linked); DELETE delinks; GET returns the
session's plans; mirror `tests/test_*route*` with a mock store.
- UI: link/delink controls on both surfaces (node --check + Chrome audit of the Plans-tab item detail +
the session "Linked plans" panel); link from one side shows on the other after refresh.
5 changes: 2 additions & 3 deletions gently/app/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ def exit_plan_mode(self) -> str:
from gently.app.tools.operation_plan_seed import (
seed_operation_plan_from_plan_item,
)

seed_operation_plan_from_plan_item(self.context_store, self.session_id)
except Exception:
logger.exception("operation-plan seeding failed")
Expand Down Expand Up @@ -650,9 +651,7 @@ def _init_timelapse_orchestrator(self):
store=self.store,
claude_client=self.claude,
temperature_provider=lambda: (
self.temperature_sampler.latest
if self.temperature_sampler
else None
self.temperature_sampler.latest if self.temperature_sampler else None
),
)
except Exception as e:
Expand Down
5 changes: 3 additions & 2 deletions gently/app/operation_plan_updater.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
`tactic_id` or session → skip. Handler exceptions are caught so a bad event
never crashes the bus.
"""

from __future__ import annotations

import logging
from typing import Callable
from collections.abc import Callable

from gently.core.event_bus import EventType, get_event_bus
from gently.core.service import Service
from gently.core.event_bus import get_event_bus, EventType

logger = logging.getLogger(__name__)

Expand Down
4 changes: 1 addition & 3 deletions gently/app/orchestration/role_scope.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,7 @@ def resolve_scope_embryos(scope: dict | None, embryos: list[dict]) -> list[str]:
if mode == "role":
target_role = scope.get("role")
return [
e["embryo_id"]
for e in embryos
if "embryo_id" in e and e.get("role") == target_role
e["embryo_id"] for e in embryos if "embryo_id" in e and e.get("role") == target_role
]

return []
92 changes: 69 additions & 23 deletions gently/app/orchestration/temperature_protocol.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import asyncio, logging
import asyncio
import logging

from gently.app.orchestration.exclusive import BurstAcquisition
from gently.core.event_bus import EventType

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()
Expand All @@ -11,61 +15,103 @@ async def wait_for_temperature_lock(client, timeout_s: float, poll_s: float = 2.
try:
resp = await client.get_temperature()
except Exception as exc:
logger.warning("wait_for_temperature_lock poll failed: %s", exc); resp = {}
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)


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, tactic_id=None):
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,
tactic_id=None,
):
"""Scripted temp-change burst protocol: brightfield before/during/after a setpoint change."""
client = orchestrator.client
if burst_runner is None:
async def burst_runner(b): await b.run(orchestrator)

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 = 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
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,
"tactic_id": tactic_id})
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,
"tactic_id": tactic_id,
},
)
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()
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
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
cancelled = True
raise
except Exception as exc:
error = str(exc); logger.exception("temp-change burst protocol failed")
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,
"tactic_id": tactic_id})
orchestrator._emit_event(
EventType.TEMP_PROTOCOL_COMPLETED,
{
"embryo_id": embryo_id,
"locked": locked,
"cancelled": cancelled,
"error": error,
"tactic_id": tactic_id,
},
)
return {"locked": locked, "cancelled": cancelled, "error": error}
3 changes: 2 additions & 1 deletion gently/app/temperature_sampler.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
and publishes TEMPERATURE_UPDATE for the live graph. A failed poll is a gap, not a
crash; with no active session the loop idles.
"""

from __future__ import annotations

import asyncio
import logging
from datetime import datetime, timezone

from gently.core.event_bus import EventType, get_event_bus
from gently.core.service import Service
from gently.core.event_bus import get_event_bus, EventType

logger = logging.getLogger(__name__)

Expand Down
Loading
Loading