' +
+ section('Open questions', qHtml) + section('Watching', wHtml) +
+ section('Expectations', eHtml) + section('From the notebook', nHtml);
+ wire();
+ }
+```
+
+- [ ] **Step 3: Make notebook rows click through to the Notebook tab**
+
+In `wire()`, after the existing `.cx-item` loop, add a handler for note rows (append inside `wire`, before its closing brace):
+
+```javascript
+ el.querySelectorAll('.cx-note').forEach(row => {
+ row.style.cursor = 'pointer';
+ row.addEventListener('click', () => {
+ if (typeof switchTab === 'function') switchTab('notebook');
+ });
+ });
+```
+
+- [ ] **Step 4: Verify in the browser**
+
+Restart the server, open `http://localhost:8080`, confirm the Home "Agent's view" panel shows a "From the notebook" section with recent notes, and clicking a row switches to the Notebook tab. Use chrome-devtools (navigate, evaluate_script to click, take_screenshot, list_console_messages → zero errors).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/static/js/context-surface.js
+git commit -m "feat(notebook): Agent's-view live edge — recent notes section -> Notebook tab"
+```
+
+---
+
+## Self-Review
+**Spec coverage:** design §3 two-faced presentation — the ambient live edge now surfaces recent notebook notes on Home, clicking through to the tab. ✓ Reuses `/api/notebook/notes` (+ new `limit`) and existing `cx-dot` colors. ✓
+**Placeholder scan:** none. ✓
+**Type consistency:** `render(data, notes)`, `dotFor`, `limit` param, `switchTab('notebook')` all consistent; kinds map to existing cx classes. ✓
diff --git a/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md
new file mode 100644
index 00000000..d518f621
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-notebook-producer-bridge.md
@@ -0,0 +1,251 @@
+# Notebook Producer Bridge Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Make the existing agent-memory write path actually populate the shared notebook — when `FileContextStore.apply_updates()` records observations and learnings, mirror them into the `NotebookStore` as Notes.
+
+**Architecture:** Pure converters (`observation_to_note`, `learning_to_note`) in `notebook.py`; a lazy `FileContextStore.notebook` property rooted at `agent_dir/notebook`; a guarded mirror step at the end of `apply_updates`. Builds on the foundation plan (`2026-06-16-notebook-foundation.md`). Backend-only, no UI/agent-loop changes. Transitional dual-write (legacy YAML + notebook) — legacy silos retire in a later increment.
+
+**Tech Stack:** Python 3.11, dataclasses, PyYAML, pytest (`file_context_store` fixture in `tests/conftest.py`).
+
+**Out of scope:** wiring the live loop to *call* `apply_updates` (separate increment); read API + Notebook tab; mapping expectations/watchpoints (they're working memory, not notebook entries — see design doc §2).
+
+---
+
+### Task 1: Converters — Observation/Learning → Note
+
+**Files:**
+- Modify: `gently/harness/memory/notebook.py`
+- Test: `tests/test_notebook_store.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_store.py
+from datetime import datetime as _dt
+
+from gently.harness.memory.model import Learning, Observation
+from gently.harness.memory.notebook import learning_to_note, observation_to_note
+
+
+class TestConverters:
+ def test_observation_to_note(self):
+ obs = Observation(
+ id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0), type="milestone",
+ content="nerve ring formed", embryo_id="e1", session_id="s1",
+ relates_to=["o0"], gently_refs={"kind": "projection", "t": 42},
+ )
+ n = observation_to_note(obs)
+ assert n.id == "o1"
+ assert n.kind == NoteKind.OBSERVATION
+ assert n.body == "nerve ring formed"
+ assert n.author == Author.AGENT
+ assert n.embryos == ["e1"]
+ assert n.sessions == ["s1"]
+ assert {"rel": "relates_to", "to": "o0"} in n.links
+ assert n.artifacts == [{"kind": "projection", "t": 42}]
+ assert n.created_at == _dt(2026, 6, 16, 9, 0, 0)
+
+ def test_learning_to_note(self):
+ lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH)
+ n = learning_to_note(lrn)
+ assert n.id == "l1"
+ assert n.kind == NoteKind.FINDING
+ assert n.body == "rings form by comma"
+ assert n.status == NoteStatus.PROPOSED # agent-drafted, awaits confirm
+ assert n.confidence == Confidence.HIGH
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v`
+Expected: FAIL — `ImportError: cannot import name 'observation_to_note'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add the model imports and converters to `notebook.py`. Extend the existing model import line:
+
+```python
+from .model import Confidence, Learning, Observation
+```
+
+Append at end of `notebook.py` (module-level functions, after `note_from_dict`):
+
+```python
+def observation_to_note(obs: Observation) -> Note:
+ """Bridge a legacy Observation into a notebook Note (kind=observation)."""
+ return Note(
+ id=obs.id,
+ kind=NoteKind.OBSERVATION,
+ body=obs.content,
+ author=Author.AGENT,
+ embryos=[obs.embryo_id] if obs.embryo_id else [],
+ sessions=[obs.session_id] if obs.session_id else [],
+ links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])],
+ artifacts=[obs.gently_refs] if obs.gently_refs else [],
+ created_at=obs.timestamp,
+ updated_at=obs.timestamp,
+ )
+
+
+def learning_to_note(learning: Learning) -> Note:
+ """Bridge a legacy Learning into a notebook Note (kind=finding, proposed)."""
+ return Note(
+ id=learning.id,
+ kind=NoteKind.FINDING,
+ body=learning.content,
+ author=Author.AGENT,
+ status=NoteStatus.PROPOSED,
+ confidence=learning.confidence,
+ created_at=learning.created_at,
+ updated_at=learning.created_at,
+ )
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestConverters -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/notebook.py tests/test_notebook_store.py
+git commit -m "feat(notebook): Observation/Learning -> Note converters"
+```
+
+---
+
+### Task 2: `FileContextStore.notebook` property
+
+**Files:**
+- Modify: `gently/harness/memory/file_store.py` (add property near `apply_updates`)
+- Test: `tests/test_notebook_store.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_store.py
+class TestContextStoreNotebook:
+ def test_notebook_property_rooted_under_agent_dir(self, file_context_store):
+ nb = file_context_store.notebook
+ assert nb.root == file_context_store.agent_dir / "notebook"
+
+ def test_notebook_property_is_cached(self, file_context_store):
+ assert file_context_store.notebook is file_context_store.notebook
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v`
+Expected: FAIL — `AttributeError: 'FileContextStore' object has no attribute 'notebook'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `gently/harness/memory/file_store.py`, add this property immediately **before** `def apply_updates(self, updates: ContextUpdates):` (line ~2178):
+
+```python
+ @property
+ def notebook(self):
+ """The shared lab notebook, rooted at agent_dir/notebook (lazy)."""
+ nb = getattr(self, "_notebook", None)
+ if nb is None:
+ from .notebook import NotebookStore
+ nb = NotebookStore(self.agent_dir / "notebook")
+ self._notebook = nb
+ return nb
+
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestContextStoreNotebook -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/file_store.py tests/test_notebook_store.py
+git commit -m "feat(notebook): FileContextStore.notebook lazy property"
+```
+
+---
+
+### Task 3: Mirror observations & learnings in `apply_updates`
+
+**Files:**
+- Modify: `gently/harness/memory/file_store.py` (`apply_updates`)
+- Test: `tests/test_notebook_store.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_store.py
+class TestApplyUpdatesMirror:
+ def test_apply_updates_mirrors_observations_and_learnings(self, file_context_store):
+ from gently.harness.memory.model import ContextUpdates
+
+ cs = file_context_store
+ obs = Observation(id="o1", timestamp=_dt(2026, 6, 16, 9, 0, 0),
+ type="milestone", content="ring formed", embryo_id="e1")
+ lrn = Learning(id="l1", content="rings form by comma", confidence=Confidence.HIGH)
+ cs.apply_updates(ContextUpdates(new_observations=[obs], new_learnings=[lrn]))
+
+ bodies = {n.body for n in cs.notebook.query_notes()}
+ assert "ring formed" in bodies
+ assert "rings form by comma" in bodies
+ assert cs.notebook.ids_for_embryo("e1") == ["o1"]
+
+ def test_apply_updates_empty_is_noop_for_notebook(self, file_context_store):
+ from gently.harness.memory.model import ContextUpdates
+
+ cs = file_context_store
+ cs.apply_updates(ContextUpdates())
+ assert cs.notebook.query_notes() == []
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v`
+Expected: FAIL — `assert "ring formed" in set()` (notebook not populated yet)
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `gently/harness/memory/file_store.py`, at the END of `apply_updates` (after the `if updates.new_focus is not None:` block), append:
+
+```python
+ # Mirror new observations & learnings into the shared notebook
+ # (best-effort — a notebook failure never breaks the legacy write).
+ from .notebook import learning_to_note, observation_to_note
+
+ try:
+ for obs in updates.new_observations:
+ self.notebook.write_note(observation_to_note(obs))
+ for learning in updates.new_learnings:
+ self.notebook.write_note(learning_to_note(learning))
+ except Exception:
+ logger.warning("notebook mirror failed", exc_info=True)
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_store.py::TestApplyUpdatesMirror -v`
+Expected: PASS (2 passed)
+
+- [ ] **Step 5: Run full notebook suite + commit**
+
+Run: `python -m pytest tests/test_notebook_store.py -q`
+Expected: all pass
+
+```bash
+git add gently/harness/memory/file_store.py tests/test_notebook_store.py
+git commit -m "feat(notebook): apply_updates mirrors observations & learnings into notebook"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** Producer wiring (design doc increment 1b) — `apply_updates` now populates the notebook. ✓ Converters honor the model (Observation→observation note, Learning→finding/proposed). ✓ Working-memory types (expectation/watchpoint) intentionally not mirrored (design §2). ✓
+**Placeholder scan:** none; complete code + commands throughout. ✓
+**Type consistency:** `observation_to_note`/`learning_to_note`, `FileContextStore.notebook`, `NoteKind`/`Author`/`NoteStatus`/`Confidence` match the foundation module and `model.py` (`Observation`, `Learning`, `ContextUpdates` confirmed at `file_store.py:2178-2203`). ✓
diff --git a/docs/superpowers/plans/2026-06-16-notebook-read-api.md b/docs/superpowers/plans/2026-06-16-notebook-read-api.md
new file mode 100644
index 00000000..f4a5f20d
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-16-notebook-read-api.md
@@ -0,0 +1,265 @@
+# Notebook Read API Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans (or subagent-driven-development) to implement task-by-task. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Expose the shared notebook over HTTP so the frontend can read it — list/filter notes, fetch one note, and list inquiry-threads with counts.
+
+**Architecture:** A new route module `gently/ui/web/routes/notebook.py` exposing `create_router(server)` (the established pattern), reading `server.context_store.notebook` (the `NotebookStore` added in the producer-bridge plan) and serializing via `note_to_dict`. Registered in `routes/__init__.py`. Read-only; authoring/curation is a later increment.
+
+**Tech Stack:** FastAPI `APIRouter`, pytest + `fastapi.testclient.TestClient`, the `file_context_store` fixture (`tests/conftest.py`).
+
+**Out of scope:** the Notebook tab UI (next plan, browser-verified); the Agent's-View rewire; retrieval/embeddings.
+
+---
+
+### Task 1: Route module — list & get notes
+
+**Files:**
+- Create: `gently/ui/web/routes/notebook.py`
+- Modify: `gently/ui/web/routes/__init__.py`
+- Test: `tests/test_notebook_api.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_notebook_api.py
+"""Tests for the notebook read API."""
+
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from gently.harness.memory.notebook import Note, NoteKind, NoteStatus
+
+
+def _make_app(context_store):
+ from gently.ui.web.routes.notebook import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ app.include_router(create_router(server))
+ return app
+
+
+def _seed(cs):
+ nb = cs.notebook
+ nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"]))
+ nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="rings by comma",
+ status=NoteStatus.PROPOSED, strains=["N2"], threads=["t1"]))
+ return cs
+
+
+class TestListNotes:
+ def test_no_store_available_false(self):
+ client = TestClient(_make_app(None))
+ data = client.get("/api/notebook/notes").json()
+ assert data == {"available": False, "notes": []}
+
+ def test_list_all(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes").json()
+ assert data["available"] is True
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+ def test_filter_by_kind(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?kind=finding").json()
+ assert {n["id"] for n in data["notes"]} == {"f1"}
+
+ def test_filter_by_strain(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?strain=N2").json()
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+ def test_invalid_kind_is_ignored(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes?kind=bogus").json()
+ assert {n["id"] for n in data["notes"]} == {"o1", "f1"}
+
+
+class TestGetNote:
+ def test_get_existing(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ data = client.get("/api/notebook/notes/o1").json()
+ assert data["id"] == "o1"
+ assert data["body"] == "ring formed"
+
+ def test_get_missing_404(self, file_context_store):
+ client = TestClient(_make_app(_seed(file_context_store)))
+ resp = client.get("/api/notebook/notes/nope")
+ assert resp.status_code == 404
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_api.py -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'gently.ui.web.routes.notebook'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Create `gently/ui/web/routes/notebook.py`:
+
+```python
+"""Notebook (shared lab notebook) read routes.
+
+Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge.
+Read-only here; authoring/curation come in a later increment.
+"""
+
+from fastapi import APIRouter, HTTPException
+
+from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict
+
+
+def _coerce(enum_cls, value):
+ """Parse a query-param string into an enum; invalid/None → None (no filter)."""
+ if value is None:
+ return None
+ try:
+ return enum_cls(value)
+ except ValueError:
+ return None
+
+
+def create_router(server) -> APIRouter:
+ router = APIRouter()
+
+ def _nb():
+ cs = getattr(server, "context_store", None)
+ return cs.notebook if cs is not None else None
+
+ @router.get("/api/notebook/notes")
+ async def list_notes(
+ kind: str | None = None,
+ author: str | None = None,
+ status: str | None = None,
+ strain: str | None = None,
+ embryo: str | None = None,
+ thread: str | None = None,
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "notes": []}
+ notes = nb.query_notes(
+ kind=_coerce(NoteKind, kind),
+ author=_coerce(Author, author),
+ status=_coerce(NoteStatus, status),
+ strain=strain,
+ embryo=embryo,
+ thread=thread,
+ )
+ return {"available": True, "notes": [note_to_dict(n) for n in notes]}
+
+ @router.get("/api/notebook/notes/{note_id}")
+ async def get_note(note_id: str):
+ nb = _nb()
+ if nb is None:
+ raise HTTPException(status_code=404, detail="notebook unavailable")
+ note = nb.get_note(note_id)
+ if note is None:
+ raise HTTPException(status_code=404, detail="note not found")
+ return note_to_dict(note)
+
+ return router
+```
+
+Then register it in `gently/ui/web/routes/__init__.py`. Add the import after the `images` import line:
+
+```python
+from .notebook import create_router as create_notebook_router
+```
+
+And add `create_notebook_router,` to the factory tuple in `register_all_routes` (after `create_context_router,`):
+
+```python
+ create_context_router,
+ create_notebook_router,
+ ):
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_api.py -v`
+Expected: PASS (7 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/routes/notebook.py gently/ui/web/routes/__init__.py tests/test_notebook_api.py
+git commit -m "feat(notebook): read API — GET /api/notebook/notes + /notes/{id}"
+```
+
+---
+
+### Task 2: `GET /api/notebook/threads`
+
+**Files:**
+- Modify: `gently/ui/web/routes/notebook.py`
+- Test: `tests/test_notebook_api.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_api.py
+class TestThreads:
+ def test_no_store(self):
+ client = TestClient(_make_app(None))
+ assert client.get("/api/notebook/threads").json() == {"available": False, "threads": []}
+
+ def test_thread_counts(self, file_context_store):
+ cs = file_context_store
+ nb = cs.notebook
+ nb.write_note(Note(id="a", kind=NoteKind.QUESTION, body="q", threads=["t1"]))
+ nb.write_note(Note(id="b", kind=NoteKind.FINDING, body="f", threads=["t1", "t2"]))
+ client = TestClient(_make_app(cs))
+ data = client.get("/api/notebook/threads").json()
+ assert data["available"] is True
+ assert data["threads"] == [{"id": "t1", "count": 2}, {"id": "t2", "count": 1}]
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `python -m pytest tests/test_notebook_api.py::TestThreads -v`
+Expected: FAIL — 404 (route not defined)
+
+- [ ] **Step 3: Write minimal implementation**
+
+Add this endpoint inside `create_router`, just before `return router`:
+
+```python
+ @router.get("/api/notebook/threads")
+ async def list_threads():
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "threads": []}
+ counts: dict[str, int] = {}
+ for n in nb.query_notes():
+ for t in n.threads:
+ counts[t] = counts.get(t, 0) + 1
+ threads = [{"id": t, "count": c} for t, c in sorted(counts.items())]
+ return {"available": True, "threads": threads}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `python -m pytest tests/test_notebook_api.py -q`
+Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py
+git commit -m "feat(notebook): read API — GET /api/notebook/threads with counts"
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** read surface API for the notebook (design increment 1c, backend half) — query notes by kind/author/status/scope, fetch one, list threads. ✓ Reuses `NotebookStore.query_notes` + `note_to_dict` from the foundation. ✓ Follows `create_router(server)` + `server.context_store` convention (`context.py`). ✓
+**Placeholder scan:** none — complete code + commands. ✓
+**Type consistency:** `create_router`, `note_to_dict`, `NoteKind`/`Author`/`NoteStatus`, `server.context_store.notebook`, `nb.query_notes`/`get_note` all match the foundation + producer-bridge modules. Registration matches the existing tuple in `routes/__init__.py`. ✓
diff --git a/docs/superpowers/plans/2026-06-17-notebook-ask.md b/docs/superpowers/plans/2026-06-17-notebook-ask.md
new file mode 100644
index 00000000..c03ea526
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-17-notebook-ask.md
@@ -0,0 +1,408 @@
+# "Ask the Notebook" Implementation Plan (Increment 2, backend)
+
+> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:executing-plans. Steps use checkbox (`- [ ]`) syntax.
+
+**Goal:** Let the notebook be *reasoned with* — given a question (+ optional scope), retrieve relevant Notes, ask Claude to synthesize a **grounded, cited** answer, and return it as a validated structured object.
+
+**Architecture:** A new module `gently/harness/memory/notebook_ask.py`: structural retrieval (`select_notes`) + a forced-`tool_choice` synthesis call (`answer_question`) that reuses gently's conventions — `anthropic.AsyncAnthropic` (per `chat.py:198`), `settings.models.main` (Opus 4.8), structured output via a pinned tool (per `verifier.py`), and **no self-rated confidence** (per the lab rule). A `POST /api/notebook/ask` route wires retrieval → synthesis. The Claude client is injected so everything is unit-testable with a fake.
+
+**Tech Stack:** Python 3.11, `anthropic` SDK, FastAPI, pytest + TestClient (venv).
+
+**Out of scope (later plans):** embeddings/semantic recall (structural-only here); the "Ask" UI box on the Notebook tab; proactive surfacing.
+
+---
+
+### Task 1: Structural retrieval — `select_notes`
+
+**Files:**
+- Create: `gently/harness/memory/notebook_ask.py`
+- Test: `tests/test_notebook_ask.py`
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# tests/test_notebook_ask.py
+"""Tests for 'Ask the notebook' — retrieval + grounded synthesis."""
+
+from gently.harness.memory.notebook import Note, NoteKind
+from gently.harness.memory.notebook_ask import select_notes
+
+
+def _seed(cs):
+ nb = cs.notebook
+ nb.write_note(Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed", strains=["N2"], threads=["t1"]))
+ nb.write_note(Note(id="f1", kind=NoteKind.FINDING, body="12 min/degC", strains=["N2"], threads=["t1"]))
+ nb.write_note(Note(id="x1", kind=NoteKind.OBSERVATION, body="unrelated", strains=["OH904"]))
+ return nb
+
+
+class TestSelectNotes:
+ def test_scope_by_thread(self, file_context_store):
+ nb = _seed(file_context_store)
+ ids = {n.id for n in select_notes(nb, thread="t1")}
+ assert ids == {"o1", "f1"}
+
+ def test_scope_by_strain(self, file_context_store):
+ nb = _seed(file_context_store)
+ ids = {n.id for n in select_notes(nb, strain="OH904")}
+ assert ids == {"x1"}
+
+ def test_no_scope_returns_recent_capped(self, file_context_store):
+ nb = _seed(file_context_store)
+ notes = select_notes(nb, limit=2)
+ assert len(notes) == 2 # newest-first, capped
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v`
+Expected: FAIL — `ModuleNotFoundError: No module named 'gently.harness.memory.notebook_ask'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+```python
+# gently/harness/memory/notebook_ask.py
+"""Ask the notebook — retrieve relevant Notes and synthesize a grounded,
+cited answer with Claude. Structural retrieval only (semantic recall is a
+later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4.
+"""
+
+from __future__ import annotations
+
+from .notebook import Note, NotebookStore
+
+
+def select_notes(
+ store: NotebookStore,
+ *,
+ thread: str | None = None,
+ strain: str | None = None,
+ limit: int = 12,
+) -> list[Note]:
+ """Structural narrowing: scope by thread/strain when given, else recent.
+ Returns newest-first, capped at `limit`."""
+ notes = store.query_notes(thread=thread, strain=strain)
+ return notes[:limit]
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestSelectNotes -v`
+Expected: PASS (3 passed)
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py
+git commit -m "feat(notebook): select_notes — structural retrieval for ask"
+```
+
+---
+
+### Task 2: Grounded synthesis — `answer_question` (forced tool)
+
+**Files:**
+- Modify: `gently/harness/memory/notebook_ask.py`
+- Test: `tests/test_notebook_ask.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_ask.py
+import asyncio
+
+from gently.harness.memory.notebook_ask import ASK_TOOL, answer_question, build_ask_messages
+
+
+class _FakeBlock:
+ def __init__(self, inp):
+ self.type = "tool_use"
+ self.input = inp
+
+
+class _FakeResp:
+ def __init__(self, inp):
+ self.content = [_FakeBlock(inp)]
+ self.stop_reason = "tool_use"
+
+
+class _FakeMessages:
+ def __init__(self, captured, inp):
+ self._captured, self._inp = captured, inp
+
+ async def create(self, **kwargs):
+ self._captured.update(kwargs)
+ return _FakeResp(self._inp)
+
+
+class _FakeClient:
+ def __init__(self, inp):
+ self.captured = {}
+ self.messages = _FakeMessages(self.captured, inp)
+
+
+class TestAnswerQuestion:
+ def test_build_messages_embeds_note_ids(self):
+ notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")]
+ msgs = build_ask_messages("what formed?", notes)
+ text = msgs[0]["content"]
+ assert "o1" in text and "ring formed" in text and "what formed?" in text
+
+ def test_answer_returns_structured_and_forces_tool(self):
+ canned = {"answer": "A ring formed.", "points": [{"text": "ring formed", "note_ids": ["o1"]}],
+ "suggested_next": [], "coverage": "covered"}
+ client = _FakeClient(canned)
+ notes = [Note(id="o1", kind=NoteKind.OBSERVATION, body="ring formed")]
+ out = asyncio.run(answer_question(client, "m", "what formed?", notes))
+ assert out == canned
+ # tool_choice is pinned to the ask tool (forced structured output)
+ assert client.captured["tool_choice"] == {"type": "tool", "name": ASK_TOOL["name"]}
+ assert client.captured["model"] == "m"
+
+ def test_answer_no_notes_short_circuits_without_api(self):
+ client = _FakeClient({"should": "not be used"})
+ out = asyncio.run(answer_question(client, "m", "anything?", []))
+ assert out["coverage"] == "not_in_notebook"
+ assert client.captured == {} # no API call when nothing to ground on
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py::TestAnswerQuestion -v`
+Expected: FAIL — `ImportError: cannot import name 'ASK_TOOL'`
+
+- [ ] **Step 3: Write minimal implementation**
+
+Append to `gently/harness/memory/notebook_ask.py`:
+
+```python
+# ASK_TOOL pins the structured output. No confidence field — we don't ask the
+# model to self-rate (lab rule); "coverage" is a factual grounding classification.
+ASK_TOOL = {
+ "name": "answer_from_notebook",
+ "description": "Return a grounded answer built ONLY from the provided notebook entries.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."},
+ "points": {
+ "type": "array",
+ "description": "Supporting points, each citing the note ids it rests on.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string"},
+ "note_ids": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["text", "note_ids"],
+ },
+ },
+ "suggested_next": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Concrete next experiments/moves if the question asks what to do; else empty.",
+ },
+ "coverage": {
+ "type": "string",
+ "enum": ["covered", "partial", "not_in_notebook"],
+ "description": "How well the provided notes cover the question.",
+ },
+ },
+ "required": ["answer", "points", "coverage"],
+ },
+}
+
+_SYSTEM = (
+ "You reason over a shared lab notebook. Answer ONLY from the notebook entries "
+ "provided — every claim must cite the note id(s) it rests on. If the notes do "
+ "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. "
+ "Never invent facts not in the notes. Call the answer_from_notebook tool."
+)
+
+
+def _render_notes(notes: list[Note]) -> str:
+ lines = []
+ for n in notes:
+ scope = []
+ if n.strains:
+ scope.append("strains=" + ",".join(n.strains))
+ if n.embryos:
+ scope.append("embryos=" + ",".join(n.embryos))
+ tag = f" [{'; '.join(scope)}]" if scope else ""
+ lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}")
+ return "\n".join(lines)
+
+
+def build_ask_messages(question: str, notes: list[Note]) -> list[dict]:
+ body = (
+ "Notebook entries:\n"
+ + _render_notes(notes)
+ + f"\n\nQuestion: {question}\n\n"
+ "Answer using only these entries, citing note ids."
+ )
+ return [{"role": "user", "content": body}]
+
+
+async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict:
+ """Force the ask tool and return its validated input dict. Short-circuits
+ (no API call) when there are no notes to ground on."""
+ if not notes:
+ return {
+ "answer": "The notebook doesn't cover this yet.",
+ "points": [],
+ "suggested_next": [],
+ "coverage": "not_in_notebook",
+ }
+ resp = await client.messages.create(
+ model=model,
+ max_tokens=2048,
+ system=_SYSTEM,
+ tools=[ASK_TOOL],
+ tool_choice={"type": "tool", "name": ASK_TOOL["name"]},
+ messages=build_ask_messages(question, notes),
+ )
+ for block in resp.content:
+ if getattr(block, "type", None) == "tool_use":
+ return block.input
+ return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_ask.py -q`
+Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/harness/memory/notebook_ask.py tests/test_notebook_ask.py
+git commit -m "feat(notebook): answer_question — forced-tool grounded synthesis"
+```
+
+---
+
+### Task 3: `POST /api/notebook/ask`
+
+**Files:**
+- Modify: `gently/ui/web/routes/notebook.py`
+- Test: `tests/test_notebook_api.py` (append)
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+# append to tests/test_notebook_api.py
+class _AskBlock:
+ def __init__(self, inp):
+ self.type = "tool_use"
+ self.input = inp
+
+
+class _AskResp:
+ def __init__(self, inp):
+ self.content = [_AskBlock(inp)]
+ self.stop_reason = "tool_use"
+
+
+class _AskMessages:
+ def __init__(self, inp):
+ self._inp = inp
+
+ async def create(self, **kwargs):
+ return _AskResp(self._inp)
+
+
+class _AskClient:
+ def __init__(self, inp):
+ self.messages = _AskMessages(inp)
+
+
+def _make_app_with_client(context_store, client):
+ from gently.ui.web.routes.notebook import create_router
+
+ app = FastAPI()
+
+ class _Server:
+ pass
+
+ server = _Server()
+ server.context_store = context_store
+ server.claude_async = client
+ app.include_router(create_router(server))
+ return app
+
+
+class TestAsk:
+ def test_ask_returns_grounded_answer(self, file_context_store):
+ cs = _seed(file_context_store)
+ canned = {"answer": "A ring formed.", "points": [{"text": "ring", "note_ids": ["o1"]}],
+ "suggested_next": [], "coverage": "covered"}
+ client = TestClient(_make_app_with_client(cs, _AskClient(canned)))
+ resp = client.post("/api/notebook/ask", json={"question": "what happened?"})
+ assert resp.status_code == 200
+ assert resp.json()["coverage"] == "covered"
+
+ def test_ask_no_store(self):
+ client = TestClient(_make_app(None))
+ resp = client.post("/api/notebook/ask", json={"question": "x"})
+ assert resp.json() == {"available": False}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_api.py::TestAsk -v`
+Expected: FAIL — 404/405 (route not defined)
+
+- [ ] **Step 3: Write minimal implementation**
+
+In `gently/ui/web/routes/notebook.py`, add `Body` to the fastapi import and add the route inside `create_router`, before `return router`:
+
+```python
+ @router.post("/api/notebook/ask")
+ async def ask(
+ question: str = Body(..., embed=True),
+ thread: str | None = Body(None, embed=True),
+ strain: str | None = Body(None, embed=True),
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False}
+ from gently.harness.memory.notebook_ask import answer_question, select_notes
+ from gently.settings import settings
+
+ notes = select_notes(nb, thread=thread, strain=strain)
+ client = getattr(server, "claude_async", None)
+ if client is None:
+ import anthropic
+
+ client = anthropic.AsyncAnthropic()
+ result = await answer_question(client, settings.models.main, question, notes)
+ result["available"] = True
+ result["note_ids"] = [n.id for n in notes]
+ return result
+```
+
+Update the import line at the top of the file:
+
+```python
+from fastapi import APIRouter, Body, HTTPException
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `.venv/bin/python -m pytest tests/test_notebook_api.py -q`
+Expected: all pass
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add gently/ui/web/routes/notebook.py tests/test_notebook_api.py
+git commit -m "feat(notebook): POST /api/notebook/ask — grounded notebook Q&A"
+```
+
+---
+
+## Self-Review
+**Spec coverage (design §4):** structural retrieval (`select_notes`) → grounded synthesis (`answer_question`, forced tool, cited, "not_in_notebook" valid) → endpoint. ✓ No self-rated confidence (`coverage` is grounding, not correctness-confidence). ✓ Reuses `settings.models.main`, `anthropic.AsyncAnthropic`, the `verifier.py` forced-tool pattern. ✓ Client injected → unit-testable without real API. ✓
+**Deferred (correct):** embeddings/semantic recall; the "Ask" UI; proactive surfacing.
+**Placeholder scan:** none — complete code + commands. ✓
+**Type consistency:** `select_notes`, `answer_question`, `ASK_TOOL`, `build_ask_messages` named consistently across tasks/tests; route uses `server.claude_async` (tests inject) with a real `AsyncAnthropic` fallback. ✓
diff --git a/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md b/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md
new file mode 100644
index 00000000..c715d57d
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md
@@ -0,0 +1,162 @@
+# Design: The Active, Shared Lab Notebook (agent + human memory)
+
+Status: design approved in brainstorm (2026-06-16). Concept trace: gently-project/gently#52.
+Branch: `feature/memory-model` (off `feature/ux-v2`, which it depends on for UI).
+
+## 1. Overview
+
+Gently should have one **active, shared lab notebook** — a living memory the agent and
+the scientist co-author. The agent writes from doing the work; the human adds notes,
+literature, thoughts, and annotations; both read it and *think with* it. It accumulates
+across timepoints, sessions, and (eventually) systems.
+
+This replaces today's "agent mind" — a clean conceptual model (`Context`: intentions,
+understanding, observations, expectations, attention) that was never engineered into a
+working system. We keep its good bones and redesign the structure.
+
+### What we keep from the old agent mind
+- The **file-based, human-browsable YAML store** (`FileContextStore`) — auditable, no DB lock-in.
+- **`Observation` as the entry template** (`model.py:379`) — content + refs + significance + embryo/session is already ~90% of a notebook entry.
+- **`basis` + derived `confidence`** on knowledge (`Learning`, `model.py:329`).
+- **`gently_refs`** for pointing at artifacts (`model.py:394`).
+- The **sense → believe → predict → attend** cognitive framing, as the *meaning* of entries.
+- The **event bus + `CONTEXT_UPDATED` → browser** path and the existing `/api/context` surface and "Agent's View" panel (`context-surface.js`).
+
+### What was wrong (and we fix)
+- The write loop is **dormant**: `apply_updates()` (`file_store.py:2178`) is never called anywhere. The mind was designed but never wired to fill.
+- **Six parallel silos, no links** — `Observation.relates_to` exists but is never populated. A set of disconnected lists, not a connected mind.
+- **No author/provenance dimension** — everything is implicitly agent-authored, so it can't be shared/curated.
+- **Working and durable memory are mixed** — live per-embryo predictions sit beside things you'd keep forever.
+- **No consolidation** — raw never becomes distilled on its own.
+- **Type proliferation** — distinctions that should be *fields* (author, status) were encoded as separate *kinds*.
+
+## 2. The data model
+
+### Principle
+A **kind** exists only if the system stores, reasons about, or *shows* it differently.
+Otherwise it is a **field**. This prevents re-proliferation.
+
+### Three kinds
+- **Observation** — a record of something seen, done, read, or noted. *Immutable.* Timestamped evidence.
+- **Finding** — a believed claim / conclusion / insight. *Revisable and supersedable*, with a `basis` (the observations it rests on) and a confidence *derived from evidence*. Can be contradicted — a signal, not an error.
+- **Question** — an open inquiry being chased. Open→answered lifecycle. The large, long-lived ones **are the organizing spine** (the inquiry thread).
+
+### Orthogonal fields (shared by all kinds; NOT new kinds)
+- `author` — human | agent | perception (a human note, a literature ref, and an agent observation are all Observations with different authors).
+- `status` — proposed | confirmed | superseded | answered | … (lifecycle).
+- `confidence` — derived from supporting/contradicting evidence; never self-rated by the model.
+- `scope` — strain / embryo / session / thread (the cross-cut indexes).
+- `links` — typed edges to other entries (supports / contradicts / refines / answers / produced-by). The graph that makes it a wiki, not a log.
+- `artifacts` — pointers into `FileStore` (images, volumes, projections, traces, sessions) by reference, never copied.
+
+### The inquiry thread (the spine)
+A `Question` that grew a body: holds the question, a rolling synthesis, status
+(open/investigating/resolved), and denormalized scope. Membership lives on the entries
+(`threads: [...]`), not in the thread — a flat note pool, derived reverse-indexes
+(`by_thread`, `by_strain`, `by_embryo`) that are rebuildable caches. This lets "by question"
++ "by strain" + links coexist over the flat YAML store with no database.
+
+### Working memory vs. notebook (the lifespan split)
+- **Working memory** (transient, the live loop): the agent's predictions and things-to-watch
+ during a run. A thin runtime layer, *not* notebook entries. When one matters (a violated
+ prediction), it **graduates** into an Observation.
+- **Notebook** (durable): Observations, Findings, Questions — what accumulates.
+
+### The Question is the hinge between knowing and doing
+"What experiments next?" is a Question, but it resolves into **plan items (action)** in the
+existing campaign/plan layer, whereas a scientific question resolves into a **Finding
+(knowledge)**. Same kind, different exit — the system tells them apart by *what they link to*
+(plan items vs findings), no hardcoded subtype. The loop:
+
+```
+Question ("what next?") → brainstorm over Observations + Findings
+ → plan items (a campaign) → run them
+ → Observations → Findings → may answer the scientific Question → thread converges
+```
+
+Knowledge → action → knowledge. The plan/campaign layer (and its export) is the "action" arm.
+
+### Mapping the old six types
+| Old type | New representation |
+|---|---|
+| `Observation` | `Observation` (near-identity; `embryo_id/session_id` → `scope`, `gently_refs` → `artifacts`, `relates_to` → `links`) |
+| `Learning` | `Finding` (`content`→body, `basis`/`confidence` kept) |
+| `Expectation` | working memory; graduates to `Observation` when violated |
+| `Watchpoint` | working memory (live monitoring) |
+| `Question` | `Question` (passing) or inquiry thread (recurring) |
+| `EmbryoUnderstanding` | `Finding`(s) scoped to an embryo; supersession gives stage history |
+
+## 3. Presentation (two faces)
+
+The notebook is **active**, so it can't live only in a tab (the write-only-junk-drawer failure).
+It has two faces:
+
+1. **The Notebook tab** (LIBRARY group, beside Plans and Sessions): the reading room.
+ *Plans = what we'll do · Sessions = what we did · Notebook = what we know.* Landing view
+ organized by the **inquiry-thread spine**, with strain/embryo **filters** and **search +
+ link-graph** navigation. A thread page reads like a living entry — the question, its rolling
+ synthesis, Findings and Observations beneath, and **links out to its campaign(s) in Plans and
+ runs in Sessions**. The three library tabs interlink rather than duplicate.
+
+2. **The ambient edge** — the notebook coming to you, in context (Home, mid-session, planning):
+ the session-start brief, the one-thing surfacing, the brainstorm reply. **This is what
+ "Agent's View" becomes** — repurposed from near-empty transient cognition into the notebook's
+ live edge.
+
+## 4. Retrieval & extraction
+
+Principle: **structure → indexes (deterministic); meaning & judgment → models.**
+(Exact structural retrieval is not a heuristic — it's an index lookup, and a model must not do it.)
+
+Three-layer pipeline:
+1. **Narrow — structural (instant).** Fields/indexes filter by strain, embryo, thread, time,
+ status, link-traversal. Cuts thousands → dozens.
+2. **Recall + rank — semantic.** Embeddings (computed once per entry, kept in a rebuildable
+ sidecar vector cache; YAML stays source of truth) pull semantically-near candidates; an LLM
+ **re-ranks/selects** with reasons. Recall cheap, precision is judgment.
+3. **Synthesize — generation (model).** Thread summaries, session-start briefs, brainstorm
+ answers, contradiction calls, consolidated Findings. Non-negotiable: **grounded + cited**
+ (uncited claim = bug; "not in notebook" is valid), **forced structured output** (validated
+ objects, never re-scraped prose), **ranking from structural facts not self-rated confidence**.
+
+Two query faces:
+- **Agent**: full pipeline feeding its reasoning.
+- **Human search**: *Filter* mode (instant, structural, as-you-type) + *Ask* mode (full grounded
+ LLM answer with citations). No model in the typeahead path.
+
+"Unlimited API credits" removes **cost** (lean on models for judgment/synthesis, frequent
+consolidation, regenerate thread summaries on change) but not **latency** (keep interactive
+filtering model-free) or **hallucination** (grounding + citation + structured output remain the
+guardrails).
+
+## 5. Trust principles
+- Append-only evidence; **supersede, never overwrite**, conclusions (the chain is the history).
+- Agent and human are **separate, attributed voices**; the agent appends/proposes/links, never
+ silently rewrites the human's words.
+- Every claim **carries its basis** and cites the entries it rests on.
+- Confidence is **derived from evidence**, never self-rated.
+- **Revisiting is designed in** — stale findings/questions/unrevisited results resurface.
+
+## 6. Build decomposition (increments)
+
+The full vision is several independently-shippable increments. Ordered by value-vs-risk:
+
+1. **Foundation / keystone — make memory accumulate and be browsable.**
+ Unified `Note` (3 kinds + fields) + store + indexes; wire the dormant producer loop
+ (`apply_updates`) so a session actually writes Observations/Findings; surface a read-only
+ **Notebook tab** (thread-organized) + repurpose Agent's View as the live edge.
+ *Produce (perception/agent) → store (notebook) → consume (tab + ambient read).*
+2. **Retrieval & brainstorm** — structural indexes + embeddings sidecar + grounded LLM
+ synthesis; the "Ask the notebook" + agent brainstorm pipeline.
+3. **Human authorship & curation** — human-authored entries/annotations (on images/embryos),
+ proposed→confirmed curation, supersession UI.
+4. **Active surfacing & consolidation** — structural triggers (contradiction / answered-question
+ / violated-expectation / echo), throttled one-at-a-moment; scheduled reflection passes.
+5. **Cross-system** — sharing/sync of the notebook across instances ("shared brain").
+
+**First slice = Increment 1** (the keystone): it proves the model end-to-end and turns the
+"wired but starving" problem into the first visible win. Increment 1 gets its own
+implementation plan.
+
+## 7. Out of scope (for now)
+Cross-system sync (incr. 5), proactive surfacing tuning (incr. 4) — designed here, built later.
diff --git a/gently/harness/memory/file_store.py b/gently/harness/memory/file_store.py
index c19f785b..7c153073 100644
--- a/gently/harness/memory/file_store.py
+++ b/gently/harness/memory/file_store.py
@@ -2175,6 +2175,17 @@ def set_state(self, key: str, value: str):
# Batch Updates
# ==================================================================
+ @property
+ def notebook(self):
+ """The shared lab notebook, rooted at agent_dir/notebook (lazy)."""
+ nb = getattr(self, "_notebook", None)
+ if nb is None:
+ from .notebook import NotebookStore
+
+ nb = NotebookStore(self.agent_dir / "notebook")
+ self._notebook = nb
+ return nb
+
def apply_updates(self, updates: ContextUpdates):
for obs in updates.new_observations:
self.add_observation(obs)
@@ -2202,6 +2213,18 @@ def apply_updates(self, updates: ContextUpdates):
if updates.new_focus is not None:
self.set_state("current_focus", updates.new_focus)
+ # Mirror new observations & learnings into the shared notebook
+ # (best-effort — a notebook failure never breaks the legacy write).
+ from .notebook import learning_to_note, observation_to_note
+
+ try:
+ for obs in updates.new_observations:
+ self.notebook.write_note(observation_to_note(obs))
+ for learning in updates.new_learnings:
+ self.notebook.write_note(learning_to_note(learning))
+ except Exception:
+ logger.warning("notebook mirror failed", exc_info=True)
+
# ==================================================================
# ML Pipelines
# ==================================================================
diff --git a/gently/harness/memory/notebook.py b/gently/harness/memory/notebook.py
new file mode 100644
index 00000000..7f3176d9
--- /dev/null
+++ b/gently/harness/memory/notebook.py
@@ -0,0 +1,298 @@
+"""
+The shared lab notebook — unified memory entry (Note) and file-backed store.
+
+One Note kind taxonomy (observation / finding / question); everything else
+(author, status, confidence, scope, links) is an orthogonal field. See
+docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md.
+"""
+
+from __future__ import annotations
+
+import os
+import re
+import uuid
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+import yaml
+
+from .model import Confidence, Learning, Observation
+
+
+class NoteKind(str, Enum):
+ OBSERVATION = "observation" # immutable record of what was seen/done/read/noted
+ FINDING = "finding" # revisable, supersedable believed claim
+ QUESTION = "question" # open inquiry; large ones are the thread spine
+
+
+class Author(str, Enum):
+ HUMAN = "human"
+ AGENT = "agent"
+ PERCEPTION = "perception"
+
+
+class NoteStatus(str, Enum):
+ OPEN = "open" # questions not yet answered
+ PROPOSED = "proposed" # agent-drafted finding awaiting human confirm
+ CONFIRMED = "confirmed" # accepted observation/finding (default)
+ ANSWERED = "answered" # question resolved
+ SUPERSEDED = "superseded" # replaced by a newer note
+
+
+@dataclass
+class Note:
+ id: str
+ kind: NoteKind
+ body: str
+ author: Author = Author.AGENT
+ title: str | None = None
+ status: NoteStatus = NoteStatus.CONFIRMED
+ confidence: Confidence | None = None
+ strains: list[str] = field(default_factory=list)
+ embryos: list[str] = field(default_factory=list)
+ sessions: list[str] = field(default_factory=list)
+ threads: list[str] = field(default_factory=list)
+ basis: list[str] = field(default_factory=list) # note ids this rests on
+ links: list[dict] = field(default_factory=list) # [{"rel": ..., "to": ...}]
+ artifacts: list[dict] = field(default_factory=list) # FileStore pointers
+ superseded_by: str | None = None
+ created_at: datetime = field(default_factory=datetime.now)
+ updated_at: datetime = field(default_factory=datetime.now)
+
+
+def note_to_dict(n: Note) -> dict[str, Any]:
+ return {
+ "id": n.id,
+ "kind": n.kind.value,
+ "body": n.body,
+ "author": n.author.value,
+ "title": n.title,
+ "status": n.status.value,
+ "confidence": n.confidence.value if n.confidence else None,
+ "strains": list(n.strains),
+ "embryos": list(n.embryos),
+ "sessions": list(n.sessions),
+ "threads": list(n.threads),
+ "basis": list(n.basis),
+ "links": list(n.links),
+ "artifacts": list(n.artifacts),
+ "superseded_by": n.superseded_by,
+ "created_at": n.created_at.isoformat(),
+ "updated_at": n.updated_at.isoformat(),
+ }
+
+
+def note_from_dict(d: dict[str, Any]) -> Note:
+ conf = d.get("confidence")
+ return Note(
+ id=d["id"],
+ kind=NoteKind(d["kind"]),
+ body=d.get("body", ""),
+ author=Author(d.get("author", "agent")),
+ title=d.get("title"),
+ status=NoteStatus(d.get("status", "confirmed")),
+ confidence=Confidence(conf) if conf else None,
+ strains=list(d.get("strains") or []),
+ embryos=list(d.get("embryos") or []),
+ sessions=list(d.get("sessions") or []),
+ threads=list(d.get("threads") or []),
+ basis=list(d.get("basis") or []),
+ links=list(d.get("links") or []),
+ artifacts=list(d.get("artifacts") or []),
+ superseded_by=d.get("superseded_by"),
+ created_at=datetime.fromisoformat(d["created_at"]),
+ updated_at=datetime.fromisoformat(d["updated_at"]),
+ )
+
+
+def observation_to_note(obs: Observation) -> Note:
+ """Bridge a legacy Observation into a notebook Note (kind=observation)."""
+ return Note(
+ id=obs.id,
+ kind=NoteKind.OBSERVATION,
+ body=obs.content,
+ author=Author.AGENT,
+ embryos=[obs.embryo_id] if obs.embryo_id else [],
+ sessions=[obs.session_id] if obs.session_id else [],
+ links=[{"rel": "relates_to", "to": r} for r in (obs.relates_to or [])],
+ artifacts=[obs.gently_refs] if obs.gently_refs else [],
+ created_at=obs.timestamp,
+ updated_at=obs.timestamp,
+ )
+
+
+def learning_to_note(learning: Learning) -> Note:
+ """Bridge a legacy Learning into a notebook Note (kind=finding, proposed)."""
+ return Note(
+ id=learning.id,
+ kind=NoteKind.FINDING,
+ body=learning.content,
+ author=Author.AGENT,
+ status=NoteStatus.PROPOSED,
+ confidence=learning.confidence,
+ created_at=learning.created_at,
+ updated_at=learning.created_at,
+ )
+
+
+class NotebookStore:
+ """File-backed store for notebook Notes. One YAML per note under notes/;
+ flat pool, rebuildable reverse-indexes (added in Task 3)."""
+
+ _FACETS = {"strain": "strains", "embryo": "embryos", "thread": "threads"}
+
+ def __init__(self, notebook_dir: Path):
+ self.root = Path(notebook_dir)
+ self.notes_dir = self.root / "notes"
+ self.index_dir = self.root / "index"
+ self.notes_dir.mkdir(parents=True, exist_ok=True)
+ self.index_dir.mkdir(parents=True, exist_ok=True)
+ # reverse indexes: facet -> {value: [note_id, ...]}
+ self._index: dict[str, dict[str, list[str]]] = {
+ "strain": {}, "embryo": {}, "thread": {}
+ }
+ self.rebuild_index()
+
+ # ---- reverse indexes (notes are authoritative; indexes are caches) ----
+ def _index_note(self, note: Note) -> None:
+ for facet, attr in self._FACETS.items():
+ for value in getattr(note, attr):
+ bucket = self._index[facet].setdefault(value, [])
+ if note.id not in bucket:
+ bucket.append(note.id)
+
+ def rebuild_index(self) -> None:
+ """Rebuild reverse-indexes by scanning notes/."""
+ self._index = {"strain": {}, "embryo": {}, "thread": {}}
+ for f in sorted(self.notes_dir.glob("*.yaml")):
+ data = self._read_yaml(f)
+ if data:
+ self._index_note(note_from_dict(data))
+
+ def ids_for_strain(self, strain: str) -> list[str]:
+ return list(self._index["strain"].get(strain, []))
+
+ def ids_for_embryo(self, embryo: str) -> list[str]:
+ return list(self._index["embryo"].get(embryo, []))
+
+ def ids_for_thread(self, thread: str) -> list[str]:
+ return list(self._index["thread"].get(thread, []))
+
+ # ---- helpers (mirror FileContextStore conventions) ----
+ @staticmethod
+ def _gen_id() -> str:
+ return str(uuid.uuid4())[:8]
+
+ @staticmethod
+ def _slugify(text: str) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
+ return slug[:30]
+
+ def _write_yaml(self, path: Path, data: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_suffix(".tmp")
+ with open(tmp, "w", encoding="utf-8") as fh:
+ yaml.safe_dump(data, fh, default_flow_style=False, allow_unicode=True, sort_keys=False)
+ os.replace(str(tmp), str(path))
+
+ def _read_yaml(self, path: Path) -> dict | None:
+ try:
+ with open(path, encoding="utf-8") as fh:
+ return yaml.safe_load(fh)
+ except OSError:
+ return None
+
+ def _note_path(self, note_id: str) -> Path | None:
+ return next(self.notes_dir.glob(f"{note_id}_*.yaml"), None)
+
+ # ---- read/write ----
+ def write_note(self, note: Note) -> str:
+ if not note.id:
+ note.id = self._gen_id()
+ note.updated_at = datetime.now()
+ slug = self._slugify(note.title or note.body or note.kind.value)
+ # remove any stale file for this id (slug may have changed)
+ old = self._note_path(note.id)
+ if old is not None:
+ old.unlink()
+ self._write_yaml(self.notes_dir / f"{note.id}_{slug}.yaml", note_to_dict(note))
+ self._index_note(note)
+ return note.id
+
+ def get_note(self, note_id: str) -> Note | None:
+ path = self._note_path(note_id)
+ if path is None:
+ return None
+ data = self._read_yaml(path)
+ return note_from_dict(data) if data else None
+
+ def query_notes(
+ self,
+ *,
+ kind: NoteKind | None = None,
+ author: Author | None = None,
+ status: NoteStatus | None = None,
+ strain: str | None = None,
+ embryo: str | None = None,
+ thread: str | None = None,
+ ) -> list[Note]:
+ """Structural query: narrow by scope via the indexes, then filter by
+ kind/author/status. Returned newest-first. (No semantic ranking here —
+ that's a later increment.)"""
+ # 1. candidate ids — intersect any scope facets given, else all notes
+ scope_sets: list[set[str]] = []
+ if strain is not None:
+ scope_sets.append(set(self.ids_for_strain(strain)))
+ if embryo is not None:
+ scope_sets.append(set(self.ids_for_embryo(embryo)))
+ if thread is not None:
+ scope_sets.append(set(self.ids_for_thread(thread)))
+ candidate_ids: set[str] | None = (
+ set.intersection(*scope_sets) if scope_sets else None
+ )
+
+ # 2. load + filter
+ if candidate_ids is not None:
+ notes = [n for n in (self.get_note(i) for i in candidate_ids) if n]
+ else:
+ notes = [
+ note_from_dict(d)
+ for d in (self._read_yaml(f) for f in self.notes_dir.glob("*.yaml"))
+ if d
+ ]
+ results: list[Note] = []
+ for n in notes:
+ if kind is not None and n.kind != kind:
+ continue
+ if author is not None and n.author != author:
+ continue
+ if status is not None and n.status != status:
+ continue
+ results.append(n)
+ results.sort(key=lambda n: n.created_at, reverse=True)
+ return results
+
+ # ---- links + supersession (append-only history) ----
+ def link_notes(self, from_id: str, rel: str, to_id: str) -> None:
+ """Add a typed edge from one note to another (append-only)."""
+ note = self.get_note(from_id)
+ if note is None:
+ raise KeyError(from_id)
+ edge = {"rel": rel, "to": to_id}
+ if edge not in note.links:
+ note.links.append(edge)
+ self.write_note(note)
+
+ def supersede_note(self, old_id: str, new_id: str) -> None:
+ """Mark old as superseded (kept, never deleted) and link the new note
+ back to it as a refinement — the chain is the intellectual history."""
+ old = self.get_note(old_id)
+ if old is None:
+ raise KeyError(old_id)
+ old.status = NoteStatus.SUPERSEDED
+ old.superseded_by = new_id
+ self.write_note(old)
+ self.link_notes(new_id, "refines", old_id)
diff --git a/gently/harness/memory/notebook_ask.py b/gently/harness/memory/notebook_ask.py
new file mode 100644
index 00000000..acfed322
--- /dev/null
+++ b/gently/harness/memory/notebook_ask.py
@@ -0,0 +1,112 @@
+"""Ask the notebook — retrieve relevant Notes and synthesize a grounded,
+cited answer with Claude. Structural retrieval only (semantic recall is a
+later increment). See docs/superpowers/specs/2026-06-16-shared-lab-notebook-design.md §4.
+"""
+
+from __future__ import annotations
+
+from .notebook import Note, NotebookStore
+
+
+def select_notes(
+ store: NotebookStore,
+ *,
+ thread: str | None = None,
+ strain: str | None = None,
+ limit: int = 12,
+) -> list[Note]:
+ """Structural narrowing: scope by thread/strain when given, else recent.
+ Returns newest-first, capped at ``limit``."""
+ notes = store.query_notes(thread=thread, strain=strain)
+ return notes[:limit]
+
+
+# ASK_TOOL pins the structured output. No confidence field — we don't ask the
+# model to self-rate (lab rule); "coverage" is a factual grounding classification.
+ASK_TOOL = {
+ "name": "answer_from_notebook",
+ "description": "Return a grounded answer built ONLY from the provided notebook entries.",
+ "input_schema": {
+ "type": "object",
+ "properties": {
+ "answer": {"type": "string", "description": "Direct synthesis grounded in the notes."},
+ "points": {
+ "type": "array",
+ "description": "Supporting points, each citing the note ids it rests on.",
+ "items": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string"},
+ "note_ids": {"type": "array", "items": {"type": "string"}},
+ },
+ "required": ["text", "note_ids"],
+ },
+ },
+ "suggested_next": {
+ "type": "array",
+ "items": {"type": "string"},
+ "description": "Concrete next experiments/moves if the question asks what to do; else empty.",
+ },
+ "coverage": {
+ "type": "string",
+ "enum": ["covered", "partial", "not_in_notebook"],
+ "description": "How well the provided notes cover the question.",
+ },
+ },
+ "required": ["answer", "points", "coverage"],
+ },
+}
+
+_SYSTEM = (
+ "You reason over a shared lab notebook. Answer ONLY from the notebook entries "
+ "provided — every claim must cite the note id(s) it rests on. If the notes do "
+ "not contain the answer, say so plainly and set coverage to 'not_in_notebook'. "
+ "Never invent facts not in the notes. Call the answer_from_notebook tool."
+)
+
+
+def _render_notes(notes: list[Note]) -> str:
+ lines = []
+ for n in notes:
+ scope = []
+ if n.strains:
+ scope.append("strains=" + ",".join(n.strains))
+ if n.embryos:
+ scope.append("embryos=" + ",".join(n.embryos))
+ tag = f" [{'; '.join(scope)}]" if scope else ""
+ lines.append(f"[{n.id}] ({n.kind.value}){tag} {n.body}")
+ return "\n".join(lines)
+
+
+def build_ask_messages(question: str, notes: list[Note]) -> list[dict]:
+ body = (
+ "Notebook entries:\n"
+ + _render_notes(notes)
+ + f"\n\nQuestion: {question}\n\n"
+ "Answer using only these entries, citing note ids."
+ )
+ return [{"role": "user", "content": body}]
+
+
+async def answer_question(client, model: str, question: str, notes: list[Note]) -> dict:
+ """Force the ask tool and return its validated input dict. Short-circuits
+ (no API call) when there are no notes to ground on."""
+ if not notes:
+ return {
+ "answer": "The notebook doesn't cover this yet.",
+ "points": [],
+ "suggested_next": [],
+ "coverage": "not_in_notebook",
+ }
+ resp = await client.messages.create(
+ model=model,
+ max_tokens=2048,
+ system=_SYSTEM,
+ tools=[ASK_TOOL],
+ tool_choice={"type": "tool", "name": ASK_TOOL["name"]},
+ messages=build_ask_messages(question, notes),
+ )
+ for block in resp.content:
+ if getattr(block, "type", None) == "tool_use":
+ return block.input
+ return {"answer": "", "points": [], "suggested_next": [], "coverage": "not_in_notebook"}
diff --git a/gently/ui/web/routes/__init__.py b/gently/ui/web/routes/__init__.py
index bdbf3db7..9f8cd903 100644
--- a/gently/ui/web/routes/__init__.py
+++ b/gently/ui/web/routes/__init__.py
@@ -14,6 +14,7 @@
from .data import create_router as create_data_router
from .experiments import create_router as create_experiments_router
from .images import create_router as create_images_router
+from .notebook import create_router as create_notebook_router
from .pages import create_router as create_pages_router
from .sessions import create_router as create_sessions_router
from .volumes import create_router as create_volumes_router
@@ -35,6 +36,7 @@ def register_all_routes(server):
create_agent_ws_router,
create_chat_router,
create_context_router,
+ create_notebook_router,
):
router = factory(server)
server.app.include_router(router)
diff --git a/gently/ui/web/routes/notebook.py b/gently/ui/web/routes/notebook.py
new file mode 100644
index 00000000..b9cb27da
--- /dev/null
+++ b/gently/ui/web/routes/notebook.py
@@ -0,0 +1,99 @@
+"""Notebook (shared lab notebook) read routes.
+
+Exposes the notebook's Notes for the Notebook tab + Agent's-View live edge.
+Read-only here; authoring/curation come in a later increment.
+"""
+
+from fastapi import APIRouter, Body, HTTPException
+
+from gently.harness.memory.notebook import Author, NoteKind, NoteStatus, note_to_dict
+
+
+def _coerce(enum_cls, value):
+ """Parse a query-param string into an enum; invalid/None → None (no filter)."""
+ if value is None:
+ return None
+ try:
+ return enum_cls(value)
+ except ValueError:
+ return None
+
+
+def create_router(server) -> APIRouter:
+ router = APIRouter()
+
+ def _nb():
+ cs = getattr(server, "context_store", None)
+ return cs.notebook if cs is not None else None
+
+ @router.get("/api/notebook/notes")
+ async def list_notes(
+ kind: str | None = None,
+ author: str | None = None,
+ status: str | None = None,
+ strain: str | None = None,
+ embryo: str | None = None,
+ thread: str | None = None,
+ limit: int | None = None,
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "notes": []}
+ notes = nb.query_notes(
+ kind=_coerce(NoteKind, kind),
+ author=_coerce(Author, author),
+ status=_coerce(NoteStatus, status),
+ strain=strain,
+ embryo=embryo,
+ thread=thread,
+ )
+ if limit is not None and limit >= 0:
+ notes = notes[:limit]
+ return {"available": True, "notes": [note_to_dict(n) for n in notes]}
+
+ @router.get("/api/notebook/notes/{note_id}")
+ async def get_note(note_id: str):
+ nb = _nb()
+ if nb is None:
+ raise HTTPException(status_code=404, detail="notebook unavailable")
+ note = nb.get_note(note_id)
+ if note is None:
+ raise HTTPException(status_code=404, detail="note not found")
+ return note_to_dict(note)
+
+ @router.get("/api/notebook/threads")
+ async def list_threads():
+ nb = _nb()
+ if nb is None:
+ return {"available": False, "threads": []}
+ counts: dict[str, int] = {}
+ for n in nb.query_notes():
+ for t in n.threads:
+ counts[t] = counts.get(t, 0) + 1
+ threads = [{"id": t, "count": c} for t, c in sorted(counts.items())]
+ return {"available": True, "threads": threads}
+
+ @router.post("/api/notebook/ask")
+ async def ask(
+ question: str = Body(..., embed=True),
+ thread: str | None = Body(None, embed=True),
+ strain: str | None = Body(None, embed=True),
+ ):
+ nb = _nb()
+ if nb is None:
+ return {"available": False}
+ from gently.harness.memory.notebook_ask import answer_question, select_notes
+ from gently.settings import settings
+
+ notes = select_notes(nb, thread=thread, strain=strain)
+ client = getattr(server, "claude_async", None)
+ if client is None:
+ import anthropic
+
+ client = anthropic.AsyncAnthropic()
+ result = await answer_question(client, settings.models.main, question, notes)
+ result["available"] = True
+ result["note_ids"] = [n.id for n in notes]
+ return result
+
+ return router
diff --git a/gently/ui/web/static/css/notebook.css b/gently/ui/web/static/css/notebook.css
new file mode 100644
index 00000000..fa4cebab
--- /dev/null
+++ b/gently/ui/web/static/css/notebook.css
@@ -0,0 +1,104 @@
+/* ── Notebook tab (LIBRARY) ──────────────────────────────────────────────
+ The reading room for the shared lab notebook: thread rail + kind filter +
+ note cards. Tokens reuse the app theme (--accent, --accent-green, --border …). */
+
+.nb-container { max-width: 1100px; margin: 0 auto; padding: 24px 28px; }
+
+.nb-header {
+ display: flex; align-items: center; justify-content: space-between;
+ gap: 16px; margin-bottom: 18px; flex-wrap: wrap;
+}
+.nb-title { font-size: 22px; font-weight: 600; letter-spacing: -.01em; color: var(--text, #0f172a); margin: 0; }
+
+.nb-kinds { display: flex; gap: 6px; }
+.nb-kind {
+ background: none; border: 1px solid var(--border, #e4e9f0); color: var(--text-secondary, #475569);
+ border-radius: 999px; padding: 6px 13px; font: inherit; font-size: 13px; cursor: pointer;
+ transition: border-color .15s, color .15s, background .15s;
+}
+.nb-kind:hover { color: var(--text, #0f172a); border-color: var(--border-strong, #cbd5e1); }
+.nb-kind.active { background: var(--accent, #2f6df6); border-color: var(--accent, #2f6df6); color: #fff; }
+
+/* Ask the notebook */
+.nb-ask { display: flex; gap: 9px; margin-bottom: 14px; }
+.nb-ask-input {
+ flex: 1; border: 1px solid var(--border, #e4e9f0); border-radius: 11px;
+ padding: 11px 14px; font: inherit; font-size: 14px; color: var(--text, #0f172a);
+ background: var(--bg-card, #fff);
+}
+.nb-ask-input:focus { outline: none; border-color: var(--accent, #2f6df6); box-shadow: 0 0 0 4px rgba(96, 165, 250, .15); }
+.nb-ask-go {
+ flex: none; border: 0; border-radius: 11px; padding: 11px 20px; font: inherit; font-size: 14px;
+ font-weight: 600; color: #fff; background: var(--accent, #2f6df6); cursor: pointer; transition: background .15s;
+}
+.nb-ask-go:hover { background: color-mix(in srgb, var(--accent) 88%, #000); }
+
+.nb-ask-result {
+ border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 16px 18px; margin-bottom: 18px;
+ background: var(--accent-soft, #f5f8ff);
+}
+.nb-ask-result[hidden] { display: none; }
+.nb-ask-loading, .nb-ask-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; }
+.nb-ask-cov {
+ display: inline-block; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
+ padding: 2px 9px; border-radius: 999px; margin-bottom: 10px; color: #fff; background: var(--text-muted, #94a3b8);
+}
+.nb-cov-covered { background: var(--accent-green, #16a34a); }
+.nb-cov-partial { background: #d97706; }
+.nb-cov-not_in_notebook { background: var(--text-muted, #94a3b8); }
+.nb-ask-answer { font-size: 14.5px; line-height: 1.55; color: var(--text, #0f172a); }
+.nb-ask-h {
+ font-size: var(--v2-fs-eyebrow, 11px); font-weight: 700; letter-spacing: .06em; text-transform: uppercase;
+ color: var(--text-secondary, #475569); margin: 14px 0 6px;
+}
+.nb-ask-points { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 7px; }
+.nb-ask-point { font-size: 13.5px; line-height: 1.45; color: var(--text, #0f172a); display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px; }
+.nb-cite {
+ font: 600 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
+ color: var(--accent, #2f6df6); background: var(--bg-card, #fff);
+ border: 1px solid var(--border, #e4e9f0); border-radius: 6px; padding: 1px 6px;
+}
+.nb-ask-next { margin: 0; padding-left: 18px; font-size: 13.5px; line-height: 1.5; color: var(--text-secondary, #475569); }
+.nb-ask-next li { margin: 2px 0; }
+
+.nb-body-wrap { display: grid; grid-template-columns: 220px 1fr; gap: 22px; align-items: start; }
+@media (max-width: 760px) { .nb-body-wrap { grid-template-columns: 1fr; } }
+
+/* thread rail (the inquiry spine) */
+.nb-threads { display: flex; flex-direction: column; gap: 4px; position: sticky; top: 12px; }
+.nb-thread {
+ text-align: left; background: none; border: 0; cursor: pointer; font: inherit; font-size: 13px;
+ color: var(--text-secondary, #475569); padding: 8px 11px; border-radius: 9px;
+ transition: background .15s, color .15s; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.nb-thread:hover { background: var(--bg-hover, #f1f5f9); color: var(--text, #0f172a); }
+.nb-thread.active { background: var(--accent-soft, #eaf1ff); color: var(--accent, #2f6df6); font-weight: 600; }
+
+/* notes */
+.nb-notes { display: flex; flex-direction: column; gap: 12px; }
+.nb-empty { color: var(--text-muted, #94a3b8); font-size: 14px; font-style: italic; padding: 28px 4px; }
+
+.nb-card {
+ border: 1px solid var(--border, #e4e9f0); border-radius: 13px; padding: 14px 16px;
+ background: var(--bg-card, #fff); box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
+}
+.nb-card-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
+.nb-badge {
+ font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase;
+ padding: 2px 9px; border-radius: 999px; color: #fff; background: var(--text-muted, #94a3b8);
+}
+.nb-badge.nb-k-obs { background: var(--accent, #2f6df6); }
+.nb-badge.nb-k-find { background: var(--accent-green, #16a34a); }
+.nb-badge.nb-k-q { background: #d97706; }
+.nb-author { font-size: 12px; color: var(--text-secondary, #475569); }
+.nb-status {
+ margin-left: auto; font-size: 11px; color: var(--text-muted, #94a3b8);
+ text-transform: uppercase; letter-spacing: .05em;
+}
+.nb-body-text { font-size: 14px; line-height: 1.5; color: var(--text, #0f172a); white-space: pre-wrap; }
+
+.nb-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.nb-chip {
+ font-size: 11.5px; color: var(--text-secondary, #475569);
+ background: var(--bg-hover, #f1f5f9); border-radius: 7px; padding: 2px 8px;
+}
diff --git a/gently/ui/web/static/js/app.js b/gently/ui/web/static/js/app.js
index a8c6450c..41bad885 100644
--- a/gently/ui/web/static/js/app.js
+++ b/gently/ui/web/static/js/app.js
@@ -100,6 +100,11 @@ function switchTab(tabName) {
ExperimentOverview.init();
}
+ // Lazy-init Notebook tab
+ if (tabName === TABS.NOTEBOOK && typeof NotebookApp !== 'undefined') {
+ NotebookApp.init();
+ }
+
// Update statusbar for context
updateStatusbar();
}
@@ -651,7 +656,7 @@ document.addEventListener('DOMContentLoaded', () => {
const hash = window.location.hash.slice(1); // remove #
if (hash) {
const [tab, param] = hash.split(':');
- if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT) {
+ if (tab === TABS.HOME || tab === TABS.PLANS || tab === TABS.SESSIONS || tab === TABS.EMBRYOS || tab === TABS.CALIBRATION || tab === TABS.EVENTS || tab === TABS.EXPERIMENT || tab === TABS.NOTEBOOK) {
switchTab(tab);
if (tab === TABS.PLANS && param && typeof openCampaign === 'function') {
setTimeout(() => openCampaign(param), 200);
diff --git a/gently/ui/web/static/js/context-surface.js b/gently/ui/web/static/js/context-surface.js
index 2e10000d..2c11e27e 100644
--- a/gently/ui/web/static/js/context-surface.js
+++ b/gently/ui/web/static/js/context-surface.js
@@ -19,23 +19,29 @@ const ContextSurface = (() => {
async function fetchAndRender() {
if (!el || loading) return;
loading = true;
- try { render(await (await fetch('/api/context')).json()); }
- catch (e) { /* keep last render */ }
+ try {
+ const [ctx, nb] = await Promise.all([
+ fetch('/api/context').then(r => r.json()).catch(() => ({})),
+ fetch('/api/notebook/notes?limit=5').then(r => r.json()).catch(() => ({})),
+ ]);
+ render(ctx || {}, (nb && nb.notes) || []);
+ } catch (e) { /* keep last render */ }
finally { loading = false; }
}
function section(label, html) { return html ? `
${label}
${html}
` : ''; }
- function render(data) {
+ function render(data, notes) {
if (!el) return;
+ notes = notes || [];
const hc = hasControl();
const questions = data.questions || [], watchpoints = data.watchpoints || [], expectations = data.expectations || [];
el.classList.remove('hidden');
- if (!questions.length && !watchpoints.length && !expectations.length) {
+ if (!questions.length && !watchpoints.length && !expectations.length && !notes.length) {
// Show an empty-state rather than vanishing, so the surface is
// discoverable before the agent has formed any beliefs.
el.innerHTML = '
Agent’s view
' +
- '
Nothing yet — the agent’s expectations, watchpoints, and open questions appear here as it works.
';
+ '
Nothing yet — the agent’s notes, expectations, and open questions appear here as it works.