diff --git a/CLAUDE.md b/CLAUDE.md index 25d8cbb..b59116a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,13 @@ onboarding runbook every agent follows. `bdrive init` registers the hooks in each platform's USER config (`~/.claude/settings.json` and friends), once per machine: a blocking pull at UserPromptSubmit β€” which, via `bdrive sync --hook`, also injects the project's gated-link formula as additionalContext -so agents append `path` [πŸ”—](hub link) to every synced path they mention β€” an +so agents append `path` [πŸ”—](hub link) to every synced path they mention. One +run can cover several mounts (`syncTargets`) and the hook's stdout contract is +a single JSON object, so the formula carries **every** mount as a `prefix β†’ +URL` pair β€” the prefix being the mount's path as the agent sees it from the +session's folder, or an empty prefix with the session's own subpath baked into +the URL when the session runs inside the mount; emitting only the first mount +hung one project's paths on another project's base URL. Then an async push on PostToolUse Write/Edit, and `bdrive read-log` on Read/Grep/Bash for the read heatmap. The inline hook commands `internal/agenthooks` writes must stay a fast no-op outside BearDrive folders diff --git a/cmd/bdrive/cmds.go b/cmd/bdrive/cmds.go index 7f8656d..331bb22 100644 --- a/cmd/bdrive/cmds.go +++ b/cmd/bdrive/cmds.go @@ -92,19 +92,22 @@ list in .bdrive/config.json is never pruned against either.`, if hookLabel != "" { // Agent-hook mode: event JSON on stdin, silent best-effort - // sync, link-formula context on stdout. Never fails. Only the - // first mount emits context β€” the JSON contract is one object, - // so a repo with several mounts links through the first. - emit := true + // sync, link-formula context on stdout. Never fails. Every + // mount contributes its own prefixβ†’URL pair; the JSON + // contract is one object, so they are emitted together after + // the loop. + sessionID := hookSessionID(cmd) + var links []hookLink for _, target := range targets { proj, ok, err := config.LoadProject(target) if err != nil || !ok || syncBlocked(proj) != "" { continue } - if err := runHookSync(cmd, target, hookLabel, emit); err == nil && emit { - emit = false + if base, ok := runHookSync(cmd, target, sessionID, hookLabel); ok { + links = append(links, hookLinkFor(folder, target, base)) } } + emitHookContext(cmd, links) return nil } if len(targets) == 0 { diff --git a/cmd/bdrive/hooksync.go b/cmd/bdrive/hooksync.go index a7b7e5d..5dd43eb 100644 --- a/cmd/bdrive/hooksync.go +++ b/cmd/bdrive/hooksync.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "io" + "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -17,6 +19,12 @@ import ( // project's gated-link formula as additionalContext, so the agent can // append a hub link to any synced file path it mentions. // +// One run can cover several mounts (a repo root whose wiki/ and docs/ are +// separate projects, see syncTargets), and the hook's stdout contract is a +// single JSON object β€” so every mount's link goes into one context, keyed +// by the path prefix an agent sees. Emitting only the first mount's URL +// made agents hang one project's base URL on another project's paths. +// // Everything is best-effort: a hook must never fail the turn, so every // error path is a silent, successful exit. @@ -24,25 +32,35 @@ import ( // scans keep stamping this session's changes for a while. const hookNoteTTL = 30 * time.Minute -// emitContext is false for every mount after the first in one hook run: the -// hook's stdout contract is a single JSON object. -func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) error { - // The platform pipes its event JSON on stdin; the session id is all we - // need from it here. +// hookLink pairs the path prefix an agent writes with the hub URL that +// prefix maps to. +type hookLink struct { + prefix string // "wiki/", or "" when the hook ran at or inside the mount + base string // https://hub/[/] +} + +// hookSessionID reads the platform's event JSON from stdin β€” once per run, +// since stdin can only be consumed once and the sync loop may cover several +// mounts. +func hookSessionID(cmd *cobra.Command) string { data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20)) var event struct { SessionID string `json:"session_id"` } _ = json.Unmarshal(data, &event) // malformed input: just sync + return event.SessionID +} - sess, proj, err := openSession(cmd.Context(), folder, true) +// runHookSync syncs one mount and reports its hub base URL, if it has one. +func runHookSync(cmd *cobra.Command, target, sessionID, label string) (string, bool) { + sess, proj, err := openSession(cmd.Context(), target, true) if err != nil { - return nil // not a mount / no session: fast no-op + return "", false // not a mount / no session: fast no-op } defer closeSession(sess) - if event.SessionID != "" { - note := label + " session " + event.SessionID + if sessionID != "" { + note := label + " session " + sessionID if err := sess.Store.SaveNote(note, hookNoteTTL); err == nil { sess.Note = note } @@ -51,34 +69,90 @@ func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) err // The pull. Offline is fine β€” the link formula below is still valid // for teammates who are online. if _, err := sess.Cycle(cmd.Context()); err != nil { - return nil // never break the turn - } - - if !emitContext { - return nil + return "", false // never break the turn } server, projectID, err := splitHubRemote(proj.Remote) if err != nil { - return nil // non-hub remote: nothing to link to + return "", false // non-hub remote: nothing to link to + } + return server + "/" + projectID, true +} + +// hookLinkFor places one mount relative to the folder the hook ran in. +// Agents write paths as they see them from that folder, so the mount's +// position there is what turns a path into a URL: a mount BELOW the folder +// contributes a prefix to strip, while a run INSIDE a mount contributes a +// subpath that belongs in the base instead (there is no prefix to strip β€” +// every path the agent writes is already inside the mount). +func hookLinkFor(folder, target, base string) hookLink { + // resolvePath: registry paths and the run folder can name the same + // directory through different symlinks (macOS /tmp). + rel, err := filepath.Rel(resolvePath(folder), resolvePath(target)) + if err != nil { + return hookLink{base: base} + } + rel = filepath.ToSlash(rel) + switch { + case rel == ".": + return hookLink{base: base} + case rel == ".." || strings.HasPrefix(rel, "../"): + sub, err := filepath.Rel(resolvePath(target), resolvePath(folder)) + if err != nil { + return hookLink{base: base} + } + return hookLink{base: base + "/" + encodePathSegments(filepath.ToSlash(sub))} + default: + return hookLink{prefix: rel + "/", base: base} + } +} + +// emitHookContext writes the turn's additionalContext β€” one JSON object, no +// matter how many mounts the run covered. +func emitHookContext(cmd *cobra.Command, links []hookLink) { + if len(links) == 0 { + return + } + + // Shared tail: what the links mean and when NOT to use them. + const tail = "These links require hub sign-in + project membership, so they are safe to paste anywhere internal. " + + "Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. " + + "`bdrive share ` mints PUBLIC no-account links β€” use it only when the user explicitly asks for a public link." + + var context string + if len(links) == 1 && links[0].prefix == "" { + // The common case: one project, paths already relative to its root. + // Kept as short as possible β€” this is paid on every turn. + b := links[0].base + context = fmt.Sprintf( + "beardrive: this folder syncs to %s (the project's hub page; files are at %s/). "+ + "Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `` [πŸ”—](%s/) β€” the path stays plain text, the hyperlink goes on the emoji only. "+ + "The URL path is the file's path relative to this folder, with each segment percent-encoded and the `/` separators left literal. "+ + tail, b, b, b) + } else { + parts := make([]string, len(links)) + for i, l := range links { + p := l.prefix + if p == "" { + p = "./" + } + parts[i] = fmt.Sprintf("`%s` β†’ %s", p, l.base) + } + context = fmt.Sprintf( + "beardrive: hub URLs for the synced folders here β€” %s. "+ + "Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `` [πŸ”—](/) β€” the path stays plain text, the hyperlink goes on the emoji only. "+ + "Pick the folder whose prefix above matches the path longest, strip that prefix, then percent-encode each remaining segment and leave the `/` separators literal. A path matching none of these folders is not synced β€” do not link it, and never hang one folder's path on another folder's URL. "+ + tail, strings.Join(parts, ", ")) } - base := server + "/" + projectID out := map[string]any{ "hookSpecificOutput": map[string]any{ - "hookEventName": "UserPromptSubmit", - "additionalContext": fmt.Sprintf( - "beardrive: this folder syncs to %s (the project's hub page; files are at %s/). "+ - "Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `` [πŸ”—](%s/) β€” the path stays plain text, the hyperlink goes on the emoji only. "+ - "These links require hub sign-in + project membership, so they are safe to paste anywhere internal. "+ - "Only link files that actually sync (inside the shared scope, not ignored); keep paths inside code blocks or commands plain; give a raw URL only when the user needs to paste it outside this conversation. "+ - "`bdrive share ` mints PUBLIC no-account links β€” use it only when the user explicitly asks for a public link.", - base, base, base), + "hookEventName": "UserPromptSubmit", + "additionalContext": context, }, } enc, err := json.Marshal(out) if err != nil { - return nil + return } fmt.Fprintln(cmd.OutOrStdout(), string(enc)) - return nil } diff --git a/cmd/bdrive/hooksync_test.go b/cmd/bdrive/hooksync_test.go index ba56a91..7bae540 100644 --- a/cmd/bdrive/hooksync_test.go +++ b/cmd/bdrive/hooksync_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "os" "path/filepath" "strings" "testing" @@ -150,6 +151,124 @@ func TestSyncHookModeNoOps(t *testing.T) { } } +// mountAt creates a project folder under parent and enrolls it on this +// device, as `bdrive init` would. +func mountAt(t *testing.T, parent, name, remote string) config.Project { + t.Helper() + dir := filepath.Join(parent, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + proj, err := config.SaveProject(dir, config.Project{Volume: name, Remote: remote}) + if err != nil { + t.Fatal(err) + } + if _, _, err := config.ResolveMount(dir); err != nil { + t.Fatal(err) + } + return proj +} + +func runHook(t *testing.T, folder string) string { + t.Helper() + c := syncCmd() + var out bytes.Buffer + c.SetOut(&out) + c.SetIn(strings.NewReader(`{"session_id":"sess-42"}`)) + c.SetArgs([]string{folder, "--hook", "claude-code"}) + if err := c.Execute(); err != nil { + t.Fatalf("hook mode must never fail: %v", err) + } + return out.String() +} + +// A session at a root whose subfolders are separate projects must get EVERY +// project's URL, each keyed by the prefix the agent sees β€” emitting only the +// first mount's base made agents hang one project's paths on another +// project's URL. +func TestSyncHookModeMultipleMounts(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + root := t.TempDir() + root, _ = filepath.EvalSymlinks(root) + a := mountAt(t, root, "projA", "https://hub.example.com/p/p-aaaaaaaa") + b := mountAt(t, root, "projB", "https://hub.example.com/p/p-bbbbbbbb") + + got := runHook(t, root) + + // One JSON object: the hook's stdout contract. + if n := strings.Count(strings.TrimSpace(got), "\n"); n != 0 { + t.Fatalf("hook emitted %d objects, want 1:\n%s", n+1, got) + } + for _, want := range []string{ + "https://hub.example.com/p-aaaaaaaa", + "https://hub.example.com/p-bbbbbbbb", + "`projA/`", + "`projB/`", + "matches the path longest", // how to pick between them + "do not link it", // a path in neither is not synced + } { + if !strings.Contains(got, want) { + t.Errorf("hook output missing %q:\n%s", want, got) + } + } + + // stdin is consumed once, so the note must still reach every mount. + for _, proj := range []config.Project{a, b} { + vdir, err := config.VolumeDir(proj.ID) + if err != nil { + t.Fatal(err) + } + st, err := store.Open(vdir) + if err != nil { + t.Fatal(err) + } + if note := st.LoadNote(); note != "claude-code session sess-42" { + t.Errorf("%s: note = %q, want the stamped session", proj.Volume, note) + } + } +} + +// A mount that has no hub URL must not swallow the context for the mounts +// that do β€” the old "first mount emits" guard could never detect a mount +// that emitted nothing, because hook mode never returns an error. +func TestSyncHookModeSkipsNonHubMount(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + root := t.TempDir() + root, _ = filepath.EvalSymlinks(root) + mountAt(t, root, "a-plain", "file://"+t.TempDir()) // sorts first, no hub + mountAt(t, root, "b-hub", "https://hub.example.com/p/p-bbbbbbbb") + + got := runHook(t, root) + if !strings.Contains(got, "https://hub.example.com/p-bbbbbbbb") { + t.Errorf("hub mount lost its link behind a non-hub mount:\n%s", got) + } + if !strings.Contains(got, "`b-hub/`") { + t.Errorf("hub mount missing its prefix:\n%s", got) + } + if strings.Contains(got, "a-plain") { + t.Errorf("non-hub mount has no URL and must not be listed:\n%s", got) + } +} + +// A session started inside a mount writes paths relative to its own +// directory, so that subpath belongs in the base URL β€” there is no prefix +// for the agent to strip. +func TestSyncHookModeInsideMount(t *testing.T) { + t.Setenv("BDRIVE_HOME", t.TempDir()) + root := t.TempDir() + root, _ = filepath.EvalSymlinks(root) + mountAt(t, root, "wiki", "https://hub.example.com/p/p-12345678") + sub := filepath.Join(root, "wiki", "docs", "notes") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + + got := runHook(t, sub) + if !strings.Contains(got, "https://hub.example.com/p-12345678/docs/notes") { + t.Errorf("base URL missing the session's subpath (and `/` must stay literal):\n%s", got) + } +} + // Plain `bdrive sync` (the push hook's form, and what users type) refuses // unenrolled and paused mounts with instructions instead of silently // enrolling or resuming. diff --git a/web/docs/src/content/docs/guides/agent-artifacts.md b/web/docs/src/content/docs/guides/agent-artifacts.md index 4d0c255..c819fed 100644 --- a/web/docs/src/content/docs/guides/agent-artifacts.md +++ b/web/docs/src/content/docs/guides/agent-artifacts.md @@ -31,7 +31,9 @@ With no argument, `bdrive url` gives the project home. :::tip[Agents do this automatically] The sync hook `bdrive init` registers injects the project's gated-link formula into the agent's context, so a connected agent appends -`path` [πŸ”—](link) to every synced path it mentions β€” without being asked. See +`path` [πŸ”—](link) to every synced path it mentions β€” without being asked. If a +session's root holds several connected folders, each one's URL goes in, so a +path is always linked to the project it actually lives in. See [Set up with your agent](/start/setup/). :::