Vault
plan

Buddy: Parallel Research Workspaces

Created

Buddy: Parallel Research Workspaces

Related: the-orchestrator-premium Informs: Projects/research

A tmux + Ink sidebar over several resumable Claude Code sessions, so more than one research thread can be live at a time and the board says which ones are waiting on me. Lives in tools/buddy/; workspaces live in workspaces/.

The vision, checked

The ask was: a left sidebar listing the ideas I'm chasing, project folders with metadata instead of one doc per idea, state and events in JSON driven by hooks, several Claude Code instances the sidebar moves between, sessions that survive tmux dying, and folders that can host worktrees when research grows code.

It holds up. The pieces all exist and compose. Seven things needed deciding before it would actually work, and one of them would have quietly broken the vault.

1. projects/ would have collided with Projects/

The vault already has a gitignored Projects/ directory — Obsidian creates it as stubs from [[Projects/sigil]] wiki links. macOS is case-insensitive, so projects/economics and Projects/economics are the same directory, and .gitignore line 17 would have swallowed every workspace silently.

Workspaces live in workspaces/ instead. It is also the word actually used to describe them.

2. Session resume should not be scraped

The obvious approach is to read the session id out of a hook payload after startup and store it. That has a hole: between launch and the first hook there is no id, so a crash in that window strands the session.

claude --session-id <uuid> accepts an id up front. Buddy mints the UUID, hands it to Claude, and resumes with --resume <uuid>. Resume is attempted only when ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl exists on disk, because a session id that was minted but never used makes --resume fail on launch. If resume fails anyway, the launcher falls forward into a fresh session rather than leaving a dead pane.

3. Hooks on every tool call would tax the agent

A hook per PreToolUse gives a lovely "what is it doing right now" line and puts a process spawn on the agent's hot path — tens of milliseconds times hundreds of tool calls per session, times several sessions at once.

So the signal is split by frequency:

A turn quiet for 90 seconds is flagged stalled, which in practice means a permission prompt or a long tool.

4. tmux should own pane identity

Storing pane ids in JSON means reconciling them every time a pane dies, a window is renamed, or tmux restarts. Instead each pane carries tmux user options — @buddy_role=panel|ws and @buddy_ws=<slug> — set at creation. tmux is then the only source of truth for what is running; a killed pane simply stops appearing in list-panes, and there is no stale bookkeeping to repair.

Moving between sessions is join-pane / break-pane rather than window switching, which is what keeps the sidebar on screen while the staged session changes underneath it.

5. "Done vs not done" belongs in git

Runtime state (session id, activity, event log) is disposable and gitignored. The task board is not — it is the durable shape of the thread, worth reviewing in a diff and editing by hand.

So each workspace splits in two: workspace.json (committed, hand-editable, read-modify-written so it never clobbers manual edits) and .buddy/ (gitignored session state and event log).

6. Existing docs should not move

Moving 90-odd docs out of docs/ into workspace folders would break the vault app, whose DOCS_DIR is a single flat directory, and churn the wiki-link graph for no gain.

A workspace references docs that already exist by wiki-link, and owns any new ones in its own folder. So the "one doc per idea" constraint is gone for new work — a workspace can deliver four docs — without a migration.

7. Code needs to be invisible to the vault

workspaces/*/code/ is gitignored. buddy worktree add <slug> <repo> adds a real git worktree of an external repo there on a buddy/<slug> branch, owned by the upstream repo. Research can grow code without the vault ever tracking it.

The arbiter, and where skills actually come from

The second build added an arbiter: one session that manages the board rather than doing research. It is not special machinery — it is a workspace with role: "arbiter", so it gets the same resume, event log, task board and sidebar row as any thread. What differs is two things: it is pinned to the top of the list, and it is launched with an extra plugin.

That second part turned out to be the whole design question. "Give the runners the system's skills, and the arbiter its own" sounds like a file-layout problem and is actually a resolution problem, because the four ways Claude Code finds context resolve against different roots:

Layer Resolves against Survives a cd into a worktree?
~/.claude/skills/ the user yes
--plugin-dir the session yes
CLAUDE.md cwd, then every parent yes
.claude/commands/ cwd no

Only --plugin-dir is both session-scoped and cwd-independent, which makes it the one mechanism that can say "these skills, this session, wherever it runs." So the buddy skills ship as two local plugins — buddy-runner (board discipline, doc delivery, spinning out code, importing a thread) and buddy-arbiter (triage, dispatch, harvest) — and the arbiter simply gets both. Skills namespace as plugin:skill, so nothing can collide with a repo's own.

Three things were verified rather than assumed, by probing a live session inside a real worktree:

Symlinks turned out to be the right answer for the other half of the problem — mounting the vault into a workspace container, below — but not here. buddy worktree add still links .claude/commands/vault into the worktree, for the session started by hand with cd .../code/<name> && claude, which has no --add-dir. The link is registered in the upstream repo's common git dir info/exclude — not the per-worktree one, which git does not read — so the foreign repo's git status stays clean. --no-link-vault opts out.

The precedence that matters in practice: inside a worktree the code repo's conventions win for code, and the vault's win for research markdown. Nothing enforces that but the buddy-spin-out skill saying so plainly.

Containers, and docs as the shared database

The first cut rooted every session at the vault, on the reasoning that a research thread needs docs/ and the tooling in reach. That was the wrong shape, and Ryuhei named the right one: each workspace is a container, and the vault's markdown is a database that containers connect to — many clients, one corpus. Rooting every thread at the vault made them all feel like the same session wearing different labels.

So a workspace's session now has cwd set to the workspace folder, and the vault reaches it only through explicit mounts — relative symlinks in the container:

workspaces/ai-capex-mask/
  BRIEF.md  workspace.json  <deliverables>.md
  docs -> ../../docs                          the shared corpus
  .claude/commands/vault -> …/.claude/commands the shared command set
  code/<name>                                  worktrees
  .buddy/                                       state, events, settings

Four things had to be true for this to work, and all four were tested rather than assumed:

One honest limit: this is scope hygiene, not a sandbox. cwd plus deny rules stop the file tools, but Bash is not path-restricted, and reads outside a container are permitted unless a rule names them. The goal was threads that stop muddying each other, and that it achieves. It is not isolation against a session that means to leave.

The arbiter is the deliberate exception: it stays rooted at the vault with no sibling denies, because it cannot triage a board it is fenced out of. Runners are clients; the arbiter is the one seat that sees the whole database.

Two consequences worth knowing. Transcripts are keyed by working directory, so each thread's conversations now live under its own container — /resume inside a workspace offers that thread's history and nothing else. And because of that keying, a workspace that adopts a vault-rooted conversation with buddy import session is pinned back to the vault root, recorded in state.cwd so the resume keeps finding its transcript. Sessions that predate the recorded field are treated as vault-rooted for the same reason.

Code sessions have their own identity

A session rooted in a worktree cannot share the workspace's session id, because Claude Code keys transcripts by working directory — resuming from a different cwd would look for a transcript that isn't there. So buddy open <slug> <name> mints its own id, tracked under state.code[<name>], with its pane tagged @buddy_code and its hook events routed to that sub-slot. The sidebar shows them as child rows under the workspace.

Importing an existing thread

Most threads worth a workspace already exist as a chain of docs, so the CLI does the mechanics and a skill does the judgment.

buddy import scan "<query>" ranks docs by slug, title, tag and body hits, then takes one hop along wiki-links in both directions at a discount. Link adjacency is what actually reconstructs a thread — a chain usually cites its predecessor even when the wording drifts. buddy import doc <slug> --doc a --doc b builds the workspace, unions the source tags, and seeds the brief. buddy import session <slug> --last adopts an existing Claude Code conversation rooted in this vault so the next launch resumes it rather than starting cold.

The buddy-import skill covers what the CLI can't: deciding where the thread actually stops, noticing it has already split into two, and turning "what's still open" into three to seven seeded tasks.

Shape

flowchart TB
  subgraph tmux["tmux session: research"]
    panel["buddy window<br/>sidebar (Ink) + staged pane"]
    arb["ws-arbiter<br/>+ buddy-arbiter plugin"]
    w1["ws-economics<br/>+ buddy-runner plugin"]
    w2["ws-economics/edge-llm<br/>worktree, own session id"]
  end

  panel -->|join-pane / break-pane| w1
  panel -.->|staged one at a time| arb
  arb -->|buddy.sh start / say / stage| w1
  w1 -.->|buddy open| w2

  w1 -->|5 lifecycle hooks| ev["workspace .buddy/<br/>state.json + events.jsonl<br/>gitignored"]
  w1 -->|writes| tr["session transcript .jsonl<br/>mtime = heartbeat<br/>tail = current tool"]

  ev --> panel
  tr --> panel
  ws["workspace.json<br/>tasks, docs, tags<br/>committed"] --> panel
  ws --> w1

The sidebar polls rather than watches: half the signal it needs (tmux liveness, transcript mtime) is not file-change driven anyway, so one cheap sweep every 800ms beats a watcher plus a timer.

Using it

scripts/buddy.sh install       # once — symlinks ~/bin/buddy
buddy                          # start or attach the tmux session
buddy arbiter --start --stage  # open the seat that runs the board
buddy new "AI capex mask"      # scaffold a container + BRIEF.md
buddy import scan "<topic>"    # find the docs that make up a thread
buddy ls                       # what's running, what's done

Named buddy — a sibling to the cubby system at work, which does the same job for code repos.

Exiting a session closes its pane and returns to the dashboard; a session that dies non-zero keeps its pane so the error is readable. Closing the sidebar destroys its window, so buddy recreates the window rather than assuming it can split back into it.

In the sidebar: j/k move, stages (launching if needed), n new workspace, a add task, tab moves focus to the task list where cycles todo → doing → done, i types a line into the staged session, s stops, r relaunches resuming, R relaunches fresh, ? for the rest.

Every session is launched with a standing brief telling it which workspace it is in, that deliverables go in the workspace folder, and to keep the board honest with buddy task doing|done as it works — the point of the board is that the shape of a thread is legible without reading the transcript.

Known edges