Skip to content

mcp: first-class worktree support — every call names its workspace - #652

Open
oyvindberg wants to merge 28 commits into
masterfrom
mcp-stateless-directory
Open

mcp: first-class worktree support — every call names its workspace#652
oyvindberg wants to merge 28 commits into
masterfrom
mcp-stateless-directory

Conversation

@oyvindberg

@oyvindberg oyvindberg commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Agentic development has a shape: an orchestrating session fans subagents out into git worktrees, each working a branch in parallel. Bleep's compile daemon was built for that shape — one process serving every checkout from shared, deduplicated state — but the layer agents actually talk to was not.

One idea runs under everything this PR ships: bleep realizes the build as immutable data, throughout. bleep.yaml is a value, not a DSL. The resolved build model is immutable data passed structurally — no global state anywhere. Everything the daemon does while building is emitted as typed events (compilation reasons with invalidated files, diagnostics, per-test status/duration/reason). So it is only logical that a completed run is also a value — a transcript written to a file in the worktree — and that values compose like values: diffing two runs is a pure function over two files. "Build logs you can diff" is not a feature bolted on; it falls out of the architecture.

The PR lands that arc in six pieces, in dependency order: a stateless MCP server where every call names its workspace; bleep copy-state so a forked agent's first build compiles only the diff; transcripts as a durable per-worktree history written once by the daemon and read by every client; bleep.history.diff / bleep.history.diff-timing — the "edit, rerun, what changed?" primitive over those transcripts; the same surface in the CLI (bleep history / history show / history diff), no daemon required; and one allow-list deciding what compiled state may leave a workspace at all. By the end, the MCP server's "one thing kept in memory" is nothing.

1. Stateless MCP server: every call names its workspace (4d15dbd)

One MCP server serves a whole Claude session, including subagents working in other git worktrees. The server used to pin one workspace at boot, so a subagent's compile silently built the parent checkout — green result, wrong code — and the server couldn't detect it: tool calls carry project names, not paths, and MCP tells the server nothing about the caller's cwd. Any fix based on detection is impossible; the only honest design is to make the caller state its location, which it always trivially knows.

So every tool that acts on a build now requires directory, and the server holds no ambient workspace at all: each call bootstraps fresh from that directory's bleep.yaml (CLI semantics, CLI cost), opens its own connection to the shared compile daemon, and disconnects. directory is required in every tool schema, so the wrong-checkout build is not guarded against — it is unrepresentable. Boot is workspace-free, so one user-scoped registration

claude mcp add --scope user bleep -- bleep mcp-server

serves every checkout and every worktree: no per-checkout .mcp.json, no seeding on git worktree add, nothing to clean up on git worktree remove.

Statelessness deletes whole categories of invalidation logic rather than fixing them:

  • the bleep.yaml file-watcher and build-reload path — a fresh bootstrap per call sees config edits (including JVM changes) with no machinery at all
  • the persistent BSP connection and its reconnect-on-death manager
  • watch jobs (bleep.watch/sync/watch.stop) — background fibers holding connections into a daemon whose idle accounting counts connected clients
  • the "last build" result cache and its diff-vs-previous responses, which went stale the moment anyone compiled outside MCP

A build declaring a different $version than the running server fails loudly with the fix in the message (install matching binary, call bleep.restart) — the client↔daemon wire protocol is version-locked, so serving it would be a lie. The JVM leg needs no such gate: daemons are keyed by bleep version + JVM + options, and per-call bootstrap routes each workspace to its matching daemon, spawning it if needed.

2. bleep copy-state: a fork starts warm (4a1a807, 665250b)

A fresh worktree has its parent's sources and none of its compiled state, so its first build recompiles the world. Content-addressed caching cannot fix the interesting part: digests are transitive, so one dirty edit in a base project makes every downstream project a cache miss — while zinc's invalidation cone for the same edit is a few files, because it keys on API hashes. What a fork actually wants is its parent's exact state as an incremental baseline.

bleep copy-state <parent> (MCP: bleep.copy-state { directory, from }) provides exactly that, and only that: state is copied from the worktree you forked off and nowhere else — dirty-file analyses included, which is the baseline that makes the first compile touch a few files rather than a few hundred. No cache tiering, no nearest-match heuristics.

The copy runs in the compile daemon on purpose. Ask-then-clone is a TOCTOU race — another agent can start compiling the parent between the asking and the cloning — so the daemon takes the same per-project locks compiles take, and the race is closed rather than narrowed. Per project, holding the lock: classes and test-classes, the zinc analysis (byte-portable by design, and byte-identical to one already resident, so the daemon's cross-workspace analysis sharing serves the fork for free), and generated sources/resources. Deliberately NOT copied: noop-manifest.bin — its keys are absolute paths into the parent, and a copied manifest validates against them, yielding a false noop that points the fork at the parent's classes. Transport is APFS clonefile on macOS, --reflink=auto on Linux, a JVM recursive copy elsewhere — chosen per OS up front, no fallback chain.

665250b fixed enumeration for hierarchical project names (dfmt/main-style cross names nest their state dirs): walk for dirs containing builds/<variant> instead of listing flat — found on a real 130-project repo where the flat listing silently copied 13 projects and skipped the rest.

The dangerous case, tested end-to-end (2b77392, b322f6f)

The parent is a working tree: its compiled state can be ahead of git — an uncommitted edit that was compiled — so the clone carries output into a workspace whose sources never contained it. Two integration tests drive real compiles through the BSP server to pin that zinc reconciles the fork's actual sources against the inherited analysis:

  • Dirty downstream (2b77392): workspace A compiles core+app, app gains an uncommitted marker method and is recompiled, workspace B is created with the original sources and seeded via copy-state (asserted: the marker is byte-for-byte present in B's App.class). B's compile recompiles app, noops core (classfile mtime and bytes untouched), and afterwards the marker is gone from B's App.class. That byte-level assertion is the load-bearing one: the inherited classfile declares a method B's sources never wrote, and if zinc ever trusted inherited output, this is the assertion that catches it.
  • Dirty upstream (b322f6f): the uncommitted edit lives in core — an added overload of the very name app calls (zinc's name-hashing invalidation only invalidates dependents that use an affected name), plus a marker. B's compile must purge both from Core.class and recompile app even though app's own source matches the inherited analysis — the core API it was compiled against no longer exists. Asserted via classfile mtime (a correct recompile of unchanged source is byte-identical), made non-vacuous by the sibling test proving uninvalidated seeded projects' mtimes stay put.

CI runs these on the real filesystem on every platform, so every clone transport lane is proven, alongside the endpoint's own contract tests: locking (copy-state blocks on an exclusively-locked mid-compile project and completes on release), manifest/lock-file exclusion, and loud refusals for every invalid input.

3. Transcripts live in the worktree (bef2ba8, e906dcf, fdcbaef)

What replaced the deleted result cache went through two stages. The first was an in-server RequestLog — a bounded ring keyed by explicit requestId. That already fixed staleness (a transcript describes its own run, never current state, so it cannot go stale), but it was still per-process memory: invisible to the CLI and IDEs, gone on restart, blind to compiles that didn't pass through MCP. The architecture points somewhere better: run history is a property of the worktree, not of any client. So:

Transcript is a value in bleep-core (bef2ba8, bleep.history): a header (id, workspace, variant, mode, targets, client) plus the full typed event stream and test-run result, circe-codecked for disk. Paths inside events stay absolute — transcripts are grepped and clicked, and absolute + workspace can always derive relative while the reverse loses paths outside the workspace.

The daemon writes it, once, for every client (e906dcf). Recording happens in the daemon at the one choke point every request event already passes through — sendEvent — via a per-request TranscriptRecorder created at the top of handleCompile/handleTest and threaded structurally through the whole chain. No connection- or daemon-scoped mutable state; what the client sees and what the transcript stores are the same stream by construction. At completion — success, failure or cancellation alike; a red run's transcript is exactly what a diff wants — the stream is persisted to .bleep/builds/<variant>/history/<id>.json in the workspace, tagged with the client name from the BSP handshake ("bleep", "bleep-mcp", "Metals", …). Every client shares one durable per-worktree history that survives daemon restarts, MCP-server restarts, and inspection on a machine with no daemon at all. Ids are per-workspace monotonic, minted under a file lock so racing writers cannot collide; writes are tmp+rename atomic so readers never see torn files and take no lock; retention is newest-32 / 64MB, noop transcripts kept deliberately — "everything up-to-date" is exactly what a copy-state verification diff wants to see. The id travels back in the BSP response (compile: CompileResult dataKind bleep-history-id; test: TestRunResult.historyId). One SANCTIONED EXCEPTION to fail-loudly: a transcript-write failure must not fail a build that already ran — the daemon logs and returns the result without an id. Everything else still throws.

The noop fast path now emits CompilationReason(UpToDate) before returning: without it a noop's transcript was just Started/Finished — indistinguishable from a compile whose reason was lost, and two noop runs would not have carried identical logical facts. With it, the flagship determinism property holds end-to-end through the real daemon, pinned by TranscriptStoreIntegrationTest: three compiles over the real protocol leave history/1..3.json, the responses carry the ids, clean-vs-noop diffs as exactly one reason transition, and noop-vs-noop diffs identical while every duration differs.

The MCP server now holds nothing (fdcbaef). bleep.compile/bleep.test report the daemon-assigned id instead of minting their own; bleep.history.show/bleep.history.diff/bleep.history.diff-timing read TranscriptStore directly — pure file reads, deliberately no bootstrap and no daemon spawn. RequestLog, the bridge, and the last Ref in the server are deleted: the answer to "what does the MCP server keep in memory?" is nothing. history.show/diff gain the required directory every other tool has — ids are per-workspace, so an id means nothing without saying whose history to look in — and diff gains optional baseDirectory for cross-worktree comparisons.

bleep.history.show is unchanged in spirit: expand any past run — every diagnostic, every stack trace, paginated, and searchable with a regex query over messages, paths, suite/test names and stack traces (agents grep, so give them grep — server-side, over structured data, returning exactly the matching items). The verbose flag stays dead: details are always one call away instead of a re-run away.

4. bleep.history.diff and bleep.history.diff-timing: "what changed?" (0cb899c, bef2ba8)

A transcript stores the complete event stream per request — all nine per-test statuses with durations and messages, per-project compilation reasons with invalidated files, diagnostics — and this turns that stored stream into the primitive of the agent loop: edit, rerun, "what changed?". Two tools, deliberately separate:

bleep.history.diff(base, target) is the mechanical diff. It is computed over a projection of the transcript that contains no time by type — not "durations zeroed out", they never enter the compared data structures — so two runs with the same logical outcome diff as identical no matter how timings jittered. Tests keyed by (project, suite, test): newlyFailing / fixed / newlySkipped (with the assume reason) / unskipped / added / removed, and stillFailing listed as context with a messageChanged flag (same failure vs a different failure — only the latter counts as a difference). Scope drift shows as added/removed, never as "fixed": a failing test that left the scope is not a fix. Compiles keyed by project: reason transitions (up-to-date→incremental), invalidated-file deltas, status changes, and new/resolved diagnostics keyed by (severity, path, message) with line as an attribute — a diagnostic that only moved lines is neither new nor resolved.

Cross-worktree diffs are allowed, flagged, and made honest by identity relativization (bef2ba8): diagnostic identity over absolute paths would count the same warning in a parent and its fork — same file, different root — as new + resolved. Now each side's paths, and occurrences of each side's workspace root embedded in message text, are relativized against that side's own root — ephemerally, for the comparison key only, never serialized and never shown. Paths outside any workspace (coursier, JDK) stay absolute for identity, correct on one machine. For single-workspace diffs the transformation is applied to both sides against the same root: an identity-preserving no-op, so the determinism property is untouched. Compile parent, copy-state, compile fork, diff with --base-dir — the "everything nooped except the dirty project" verification becomes one call.

bleep.history.diff-timing(base, target) owns durations: per-item deltas above max(50ms, 20% of base) as slower/faster, jitter suppressed and counted, plus slowestInTarget for the absolute question. The old diff-vs-last cache went stale the moment anyone compiled outside MCP; diffing two immutable transcripts by explicit historyId cannot.

16 scenario tests + 4 store tests in bleep-tests/src/scala/bleep/history/, the first of which pins the load-bearing property: identical outcomes with wildly different timings and event order diff as identical — and TranscriptStoreIntegrationTest pins the same property end-to-end through the real daemon.

From the deployed binary — break a test, rerun, diff:

{"identical":false,"summary":"1 newlyFailing","newlyFailing":[{...,"from":"passed","to":"failed","message":"\"expected 2, got [3]\" was not equal to \"expected 2, got [4]\""}]}

revert, rerun, diff the first run against the last:

{"identical":true,"summary":"No logical differences."}

despite ~800ms of wall-clock difference between the two runs — and bleep.history.diff-timing on the same pair suppressed all 14 per-test deltas as jitter (116ms → 120ms total).

5. The same history from the terminal (18c1d70)

Because transcripts are files in the worktree, the CLI surface is pure reads — it works with no daemon running and no MCP session anywhere:

  • bleep history — list recorded runs: id, time, mode, targets, which client ran it
  • bleep history show [id] — full transcript as JSON (latest when omitted); --project, --query (regex), --limit, --offset
  • bleep history diff <base> <target> — the mechanical diff; --timing switches to the jitter-suppressed duration comparison; --base-dir <path> resolves base in another worktree's history (the copy-state verification flow)

The details rendering moved out of the MCP server into bleep-core as history.TranscriptFormat, so bleep history show and bleep.history.show are one implementation that cannot drift (contract pinned by TranscriptFormatTest). Compile/test summary lines print the id when the response carried one — History: #N (bleep history show N) — so a human sees the same handle an agent would use. HistoryCliIT closes the loop end-to-end: bleep new, two compiles through the production BSP server, transcripts land tagged client="bleep", the summary names both ids, and history list/show/diff (including --base-dir and --timing) run over the files.

6. One allow-list decides what leaves a workspace (ef5997b)

copy-state and the remote cache each kept a hand-maintained notion of which parts of a project's compiled state may cross the workspace boundary, and they had already drifted: copy-state correctly skipped .zinc/cache, KSP caches and lock files, while the remote cache's filter denied only noop-manifest.bin BY NAME and shipped everything else in the variant dir — zinc's classfile cache (absolute paths), per-machine KSP state, and even a .bleep-lock if one existed at pack time.

bleep.StateSharing (bleep-model) is now the single authority both consume: an ALLOW-list over paths relative to the variant build dir — {classes/, test-classes/, .zinc/analysis.zip} — with deny-by-default semantics, so state invented in the future is workspace-private until someone consciously declares it shareable, never leaked by omission. History transcripts are workspace-level and outside both mechanisms' reach by construction; CopyStateEndpointTest now pins that a seeded fork inherits neither them nor the zinc cache. The layout-level version of this rule — physically separating shared/ from local/ so the classification is enforceable by a directory walk — is deliberately left for a follow-up: it breaks on-disk layout and deserves its own change.

Why an MCP layer at all

The daemon does the heavy lifting, and agents could shell out to the CLI. The MCP layer earns its existence on three things:

  • Force the workspace. Subagents share the session's server connection, so workspace identity must be explicit. A required schema field is stronger than convention, prompts, or docs: the call physically cannot be made without it.
  • Save tokens — and the failure mode behind them. CLI output is for humans. An agent fed thousands of log lines starts managing output instead of fixing code: it diverts the log to a file, greps the file, and routinely misses the line that mattered. A ~200-token summary, streamed failures, and a searchable transcript one call away mean the divert-and-grep expedition never starts.
  • Declare effects. Every tool carries the MCP effect annotation (read-only / additive / destructive), so a harness can auto-approve bleep.compile while still gating bleep.clean. Bash invocations get no such structure.

Also in this PR

  • The whole run-history surface lives under one namespace: CLI bleep history / bleep history show / bleep history diff, MCP bleep.history.list / bleep.history.show / bleep.history.diff / bleep.history.diff-timing, wire dataKind bleep-history-id, field historyId, on-disk .bleep/builds/<variant>/history/. history because runs collides with bleep run and builds with bleep build; show follows the bleep build show precedent. bleep.history.list is new — the CLI listing as JSON, for parity.

  • /deploy-local as a checked-in skill (eab72f2, f77c312): the local snapshot deploy recipe, executable, including the gotcha that bleep.restart severs the calling session's own MCP connection.

  • The landing page tells this story in two sections — orchestration (one server, every worktree; forks start warm) and observability (the build that answers questions) — and sells the architecture behind it by name: data all the way down, from bleep.yaml to diffable runs, with the CLI surface alongside MCP (cdcaeae, 74625dd).

  • The MCP docs page stops claiming run history lives in server memory, documents the per-worktree store, and lists bleep.history.diff / bleep.history.diff-timing in the tool table (fc3cfc4).

  • docs/reference/cli regenerated via bleep gen-cli-docs — the new history pages plus the previously ungenerated copy-state page.

Verified

Over real stdio JSON-RPC against a live daemon (locally published snapshot): every workspace-acting tool lists directory as required in its schema; a valid directory bootstraps, compiles, and answers with a summary + historyId; relative paths, missing directories, and unknown historyIds fail with named errors; bleep.history.show with an invalid regex fails with the parse error, caret and all. The daemon-side store is pinned end-to-end by TranscriptStoreIntegrationTest (real protocol, ids in responses, determinism through the daemon), the CLI by HistoryCliIT (production BSP server, transcripts tagged and diffed over files), copy-state by the integration tests above plus CopyStateEndpointTest's state-sharing assertions, and the diff semantics by the 16-scenario suite. The demo output quoted here is verbatim from the deployed binary.

🤖 Generated with Claude Code

… stale

One MCP server serves a whole Claude session, including subagents working in
other git worktrees. The server pinned one workspace at boot, so a subagent's
compile silently built the parent checkout — green result, wrong code — and
the server couldn't detect it: tool calls carry project names, not paths, and
MCP tells the server nothing about the caller's cwd.

So the caller states it. Every tool that acts on a build now requires
`directory`, and the server holds no ambient workspace at all: each call
bootstraps fresh from that directory's bleep.yaml (CLI semantics, CLI cost),
opens its own connection to the shared compile daemon, and disconnects.
Boot is workspace-free — `bleep mcp-server` starts from any directory, which
makes one user-scoped registration (`claude mcp add --scope user bleep`)
serve every checkout and worktree.

Statelessness deletes whole categories of invalidation logic rather than
fixing them:

  - the bleep.yaml file-watcher and build reload path: a fresh bootstrap per
    call sees config edits (including JVM changes) with no machinery at all
  - the persistent BSP connection and its reconnect-on-death manager
  - watch jobs (bleep.watch/sync/watch.stop): background fibers holding
    connections into a daemon whose idle accounting counts connected clients
  - the "last build" result cache and its diff-vs-previous responses, which
    went stale the moment anyone compiled outside MCP

What replaces the result cache is a RequestLog keyed by explicit requestId:
compile/test return a compact summary plus their id, and the new
bleep.details tool returns that run's full transcript — every diagnostic,
every stack trace, paginated. A transcript describes its own run, never
current state, so it cannot go stale; retention is a bounded ring of the
last 32 requests. The verbose flag dies with the cache: details are always
one call away instead of a re-run away.

A build declaring a different $version than the running server fails loudly
with the fix in the message (install matching binary, call bleep.restart) —
the wire protocol between client and daemon is version-locked, so serving it
would be a lie. The JVM leg needs no such gate: daemons are keyed by
bleep version + JVM + options, and per-call bootstrap routes each workspace
to its matching daemon, spawning it if needed.

Verified over real stdio JSON-RPC: 14 tools listed with directory required
in the schema; a call with a valid directory bootstraps and answers; a
relative path and a missing directory both fail with named errors; details
without history and with an unknown id name exactly what is wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oyvindberg oyvindberg changed the title mcp: stateless server — every call names its workspace, nothing to go stale mcp: first-class worktree support — every call names its workspace Aug 10, 2026
oyvindberg and others added 27 commits August 10, 2026 23:53
…arned

The "Built for the agents" section described the previous MCP server: 18
tools, in-process claims, diff-against-previous-run responses. Rewritten
around what actually ships now: subagents fanning out into git worktrees as
the design center, one user-scoped registration covering every checkout on
the machine, every call naming its target so the wrong-checkout build cannot
happen, one shared daemon deduplicating analyses across worktrees, and
summary-plus-requestId responses whose transcripts cannot go stale.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…hem grep

Given a long build log, an agent's reflex is to divert it to a file and grep
it — losing the structure and, often, the answer. bleep.details gains a
`query` parameter: a case-insensitive regex matched against each
diagnostic's message, rendered text and path (compile) or each failure's
project, suite, test, message and stack trace (test). Only matching items
are returned, still as structured data; summary counts keep describing the
full run, and the response echoes the query. An invalid regex fails with
the parse error, and searching composes with project/limit/offset.

Also sharpens the docs and front page on why compact structured output
matters for agents: it is not merely fewer tokens, it removes the
divert-grep-and-miss failure mode entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The page sold build-as-data, fail-hard, and the fast inner loop to humans
tired of build tools, while the same features are the strongest arguments
for the visitor who now ships through agents. Re-aim rather than invent:

- hero names agents and gains a "Bleep for agents" button anchoring to the
  MCP section (#agents)
- the specimen, inner-loop and CI sections each get one connective line:
  agents can safely edit a data build file, an agent compiles fifty times a
  session, and fail-hard means nothing degrades silently
- the agents section gains its number (~200 tokens instead of a
  30,000-token build log), a five-line terminal vignette of four subagents
  working four worktrees, and the dogfood line: bleep is developed by
  parallel agents in worktrees, building bleep with bleep
- the install CTA becomes two pasted lines — curl install plus
  `claude mcp add --scope user` — with per-line copy buttons
- page description mentions MCP-native and agentic development, since
  people search for exactly that and no credible result exists yet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The compile/test summaries said "2 errors in 1 projects" and "All 1 tests
passed" — and the docs example faithfully quoted the bug. Adds a plural
helper and uses it for projects, errors, warnings and tests.

Landing page: the local-build-cache link now points at the
#local-directory-cache anchor; the agents lede no longer says "built for
this shape" twice; the flailing-agent sentence gets real verbs; the
vignette's bare "warm" becomes "instant"; "matters double" becomes
"matters more than ever".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The stat and vignette now carry the mechanics, so each card keeps only
what nothing else on the page says: zero-setup worktrees, one shared
hot daemon plus the local cache, and searchable structured results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…daemon's locks

A fresh git worktree has its parent's sources and none of its compiled state,
so its first build recompiles the world. Content-addressed caching cannot fix
the interesting part: digests are transitive, so one dirty edit in a base
project makes every downstream project a cache miss — while zinc's invalidation
cone for the same edit is a few files, because it keys on API hashes. What a
fork actually wants is its parent's exact state as an incremental baseline.

`bleep copy-state <parent>` (MCP: `bleep.copy-state { directory, from }`)
provides exactly that, and only that: state is copied from the worktree you
forked off and nowhere else. What you start with is exactly what your parent
had at the moment of copying — dirty-file analyses included, which is the
baseline that makes the first compile touch a few files rather than a few
hundred. No cache tiering, no nearest-match heuristics, nothing to explain
after the fact.

The copy runs in the compile daemon on purpose. Ask-then-clone is a TOCTOU
race — another agent can start compiling the parent between the asking and
the cloning — so the daemon takes the same per-project locks compiles take
(shared on each source project, one at a time, in the same global order) and
the race is closed rather than narrowed. bleep/copyState joins bleep/status
and bleep/shutdown on the admin surface: no build/initialize, workspaces
named by absolute path, neither build needs to be resolved — projects are
enumerated from disk.

Per project, holding the lock: classes and test-classes, the zinc analysis
(byte-portable by design, and byte-identical to one already resident, so the
daemon's cross-workspace analysis sharing serves the fork for free), and
generated sources/resources. Deliberately NOT copied: noop-manifest.bin — its
keys are absolute paths into the parent, and a copied manifest VALIDATES
against them, yielding a false noop that points the fork at the parent's
classes. The fork's first compile does one zinc round-trip per project and
writes its own manifest. Also skipped: .zinc/cache, ksp/, and .bleep-lock.

Transport is CloneDir, chosen per OS up front with no fallback chain:
`cp -Rc` on macOS (APFS clonefile), `cp -a --reflink=auto` on Linux, a JVM
recursive copy elsewhere. The `.zinc`/`analysis.zip` path layout moves into
BuildPaths (zincDir/zincAnalysisFile, generated*BaseDir) instead of being
respelled at each call site.

Tested on the real filesystem so CI proves every transport lane: cloning is
byte-faithful and clones are independent copies; copy-state waits for an
exclusively-locked (mid-compile) project and completes when released; the
manifest and lock file never travel; target-with-state, empty source,
from==to and non-root directories all refuse loudly. The test fixture drives
raw JSON-RPC over in-memory pipes — java.io.Piped* throws spurious "Pipe
broken" when responses arrive from short-lived fiber threads — and reads
Content-Length as bytes, not chars, which matters the moment an error
message contains a multi-byte character.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…don't list

Benchmarked on dlab (130 projects): a flat listing of .bleep/projects/ copied
only the 13 flat-named projects and silently skipped every `dfmt/main`-style
cross name, whose value contains a slash and therefore nests. Enumerate by
walking for dirs containing builds/<variant> and derive the cross name from
the relative path. Endpoint test now covers a nested name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t purge inherited output

bleep/copyState seeds a fresh worktree from its parent, but the parent is a
working tree: its compiled state can be AHEAD of git — an uncommitted edit that
was compiled. The clone carries that dirty output into a workspace whose
sources never contained it. This test pins, through real compiles, that the
design's load-bearing half actually holds: zinc reconciles the target's actual
sources against the inherited analysis on the first compile.

Scenario (verified manually on a real repo before this test existed):
workspace A compiles core+app, app gets an uncommitted marker method
(copyStateDirtyMarker12345) and is recompiled, so A's classes AND analysis
contain the marker. Workspace B is created with the ORIGINAL sources,
bleep/copyState clones A's state into B (asserted: the marker is now
byte-for-byte present in B's App.class), then B compiles. Assertions:

  (1) app — whose source differs from the inherited analysis — recompiles,
      while core — whose source matches — noops (classfile mtime and bytes
      untouched by B's compile);
  (2) after B's compile the marker is GONE from B's App.class. This is the
      load-bearing assertion: the inherited classfile declares a method B's
      sources never wrote, and anything compiled against stale inherited
      output links against phantom API. If zinc ever trusted inherited
      output — say a copied noop manifest validating against the parent's
      absolute paths — this is the assertion that catches it;
  (3) B's compile succeeds.

Built on the two patterns that already exist: BspTestHarness drives the real
MultiWorkspaceBspServer through build/initialize + buildTarget/compile (as the
other real-compile integration tests do), and bleep/copyState goes over the
same connection as a raw JSON-RPC admin request (as CopyStateEndpointTest
does — it needs no BSP handshake). The harness gains a rawRequest method for
the latter; CopyStateEndpointTest keeps owning the endpoint's own contract
(locking, refusals, what is and isn't copied).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tes through copied analyses

The first dirty-parent test edits the downstream project: it pins "dirty
project recompiles, clean dependency noops". This adds the direction it could
not see: the uncommitted edit lives in `core`, the UPSTREAM project, and what
must be proven is that invalidation PROPAGATES across the copied dependency
analyses rather than stopping at the project whose source differs.

Scenario: A compiles core+app clean, then core gains an API edit app is
sensitive to — an added overload of `coreValue`, the very name app calls
(zinc's name-hashing invalidation only invalidates dependents that USE an
affected name, so a marker method alone would leave app untouched) — plus a
marker method (coreApiMarker67890), and A recompiles. B is created with the
ORIGINAL sources and seeded via bleep/copyState. B's compile must then:

  - recompile core and purge the marker+overload from B's Core.class
    (byte-searched, same as the first test);
  - recompile app EVEN THOUGH app's own source is identical to what the
    inherited analysis recorded — the core API it was compiled against no
    longer exists. If the copied analyses carried cross-project API hashes
    wrongly, app would silently keep linking against the phantom overload.
    Asserted via classfile mtime, not bytes: app's source never changed, so
    a correct recompile is byte-identical — the rewrite is the observable.

Not vacuous by construction: the sibling test proves an uninvalidated seeded
project's classfile mtime stays put across B's compile, so app's moving mtime
here can only mean propagation, not a blanket recompile-everything.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…as a checked-in skill

The flow (sourcegen → compile bleep-cli → publish local-ivy → native-image →
verify → install → cycle daemons) runs several times a week and every one of
its failure modes has cost real time at least once: GenNativeImage silently
imaging stale classes, version skew from a dirty tree, cp breaking the macOS
signature (SIGKILL with no output), stop-all missing orphaned daemons from
older versions, and long-lived mcp-server processes pinning old jars.

Captured as a project skill so the procedure and its gotchas live in the repo
rather than in one person's session memory. Verified end-to-end today
deploying 1.0.0-M12+15-b322f6f4-SNAPSHOT.

.gitignore: .claude/ becomes .claude/* with !.claude/skills/ — per-user Claude
state (settings.local.json, session data) stays ignored, shared skills don't.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… connection

Observed during the first real run of the skill: the restart tool reports
"available again in a few seconds", but from the calling session's point of
view the tools are delisted the moment the process exits, and the respawn is
lazy — it took several use attempts and a turn boundary before the connection
came back. /mcp reconnects immediately; new sessions always pick up the new
binary via the user-scope registration.

So the skill now says what the recipe implied but never stated: bleep.restart
goes LAST, after all MCP-dependent work, with the CLI as the fallback for the
rest of the turn.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mechanically and temporally

The RequestLog already stores the complete event stream per request —
per-test statuses (all nine: passed/failed/error/skipped/ignored/cancelled/
assumption-failed/pending/timeout) with durations and messages, per-project
CompilationReason with invalidated files, diagnostics on CompileFinished —
and bleep.details rendered none of it beyond failures. This turns that stored
stream into the primitive of the agent loop: edit, rerun, "what changed?".

Two tools, deliberately separate:

bleep.diff(base, target) is the MECHANICAL diff. It is computed over a
projection of the transcript that contains no time — not "durations zeroed
out", they never enter the compared data structures — so two runs with the
same logical outcome diff as identical no matter how timings jittered.
Tests keyed by (project, suite, test): newlyFailing / fixed / newlySkipped
(with the assume reason) / unskipped / added / removed, and stillFailing
listed as context with a messageChanged flag (same failure vs different
failure — only the latter counts as a difference). Scope drift shows as
added/removed, never as fixed: a failing test that left the scope is not a
fix. Compiles keyed by project: reason transitions (up-to-date→incremental),
invalidated-file deltas, status changes, new/resolved diagnostics keyed by
(severity, path, message) with line as attribute — a diagnostic that only
moved lines is neither new nor resolved. Cross-workspace diffs are allowed
and flagged: compile parent, copy-state, compile fork, diff — the "everything
nooped except the dirty project" verification becomes one call.

bleep.diff-timing(base, target) owns durations: per-item deltas above
max(50ms, 20% of base) as slower/faster (jitter suppressed and counted),
plus slowestInTarget for the absolute question. Old diff-vs-last caches went
stale the moment anyone compiled outside MCP; diffing two immutable ring
entries by explicit requestId cannot.

14 scenario tests in bleep-tests/src/scala/bleep/mcp/RequestDiffTest.scala,
the first of which pins the load-bearing property: identical outcomes with
wildly different timings and event order diff as identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ability

The agents section was one section carrying two arguments: how parallel
agents in worktrees share one server, and how an agent finds out what its
edit actually did. With bleep.diff and bleep.diff-timing landed there is
too much of the second argument to keep them fused.

Section one keeps the #agents anchor and the fan-out vignette: one
user-scope registration, every call names its directory (a subagent can't
silently build the parent checkout, nothing pinned at boot to go stale),
copy-state so forks start warm, one hot daemon. Section two gets the
~200-tokens stat and a new vignette of the diff beat from a real session:
break a test and diff says "1 newlyFailing"; revert and the diff of first
vs last run is {"identical":true,"summary":"No logical differences."}
across ~800ms of timing noise. Cards: details' regex-searchable
transcripts, bleep.diff (durations never enter the compared data, so
timing can't fake a difference), bleep.diff-timing (jitter suppressed,
slowest named).

Migration flips to sectionPaper so backgrounds keep alternating with the
inserted section. Verified with a full docusaurus build; both anchors
render (the /#agents broken-anchor warning is docusaurus not seeing
JSX-set ids, and predates this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity survives worktrees

First stage of moving run history out of the MCP server's memory and into
the build itself. bleep.requests gains:

Transcript — the record of one completed compile/test request: header
(id, workspace, variant, mode, targets, client) plus the full event stream
and test-run result, circe-codecked for disk. Paths inside events stay
ABSOLUTE: transcripts are grepped and clicked, and absolute + workspace can
always derive relative while the reverse loses paths outside the workspace.

TranscriptStore — <workspace>/.bleep/builds/<variant>/requests/<id>.json.
Workspace-local on purpose: the socket dir dies with the daemon (stopping a
daemon deletes metrics.jsonl history — lesson learned), while a worktree's
history should live and die with the worktree. Ids are per-workspace
monotonic, never reused; assignment happens under a file lock so racing
writers cannot mint the same id; writes are tmp+rename atomic so readers
never see torn files and take no lock. Retention: newest 32 entries, 64MB
cap, oldest evicted first — noop transcripts kept deliberately, "everything
up-to-date" being exactly what a copy-state verification diff wants to see.

RequestDiff moves from bleep-cli's mcp package to bleep-core, now operating
on Transcript, and gains the rule that makes cross-worktree diffs honest:
identity relativization. Diagnostic identity was (severity, path, message)
over absolute paths, so the same warning in a parent and its fork — same
file, different root — would have counted as new + resolved. Now each side's
paths, and occurrences of each side's workspace root embedded in message
text, are relativized against that side's own root — ephemerally, for the
comparison key only, never serialized and never shown. Paths outside any
workspace (coursier, JDK) stay absolute for identity, which is correct on
one machine. For single-workspace diffs the transformation is applied to
both sides against the same root: an identity-preserving no-op, so the
mechanical diff's determinism property is untouched (still pinned by the
scenario suite, now 16 scenarios + 4 store tests in bleep.requests).

The MCP server bridges its in-session ring into the shared model for now;
the daemon-side store replaces that ring in the next stage.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t caught a leak

copy-state and the remote cache each kept a hand-maintained notion of which
parts of a project's compiled state may cross the workspace boundary, and
they had already drifted: copy-state correctly skipped .zinc/cache, KSP
caches and lock files, while the remote cache's filter denied only
noop-manifest.bin BY NAME and shipped everything else in the variant dir —
zinc's classfile cache (absolute paths), per-machine KSP state, and even a
.bleep-lock if one existed at pack time.

bleep.StateSharing is now the single authority both consume: an ALLOW-list
over paths relative to the variant build dir — {classes/, test-classes/,
.zinc/analysis.zip} — with deny-by-default semantics, so state invented in
the future is workspace-private until someone consciously declares it
shareable, never leaked by omission. Request transcripts
(.bleep/builds/<variant>/requests/) are workspace-level and outside both
mechanisms' reach by construction; CopyStateEndpointTest now pins that a
seeded fork inherits neither them nor the zinc cache.

The layout-level version of this rule — physically separating shared/ from
local/ under the variant dir so the classification is enforceable by a
directory walk — is deliberately left for a follow-up: it breaks on-disk
layout (one cold build per workspace) and deserves its own change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eturns the id

Recording moves from the MCP server's client-side ring into the daemon
itself, at the one choke point every request event already passes through:
sendEvent. It now takes a per-request TranscriptRecorder — created at the
top of handleCompile/handleTest and threaded structurally through the whole
chain (compile handler, heap-pressure listener, lock-contention callbacks,
the DAG event consumers, sendTestEvent) — so what the client sees and what
the transcript stores are the same stream by construction. No connection-
or daemon-scoped mutable state; the recorder is a parameter with request
scope, thread-safe because events are produced concurrently.

At completion — success, failure or cancellation alike; a red run's
transcript is exactly what a diff wants — the daemon persists the stream
via bleep.requests.TranscriptStore under the workspace's own
.bleep/builds/<variant>/requests/, tagged with the client name from the
build/initialize handshake ("bleep", "bleep-mcp", "Metals", ...). Every
client now shares one durable per-worktree history.

SANCTIONED EXCEPTION to fail-loudly: a transcript-write failure must not
fail a build that already ran — the daemon logs and returns the result
without an id. Everything else still throws.

The id travels back in the response:
  - compile: CompileResult dataKind "bleep-request-id", data
    {"requestId": <id>} (RequestIdPayload in bleep-bsp-protocol)
  - test: TestRunResult gains requestId: Option[Long] — None exactly when
    the write failed, and in the copy embedded inside the transcript
    itself, whose own id is authoritative. Missing-on-the-wire decodes as
    None, so older daemons' responses keep decoding.

The noop fast path now emits CompilationReason(UpToDate) before returning:
without it a noop's transcript was just Started/Finished, indistinguishable
from a compile whose reason was lost — and two noop runs would not have
carried identical logical facts. With it, the flagship determinism property
holds end-to-end through the real daemon, pinned by
TranscriptStoreIntegrationTest: three compiles over the real protocol leave
requests/1..3.json, the responses carry the ids, a clean build vs a noop
diffs as exactly one reason transition, and noop vs noop diffs identical
while every duration differs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mon needed

Three new commands over the per-worktree history the daemon now writes,
all pure file reads via TranscriptStore (history survives daemon restarts
and is inspectable on a machine with no daemon at all):

  bleep requests            list recorded requests: id, time, mode,
                            targets, which client ran it
  bleep details [id]        full transcript as JSON (latest when omitted);
                            --project, --query (regex), --limit, --offset
  bleep diff <base> <target> mechanical diff as JSON; --timing switches to
                            the jitter-suppressed duration comparison;
                            --limit N; --base-dir <path> resolves base in
                            another worktree's history (the copy-state
                            verification flow)

The details rendering moves mechanically out of the MCP server into
bleep-core as requests.TranscriptFormat, so `bleep details` and the MCP
`bleep.details` are one implementation that cannot drift; the MCP server
now delegates to it. New TranscriptFormatTest pins the contract: header
rides along, query/project narrow items while summary counts keep
describing the full run, bad regex fails loudly.

Compile/test summary lines gain the id when the response carried one:
ReactiveBsp decodes the compile response's "bleep-request-id" data (test:
TestRunResult.requestId) into a new BuildEvent.RequestRecorded, the
reducer folds it into BuildState/BuildSummary, and both summary renderers
print `Request:  #N (bleep details N)`. Absence — older daemon, failed
write — simply means no line.

RequestsCliIT closes the loop end-to-end: `bleep new`, two compiles
through the (in-process, but production) BSP server, then asserts the
transcripts landed in the workspace tagged client="bleep", the summary
named both ids, and requests/details/diff (including --base-dir and
--timing) run over the files.

docs/reference/cli regenerated via `bleep gen-cli-docs` (this also picks
up the previously ungenerated copy-state page).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
bleep.compile / bleep.test report the daemon-assigned requestId from the
BSP response (compile: the "bleep-request-id" data; test:
TestRunResult.requestId) instead of minting their own. Streamed events are
still collected — the compact summary and failure streaming need them —
but nothing stores them: RequestLog, the transcriptOf bridge and the
requestLog Ref are gone, and with them the last per-session state in the
MCP server. A response without an id (transcript write failed, older
daemon) simply omits the field.

bleep.details / bleep.diff / bleep.diff-timing read TranscriptStore
directly. DetailsArgs and DiffArgs gain the REQUIRED `directory` every
other tool already has — request ids are per-workspace, so an id means
nothing without saying whose history to look in — and DiffArgs gains
optional `baseDirectory` for cross-worktree diffs (copy-state
verification), riding on RequestDiff's path-relativized identity. These
are pure file reads: BuildLoader.find + BuildPaths, deliberately no
bootstrap and no daemon spawn. The path derivation is shared with the
CLI's --base-dir as commands.Requests.workspacePaths, which now fails
loudly when the directory holds no bleep build instead of degrading into
"no request with that id".

Server scaladoc and instructions rewritten to match: history is
per-worktree, written by the daemon, shared with `bleep requests` and
IDEs, and survives MCP server restarts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
It lives in the worktree now — written by the compile daemon, shared with
the CLI and IDEs, surviving MCP server restarts — and details/diff take
`directory` like every other tool. Also list bleep.diff / bleep.diff-timing
in the tool table; they existed but the page never mentioned them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The diff vignette showed the what; nothing on the page said why only
bleep can do it. The answers section's eyebrow becomes "Data all the
way down" and the lede walks the chain: bleep.yaml is data, the
resolved model is immutable data, the daemon emits typed events, a
completed run is a transcript written into the worktree — and values
compose, so diffing two runs is a pure function over two files. Not a
feature bolted on; it falls out of the architecture.

The specimen section gets a forward pointer (the YAML is just the
first layer), and a new footer under the diff cards surfaces the CLI
side the page never mentioned: bleep requests / details / diff are
pure file reads over the worktree's transcripts — no daemon, and
--base-dir diffs across worktrees.

Verified with a full docusaurus build; the /#agents and /#answers
broken-anchor warnings are docusaurus not seeing JSX-set ids (known,
predates this change).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… never depend on hash order

CI caught a real one: the two cross-worktree scenarios failed on
windows-latest because relPath/relMessage built the root prefix with
File.separator — the separator of the machine READING the transcript, which
is irrelevant to the machine that wrote it. On Windows the unix-style roots
never matched, relativization never fired, and the same diagnostic under two
roots compared as different. Both helpers are now separator-agnostic:
everything normalizes to forward slashes for the ephemeral identity key
(paths entirely; message text gets both spellings of the root replaced).
The contract stays same-machine cross-WORKTREE identity — a Windows parent
diffed against a unix fork is a cross-MACHINE diff and out of scope, since
message tails keep their native spelling; the test pins Windows-vs-Windows
and documents the boundary.

And the order-independence audit the question deserved: the mechanical diff
was already interleaving-safe (per-key maps make concurrent projects'
event ordering invisible — now pinned by a permuted-stream scenario), but
the timing diff broke ties by hash-set iteration order: two equal deltas or
equal durations could render in different order on different runs. Ties now
break on the item key, so the same two transcripts render byte-identical
JSON every time — also pinned. The one ordering that remains is the one
that should: diff(base, target) is directional by explicit input; ids
record completion order, and the diff of two racing compiles' transcripts
does not care who won the race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The run-history surface grew piecewise and its names showed it: three flat
CLI commands (requests / details / diff), MCP tools under three different
prefixes, a wire dataKind saying "request", a package saying "requests",
and a response field requestId. One concept, five spellings. Nothing here
is released, so it is renamed outright — no shims, no aliases.

The namespace is `history`: `runs` collides with `bleep run` and `builds`
with `bleep build`; `show` follows the `bleep build show` precedent.

  CLI    bleep requests             -> bleep history
         bleep details [id]         -> bleep history show [id]
         bleep diff <a> <b>         -> bleep history diff <a> <b>
  MCP    bleep.details              -> bleep.history.show
         bleep.diff                 -> bleep.history.diff
         bleep.diff-timing          -> bleep.history.diff-timing
         (new) bleep.history.list   -- the CLI listing as JSON, for parity
  wire   dataKind bleep-request-id  -> bleep-history-id
         TestRunResult.requestId    -> historyId (and every response field)
  disk   .bleep/builds/<v>/requests/ -> .../history/ (BuildPaths.historyDir)
  code   package bleep.requests     -> bleep.history
         RequestDiff                -> TranscriptDiff (it diffs transcripts;
         Transcript/TranscriptStore keep their accurate names)
         commands.Requests          -> commands.History (ListEntries/Show/Diff)
         BuildEvent.RequestRecorded -> HistoryRecorded

Build summaries print `History:  #N (bleep history show N)`; store errors
say "No history entry #..." / "No history recorded ..." (retention hint
kept). The JSON-RPC message id in MultiWorkspaceBspServer is renamed rpcId
so it can never be confused with a history id again.

docs/reference/cli regenerated via `bleep gen-cli-docs` (requests/details/
diff pages replaced by history/{index,show,diff}); the MCP docs page and
the landing page follow (bleep-site builds clean); PR #652's body updated
to the new names.

Verified: full compile green; bleep-tests 422 passed (HistoryCliIT covers
the CLI loop end-to-end); bleep-bsp-tests 721 passed
(TranscriptStoreIntegrationTest pins the new dataKind and the history/
directory through the real daemon).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@oyvindberg
oyvindberg force-pushed the mcp-stateless-directory branch from 7e9cc78 to 282fb71 Compare August 13, 2026 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant