feat: v0.8.0 — Claude Code subagent capture, own Joplin notebook, git-root repo tags, multi-root scanning

- Subagents: fold Task-tool transcripts (subagents/*.jsonl) inline as
  collapsible <details> blocks at the spawn point; their own tool traffic
  collapses under the same policy; nesting handled via toolUseId matching
- Joplin: Claude Code gets its own top-level AI-ClaudeCode notebook;
  update_note sets parent_id so notes self-heal/relocate on re-sync
- Repo [tags] in titles via git-root detection (nearest .git ancestor),
  home-wide and cross-workspace; CLAUDE_CODE_REPO_TAG_IGNORE escape hatch
- Multi-root scanning: CLAUDE_CODE_DIR as os.pathsep list + CLAUDE_CONFIG_DIR;
  merged by launch-folder, newer-mtime wins on UUID collision
- 298 tests passing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
JesseMarkowitz
2026-07-06 05:04:41 -04:00
co-authored by Claude Opus 4.8
parent bbcb29c856
commit 1f5a445ada
12 changed files with 779 additions and 71 deletions
+273
View File
@@ -228,3 +228,276 @@ class TestClaudeCodeProvider:
rendered = render_blocks_to_markdown(result["messages"][1]["blocks"])
assert "> 🔧 **Tool output** — `3 calls: Read ×2, Bash ×1`" in rendered
assert "omitted (EXPORTER_HIDDEN_CONTENT=full to keep)" in rendered
# ---------------------------------------------------------------------------
# Subagent folding (Part B)
# ---------------------------------------------------------------------------
def _write_subagent(
session_file, tool_use_id, records, agent_type="Explore",
description="do a thing", name="agent-x",
):
"""Write a <session>/subagents/<name>.jsonl + .meta.json sidecar."""
subdir = session_file.parent / session_file.stem / "subagents"
subdir.mkdir(parents=True, exist_ok=True)
(subdir / f"{name}.jsonl").write_text(
"\n".join(json.dumps(r) for r in records), encoding="utf-8"
)
(subdir / f"{name}.meta.json").write_text(
json.dumps({
"agentType": agent_type,
"description": description,
"toolUseId": tool_use_id,
}),
encoding="utf-8",
)
def _parent_with_task(tool_use_id="toolu_sub1"):
return [
{"type": "ai-title", "aiTitle": "Parent session"},
{
"type": "user", "cwd": "/home/jesse/myproj",
"timestamp": "2026-05-01T10:00:00.000Z",
"message": {"role": "user", "content": "delegate the research"},
},
{
"type": "assistant", "timestamp": "2026-05-01T10:00:05.000Z",
"message": {"role": "assistant", "content": [
{"type": "text", "text": "I'll delegate this."},
{"type": "tool_use", "id": tool_use_id, "name": "Task",
"input": {"description": "research"}},
]},
},
]
def _subagent_records():
return [
{
"type": "user", "isSidechain": True,
"timestamp": "2026-05-01T10:00:06.000Z",
"message": {"role": "user", "content": "Go research X"},
},
{
"type": "assistant", "isSidechain": True,
"timestamp": "2026-05-01T10:00:07.000Z",
"message": {"role": "assistant", "content": [
{"type": "text", "text": "Here is the research result."},
{"type": "tool_use", "id": "st1", "name": "Grep",
"input": {"pattern": "x"}},
]},
},
]
class TestSubagentFold:
def _provider(self, tmp_path, policy="placeholder"):
return ClaudeCodeProvider(projects_dir=tmp_path, hidden_content=policy)
def _normalize(self, tmp_path, policy="placeholder"):
f = _write_session(tmp_path, _parent_with_task(), name="parent-1")
_write_subagent(
f, "toolu_sub1", _subagent_records(),
agent_type="Explore", description="research X",
)
p = self._provider(tmp_path, policy)
p.fetch_all_conversations()
return p.normalize_conversation(p.get_conversation("parent-1"))
def test_subagent_folded_into_parent(self, tmp_path):
conv = self._normalize(tmp_path)
blocks = [b for m in conv["messages"] for b in m["blocks"]]
subs = [b for b in blocks if b["type"] == "subagent"]
assert len(subs) == 1
assert subs[0]["agent_type"] == "Explore"
assert subs[0]["description"] == "research X"
inner_text = [
bb.get("text") for m in subs[0]["messages"] for bb in m["blocks"]
]
assert "Go research X" in inner_text
assert "Here is the research result." in inner_text
def test_subagent_tools_collapsed(self, tmp_path):
conv = self._normalize(tmp_path)
sub = next(
b for m in conv["messages"] for b in m["blocks"]
if b["type"] == "subagent"
)
inner_blocks = [bb for m in sub["messages"] for bb in m["blocks"]]
assert any(b["type"] == BLOCK_TYPE_COLLAPSED for b in inner_blocks)
assert not any(b["type"] == BLOCK_TYPE_TOOL_USE for b in inner_blocks)
collapsed = next(b for b in inner_blocks if b["type"] == BLOCK_TYPE_COLLAPSED)
assert "Grep" in collapsed["origin"]
def test_subagent_not_listed_as_conversation(self, tmp_path):
f = _write_session(tmp_path, _parent_with_task(), name="parent-1")
_write_subagent(f, "toolu_sub1", _subagent_records())
p = self._provider(tmp_path)
convs = p.fetch_all_conversations()
assert [c["id"] for c in convs] == ["parent-1"]
def test_renders_as_details_block(self, tmp_path):
conv = self._normalize(tmp_path)
spawning = conv["messages"][1] # the assistant turn with the Task call
rendered = render_blocks_to_markdown(spawning["blocks"])
assert "<details>" in rendered
assert "<summary>🤖 Subagent: Explore — research X</summary>" in rendered
assert "</details>" in rendered
assert "Here is the research result." in rendered
def test_main_pass_still_skips_stray_sidechain(self, tmp_path):
# A sidechain record with no matching subagent file must NOT leak into
# the top-level dialogue.
records = _parent_with_task() + [
{"type": "assistant", "isSidechain": True,
"message": {"role": "assistant",
"content": [{"type": "text", "text": "stray sidechain"}]}},
]
f = _write_session(tmp_path, records, name="parent-2")
_write_subagent(f, "toolu_sub1", _subagent_records())
p = self._provider(tmp_path)
p.fetch_all_conversations()
conv = p.normalize_conversation(p.get_conversation("parent-2"))
top_text = [
b.get("text") for m in conv["messages"] for b in m["blocks"]
]
assert "stray sidechain" not in top_text
# ---------------------------------------------------------------------------
# Repo tags in title (Part C)
# ---------------------------------------------------------------------------
class TestRepoTags:
@staticmethod
def _mkrepo(base, name):
"""Create a git repo dir (bare .git marker) and return its path."""
(base / name / ".git").mkdir(parents=True, exist_ok=True)
return base / name
def _title(self, tmp_path, content):
(tmp_path / "ws").mkdir(exist_ok=True)
records = [
{"type": "ai-title", "aiTitle": "Work session"},
{"type": "user", "cwd": str(tmp_path / "ws"),
"timestamp": "2026-05-01T10:00:00.000Z",
"message": {"role": "user", "content": "go"}},
{"type": "assistant",
"message": {"role": "assistant", "content": content}},
]
_write_session(tmp_path, records, name="ws-1")
p = ClaudeCodeProvider(projects_dir=tmp_path)
p.fetch_all_conversations()
return p.normalize_conversation(p.get_conversation("ws-1"))["title"]
@staticmethod
def _read(path):
return {"type": "tool_use", "id": str(path), "name": "Read",
"input": {"file_path": str(path)}}
def test_repos_by_git_root_ordered_by_frequency(self, tmp_path):
a = self._mkrepo(tmp_path, "repo-a")
b = self._mkrepo(tmp_path, "repo-b")
content = [
self._read(a / "src/x.py"), self._read(a / "src/y.py"),
self._read(b / "z.py"),
# a file not inside any git repo → excluded
self._read(tmp_path / "CLAUDE.md"),
]
assert self._title(tmp_path, content) == "Work session [repo-a, repo-b]"
def test_cross_workspace_repo_tagged(self, tmp_path):
# Launched in ws/, but touched a repo in a sibling tree — still tagged.
other = self._mkrepo(tmp_path / "elsewhere", "faraway-repo")
content = [self._read(other / "deep/nested/file.py")]
assert self._title(tmp_path, content) == "Work session [faraway-repo]"
def test_no_repos_no_bracket(self, tmp_path):
content = [self._read(tmp_path / "loose/file.txt")] # no .git anywhere
assert self._title(tmp_path, content) == "Work session"
def test_cap_three_with_ellipsis(self, tmp_path):
content = [
self._read(self._mkrepo(tmp_path, f"repo-{c}") / "f/x.py")
for c in "abcd"
]
title = self._title(tmp_path, content)
assert title.startswith("Work session [repo-a, repo-b, repo-c, …]")
assert "repo-d" not in title
def test_paths_inside_bash_commands_count(self, tmp_path):
x = self._mkrepo(tmp_path, "repo-x")
content = [
{"type": "tool_use", "id": "a", "name": "Bash",
"input": {"command": f"cat {x / 'main.go'}"}},
]
assert self._title(tmp_path, content) == "Work session [repo-x]"
def test_ignore_list_via_env(self, tmp_path, monkeypatch):
a = self._mkrepo(tmp_path, "repo-a")
b = self._mkrepo(tmp_path, "repo-b")
monkeypatch.setenv("CLAUDE_CODE_REPO_TAG_IGNORE", "repo-b, notes")
content = [self._read(a / "x.py"), self._read(b / "y.py")]
assert self._title(tmp_path, content) == "Work session [repo-a]"
# ---------------------------------------------------------------------------
# Multiple projects roots (Part D)
# ---------------------------------------------------------------------------
class TestMultiRoot:
def _titled(self, aititle, cwd="/home/jesse/p"):
return [
{"type": "ai-title", "aiTitle": aititle},
{"type": "user", "cwd": cwd, "timestamp": "2026-05-01T10:00:00.000Z",
"message": {"role": "user", "content": "hi"}},
]
def test_two_roots_merged(self, tmp_path):
r1, r2 = tmp_path / "root1", tmp_path / "root2"
_write_session(r1, self._titled("From root1"), name="s1")
_write_session(r2, self._titled("From root2"), name="s2")
p = ClaudeCodeProvider(projects_dir=[r1, r2])
ids = sorted(c["id"] for c in p.fetch_all_conversations())
assert ids == ["s1", "s2"]
def test_duplicate_uuid_newer_wins(self, tmp_path):
import os
import time
r1, r2 = tmp_path / "root1", tmp_path / "root2"
_write_session(r1, self._titled("Old copy"), name="dup")
f2 = _write_session(r2, self._titled("New copy"), name="dup")
os.utime(f2, (time.time() + 10, time.time() + 10))
p = ClaudeCodeProvider(projects_dir=[r1, r2])
convs = p.fetch_all_conversations()
assert len(convs) == 1
assert convs[0]["title"] == "New copy"
assert p._path_map["dup"] == f2
def test_single_path_backward_compatible(self, tmp_path):
_write_session(tmp_path, _records())
p = ClaudeCodeProvider(projects_dir=tmp_path)
assert len(p.fetch_all_conversations()) == 1
def test_env_pathsep_list(self, tmp_path, monkeypatch):
import os
from src.providers.claude_code import resolve_roots
r1, r2 = tmp_path / "root1", tmp_path / "root2"
_write_session(r1, self._titled("From root1"), name="s1")
_write_session(r2, self._titled("From root2"), name="s2")
monkeypatch.setenv("CLAUDE_CODE_DIR", f"{r1}{os.pathsep}{r2}")
assert r1 in resolve_roots() and r2 in resolve_roots()
p = ClaudeCodeProvider()
assert len(p.fetch_all_conversations()) == 2
def test_config_dir_projects_included(self, tmp_path, monkeypatch):
from src.providers.claude_code import resolve_roots
monkeypatch.delenv("CLAUDE_CODE_DIR", raising=False)
monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "cfg"))
roots = resolve_roots()
assert (tmp_path / "cfg" / "projects") in roots
+25
View File
@@ -17,6 +17,7 @@ from src.blocks import (
make_file_placeholder,
make_hidden_context_marker,
make_image_placeholder,
make_subagent_block,
make_text_block,
make_thinking_block,
make_tool_result_block,
@@ -119,6 +120,30 @@ class TestMarkdownFrontmatter:
assert "```python" in content
assert "print('hello')" in content
def test_subagent_block_renders_as_details(self, tmp_path):
sub = make_subagent_block(
agent_type="Explore",
description="find the thing",
messages=[
{"role": "user", "blocks": [make_text_block("Go find it")]},
{"role": "assistant", "blocks": [make_text_block("Found it here.")]},
],
)
conv = {
**SAMPLE_CONV,
"provider": "claude-code",
"messages": [
{"role": "assistant", "content_type": "text", "timestamp": None,
"blocks": [make_text_block("Delegating."), sub]},
],
}
content = MarkdownExporter(tmp_path).export(conv).read_text()
assert "<details>" in content
assert "<summary>🤖 Subagent: Explore — find the thing</summary>" in content
assert "Go find it" in content
assert "Found it here." in content
assert "</details>" in content
class TestMarkdownFilenameGeneration:
def test_filename_format(self, tmp_path):
+21
View File
@@ -48,6 +48,9 @@ class TestNotebookPath:
def test_claude_provider(self):
assert notebook_path("claude", "budget-tracker") == ("AI-Claude", "Budget Tracker")
def test_claude_code_gets_own_top_level_notebook(self):
assert notebook_path("claude-code", "services") == ("AI-ClaudeCode", "Services")
def test_multi_word_project(self):
assert notebook_path("claude", "ai-research-notes") == ("AI-Claude", "Ai Research Notes")
@@ -61,6 +64,24 @@ class TestNotebookPath:
# ---------------------------------------------------------------------------
class TestUpdateNote:
def test_parent_id_moves_note(self):
client = _make_client()
client._put = MagicMock()
client.update_note("nid", "Title", "body", parent_id="nb1")
args, _ = client._put.call_args
assert args[0] == "/notes/nid"
assert args[1]["parent_id"] == "nb1"
assert args[1]["title"] == "Title"
def test_no_parent_id_omits_field(self):
client = _make_client()
client._put = MagicMock()
client.update_note("nid", "Title", "body")
args, _ = client._put.call_args
assert "parent_id" not in args[1]
class TestPing:
def test_ping_success(self):
client = _make_client()