Skip to content

Merge development into main#104

Open
pskeshu wants to merge 423 commits into
mainfrom
development
Open

Merge development into main#104
pskeshu wants to merge 423 commits into
mainfrom
development

Conversation

@pskeshu

@pskeshu pskeshu commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

main has not moved since 1 June while development has taken 397 commits. Anyone landing on the repository sees a snapshot that is two months behind the work.

This brings to main:

  • the lint CI workflow (ruff check, ruff format, mypy), currently passing on development
  • CONTRIBUTING.md — dev setup, pre-commit hooks, how to run the checks, and the incremental typing policy
  • the mypy-strict work and the accumulated feature branches merged since June

development is 397 ahead and 0 behind, so this is a fast-forward with no conflicts.

Requires one approving review — main is protected with admin enforcement on.

subindevs and others added 30 commits June 17, 2026 11:34
Removes all 9 hardware modules from [[tool.mypy.overrides]].

Latent bugs fixed (flagged — no test env here):
- devices/camera.py: BottomCamera.__init__ annotated led_control: "DiSPIMLED"
  but never imported the type (mypy name-defined) -> added TYPE_CHECKING
  import from .optical.
- devices/test_temperature_controller.py: __main__ demo re-imports
  AcuityNanoPrecisionThermalizerAPI, shadowing the in-file test double
  (no-redef) -> targeted ignore.

Type-only changes: annotate Optional instance attrs (client _session,
device_layer system/devices/plans/history, camera _last_image(_time),
acquisition num_slices/exposure/laser_config, sam _predictor/_mask_generator);
dict[str, Any] for accumulator dicts inferred too narrowly (device_factory
devices, device_layer extract results, sam result/changes/verification/
review_r2/results, plans results, switchbot status, client plan_kwargs/
result); assert _session not-None at use sites after connect(); `or 0`
guard on a piezo-position read; 4-tuple/float coercions.

mypy . clean (300 files); ruff clean.
…, top-level) (#49)

Removes 19 modules from [[tool.mypy.overrides]] (ui.web routes + server +
strategy_snapshot, ml.data_loader/federated, eval.decision_log/event_capture,
analysis.core/steps, organisms.celegans.developmental_tracker, detection,
log_config, gently top-level __init__/gently.py/agent, launch_gently).

Latent bugs fixed (flagged — no test env here):
- ml/federated.py: results from gather(return_exceptions=True) were filtered
  with isinstance(result, Exception), which misses non-Exception
  BaseExceptions -> use isinstance(..., BaseException).
- ui/web/server.py: VisualizationServer.mesh_service / agent_bridge were set
  from launch_gently but never declared on the class -> declared in __init__.

Type-only changes: dict[str, Any]/list[Any] container annotations; Optional
attr annotations (eval file handles, viz/predictor None-inits) with
TYPE_CHECKING imports; frozenset annotation on EventCapture.DEFAULT_SKIP;
coerce strategy_snapshot embryo_id to str key; legacy optional-import-to-None
ignores (__init__ FileContextStore, agent create_devices, data_loader Dataset
stub); targeted arg-type ignores where float(None) is caught by except;
list[str|None] for the chrome-path candidates; str(log_dir/...) consistency.

mypy . clean (300 files); ruff clean.
Persistence (session-scoped temperature.jsonl + per-acquisition meta stamp)
+ live SVG graph (water trace, stepped setpoint) on a Devices-tab card.
First slice of the temp-strain experiment prep roadmap (A before B/manual mode).

Brainstormed and approved 2026-06-27.
- BurstAcquisition accepts temperature_provider (zero-arg callable);
  _persist_burst_to_disk injects temperature_stamp() into per-frame
  metadata["metadata"]["temperature"] and burst.yaml top-level "temperature"
- TimelapseOrchestrator accepts temperature_provider and threads it through
  queue_burst() and _apply_runtime_state() → BurstAcquisition
- agent.py passes lambda: temperature_sampler.latest when constructing the
  orchestrator so live sessions stamp real readings
- acquisition_tools: folds temperature_stamp into acq_metadata on every
  put_volume / register_volume call (volume path)
- FileStore.get_volume_meta() accessor added (reads tNNNN.meta.yaml)
- tests/test_temperature_stamp.py: TDD — None-guard + volume round-trip
The water trace, gridlines, and tick labels used .devices-container-map-scoped
--map-* vars, so the graph rendered blank outside that container. Promote them to
:root-level --temp-* vars (mirroring --temp-setpoint-color) so the component is
reusable when manual mode (sub-project B) re-mounts it. Same colors in-app.
…presets

Replace set_laser_power(488, 0) with set_laser_config("ALL OFF") in the
laser/off proxy route so the PLogic gate turns off every laser line, not
just the 488 nm setpoint (spec §2.7 brightfield safety).

Add device-layer handlers handle_set_laser_config / handle_get_laser_configs
(POST /api/laser/config, GET /api/laser/configs) calling
DiSPIMLightSource.set() / ._get_available_configs() on the laser_control
device (group_name="Laser"). Wire matching client methods set_laser_config /
get_laser_configs. Add unguarded GET /api/devices/laser/configs proxy route.
Clears the remaining mypy errors in the last exempted modules (scripts,
dataset, benchmarks/perception, diagnostics, core/store) and removes the
entire [[tool.mypy.overrides]] block from pyproject.toml. `mypy .` is now
clean across all 297 source files with no per-module exemptions.

Notable fixes among the final 11 errors:
- explorer_server: key session_embryo_counts by (session_id, embryo_id)
  tuple instead of str
- launch_viz_server: pass the Path half of the (timestamp, Path) tuple
  returned by _discover_volumes to project_volume
- projection_explorer: declare ExplorerHandler.session_manager as a
  required class var (assigned before serving) instead of `= None`
- metrics: skip None ground-truth-stage keys in tool_use_by_stage
- auto_label_examples: annotate save_data as dict[str, Any]

Deletes three dead files that imported removed/never-existent symbols and
could not run:
- examples/example_dispim_workflows.py (AutofocusConfig/CalibrationConfig
  and dispim_* plans removed in an earlier refactor)
- tests/test_text_tool_call_extraction.py (_extract_text_tool_calls never
  existed in committed history)
- tests/validate_recursive_autofocus.py (recursive_focus_single_round
  never existed)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Commit 2be3587 ("mypy: clear gently library modules") silently deleted
every entry from [project.dependencies], all [project.optional-dependencies]
extras (sam, torch-gpu, torch-cpu, device), the entire [dependency-groups]
dev group (pytest, ruff, pre-commit, mypy), and the project classifiers —
leaving only the explanatory comments. The commit message mentions none of
this; it was an unintended edit.

With the dependency lists empty, `uv sync` installs nothing, so the project
cannot be installed or run and mypy can only ever see Any-typed third-party
imports. Restores all stripped entries verbatim from 2be3587^ (the only
intended pyproject change on this branch remains the removal of the
[[tool.mypy.overrides]] block).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The existing mypy step runs deps-less (pip install mypy only), so with
ignore_missing_imports every third-party import is Any and real type
mismatches stay hidden — `mypy .` reports clean against the weaker check.

Adds a separate mypy-strict job that installs the project via uv (cloning
the gently-perception sibling first, as [tool.uv.sources] requires) and runs
`uv run mypy .` with real stubs present. This is the check contributors get
locally per CONTRIBUTING.md and catches genuine mismatches the deps-less run
cannot.

Marked continue-on-error for now: the deps-installed run still surfaces
errors the deps-less baseline hid, and isolating it in its own job keeps the
heavy uv/sibling setup from breaking the required lint gate. Flip to a
required gate once it is clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pskeshu and others added 23 commits July 18, 2026 12:45
Six call sites carried `if width > height * 2: take the left half`, a guess at
splitting a stitched dual-camera frame. This rig is single-camera and its native
SPIM frames are 2048x512 (4:1), so the heuristic fired on every real frame and
silently threw away half the image. An embryo right of centre produced an empty
or garbage projection, one straddling the midline got sliced, and the same path
feeds the perceiver.

A genuine dual-view volume is 4D (Views, Z, Y, X) and every one of these sites
already handles that case explicitly on the preceding line. So the aspect-ratio
branch was never selecting a view — only truncating one. Removed at all six.

tests/test_view_splitting_regression.py puts a marker in the right half of a
synthetic 2048x512 volume and asserts it survives. Five of its six cases fail
against the previous code and pass now; the images.py case passes either way and
is kept as a guard rather than a proof.

Claude-Session: https://claude.ai/code/session_01DNw6x8E3Kbd3yNXsgZxxrF
…tream

Ryan tests on the rig tomorrow. Three things stood between him and the workflow.

He never would have seen it. The devices tab opened on Map and the finished
guided flow sat behind an unlabelled fifth button. Operate is now the landing
view.

It could not be left or reversed. Progressive disclosure had been implemented as
an exclusive CSS selector list (one .op-group at a time) with every control
inside a group, so no control could outlive a step and setStep was only ever
called forward; the header stepper was styled as a breadcrumb but answered
clicks on one of its eight nodes. Every node is now a real button and the cursor
moves in both directions. This is safe because the gates that matter are
evaluated against live hardware state, not step position — revisiting Center
while the head is down still refuses to move. Progress (_states) stays monotonic
and is never rewound by navigation. Escape and an always-visible Cancel leave
any step.

Z was text, not an instrument. The device layer already returned {position, min,
max, distance_to_floor} on every axis call and the frontend dropped min/max on
the floor; a 1 Hz fdrive/bottom_z stream existed with a comment saying it was
for this view and no subscribers. Both axes now render as travel gauges — real
range, current position marked on it, hard-limit line on the F-drive — living on
a shelf outside the step system, so raising the head is one click from anywhere.
The F-drive gains a fine 1 um step.

Also:
- The head-down interlock survives a reload. It was a client-only flag starting
  false, so refreshing mid-embryo re-enabled Center and would have commanded an
  absolute XY move with the objective down while the strip read "up". Retract is
  a relative +100, so there is no absolute safe height to derive it from — it is
  persisted instead. A retract that fails no longer reports the head as up.
- One surface per camera stream. Deleted the Map's embedded bottom-cam panel and
  guarded the frame handlers and renderStep on visibility, so a hidden Operate
  stops decoding frames and racing the visible view for stream ownership.
- The Run chooser defaults to Manual. It defaulted to Adaptive, so the default
  path launched a timelapse and never reached Center.
- The grid responds to its container, not the window. With the agent panel open
  the container is ~560px on a 1792px display, which the fixed 264/248px side
  columns overflowed — collapsing the viewport to a 24px strip and letting the
  rail overlap it, while the stacking breakpoint never fired because it keyed off
  viewport width.

Deliberately untouched: the stream encoder. Image quality was never reduced —
only frame rate (commit 6797e0a) — and the 15fps cap is what stands between a
lowered head and a repeat of the 2026-06-29 Video-TDR freeze.

Verified in-browser: landing view, forward and backward jumps, Escape from three
targets, Cancel, gauge geometry against known positions, the reload interlock,
and both layouts in light and dark.

Spec: docs/superpowers/specs/2026-07-18-operate-navigable-instruments-design.md

Claude-Session: https://claude.ai/code/session_01DNw6x8E3Kbd3yNXsgZxxrF
…t strip

Follow-up to review on the rig-facing surface.

Only the axis in play is shown. Offering SPIM-head controls during the
bottom-camera steps invited moving the wrong drive; the bottom objective is
equally irrelevant once the SPIM camera is live. Gauge visibility now follows
the active camera. Awareness of the other axis is not lost — the safety strip
carries the sample stage at every step.

F-drive steps follow the height. The drive travels ~25000 µm down to a sample
sitting around 50-60, and operators work it in bands: a jump to ~5000, then
thousands, then hundreds, then tens. A fixed ±10/±100 was 2500 clicks at the top
and a crash risk at the bottom. Steps are now generated per band and captioned
with which band you are in, so the change is legible rather than surprising.

Engagement is derived from telemetry, not just from clicks. The drive can be
moved at the controller box, so a latched UI flag read "clear" while someone
hand-drove the sample up to the objective — with XY centering still enabled.
Either the latch or telemetry within 1000 µm of the limit now locks XY, and
neither alone can report "clear". The threshold is a RIG-NOTE, unconfirmed
against real geometry.

Fixed a stale-telemetry bug this surfaced: the safety strip only repainted from
renderStep, so under uncommanded motion TRAVEL LEFT sat frozen at its last
commanded value while the sample closed in. It repaints with the gauges now.

Also:
- Removed the viewport floor bar. It hardcoded a 500 µm full scale and a literal
  "30 µm hard floor" caption, so it disagreed with the real axis limits and was
  showing 24800 while the drive was at 30.
- The left spine says what it holds: "Where they are" over the map, "Marked
  embryos" over the worklist, "Mark more embryos" on the button, and an empty
  state that names the next action instead of "No embryos yet".
- The safety strip labels what each reading means (SAMPLE STAGE / TRAVEL LEFT /
  LED / LASER / CAMERA) with a tooltip on each, rather than bare abbreviations.
- Copy is direction-neutral about the hardware: the F-drive number falls as the
  sample closes on the objective, but which way it physically travels is not
  asserted here. "Lower the SPIM head" became "Approach the objective".

Verified in-browser by replaying an approach as 1 Hz device-state ticks with no
UI interaction: readout, marker, gauge foot and strip all follow, the band walks
coarse→approach→close→fine, steps that would cross the limit grey out, and XY
locks at 400 µm having never been clicked.

Claude-Session: https://claude.ai/code/session_01DNw6x8E3Kbd3yNXsgZxxrF
The deps-installed mypy job (`mypy-strict`) has been passing since the
remaining ~200 deps-only errors were cleared (#83 and follow-ups). Remove
`continue-on-error: true` so a regression in the real-dependency type
check now fails the workflow like the deps-less run, and refresh the
now-stale comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cv4znxcdvcX4HpXUbMsRF7
The desktop shell spawns the backend with CREATE_NO_WINDOW in release, so when
something misbehaves mid-session there is no console to look at. The device
layer already had a captured-stdout tail buried in a card on the Devices tab;
the agent side had nothing reachable from the app at all.

A console button in the header opens a drawer with two tabs. Device layer reuses
the supervisor's live captured stdout and falls back to the layer's own log file
when it runs externally and the supervisor captured nothing. Agent tails the
newest {storage}/logs/gently_*.log via a new /api/logs/{source}, reading only
the last 512 KB so a multi-MB session log stays cheap.

Level filter, follow-tail, copy, Esc and Ctrl+` to toggle. Scrolling up turns
following off, because yanking the view back to the end while someone is reading
a traceback is the whole reason log viewers get abandoned. A dot on the header
button marks errors in the tail so a failure that happens while the drawer is
closed still gets noticed.

Polling runs ONLY while the drawer is open — a console that keeps fetching
behind a closed panel is the same class of hidden work that had the camera
streams fighting each other.

Two bugs found by driving it:
- The body used --img-bg, which stays dark under the LIGHT theme while --text is
  also dark: black on black, every line invisible. --bg-dark/--text are a
  matched pair in both themes.
- A refresh requested while a poll was in flight was dropped, so changing the
  tab or the level filter appeared to do nothing for up to two seconds. Pending
  refreshes are now remembered and re-run.

Also relabelled a misleading log line: "TUI client disconnected" fired on EVERY
agent-websocket disconnect, including ordinary browser tabs, which reads as the
TUI running when it is not. It now says what it is.

Claude-Session: https://claude.ai/code/session_01DNw6x8E3Kbd3yNXsgZxxrF
ci: make mypy-strict a required gate (closes #63)
Delete the step/workflow layer rather than simplify it. Operate was an 8-node
phase stepper (focus/mark/choose/center/approach/focus/calibrate/acquire) with
per-embryo progress states, a five-mode run chooser and CSS-exclusive
disclosure. It is now three independent surfaces:

  Bottom cam   feed + bottom-Z + detect/mark, click an embryo to centre
  SPIM head    feed + F-drive + sheet alignment + calibrate
  Acquisition  embryo roster + what to run

The invariant: no control reads the current pane. `_pane` is consulted only by
render dispatch and camera-stream ownership. Gating comes from live hardware
state — the XY interlock and the F-drive floor — never from position in a
sequence. Selection is a cursor: it parameterises requests and disables nothing.

Frontend only; every endpoint already existed.

Safety:
- One chokepoint for absolute XY moves. The server does not interlock XY
  against the F-drive, so this predicate is the whole guard.
- The interlock is now visible, not merely enforced: a banner on both camera
  surfaces, markers drawn locked, the canvas cursor withdrawing its affordance,
  and an always-reachable back-off. Previously the latch could only be cleared
  on a step you might never reach, stranding XY locked until sessionStorage was
  cleared by hand.
- Telemetry may now clear a stale latch (2x hysteresis), so raising the head at
  the controller box is noticed. It could previously only add lock.
- Camera ownership keys on pane visibility, so at most one decoder ever runs —
  the condition the 15fps cap exists to protect against.

Fixes found on the way:
- replay-recorder blocked #op-cam-img by name; the split into two viewports
  would have silently started recording ~100KB/frame on the main thread.
- The F-drive gauge was linear over 30-25000um, making the last 200um — the
  entire approach-and-crash region — sub-pixel. Now log-scaled with ticks.
- The marking overlay sized its backing store once during layout and never
  re-synced, drawing every marker at the wrong place on a stretched canvas.
  Now tracks the CSS box, is dpr-aware, and observes resizes.
- Markers live in stage coordinates and re-project onto each frame, so they
  track the sample instead of the viewport. The freeze-the-frame marking mode
  is gone.
- Hidden elements could be defeated by a class setting `display`.

Operate subtabs deliberately have no number-key shortcuts: 1-6 are global
main-tab navigation, so binding them either steals a key or never fires.

Extracted the risky pure logic to operate-math.js with 26 node --test cases
(no JS toolchain added): the pixel<->stage transform, F-drive banding, the
floor gate, gauge fractions, and the interlock truth table.

Stories US-10..US-15 and US-18 rewritten for the new structure; US-19
(surfaces always live) and US-20 (interlock visible) added as the acceptance
tests. US-16/17 untouched — they target the Operations tab.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
It describes a design that no longer exists. Kept rather than deleted as the
record of why the step model was abandoned instead of repaired.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
A blockSelector that matches nothing does not error — it silently stops
blocking. The failure is severe and invisible: rename the camera <img> and
rrweb starts capturing a base64 data-URI src swap at frame rate on the main
thread. That is exactly what the Operate rewrite would have caused, since
BLOCK_SELECTOR still named the old single-viewport id.

Audit the composed selector string actually handed to rrweb (BLOCK_SELECTOR,
plus HIGH_CHURN in balanced fidelity) once at boot. Misses go to one console
warning and ride along on the page-load action, so a postmortem explains an
unexpectedly huge or janky recording instead of leaving it a mystery.

Self-calibrating rather than gated on a page sentinel: this recorder also runs
on launch/login/settings where none of these elements exist, so it only reports
when SOME selector in the list matched — that is what identifies the page as
the one the list belongs to. Blind spot: every selector rotting at once reads
as "wrong page" and stays silent. They live in different subsystems, so that is
far less likely than the false positives a sentinel would produce.

Verified in-browser on all three paths: silent on launch (no matches), silent
on a healthy index (6/6 match), and on a simulated rot it warns naming the
selector and persists blockSelectorMisses into the session's
ui-replay/actions.jsonl.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
A trailing comment pushed the dom_count call over the line limit. Lifted the
comment above the call rather than taking ruff's split-the-arguments fix, which
reads worse.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
Written for any contributor, not just those with write access to the org repo.

Development workflow: branch off `development`, PR against `development` in
gently-project/gently. Covers both paths — pushing branches straight to the org
repo with write access, and the fork + `upstream` remote route without it.

Before every commit: the three commands CI actually gates on (ruff check, ruff
format --check, mypy), plus `pre-commit install`. .pre-commit-config.yaml
already wires exactly those hooks at matching pins, but the git hook is not
installed in a fresh clone, so commits bypass it — which is how an unformatted
file reached CI on PR #100.

Also records the traps found while fixing that: the lint job stops at the first
failing step, so green ruff is not evidence mypy ran; mypy-strict is
continue-on-error and must not be read as the required gate; no CI runs on a
feature branch until a PR exists; JS has no CI coverage at all; and gh defaults
to `main` and to whichever remote it resolves unless --base/--repo are passed.

Corrects the gently-perception repo reference to gently-project/gently-perception,
which is what CI clones and what pyproject.toml expects beside this repo.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
…ents

CLAUDE.md is the file an agent reads before touching this repo, so it should
carry what an agent cannot infer and defer everything else.

The first draft duplicated most of CONTRIBUTING.md's toolchain section, which
is how docs drift apart. Now it points at README.md for setup/run and
CONTRIBUTING.md for the lint/type toolchain, and keeps only what neither says:

- Where work lands: branch off `development`, PR against `development` in
  gently-project/gently, with and without write access to the org repo.
- The pre-commit hook is not installed in a fresh clone, so commits silently
  bypass ruff and mypy — how an unformatted file reached CI on #100.
- Non-obvious CI behaviour: the lint job stops at its first failing step (so
  green ruff is not evidence mypy ran), mypy-strict is continue-on-error and
  does not gate, no CI runs until a PR exists, and JS has no CI coverage at all.
- Running off-Windows: the `D:/Gently3` default is not an absolute path on
  Linux/macOS and silently creates a junk `D:` directory (issue #56), so
  GENTLY_STORAGE_PATH must be set. Records the --no-api/--no-auth/--no-browser
  combination and the 8080 UI port.

Also corrects the gently-perception reference to gently-project/gently-perception,
matching what CI clones and what pyproject.toml expects beside this repo.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
The instruction stands on its own. Which PR happened to expose the missing hook
is trivia a future reader has no use for.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
Pass over the whole file for content that does not help someone working here.

Removed:
- "Debugging Data Sources" — all eight bullets restated paths already annotated
  in the directory-layout tree directly above, and re-hardcoded the Windows
  storage root eight more times.
- "Drop-in API replacement" and the "replaces GentlyStore/ContextStore" framing
  in Key store classes — migration-era wording, and duplicated by the section
  below it.
- The issue reference in the off-Windows note. The behaviour is described on its
  own terms; the issue now carries the reminder to update this file when fixed.

Reframed the legacy-store section around what actually matters: GentlyStore and
ContextStore are still in the package and still have passing tests, so they look
live, but nothing outside tests/ instantiates them. Stated as "do not wire new
code to these" rather than as data-migration history.

Fixed the log-tailing commands, which never worked: `ls -t <dir>/gently_*.log`
prints a full path, and the documented form prefixed the directory again,
yielding `D:/Gently3/logs/D:/Gently3/logs/gently_*.log`. They now resolve the
storage root through GENTLY_STORAGE_PATH so they also work off-Windows.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
Review catch from @subindevs: the mypy-strict bullet said the job was
non-blocking, but #99 made it a required gate and removed continue-on-error.
The bullet went stale in the week this PR sat open, and told an agent to ignore
the exact job that would block their merge.

Rather than correct the fact and leave the same trap, apply this PR's own
defer-don't-duplicate principle to CI as well. lint.yml is now named as the
source of truth for what gates, and the bullets keep only what does not depend
on current config: that two mypy runs exist and can disagree (deps-less resolves
third-party imports as Any, deps-installed sees real types), that a job stops at
its first failing step, that CI is Python-only, and that a branch with no PR
gets no signal.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
…st frame

- Stop now freezes the last camera frame instead of clearing it, so the
  operator can keep reading the sample surface after ending live view; the
  "LIVE" badge dropping is the cue the frame is static. Falls back to the
  placeholder only when no frame was ever shown.
- The viewport box (.op-cam-fit) is sized to the frame's own aspect ratio,
  set from the image, so the border hugs the image instead of letterboxing
  it inside an oversized box.
- "Detect automatically" runs on the frame already on screen when one
  exists: the device layer caches the last full-res stream frame and the
  stage XY it was taken at, and detect_embryos reuses it (skipping capture
  and the LED/room-light dance). Falls back to a fresh capture when the
  viewport is empty.
- A spinner overlay is shown over the image while detection runs.
- Log the traceback in the detect_embryos capture/SAM except blocks (device
  layer + client) instead of only returning it in the response body, so
  environment failures (missing deps, checkpoint, CUDA) are diagnosable from
  the device log rather than leaving a clean-looking log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
uv sync (--extra torch-gpu --extra sam) caught the lock up to the already
committed pyproject: gently version bump to 1.0.0.dev0 and the playwright
dev dependency (plus its transitive greenlet/pyee). No runtime dep changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shared embryo rail (left of the camera, side-by-side across Bottom/SPIM):
- A persistent embryo list left of every instrument surface, rendering the
  canonical set. Click a row to select (highlights everywhere); an × removes
  a false positive. Bootstraps from /api/embryos/current on load so it
  survives a browser refresh instead of vanishing until the next mutation.
- Hidden on Acquisition (its own roster is richer); stacks above the pane
  only at the smallest width.

Count bootstrap:
- The global "N embryos" header/footer read /api/embryos (disk store), which
  is empty until a volume is acquired, so they showed 0 on load. Bootstrap
  from the in-memory /api/embryos/current (mapped to ids) and fan out an
  EMBRYOS_UPDATE so the header strip refreshes too.

Detect UX:
- Return and display the frame SAM ran on, so a fresh-capture detect shows
  the image the candidates came from instead of a blank viewport.
- Re-running Detect replaces the previous auto-detected set instead of
  stacking onto it (kept: manual marks).

Persist embryos to session files:
- Register writes each embryo to embryos/{id}/embryo.yaml (best-effort,
  never blocks registration); delete removes it from disk via a new
  FileStore.delete_embryo so a removed false positive doesn't resurrect.
  Reload on restart is via the existing --resume path (list_embryos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Detect on an empty viewport was a single capture+SAM round-trip, so the
image only appeared at the very end, together with the overlays. Split it
into two phases so the operator sees the image first:

- device: detect_embryos now caches the frame it captures, and honours a
  capture_only flag that returns the (illuminated) frame WITHOUT running SAM.
- client/route: thread capture_only through.
- operate.js: when the viewport is empty, runDetect first calls capture_only
  and displays the frame ("Capturing…"), then calls use_last_frame to run SAM
  on that exact image ("Detecting…") and overlays the results. When a frame is
  already on screen it skips straight to detection on it.

Sequence is now: image → detecting spinner → overlaid candidates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review catch from @subindevs: `node --test tests/js/` fails on this branch —
`tests/js/` exists only on the operate branch (#100), not on development. The
old wording also implied JS tests already exist on development, which they
don't.

Reworded to the durable fact — CI runs no JavaScript, so verify UI changes by
running the app — which holds whether or not an in-tree JS suite exists, so it
does not go stale when #100 lands one.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
#102 deletes CLAUDE.md's duplicated toolchain text and defers to CONTRIBUTING.md
as canonical — which only holds if CONTRIBUTING.md is right. It wasn't; the
incremental-typing migration finished and the doc never followed.

CONTRIBUTING.md:
- "Type checking" described a `[[tool.mypy.overrides]]` / `ignore_errors = true`
  ignore-list and a policy for managing it. That block no longer exists in
  pyproject.toml (ignore_errors: 0 occurrences), so the whole mechanism and its
  policy were describing something gone. Referenced issue #46, which is closed.
  Replaced with the durable fact: two mypy runs (deps-less `mypy .` with
  ignore_missing_imports, and deps-installed `uv run mypy .`) that can disagree,
  and how to reproduce each. New code must pass both.
- "CI" said one mypy runs and `pre-commit run --all-files` fixes any failure.
  Since #99 there are two mypy jobs and pre-commit reproduces only the deps-less
  one. Now points at lint.yml as the source of truth for gating and names what
  pre-commit does and does not reproduce.

Deliberately cites no issue numbers: #49 is OPEN but its body describes 369
mypy errors that have since been cleared (mypy . passes clean today), so the
tracker is not a reliable source — only pyproject.toml and lint.yml are.

CLAUDE.md: its one-line pointer described CONTRIBUTING.md as covering "the
incremental-typing policy", the section just removed — updated to match.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
`lint.yml` installed ruff unpinned (`pip install ruff`), so CI floated onto
ruff 0.16.0 the moment it released and `ruff format --check .` began failing
every PR — while the pre-commit hook and local dev stayed on the pinned
v0.15.17. The pre-commit config's own comment promised "local runs, the
pre-commit hook, and CI all use the same" ruff, but only mypy was actually
pinned everywhere; ruff had an exact pin in pre-commit and a floor (`>=0.4.0`)
in pyproject with nothing pinning CI at all.

Adopt 0.16.0 deliberately and pin it in all three places, matching how mypy is
already handled:

- `.github/workflows/lint.yml`: `pip install ruff==0.16.0`
- `.pre-commit-config.yaml`: ruff-pre-commit rev `v0.16.0`
- `pyproject.toml` dev deps: `ruff==0.16.0` (was `ruff>=0.4.0`)

0.16.0 formats Python code blocks inside Markdown by default, so `ruff format .`
restyled the examples in 19 docs (quote normalisation, comment spacing, blank
lines). No source files changed — all `.py` was already clean under 0.16.0.
Verified: `ruff check .` and `ruff format --check .` both pass, and code-fence
counts are unchanged in every doc (no block merged or dropped).

Future ruff bumps are now a deliberate `pre-commit autoupdate` plus the two
matching pins, not a silent CI drift.

Claude-Session: https://claude.ai/code/session_01XMWRh8uasxiH9jKiDxYtrZ
chore: pin ruff to 0.16.0 across CI, pre-commit, and pyproject
@pskeshu
pskeshu requested review from schneidermc and subindevs July 26, 2026 07:45
pskeshu added 5 commits July 26, 2026 03:51
docs: make agents and contributors effective — CLAUDE.md + CONTRIBUTING.md
Bind the capture error to a local before formatting it into the response
so the line fits the 100-column limit, and let ruff format rejoin a log
message that fits on one line. No behaviour change.
…-instruments

Operate: navigable steps, position-aware instruments, and the half-frame fix
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants