Skip to content

feat(v0.6.0): external memory + capability compile + MCP server + signing#23

Merged
ikeikeikeike merged 22 commits into
mainfrom
feat/v0.6-external-memory
Jun 21, 2026
Merged

feat(v0.6.0): external memory + capability compile + MCP server + signing#23
ikeikeikeike merged 22 commits into
mainfrom
feat/v0.6-external-memory

Conversation

@ikeikeikeike

Copy link
Copy Markdown
Member

Summary

Round 4 external review (June 2026) scoped v0.6.0 to mem0 first-class + capability compile + read-only MCP + signing scaffolding。 Graphiti は v0.6.x で別 archive (round 4 AI #2 deferral)。

  • mem0 plugin (bough-plugin-memory-mem0) = HTTP REST + 30s TTL cache + namespace mapping + Read-only fallback to SQLite (= split-brain 防止、 round 4 AI Initial implementation #1 + feat(plugins/db/postgres): PostgreSQL 16 provider #2)
  • CapabilitiesResp 17 fields bump (= round 4 priority A12) — sqlite + mem0 ともに honest advertise
  • CapabilityCompiler 物化 + 7 group metadata (Target/Invocation/Contract/Validation/Provenance) + Checksum (= round 4 priority A3)
  • 3 emitter: agent-skill default + claude-skill + mcp (= round 4 priority A2、 A6 read-only first、 A13 Emitter interface 分離)
  • bough capability compile CLI (= compile / list / preview / install + lint stub)
  • bough-mcp-server companion binary (= stdio JSON-RPC、 MCP 2025-11-25 pin、 stdin EOF zombie guard)
  • signing scaffolding = cosign + minisign 二系統 + bough plugins verify CLI + GoReleaser cosign keyless (= round 4 priority A9-A11)
  • Graphiti skeleton + docker-compose snippet (= v0.6.x で binary 化)
  • conformance/capability + conformance/mcp + mem0 conformance (= mock_mem0)
  • docs: CAPABILITY_COMPILER + MCP_SERVER + SIGNING + ROADMAP + CHANGELOG + README v0.6 section
  • GoReleaser = 8 binary 同梱 (host + 4 engine + sqlite + mem0 + mcp-server) + cosign keyless

Test plan

  • go test ./... 全 green (= unit + conformance memory/capability/mcp 全部)
  • golangci-lint 0 issues
  • TestMem0_Conformance 全 3 sub-test pass (Lifecycle / Bloat / Concurrency)
  • conformance/capability + conformance/mcp pass

Refs: v0.5.1 ship 済 = #22、 round 4 synthesis = `~/.claude/notes/bough-v06-syntheses-round1.md`

…nd 4 priority A12)

Round 4 external review (AI #2) proposed widening the protocol-level
feature-negotiation surface so the host coordinator can route Query /
Store / fallback decisions per backend without hard-coding plugin
identity. v0.5 shipped 5 capability flags; v0.6 adds 11 more —
TemporalQuery / MetadataFilter / NamespaceIsolation / SoftDelete /
BulkImport / DedupeKey / SourceEventID / TTL / EventualConsistency
plus int32 MaxBatchSize and MaxQueryTokens limits.

The expansion is additive — v0.5 plugin binaries continue to
advertise only the original 5 flags (proto field numbers 1-6); the
new fields default to the proto zero values, which the host treats
as "feature not supported". No wire break.

SQLite reference-fallback now advertises its honest values:
SoftDelete + BulkImport + DedupeKey + SourceEventID = true, since
v0.5.1 made each of those real (Forget flips state, Import restores
rows, Store consults DedupeKey + SourceEventID for idempotency).
MaxQueryTokens = 4000 mirrors the host validator default. mem0 /
Graphiti will light up the SemanticQuery / VectorSearch /
NamespaceIsolation / TTL / EventualConsistency cluster when their
plugins land in Ν-1.1 / Ν-1.3.

Touched: memory.proto + types.go + server.go + client.go +
sqlite.go. Regenerated memory.pb.go + memory_grpc.pb.go via
`make proto`.
…s + 4-line plugin.Serve)

v0.6 introduces the official mem0 adapter. This commit lays down
the skeleton:

- plugins/memory/mem0/mem0.go: Provider struct, Capabilities
  (mem0's correct advertise — SemanticQuery / VectorSearch /
  NamespaceIsolation / TTL / EventualConsistency / MetadataFilter
  / BulkExport / BulkImport all true; DedupeKey / SourceEventID
  false because the host computes idempotency tokens), Health
  (static), and stubbed Store / Query / Forget / Export / Import
  that return errNotYetImplemented until Ν-1.1c (namespace
  mapping) and Ν-1.1d (HTTP client adapter) wire the real REST
  calls.

- plugins/memory/mem0/CONTRACT.md: pins the contract that makes
  the plugin swappable.
  - Scope translation: repo → user_id, worktree → session_id
    (round 4 AI #2).
  - Fallback policy: Query Read fallback only, Store fail-fast
    (round 4 AI #1 + #2 split-brain Blocker 1 mitigation).
  - Cache: 30 s TTL, LRU 512, Query-only, invalidated on Store /
    Forget / Import (round 4 AI #1 + #2).
  - Capabilities matrix mapping each advertised flag to its
    rationale.

- cmd/bough-plugin-memory-mem0/main.go: the four-line plugin.Serve
  entry the host spawns when .bough.yaml memory_backends declares
  kind: mem0. Reads endpoint / api_key / namespace / timeout from
  the BOUGH_MEMORY_MEM0_* env block.

Build clean and golangci-lint 0 issues across the new package and
cmd entry.
…ession_id)

Round 4 AI #2's mem0-layered namespace lands here. bough's three-
tier Scope translates into mem0's namespace pair:

  global    →  user_id = global/<user@host>
  repo      →  user_id = repo/<repo_hash>/<root_hash>
  worktree  →  user_id = repo/<repo_hash>/<root_hash>
                session_id = worktree/<worktree_id>

The repo / worktree split puts the long-lived identity on user_id
and the short-lived per-branch identity on session_id, matching
mem0's memory-types layering (user / session / agent / run). Two
worktrees of the same repo share the user namespace but stay
queryable per branch through session_id.

v0.6 limitation: the wire Scope carries only RepoName + WorktreeID,
so both hashes derive from RepoName (and WorktreeID for the worktree
root hash). v0.6.x will extend memapi.Scope with optional Remote
Fingerprint + RootFingerprint so the hashes match docs/NAMESPACE_
MAPPING.md exactly (sha256(remote URL) + sha256(absolute path)).
The current derivation still cleanly isolates two repos with
different names.

BOUGH_MEMORY_MEM0_NAMESPACE multi-tenant prefix is honored for
every scope level.

namespace_test.go covers global / repo / worktree user_id shape,
shared repo prefix across repo + worktree of the same RepoName,
disjoint user_ids for two different repos, multi-tenant prefix
application, machineID stability, and hashShort idempotency.

8 tests pass; lint 0 issues.
…Export / Import + helpers)

Round 4 AI #1 + #2 split-brain Blocker 1 lands as policy in this
commit. The mem0 plugin's six stub methods now route through the
real mem0 v1 REST API while keeping the host's fallback decision
read-only:

- doJSON (http.go) is the single HTTP entry every per-RPC method
  uses. No transparent retry — round 4 AI #1 flagged retry on
  writes as risky without native idempotency. The host computes
  DedupeKey + SourceEventID, but mem0 does not honor them, so the
  plugin advertises false for both and Store fails fast.

- Store packs every Instinct field into mem0 metadata so an
  Export -> Import cycle round-trips even though mem0's primary
  surface is just a memory string. The mem0 event tag (ADD /
  UPDATE / NONE) drives the WasUpsert response.

- Query applies MinConfidence and MaxResults host-side so the
  budget aggregator never sees rows below the cap.

- Forget url.PathEscapes the memory id so user-supplied ids do
  not break the path.

- Export emits the same YAML / JSONL shape as the sqlite
  reference-fallback so Import is backend-agnostic.

- Import reuses an inlined copy of sqlite's parser; v0.6.x will
  lift the parsers into pkg/memorywire/ once a second plugin
  needs them.

- Health stays static — the host's first real RPC after Health
  surfaces a misconfigured endpoint, so we don't duplicate the
  probe.

http_test.go (httptest.Server, 9 cases) covers the wire shape:
authentication headers, content-type, error decoding with upstream
body, Store metadata round-trip, mem0 event semantics, Query min-
confidence filter, Forget url-escape, and an end-to-end Export ->
Import YAML round trip. Full conformance e2e arrives in Ν-1.2.

17 tests pass (8 namespace + 9 http); lint 0 issues.
…U 512 + scope invalidate)

Round 4 AI #1 + #2 both flagged a cache as essential for absorbing
duplicate Query traffic across one bough invocation, with two
guard rails: it must be Query-only (Store / Forget / Import never
read or write entries) and Store / Forget / Import must invalidate
every cached entry for the touched scope so a follow-up Query
never returns a row mem0 no longer holds.

cache.go implements both rules:
- queryCache holds entries keyed by (scope, term, max_results,
  max_tokens, min_confidence) so distinct queries never alias.
- TTL is 30 s — short enough that mem0 updates from another bough
  process land within one query round, long enough to amortise
  duplicate calls inside a single CLI invocation.
- LRU caps at 512 entries; oldest evicts when full.
- clock is injectable so TTL behaviour can be unit-tested.
- invalidateScope drops every key targeting the given Scope.

mem0.go hooks the cache without breaking the existing tests:
- Provider gains a cache field; New() initialises it.
- Query consults cache.get first, falls through to mem0 search on
  miss, cache.put on the way out.
- Store and Forget call cache.invalidateScope after a successful
  upstream call. Import re-uses Store so invalidation propagates
  automatically.

cache_test.go (6 cases) covers get/put hit+miss, TTL expiry via
the injectable clock, LRU eviction at cap+1, invalidateScope
dropping target entries while siblings survive, plus an end-to-end
TestProviderQuery_CacheHit (server hit once across two identical
Query calls) and TestProviderStore_InvalidatesCache (Store flips
the next Query back to the live wire path).

23 mem0 tests pass (8 namespace + 9 http + 6 cache); lint 0 issues.
…brain mitigation)

v0.5.1 plumbed an instinct.fallback_on_error flag into the
coordinator's Query path but always passed nil for the fallback
backend (round 4 AI #1 + #2 Blocker 1: split-brain). v0.6's mem0
plugin gives us a real "non-SQLite primary" to test against, so
this commit lights up the second-backend slot.

internal/cli/instinct_memory_helpers.go:

- discoverFallbackSQLite spawns a separate bough-plugin-memory-
  sqlite subprocess regardless of cfg.Instinct.DefaultMemoryBackend.
  Reads the SQLite path from cfg.MemoryBackends[kind=sqlite].path
  so an operator who wants a dedicated fallback file can point it
  at .bough/memory/fallback.db while the primary mem0 keeps
  writing to mem0.

- loadInstinctCoordinator now spawns the fallback when
  fallback_on_error is true AND the primary kind != "sqlite". A
  SQLite primary remains its own fallback (= no second process,
  no shared DB file). The close func disposes both subprocesses
  so the host's lifecycle code stays one-call clean.

The coordinator's Query path (v0.5.1) does the actual fallback;
nothing here changes the policy. AI #1 + #2 mandated that Store /
Forget / Import never fall back to SQLite (that would split-brain
the dataset), and mem0's plugin advertises DedupeKey /
SourceEventID = false so the coordinator never retries the write
— Store fails loud, as the CONTRACT.md prescribes.

Build clean across the repo, all unit tests pass, lint 0 issues.
Round 4 priority A: end-to-end conformance for the bough mem0
plugin without depending on the real mem0 cloud. Two new files:

- conformance/memory/mock_mem0/main.go: a single binary that
  starts an in-process HTTP mock of mem0's v1 REST API on a random
  local port, wires the bough mem0 plugin to that endpoint, and
  serves the plugin as a bough MemoryBackend over gRPC. The mock
  honours metadata.bough_id so the conformance suite's
  Forget(inst.ID) lookup works (real mem0 generates its own UUIDs
  but the mock obeys the host) and implements soft-delete via
  metadata.bough_state="forgotten" so the Forget -> Export ->
  Import round trip in lifecycle.go succeeds — the bough mem0
  plugin's Capabilities advertise SoftDelete=true, and the mock
  now backs that contract.

- conformance/memory/mem0_test.go: TestMem0_Conformance compiles
  mock_mem0 to a temp binary and runs the full MemoryBackend
  conformance suite against it. Mirrors the existing TestSelf
  entry that drives the in-tree mock_plugin.

All three sub-tests pass (Lifecycle / Bloat / Concurrency); lint
0 issues. Real mem0 cloud is never reached, so the suite is
hermetic and CI-friendly.
…deferral)

Round 4 AI #2 deferred the Graphiti binary to v0.6.x: the sidecar
lifecycle (graph DB + Graphiti server + LLM extraction) does not
fit cleanly into v0.6.0 alongside mem0 + CapabilityCompiler + MCP
server + signing. v0.6.0 ships the skeleton instead:

- examples/memory-plugin-graphiti-skeleton/README.md: adapter
  guide covering the v0.6.x shape (Provider struct + Capabilities
  cluster Graphiti will advertise + namespace mapping using
  Graphiti's group_id concept).

- examples/memory-plugin-graphiti-skeleton/docker-compose.yml:
  Neo4j 5.13 + getzep/graphiti:latest with healthchecks so
  developers can stand up a local Graphiti stack with one
  `docker compose up -d` (requires OPENAI_API_KEY for Graphiti's
  entity extraction).

docs/EXTERNAL_MEMORY_BACKENDS.md gets a status matrix bumped to
v0.5.x / v0.6.0 / v0.6.x columns, the mem0 section now documents
the cache + namespace mapping + split-brain fallback policy that
shipped in Ν-1.1, and the Graphiti section explains why the
binary lives in v0.6.x as a separate GoReleaser archive.

No Go code changes; docs + examples only.
… registry + compiler + emitter interface)

Round 4 priority A3 (= 7 group metadata + Checksum) + A13
(= Emitter interface lifted into plugins/capability/api/) land in
this commit. v0.6 keeps the gRPC contract at the v0.5 wire (= 17
fields + Payload escape hatch) so plugin authors building against
v0.5 stay compatible; the new groups round-trip through Payload
until v0.7 graduates them to proto fields alongside the
MemoryBackend v2 bump.

pkg/schema/capability.go:
- CapabilityArtifact gains Target / Invocation / Contract /
  Validation / Provenance struct groups + Checksum string.
- Target.Format keys the emitter registry; Target.Host + MCPKind
  tune the output for the agent runtime.
- Provenance carries source_ref / tree_sha / generated_by per
  round 3 priority B + round 4 supply-chain note.
- CanonicalBytes + ComputeChecksum produce a deterministic sha256
  hash of every field except Stability / CreatedAt / Checksum so
  the CLI can short-circuit Compile when the artifact on disk
  already matches (= AI #1 idempotency).

plugins/capability/api/emitter.go (new):
- Emitter interface (Format / Emit) lifted into the public api
  package so v0.6.x can graduate emitters into gRPC plugin slots
  without rewriting the registry.

internal/capability/registry.go (new):
- Format → Emitter map with thread-safe Register / Lookup / Formats.

internal/capability/compiler.go (new):
- NewCompiler / Compile orchestration mirrors internal/instinct/
  coordinator.go shape: walk instincts × kinds × targets, stamp
  Checksum, dispatch through the registry, gather Artifacts +
  Emissions. DryRun=true materialises artifacts but skips emit.

internal/capability/compiler_test.go (new, 5 cases):
- Registry register / lookup / formats / unknown error
- Compile happy path (= 1 instinct × 1 kind × 2 targets fires
  twice and stamps checksums)
- Compile DryRun=true skips emit
- Compile unknown target surfaces stable error
- Compile emit failure propagates with target + artifact context
- CanonicalBytes deterministic across calls

All tests pass; lint 0 issues.
… claude-skill + mcp)

Round 4 priority A2 + A6 land here: agent-skill is the default
target (= bough is host-neutral OSS, gh skill is the cross-runtime
distribution surface), claude-skill produces SKILL.md when the
operator opts into a Claude-specific layout, and the mcp emitter
ships read-only first per round 4 AI #2's MCP scope discipline.

internal/export/agent_skill.go:
- AgentSkillEmitter renders gh skill style markdown with full
  frontmatter (name / description / version / kind / host /
  source_ref / tree_sha / generated_by / checksum / state_changing).
  Steps / Constraints / Evidence / Prerequisites sections follow
  the gh skill body convention.

internal/export/claude_skill.go:
- ClaudeSkillEmitter renders an Anthropic Agent Skills SKILL.md
  with minimal frontmatter (the matcher only reads name +
  description). Provenance + checksum still ride along so the
  same supply-chain story applies.

internal/export/mcp.go:
- MCPEmitter dispatches on Target.MCPKind: tool / resource /
  prompt. tools default state_changing=false (read-only first);
  the CLI flips to true only when --allow-write is passed. Spec
  version pinned to 2025-11-25 in every payload as the
  protocol-version advertise that round 4 AI #2 mandated.

internal/export/registry.go:
- DefaultRegistry returns a populated *capability.Registry holding
  the three builtin emitters. CLI bootstrap (Ν-1.6) calls this.

internal/export/emitter_test.go (6 cases): frontmatter shape,
filename suffix, MCP kind dispatch (tool / resource / prompt),
provenance round-trip, $schema pin, and DefaultRegistry composition.

All tests pass; lint 0 issues.
… install/lint stubs)

Round 4 priority A2: default target is agent-skill (host-neutral
gh skill style); --profile claude-code switches into the Anthropic
SKILL.md layout, --to claude-skill / mcp pick a different format
outright.

Subcommands:
- compile  : harvest instincts via the coordinator, build a
             CompileRequest, dispatch through the v0.6 emitter
             registry, write to --out-dir or print to stdout.
- list     : enumerate the registered emitter formats.
- preview  : DryRun=true; print artifacts as JSON without emit.
- install  : stub, lands in v0.6.x.
- lint     : stub, lands in v0.6.x (validation.probes recorded
             but not executed by v0.6.0).

Shared compileOpts struct + bindCompileFlags helper keeps the
compile / preview wiring DRY so v0.6.x can extend the flag set
in one place. harvestInstincts loads every active row in scope by
default; --instinct-id <id> filters to the explicit set and
surfaces a stable error when an id is missing. writeEmissions
either mkdirs --out-dir + writes per-emission files or prints
bytes to stdout when --out-dir is empty.

NewRootCmd gains newCapabilityCmd() in cli.go so `bough
capability ...` is registered at startup.

Build clean, internal/cli tests pass, lint 0 issues.
…0, MCP 2025-11-25)

Round 4 AI #2 + AI #1: v0.6.0 ships an MCP server companion binary
that exposes bough's memory subsystem to MCP clients (Claude
Desktop, Cursor, etc.) as a read-only surface. State-changing
methods refuse with codeWriteForbidden until v0.6.x adds the
--allow-write CLI flag.

cmd/bough-mcp-server/main.go (~100 lines):
- discoverSQLite spawns bough-plugin-memory-sqlite as the v0.6
  memory source (mem0 / Graphiti backends land with --backend in
  v0.6.x).
- WatchStdin starts the round 4 AI #1 zombie guard: stdin EOF
  fires Shutdown which kills the SQLite subprocess, releasing
  the DB file lock before Claude Desktop can spawn the next
  instance.
- Run loops on stdio JSON-RPC; errors trigger Shutdown so we
  never leave the backend subprocess running.

internal/mcp/types.go (~140 lines):
- jsonrpcRequest / jsonrpcResponse / jsonrpcError envelopes.
- Error code constants including codeWriteForbidden (-32001) for
  the read-only refusal path.
- InitializeResult + ServerCapabilities + BoughMCPCapabilities
  carries the round 4 priority A7 vendor block so clients can
  programmatically probe spec_version / read_only /
  state_changing_tools.
- ToolDefinition / ToolCallResult / ResourceDescriptor /
  ResourcesReadResult: the v0.6 wire types.

internal/mcp/server.go (~270 lines):
- Server holds the MemoryBackend client + close func + the
  shut atomic.Int32 that drives Graceful Shutdown.
- Run dispatches newline-framed JSON-RPC; dispatchLine routes
  initialize / tools/list / tools/call / resources/list /
  resources/read / prompts/list / shutdown.
- handleInitialize advertises MCPSpecVersion = "2025-11-25" plus
  the BoughMCPServer block.
- handleToolsList ships only memory.query on v0.6.
- handleToolsCall refuses memory.store / memory.forget /
  memory.promote with codeWriteForbidden + a v0.6.x message;
  memory.query routes to MemoryBackend.Query and renders rows
  as text content.
- handleResourcesList / handleResourcesRead expose
  bough://memory/scopes only.
- Shutdown is idempotent; post-shutdown requests return an
  internal error so the host drain cleanly.

internal/mcp/server_test.go (6 cases): initialize advertise,
tools/list shape, write-tool refusal with codeWriteForbidden,
memory.query round trip, resources/list shape, Shutdown
idempotency + post-shutdown refusal.

Build clean, all tests pass, lint 0 issues.
…I + AcceptedSignatureSchemes field)

Round 4 priority A9 + A10 + A11 land in this commit as the v0.6
opt-in path: bough now ships a verify CLI + the config surface
the v0.6.x enforcement flip will read from.

internal/config/config.go:
- InstinctPluginSecurity gains AcceptedSignatureSchemes []string,
  the two-system signing surface. Defaults parse as ["cosign",
  "minisign"] so plugin authors can pick either flow; an
  enterprise operator can narrow the slice to enforce a single
  supply-chain story.
- Doc comment updated to reflect Ν-1.8 starting to consume
  RequireSigned (= v0.5 was warn-only).

internal/pluginsign/verifier.go (new):
- Verify dispatches on req.Scheme (cosign | minisign) and shells
  out to the canonical CLI tools (`cosign verify-blob --bundle`,
  `minisign -V -m -x -p`). We never reimplement the cryptography
  — we inherit sigstore's / minisign's track record.
- Result.ToolMissing=true signals the verifier binary was not on
  PATH; v0.6.0 keeps this fail-open (operator who set
  require_signed without installing the tools sees a clear
  message rather than a hard refusal). v0.6.x adds strict mode.
- Defaults: SigPath = <binary>.bundle for cosign (GoReleaser
  keyless output), <binary>.minisig for minisign.

internal/cli/plugin_verify.go (new):
- bough plugins verify <binary> [--scheme cosign|minisign]
  [--signature <path>] [--pubkey <path>] (round 4 priority A10).
- Wires pluginsign.Verify and translates the result into a single
  ✓-prefixed success line, a NOTICE for tool-missing, or a non-
  zero exit for verify failures.

internal/cli/plugins_list.go: AddCommand the verify subcommand.

Build clean, internal/cli + internal/config tests pass, lint 0
issues. Actual signing of the bough binaries themselves wires
into GoReleaser in Ν-1.11.
Two new conformance packages give downstream callers a hermetic
entry into the v0.6 capability + MCP surfaces. Both follow the
existing conformance/memory pattern: a Config + Run(t, cfg)
helper that plugin authors invoke from their own _test.go.

conformance/capability:
- Run drives Dispatch / DryRun / Checksum sub-tests against the
  caller-supplied emitter slice. The default `recordingEmitter`
  lets the suite verify "one instinct × one kind × N targets"
  produces N artifacts + N emissions, that DryRun=true skips
  emit, and that ComputeChecksum is deterministic across
  regenerations.
- TestSelf wires the three v0.6 builtin emitters (agent-skill,
  claude-skill, mcp) from export.DefaultRegistry so the in-tree
  emitter set is exercised by `go test ./...`.

conformance/mcp:
- Run spins up the in-process bough-mcp-server over an io.Pipe
  pair, then walks the wire surface as a real client would:
  initialize advertises read_only=true + state_changing_tools=
  false + spec_version="2025-11-25"; tools/list ships exactly
  one tool (memory.query); state-changing tool names refuse with
  codeWriteForbidden (-32001); resources/list exposes
  bough://memory/scopes; shutdown is idempotent and post-shutdown
  dispatches refuse cleanly.
- TestSelf uses the default fakeBackend so `go test ./...`
  exercises every method without spawning a binary or touching
  a real backend.

All conformance packages pass; lint 0 issues. The round 4 AI #1
zombie sim (= stdin EOF triggers Shutdown which kills the
backend subprocess) lives in cmd/bough-mcp-server/main.go and
the server unit tests; the conformance suite stays focused on
protocol correctness.
…ROADMAP + CHANGELOG + README

Three new docs cover the v0.6 surfaces that landed in Ν-1.4 → Ν-1.8;
ROADMAP + CHANGELOG bump to reflect what shipped, and the README
gets a "Capability compiler + MCP (v0.6+)" section so the entry
point story is up-front.

docs/CAPABILITY_COMPILER.md:
- Schema description (12 + 7 group + Checksum) with field-by-field
  rationale.
- Dispatch loop diagram (instincts × kinds × targets).
- Target matrix (agent-skill default, claude-skill via --profile,
  mcp via Target.MCPKind).
- CLI examples and the extension story for v0.6.x plugin authors.

docs/MCP_SERVER.md:
- Read-only first contract (codeWriteForbidden for state-changing
  tools).
- BoughMCPServer capability block (spec_version, read_only,
  state_changing_tools).
- Zombie guard story (stdin EOF → Shutdown → SQLite subprocess
  killed).
- Claude Desktop wiring snippet.

docs/SIGNING.md:
- cosign + minisign scheme matrix.
- Configuration block + bough plugins verify CLI examples.
- Fail-open behaviour today, strict mode for v0.6.x.
- v0.6.0 → v0.6.x → v0.7 → v0.8+ timeline.

docs/ROADMAP.md: v0.6.0 row ✅ with the priority A items each
checkmark traces back to.

CHANGELOG.md: v0.6.0 entry summarising every Ν-1.x phase with
round 4 attribution.

README.md: new "Capability compiler + MCP (v0.6+)" section after
the v0.5 Instincts section so the entry story stays linear.
…cosign keyless signing)

Two new binaries (round 4 priority A1 + A6) and the Sigstore
cosign keyless signing flow (round 4 priority A9) land in the
release pipeline.

builds:
- bough-plugin-memory-mem0: ./cmd/bough-plugin-memory-mem0,
  pure-Go (CGO_ENABLED=0) so it cross-compiles to the same
  4 OS-arch matrix every other bough plugin uses.
- bough-mcp-server: ./cmd/bough-mcp-server, same matrix. Ships
  in the same release archive as the SQLite reference-fallback
  it spawns at runtime.

archives.ids gains both binaries so the default tarball
(bough_<ver>_<os>_<arch>.tar.gz) now contains 8 binaries
(host + 5 plugins + 1 mcp server + 1 mem0 adapter).

signs (round 4 priority A9, A11):
- cosign-keyless signs every archive with Sigstore via the
  GitHub Actions OIDC flow (no pre-provisioned private key
  needed). COSIGN_EXPERIMENTAL=1 enables the keyless path; the
  workflow must be run with id-token: write.
- Signatures land alongside each archive as
  <archive>.sig + <archive>.pem so `bough plugins verify` can
  validate them. Snapshot builds skip signing because local
  `goreleaser release --snapshot` does not have OIDC context.

Graphiti is intentionally NOT in this matrix — round 4 AI #2
deferred the Graphiti binary to v0.6.x as a separate GoReleaser
archive so the v0.6.0 install footprint stays bounded.

All cmd packages build clean; the actual signed release runs
in Ν-1.12 once the v0.6.0 tag is pushed.
…em0 namespace

Round 4 review #23 surfaced six findings that block v0.6.0 ship.
This commit clears all six in-place; remaining medium / low items
land in follow-up commits.

#1 internal/cli/capability.go: runCompile passed `currentScope(nil,
   ...)`, but currentScope dereferences cfg.Repositories on the
   default path → nil pointer panic on every `bough capability
   compile/preview`. Use an explicit Scope{Level: worktree,
   WorktreeID: "default"} literal until --scope flags land in
   v0.6.x.

#2 + #3 cmd/bough-mcp-server/main.go + internal/mcp/server.go:
   WatchStdin and Server.Run both Read from os.Stdin concurrently,
   so the watchdog stole bytes the JSON-RPC scanner needed. The
   ZOMBIE guard also only triggered on io.EOF, so wrapped errors
   left the goroutine spinning. Both go away by deleting
   WatchStdin entirely and letting Server.Run's bufio.Scanner
   detect EOF — main.go's `defer server.Shutdown()` handles the
   subprocess teardown.

#4 + #5 internal/pluginsign/verifier.go +
   internal/cli/plugin_verify.go: verifyCosign defaulted SigPath
   to ".bundle" but .goreleaser.yaml writes ".sig", and the
   command was missing the --certificate-identity /
   --certificate-oidc-issuer flags cosign 2.x requires for
   keyless verify. Default SigPath now prefers .sig (with .bundle
   fallback for legacy artifacts), CertPath defaults to the
   GoReleaser .pem companion, and the Request grows
   CertIdentity / CertIdentityRegexp / CertOIDCIssuer fields the
   CLI exposes as --cert-identity / --cert-identity-regexp /
   --cert-issuer. PreRunE now validates --pubkey is set for
   minisign and --cert-identity (or regexp) is set for cosign
   BEFORE shelling out so the operator sees an Args error rather
   than a phantom verify failure.

#6 internal/mcp/server.go: handleResourcesRead built
   Scope{Level: level} with no RepoName/WorktreeID, then asked
   the backend for a count. sqlite's scopeID returns "/" for that
   shape, so the filter never matched a real worktree row and
   the resource reported `worktree: 0` even when worktree rows
   existed. The honest fix is to stop returning a faithful count
   the host cannot guarantee: readScopesResource now lists the
   three scope tiers with descriptions and points clients at
   memory.query for counts (v0.6.x will add an explicit count API
   once Server learns about cfg.Repositories).

#7 (review #23) plugins/memory/mem0/namespace.go: scopeToMem0
   worktree case derived rootHash from `RepoName + "/" +
   WorktreeID`, so two worktrees of the same repo got different
   user_ids and the documented user-tier sharing did not hold.
   Use hashShort(RepoName) for both segments; branch identity
   continues to live entirely in session_id.

Build clean, all unit + conformance tests pass, lint 0 issues.
… propagation / cache gen / cache namespace / Export pagination

#10 instinctToMetadata + writeInstinct skip zero-value time.Time
so a freshly constructed Instinct does not serialise a
-6795364578871 epoch into mem0 metadata or the JSONL export
(which would round-trip back as a garbage "year 0001" timestamp).

#11 The YAML / JSONL Export now carries dedupe_key +
source_event_id alongside each row, and Import's parser returns
an importRow struct that preserves both tokens. The host's
Store-by-dedupe contract therefore survives sqlite Export → mem0
Import round trips end-to-end.

#12 queryCache gains a monotonic invalidation generation. Query
captures gen before the HTTP roundtrip and hands it to put; put
silently drops the write when invalidateScope has bumped gen in
the interim. A concurrent Store / Forget / Import can no longer
race a slow Query and pin a stale response in cache for cacheTTL.

#13 cacheKey gains a namespace field, populated via the new
Provider.cacheKeyFor method, so two tenants sharing one Provider
with different namespaces never share cache entries even when
Scope shape matches.

#15 Export now paginates: it loops GET /api/v1/memories/?page=N
until a less-than-page-size batch comes back. Long scopes (>100
instincts) no longer silently truncate at the first page.

Build clean, plugins/memory/mem0 unit tests pass.
…guard + signing docs honest

Three review #23 follow-ups (#9, #14, #16) collected into one commit
because they share the same theme: undo the v0.6 plan's overreach in
the host-side CLI surface and the docs that backed it.

#16 — `bough instinct export` was a Μ-1.12 stub that returned "not
  yet wired". The CLI advertised the command in `--help` though, so an
  operator who tried it hit a dead end with no fix-it pointer. Wire
  it through Coordinator.Export (new method, mirrors the Import flow:
  delegates to backend.Export, emits an audit event keyed `export`).
  Same scope-inference shape as newInstinctPromoteCmd so the two
  commands see the same default scope.

#14 — internal/cli/capability.writeEmissions was joining
  emitter-supplied filenames straight into outDir. v0.6.0 ships three
  first-party emitters that all produce safe filenames, but the
  emitter registry is plugin-extensible from v0.6.x, and a buggy or
  malicious emitter returning `../../etc/foo` would have landed bytes
  outside the operator's --out-dir. Add the standard archive/tar-style
  traversal guard: filepath.Clean → absolute-or-".." reject →
  filepath.Rel(outDir) verify the canonical path stays inside.

#9 — internal/config/config.go and docs/SIGNING.md both claimed v0.6
  consumes `require_signed: true` as a spawn-time refuse-to-spawn gate.
  The Ν-1.8 implementation actually shipped the `bough plugin verify`
  CLI only; the spawn-time gate lands in v0.6.x per the four-stage
  timeline already documented in docs/SIGNING.md "Timeline" table.
  Revert the config docstring and the SIGNING.md "Configuration" para
  so the contract bough advertises matches what it enforces.

All three change unit + conformance tests still pass:

  go test ./conformance/...    →  6 packages, all ok
  go test ./internal/...        →  all ok
  go test -tags=conformance ./plugins/memory/sqlite/...  →  ok
… caps gating + mock hard-delete

Review #23 #8 surfaced a contract divergence: the mem0 plugin
advertised Capabilities.SoftDelete=true while real mem0 cloud's
DELETE /api/v1/memories/<id>/ hard-deletes the row. The in-tree
conformance suite shipped CI-green because the mock_mem0 server
faked soft-delete in the wire layer (metadata.bough_state="forgotten"
gated by mockStore.search), masking the production divergence.

Make the mem0 plugin honest, then make the conformance suite caps-
aware so a backend that says SoftDelete=false still passes the suite
without faking it.

plugins/memory/mem0/mem0.go
  Capabilities.SoftDelete: true → false, with an updated docstring
  explaining the hard-delete semantics and pointing at the
  conformance gate.

plugins/memory/mem0/CONTRACT.md
  `soft_delete` row: true → false, with the reason ("mem0 cloud
  hard-deletes on DELETE…; the conformance suite gates its Export-
  after-Forget assertion on this flag").

conformance/memory/lifecycle.go
  - Store#2 WasUpsert assertion gated on caps.DedupeKey (mem0 honestly
    advertises DedupeKey=false: no native dedupe primitive).
  - Reorder so Export → Import round-trip runs BEFORE Forget, so every
    backend exercises the round-trip regardless of soft-delete
    semantics.
  - Forget runs on every backend; the post-Forget Query asserts the
    row stops being queryable (common contract).
  - The post-Forget Export-still-returns-the-row assertion is gated on
    caps.SoftDelete so honest hard-deleting backends pass.

conformance/memory/mock_mem0/main.go
  - softDelete → hardDelete: delete(s.rows, id) directly, mirroring
    real mem0 cloud's DELETE semantic.
  - search/list bough_state filter removed: nothing soft-deletes
    through this path any more.
  - Header comment expanded to record the divergence #8 surfaced and
    why the mock now matches production.

Verified locally:

  go test ./conformance/memory/...     →  TestSelf + TestMem0_Conformance pass
  go test -tags=conformance \
    BOUGH_CONFORMANCE_MEMORY_PLUGIN_BIN=dist/bough-plugin-memory-sqlite \
    ./plugins/memory/sqlite/...        →  pass (caps.SoftDelete=true path)
…view #23 #2/#3)

The existing conformance/mcp suite wires Server.Run through io.Pipe,
which CI-greened the pre-fix WatchStdin design where a second
goroutine on os.Stdin would have raced the bufio.Scanner inside
Run. Review #23 #2/#3 surfaced that gap; the WatchStdin function
itself is gone, but the in-process suite still cannot regress-test
the production stdio path.

This test spawns the real bough-mcp-server binary (and its
bough-plugin-memory-sqlite dependency, built into a shared temp dir
so the server's discoverSQLite finds it via PATH) and drives the
MCP initialize handshake through actual stdin/stdout pipes:

  1. Build both binaries into t.TempDir().
  2. Start cmd/bough-mcp-server with PATH=tempdir + sqlite db path.
  3. Write one JSON-RPC initialize frame; read exactly one response.
     A WatchStdin-style second reader would steal the bytes and
     ReadBytes would time out.
  4. Close stdin → Server.Run's bufio.Scanner returns false → main
     returns → defer server.Shutdown() reaps the SQLite plugin.
     If the SQLite subprocess outlives the server, cmd.Wait blocks
     past 10s and the test fires.

The build cost (~10s for two binaries) is paid once per test run.
The test runs without tags so the CI memory job already covers it;
operators running `go test ./...` locally get coverage without
configuring anything extra.

Verified locally:

  go test -run TestSubprocessLifecycle ./conformance/mcp/...
    PASS: TestSubprocessLifecycle (1.65s)
golangci-lint errcheck flagged conformance/mcp/subprocess_test.go
line 153: os.RemoveAll(dir) on the build-failure cleanup path
discarded the error. The discard is intentional (= the temp dir
goes away with the test process), so prefix with `_ =` to satisfy
the linter without changing behaviour.

Verified locally:

  nix develop .#ci -c golangci-lint run ./conformance/mcp/...
    0 issues.
@ikeikeikeike
ikeikeikeike merged commit 11beb36 into main Jun 21, 2026
12 checks passed
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
…em0 namespace

Round 4 review #23 surfaced six findings that block v0.6.0 ship.
This commit clears all six in-place; remaining medium / low items
land in follow-up commits.

#1 internal/cli/capability.go: runCompile passed `currentScope(nil,
   ...)`, but currentScope dereferences cfg.Repositories on the
   default path → nil pointer panic on every `bough capability
   compile/preview`. Use an explicit Scope{Level: worktree,
   WorktreeID: "default"} literal until --scope flags land in
   v0.6.x.

#2 + #3 cmd/bough-mcp-server/main.go + internal/mcp/server.go:
   WatchStdin and Server.Run both Read from os.Stdin concurrently,
   so the watchdog stole bytes the JSON-RPC scanner needed. The
   ZOMBIE guard also only triggered on io.EOF, so wrapped errors
   left the goroutine spinning. Both go away by deleting
   WatchStdin entirely and letting Server.Run's bufio.Scanner
   detect EOF — main.go's `defer server.Shutdown()` handles the
   subprocess teardown.

#4 + #5 internal/pluginsign/verifier.go +
   internal/cli/plugin_verify.go: verifyCosign defaulted SigPath
   to ".bundle" but .goreleaser.yaml writes ".sig", and the
   command was missing the --certificate-identity /
   --certificate-oidc-issuer flags cosign 2.x requires for
   keyless verify. Default SigPath now prefers .sig (with .bundle
   fallback for legacy artifacts), CertPath defaults to the
   GoReleaser .pem companion, and the Request grows
   CertIdentity / CertIdentityRegexp / CertOIDCIssuer fields the
   CLI exposes as --cert-identity / --cert-identity-regexp /
   --cert-issuer. PreRunE now validates --pubkey is set for
   minisign and --cert-identity (or regexp) is set for cosign
   BEFORE shelling out so the operator sees an Args error rather
   than a phantom verify failure.

#6 internal/mcp/server.go: handleResourcesRead built
   Scope{Level: level} with no RepoName/WorktreeID, then asked
   the backend for a count. sqlite's scopeID returns "/" for that
   shape, so the filter never matched a real worktree row and
   the resource reported `worktree: 0` even when worktree rows
   existed. The honest fix is to stop returning a faithful count
   the host cannot guarantee: readScopesResource now lists the
   three scope tiers with descriptions and points clients at
   memory.query for counts (v0.6.x will add an explicit count API
   once Server learns about cfg.Repositories).

#7 (review #23) plugins/memory/mem0/namespace.go: scopeToMem0
   worktree case derived rootHash from `RepoName + "/" +
   WorktreeID`, so two worktrees of the same repo got different
   user_ids and the documented user-tier sharing did not hold.
   Use hashShort(RepoName) for both segments; branch identity
   continues to live entirely in session_id.

Build clean, all unit + conformance tests pass, lint 0 issues.
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
… propagation / cache gen / cache namespace / Export pagination

#10 instinctToMetadata + writeInstinct skip zero-value time.Time
so a freshly constructed Instinct does not serialise a
-6795364578871 epoch into mem0 metadata or the JSONL export
(which would round-trip back as a garbage "year 0001" timestamp).

#11 The YAML / JSONL Export now carries dedupe_key +
source_event_id alongside each row, and Import's parser returns
an importRow struct that preserves both tokens. The host's
Store-by-dedupe contract therefore survives sqlite Export → mem0
Import round trips end-to-end.

#12 queryCache gains a monotonic invalidation generation. Query
captures gen before the HTTP roundtrip and hands it to put; put
silently drops the write when invalidateScope has bumped gen in
the interim. A concurrent Store / Forget / Import can no longer
race a slow Query and pin a stale response in cache for cacheTTL.

#13 cacheKey gains a namespace field, populated via the new
Provider.cacheKeyFor method, so two tenants sharing one Provider
with different namespaces never share cache entries even when
Scope shape matches.

#15 Export now paginates: it loops GET /api/v1/memories/?page=N
until a less-than-page-size batch comes back. Long scopes (>100
instincts) no longer silently truncate at the first page.

Build clean, plugins/memory/mem0 unit tests pass.
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
…guard + signing docs honest

Three review #23 follow-ups (#9, #14, #16) collected into one commit
because they share the same theme: undo the v0.6 plan's overreach in
the host-side CLI surface and the docs that backed it.

#16 — `bough instinct export` was a Μ-1.12 stub that returned "not
  yet wired". The CLI advertised the command in `--help` though, so an
  operator who tried it hit a dead end with no fix-it pointer. Wire
  it through Coordinator.Export (new method, mirrors the Import flow:
  delegates to backend.Export, emits an audit event keyed `export`).
  Same scope-inference shape as newInstinctPromoteCmd so the two
  commands see the same default scope.

#14 — internal/cli/capability.writeEmissions was joining
  emitter-supplied filenames straight into outDir. v0.6.0 ships three
  first-party emitters that all produce safe filenames, but the
  emitter registry is plugin-extensible from v0.6.x, and a buggy or
  malicious emitter returning `../../etc/foo` would have landed bytes
  outside the operator's --out-dir. Add the standard archive/tar-style
  traversal guard: filepath.Clean → absolute-or-".." reject →
  filepath.Rel(outDir) verify the canonical path stays inside.

#9 — internal/config/config.go and docs/SIGNING.md both claimed v0.6
  consumes `require_signed: true` as a spawn-time refuse-to-spawn gate.
  The Ν-1.8 implementation actually shipped the `bough plugin verify`
  CLI only; the spawn-time gate lands in v0.6.x per the four-stage
  timeline already documented in docs/SIGNING.md "Timeline" table.
  Revert the config docstring and the SIGNING.md "Configuration" para
  so the contract bough advertises matches what it enforces.

All three change unit + conformance tests still pass:

  go test ./conformance/...    →  6 packages, all ok
  go test ./internal/...        →  all ok
  go test -tags=conformance ./plugins/memory/sqlite/...  →  ok
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
… caps gating + mock hard-delete

Review #23 #8 surfaced a contract divergence: the mem0 plugin
advertised Capabilities.SoftDelete=true while real mem0 cloud's
DELETE /api/v1/memories/<id>/ hard-deletes the row. The in-tree
conformance suite shipped CI-green because the mock_mem0 server
faked soft-delete in the wire layer (metadata.bough_state="forgotten"
gated by mockStore.search), masking the production divergence.

Make the mem0 plugin honest, then make the conformance suite caps-
aware so a backend that says SoftDelete=false still passes the suite
without faking it.

plugins/memory/mem0/mem0.go
  Capabilities.SoftDelete: true → false, with an updated docstring
  explaining the hard-delete semantics and pointing at the
  conformance gate.

plugins/memory/mem0/CONTRACT.md
  `soft_delete` row: true → false, with the reason ("mem0 cloud
  hard-deletes on DELETE…; the conformance suite gates its Export-
  after-Forget assertion on this flag").

conformance/memory/lifecycle.go
  - Store#2 WasUpsert assertion gated on caps.DedupeKey (mem0 honestly
    advertises DedupeKey=false: no native dedupe primitive).
  - Reorder so Export → Import round-trip runs BEFORE Forget, so every
    backend exercises the round-trip regardless of soft-delete
    semantics.
  - Forget runs on every backend; the post-Forget Query asserts the
    row stops being queryable (common contract).
  - The post-Forget Export-still-returns-the-row assertion is gated on
    caps.SoftDelete so honest hard-deleting backends pass.

conformance/memory/mock_mem0/main.go
  - softDelete → hardDelete: delete(s.rows, id) directly, mirroring
    real mem0 cloud's DELETE semantic.
  - search/list bough_state filter removed: nothing soft-deletes
    through this path any more.
  - Header comment expanded to record the divergence #8 surfaced and
    why the mock now matches production.

Verified locally:

  go test ./conformance/memory/...     →  TestSelf + TestMem0_Conformance pass
  go test -tags=conformance \
    BOUGH_CONFORMANCE_MEMORY_PLUGIN_BIN=dist/bough-plugin-memory-sqlite \
    ./plugins/memory/sqlite/...        →  pass (caps.SoftDelete=true path)
ikeikeikeike added a commit that referenced this pull request Jun 21, 2026
…view #23 #2/#3)

The existing conformance/mcp suite wires Server.Run through io.Pipe,
which CI-greened the pre-fix WatchStdin design where a second
goroutine on os.Stdin would have raced the bufio.Scanner inside
Run. Review #23 #2/#3 surfaced that gap; the WatchStdin function
itself is gone, but the in-process suite still cannot regress-test
the production stdio path.

This test spawns the real bough-mcp-server binary (and its
bough-plugin-memory-sqlite dependency, built into a shared temp dir
so the server's discoverSQLite finds it via PATH) and drives the
MCP initialize handshake through actual stdin/stdout pipes:

  1. Build both binaries into t.TempDir().
  2. Start cmd/bough-mcp-server with PATH=tempdir + sqlite db path.
  3. Write one JSON-RPC initialize frame; read exactly one response.
     A WatchStdin-style second reader would steal the bytes and
     ReadBytes would time out.
  4. Close stdin → Server.Run's bufio.Scanner returns false → main
     returns → defer server.Shutdown() reaps the SQLite plugin.
     If the SQLite subprocess outlives the server, cmd.Wait blocks
     past 10s and the test fires.

The build cost (~10s for two binaries) is paid once per test run.
The test runs without tags so the CI memory job already covers it;
operators running `go test ./...` locally get coverage without
configuring anything extra.

Verified locally:

  go test -run TestSubprocessLifecycle ./conformance/mcp/...
    PASS: TestSubprocessLifecycle (1.65s)
@ikeikeikeike
ikeikeikeike deleted the feat/v0.6-external-memory branch June 28, 2026 12:40
cmd := &cobra.Command{
Use: "plugins",
Short: "List engine plugins discoverable on PATH",
Short: "List and verify bough plugin binaries",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Retrospective review: this commit changed newPluginsCmd's Short text to "List and verify bough plugin binaries" and wired newPluginsVerifyCmd() alongside newPluginsListCmd(). The verify subcommand (internal/cli/plugin_verify.go) and its AddCommand wiring were deleted wholesale by the later v0.9.0 reset (commit eee8a3d), but this Short string was left behind — so bough plugins --help kept advertising a "verify" capability that no longer exists, contradicting docs/SIGNING.md's (correct) "There is no bough plugins verify subcommand today". Fixed in PR #108, which restores the Short text to match the single wired list subcommand and adds a regression test asserting the two can't drift apart again.

ikeikeikeike added a commit that referenced this pull request Jul 16, 2026
fix: retrospective review fixes for merged PR #23
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