Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
05b3d02
test: RED for the header and six doctor rows reported on 2026-09-27
NetDevAutomate Sep 27, 2026
bdc6a44
fix(web): label every header picker and style the voice-engine badge
NetDevAutomate Sep 27, 2026
556d244
fix(doctor): stop calling project_aliases inert
NetDevAutomate Sep 27, 2026
f6c656a
fix(doctor): check Grok Build's AGENTS.md instead of reporting it mis…
NetDevAutomate Sep 27, 2026
0a69b67
fix(doctor): say why Obsidian export is off and how to turn it on
NetDevAutomate Sep 27, 2026
6b489e5
fix(doctor): report Kokoro model files only for the kokoro backend
NetDevAutomate Sep 27, 2026
36d10fa
fix(doctor): say what the xTiles setup has and lacks
NetDevAutomate Sep 27, 2026
942d253
fix(doctor): name the stale harness, and see Kiro's new session store
NetDevAutomate Sep 27, 2026
e528180
docs(changelog): header labels and six doctor rows that gave wrong ad…
NetDevAutomate Sep 27, 2026
4aad237
test(web): RED -- measure the header, not its HTML
NetDevAutomate Sep 28, 2026
99df223
fix(web): one line of header controls on the brand's centre line
NetDevAutomate Sep 28, 2026
20a5274
test(web): RED -- CSS and JS reach the browser with no Cache-Control
NetDevAutomate Sep 28, 2026
93f3078
fix(web): revalidate CSS and JS before reuse, like the page itself
NetDevAutomate Sep 28, 2026
66577cb
docs(changelog): the header entry describes the header as it now is
NetDevAutomate Sep 28, 2026
7dde69c
test(web): RED -- Body Double is for anything, and live it is the age…
NetDevAutomate Sep 28, 2026
8fb7c80
fix(web): Body Double is for anything, and live it is the agent's screen
NetDevAutomate Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,22 @@ experience may change before `1.0.0`.

### Changed

- Body Double is for anything you are doing, and a live session is the
agent's screen. The **Focus** card is gone from Body Double: it listed the
three most recent study topics with an "at capacity" chip above the picker
and the terminal, which read as a limit on what could be body-doubled (the
activity field was always free text). The three-topic rule is unchanged where
study threads start, and committed focus is managed with `studyloop focus`;
notes taken in Body Double are filed under the activity. While a session is
live the view's heading and big timer step aside: the session strip carries
the activity, the Pomodoro (time, Start/Pause/Resume, Break) and **End
session**, the console fills the rest of the window, and Capture folds to one
row beneath it — its Note and Park tabs open it, and ending the session gives
back the layout you had. The floating Park-a-thought button and Pomodoro
widget step aside during a live Body Double session (the `P` shortcut still
parks), because they covered the terminal's last line and the End button.
Measured at 1440×900 before the change, the terminal started 432px down the
page and ran below the window.
- The `openspec/` tree is no longer listed in `.gitignore`. Seventy-eight
tracked, load-bearing files lived under an ignored path, so every new spec
or archive file was invisible to `git status` and skipped by `git add -A`.
Expand All @@ -67,6 +83,38 @@ experience may change before `1.0.0`.

### Fixed

- The web header is one line of controls on the brand's centre line.
**Voice**, **Theme**, **Font** and **Size** each have a visible label, set
above the control and out of the layout, so every control is the same 32px
height and sits on one centre line; the voice picker matches the other
pickers instead of a smaller 12px style. The voice-engine badge ("System
voices", "Kokoro (server)") is a coloured dot and small text beside the Voice
label, yellow when something other than the host's Kokoro is speaking; it
never had a style, so it rendered as large bold text in the middle of the
header. At a 1024px tablet width with the semantic chip showing, the controls
wrap instead of running 75px past the window.
- CSS and JavaScript are revalidated before a browser reuses them
(`Cache-Control: no-cache`, answered with a 304 when unchanged), like the page
itself. They were sent with no Cache-Control, so after an update a browser
could pair the new page with a days-old stylesheet or script.
- `studyloop doctor` no longer gives advice that is wrong or leads nowhere:
- `project_aliases` is no longer called inert. Session search reads it
(`PROJECT_ALIASES.md`), and deleting it as the row advised would have cut
`--project` searches off from every aliased path.
- Grok Build's definition is checked against the repo-root `AGENTS.md` it
shares with Codex, instead of "No manifest entry for grok".
- The xTiles row says whether the wind-down skill is installed and which
harnesses register an `xtiles` MCP server; the offer can only appear in a
session connected to one.
- A stale `export_freshness` row lists every harness's own age, and says so
when kiro-cli's session store (`~/.kiro/sessions/cli/`) has moved on while
Kiro exports have not: kiro-cli 2.x keeps sessions there and the exporter
does not read it yet (#49).
- The Kokoro model-files row appears only for `tts.backend: kokoro`, the one
backend that reads them, and says they download on first use.
- "Obsidian export disabled" says it means the session-memory export
(`obsidian.export_enabled`), which is independent of `second_brain`, and
how to turn it on.
- Web (ACP) Kiro sessions run StudyLoop's own `studyloop` agent. They started
`kiro-cli acp` with no `--agent`, so each ran under whatever the learner's
default Kiro agent was: Kiro's built-in (its own system prompt, the learner's
Expand Down
26 changes: 16 additions & 10 deletions docs/web-ui-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ the move goes with the old one, and a session you rejoin carries none.
## Body Double

Use Body Double when the difficult part is starting or staying alongside the
work rather than learning a new concept.
work rather than learning a new concept. It is for anything you are doing —
study, a report, a tax return — so there is no topic list to choose from.

1. Open **Body Double**.
2. Name the activity in concrete terms, such as “trace one decorator call”.
Expand All @@ -58,15 +59,20 @@ work rather than learning a new concept.
move names an indexed lesson, **Open the lesson** opens it in the Course
Explorer panel beside the picker, so the session can start with it already
open next to you.
3. Choose an agent and start the Pomodoro timer if a time box would help.
4. Start the body-double session. The first move stays with you: it sits on
the session strip beneath the activity name for the whole session, with
**Open the lesson** beside it when a lesson was named, so the blank page
never arrives without it. It is a proposal on screen — the companion never
says it, and nothing opens unless you press the button.
5. Use **Focus** for up to three active topics and **Park a thought** for anything
that can wait.
6. End the session when the work block is complete.
3. Choose an agent, and set the Pomodoro lengths if a time box would help.
4. Start the body-double session. The page becomes the agent's screen: one
strip at the top with the activity, the Pomodoro (**Start Pomodoro**,
**Pause**, **Resume**) and **End session**, and the agent's console filling
the rest of the window. The first move stays with you: it sits on the strip
beneath the activity name for the whole session, with **Open the lesson**
beside it when a lesson was named. It is a proposal on screen — the
companion never says it, and nothing opens unless you press the button.
5. **Capture** folds to one row under the console while the session runs.
**Note** opens the note composer (notes are filed under the activity) and
**Park** keeps a tangent for later without leaving the session; the `P`
shortcut parks from anywhere.
6. End the session when the work block is complete. The page returns to the
layout you had before it started.

![A Body Double workspace with timer and a Kiro mentor](images/studyloop-body-double.png)

Expand Down
19 changes: 17 additions & 2 deletions packages/studyloop/src/studyloop/doctor/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -392,8 +392,21 @@ def check_agent_definitions() -> list[CheckResult]:
results: list[CheckResult] = []
manifest_agents = manifest.get("agents", {})

from studyloop.installers import _TOOL_LINKS

for tool in tools:
tool_keys = [k for k in manifest_agents if k.startswith(f"{tool}/")]
if not tool_keys:
# A harness with no definition of its own is checked against the
# manifest entries of the files its installer links. Grok Build reads
# the repo-root AGENTS.md that ``codex/AGENTS.md`` tracks; looking only
# for a ``grok/`` key reported "No manifest entry" and left the file it
# actually reads unchecked.
tool_keys = [
key
for spec in _TOOL_LINKS.get(tool, ())
if (key := spec.source.removeprefix("agents/")) in manifest_agents
]
if not tool_keys:
results.append(
CheckResult(
Expand All @@ -411,14 +424,15 @@ def check_agent_definitions() -> list[CheckResult]:
is_primary = Path(key).name == primary_name
check_name = f"agent_{tool}" if is_primary else f"agent_{tool}_{Path(key).stem}"
label = tool if is_primary else f"{tool} {Path(key).stem}"
shared = "" if key.startswith(f"{tool}/") else f" (shares {key})"

if not install_path.exists():
results.append(
CheckResult(
"agents",
check_name,
"warn",
f"{tool} detected but agent definition not installed",
f"{tool} detected but agent definition not installed{shared}",
"studyloop upgrade --component agents",
fix_auto=True,
)
Expand All @@ -433,7 +447,7 @@ def check_agent_definitions() -> list[CheckResult]:
"agents",
check_name,
"pass",
f"{label} agent definition current",
f"{label} agent definition current{shared}",
"",
False,
)
Expand All @@ -447,6 +461,7 @@ def check_agent_definitions() -> list[CheckResult]:
(
f"{label} agent definition outdated"
f" (local={local_hash[:8]}... expected={expected_hash[:8]}...)"
f"{shared}"
),
"studyloop upgrade --component agents",
fix_auto=True,
Expand Down
69 changes: 46 additions & 23 deletions packages/studyloop/src/studyloop/doctor/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,38 +176,43 @@ def check_pandoc() -> list[CheckResult]:
]


def _obsidian_export_disabled(vault: object, memory_dir: str) -> list[CheckResult]:
"""The one "off" row for both ways export can be off.

"Obsidian export disabled" alone named no reason and no next step, and a
learner on ``second_brain.provider: xtiles`` read it as "off because Obsidian
is not my second brain". The two settings are independent: this export writes
session-memory notes; ``second_brain`` only chooses where study notes go.
"""
target = f"{Path(str(vault)).expanduser()}/{memory_dir}" if str(vault) else memory_dir
return [
CheckResult(
"config",
"obsidian_export",
"info",
"Obsidian session-memory export disabled: obsidian.export_enabled is not true. "
"It is independent of second_brain, which only chooses where study notes go.",
f"To write session notes into {target}, set export_enabled: true under the "
"obsidian: section of config.yaml",
False,
)
]


def check_obsidian_export() -> list[CheckResult]:
"""Check Obsidian export configuration and vault writability.

If export is enabled, verify that the resolved vault_path/memory_dir
is present (or at least that the vault exists). If export is disabled,
return an informational result.
return an informational result that says how to turn it on.
"""
settings = _load_settings()
obsidian = getattr(settings, "obsidian", None)
if obsidian is None:
return [
CheckResult(
"config",
"obsidian_export",
"info",
"Obsidian export disabled",
"",
False,
)
]
return _obsidian_export_disabled(getattr(settings, "obsidian_base", ""), "AgentMemory")

if not obsidian.export_enabled:
return [
CheckResult(
"config",
"obsidian_export",
"info",
"Obsidian export disabled",
"",
False,
)
]
return _obsidian_export_disabled(obsidian.vault_path, obsidian.memory_dir)

# Export is enabled — verify vault_path / memory_dir is accessible.
vault_path = Path(obsidian.vault_path).expanduser()
Expand Down Expand Up @@ -307,13 +312,31 @@ def check_second_brain() -> list[CheckResult]:
return []

if config.provider == "xtiles":
# "prompts and an opt-in assistant skill" read as "the skill still needs
# installing". It is installed for every harness by `studyloop install
# agents`; the opt-in is the learner's yes at wind-down. What decides
# whether the offer can ever appear is an MCP server named `xtiles` in
# the session, so the row says where one is registered.
from studyloop import installers

skill = (
"The wind-down skill is installed"
if installers.XTILES_SKILL_HUB.exists()
else "The wind-down skill is not installed (studyloop install agents installs it)"
)
servers = installers.xtiles_mcp_harnesses()
where = (
f"an xtiles MCP server is registered in: {', '.join(servers)}"
if servers
else "no harness here registers an xtiles MCP server, so the offer cannot appear yet"
)
return [
CheckResult(
"config",
"second_brain_provider",
"info",
"Second brain: xTiles (no programmatic backend; prompts and an "
"opt-in assistant skill)",
f"Second brain: xTiles (no programmatic backend). {skill}; it offers to "
f"write only in a session with an xtiles MCP server connected, and {where}.",
"See docs/second-brain.md for the xTiles setup and the three prompts.",
False,
)
Expand Down
98 changes: 94 additions & 4 deletions packages/studyloop/src/studyloop/doctor/exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,20 +192,71 @@ def newest_message_at(db_path: Path) -> datetime | None:
conn.close()
if not row or not row[0]:
return None
text = str(row[0]).replace("Z", "+00:00")
return _parse_stamp(row[0])


def _parse_stamp(raw: object) -> datetime | None:
text = str(raw).replace("Z", "+00:00")
try:
stamp = datetime.fromisoformat(text)
except ValueError:
return None
return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC)


#: Where kiro-cli 2.x keeps its sessions: one ``<uuid>.jsonl`` plus ``<uuid>.json``
#: per session. The Kiro exporter still reads only data.sqlite3's conversation
#: tables, which kiro-cli stopped updating on 2026-09-05 on the machine that
#: reported the gap (StudyLoop #49). Until the exporter reads this store, the
#: directory changing after the newest ``kiro_cli`` export means Kiro sessions are
#: missing from the database. Retire this probe when #49 lands.
KIRO_SESSIONS_DIR = Path.home() / ".kiro" / "sessions" / "cli"


def newest_message_by_source(db_path: Path) -> dict[str, datetime]:
"""Newest exported message per ``sessions.source``; ``{}`` when unreadable."""
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.Error:
return {}
try:
rows = conn.execute(
"SELECT s.source, MAX(m.timestamp) FROM messages m "
"JOIN sessions s ON s.id = m.session_id GROUP BY s.source"
).fetchall()
except sqlite3.Error:
return {}
finally:
conn.close()
newest: dict[str, datetime] = {}
for source, raw in rows:
stamp = _parse_stamp(raw) if raw else None
if source and stamp:
newest[str(source)] = stamp
return newest


def _age(hours: float) -> str:
if hours < 1:
return "under 1 h"
return f"{hours:.0f} h" if hours < 48 else f"{hours / 24:.0f} d"


def check_export_freshness(
db_path: Path | None = None,
*,
now: datetime | None = None,
max_age_hours: float = EXPORT_FRESHNESS_HOURS,
kiro_sessions_dir: Path | None = None,
) -> CheckResult:
"""How long ago each harness last had a message exported.

The newest message across every harness hid a 22-day Kiro gap behind a 48 h
Codex age (reported 2026-09-27), so a warning lists every harness's own age.
Kiro gets one more probe: kiro-cli's session store changing well after the
newest ``kiro_cli`` export means sessions the exporter cannot read (#49). That
warns even when another harness is fresh, because the fresh one hides it.
"""
db_path = db_path or _db_path()
newest = newest_message_at(db_path) if db_path.exists() else None
if newest is None:
Expand All @@ -217,15 +268,52 @@ def check_export_freshness(
fix_hint="studyloop doctor --fix (installs the export hooks), then close one session",
fix_auto=True,
)
age_hours = ((now or datetime.now(UTC)) - newest).total_seconds() / 3600
now = now or datetime.now(UTC)
by_source = newest_message_by_source(db_path)
listing = ", ".join(
f"{source} {_age((now - stamp).total_seconds() / 3600)}"
for source, stamp in sorted(by_source.items(), key=lambda item: item[1], reverse=True)
)

store = kiro_sessions_dir or KIRO_SESSIONS_DIR
kiro_newest = by_source.get("kiro_cli")
try:
store_changed = datetime.fromtimestamp(store.stat().st_mtime, tz=UTC)
except OSError:
store_changed = None
if (
kiro_newest is not None
and store_changed is not None
and (store_changed - kiro_newest).total_seconds() / 3600 > max_age_hours
):
kiro_age = _age((now - kiro_newest).total_seconds() / 3600)
changed_age = _age((now - store_changed).total_seconds() / 3600)
return CheckResult(
category="harness",
name="export_freshness",
status="warn",
message=(
f"kiro_cli's newest export is {kiro_age} old, but kiro-cli wrote to {store} "
f"{changed_age} ago: kiro-cli keeps its sessions there now and the exporter "
f"does not read that store yet, so newer Kiro sessions are not in the "
f"database. By harness: {listing}"
),
fix_hint="Nothing to repair locally; the exporter has to learn this store "
"(StudyLoop #49). The session files stay on disk for a later backfill.",
fix_auto=False,
)

age_hours = (now - newest).total_seconds() / 3600
if age_hours > max_age_hours:
by_harness = f"; by harness: {listing}" if listing else ""
return CheckResult(
category="harness",
name="export_freshness",
status="warn",
message=(
f"newest exported message is {age_hours:.0f} h old (limit {max_age_hours:.0f} h); "
f"if you have used a harness since, read {installers.EXPORT_HOOK_LOG}"
f"newest exported message is {age_hours:.0f} h old (limit {max_age_hours:.0f} h)"
f"{by_harness}; if you have used a harness since, read "
f"{installers.EXPORT_HOOK_LOG}"
),
fix_hint="",
fix_auto=False,
Expand All @@ -242,10 +330,12 @@ def check_export_freshness(

__all__ = [
"EXPORT_FRESHNESS_HOURS",
"KIRO_SESSIONS_DIR",
"check_export_freshness",
"check_exporter_schema",
"database_user_version",
"exporter_schema_version",
"newest_message_at",
"newest_message_by_source",
"pinned_exporter_path",
]
Loading
Loading