feat(hook): warn agents about stale files at turn start (BEA-34)

The hub already computes the hot-but-stale judgment and shows it on a
Dashboard page nobody has open mid-task, while the agent doing most of
the reads gets a plain file and cites it with full confidence.

`bdrive sync --hook` now appends a bounded watchlist to the
additionalContext it already emits: up to 5 files whose newest journal
op is 90+ days old and that sit in a directory where something changed
in the last 30 days, ranked by age x churn.

Ages come from the journal, never mtimes — a freshly materialized clone
has today's mtime on every file and would report the whole project as
fresh for exactly the new joiner this helps most. No new hook, no
network call, no per-read cost; nothing qualifying means byte-identical
output to today's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Snow Lee
2026-07-29 09:07:23 -07:00
co-authored by Claude Opus 5
parent 4ff92c56ab
commit b515b371eb
6 changed files with 271 additions and 12 deletions
+1 -1
View File
@@ -150,7 +150,7 @@ hub's own storage, never something a syncing client points at directly:
| `bdrive forget <path>...` | Stop syncing a path *and* remove it from the hub — adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `bdrive url [path]` | Internal hub link for a file/folder (sign-in + membership required; `--sync` pushes first; no arg = project home). Computed locally |
| `bdrive share <file>` | Public URL for a synced file (`--list`, `--revoke`, `--expires`) |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula (Claude Code hook JSON) on stdout |
| `bdrive sync [folder]` | Run one sync cycle now. `--note <text>` stamps session context (e.g. an agent session id) onto changes — shown in `bdrive log` and hub history; keeps applying to daemon-committed changes until `--note-ttl` (default 30m) expires. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: event JSON on stdin, sync + note, gated-link formula plus a watchlist of files that haven't changed in 90+ days while their neighbors have (Claude Code hook JSON) on stdout |
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each agent platform's user config (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking. Once per machine, covering every session; run automatically by `bdrive init`; idempotent (`--agent` overrides detection) |
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms (`~/.codex/skills/beardrive/SKILL.md` and friends) so the agent can do the setup itself — sign in, `bdrive init`, and register the sync hooks; idempotent (`--agent` overrides detection) |
| `bdrive read-log [folder]` | Hook plumbing: queue agent file reads from a hook event (JSON on stdin) for the hub's read heatmap — native reads, grep matches, and files named in shell commands; drained on the next sync. Registered by `bdrive hooks install` |
+116 -8
View File
@@ -4,8 +4,14 @@ import (
"encoding/json"
"fmt"
"io"
"path"
"sort"
"strings"
"time"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
"github.com/spf13/cobra"
)
@@ -63,16 +69,26 @@ func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) err
}
base := server + "/" + projectID
ctx := fmt.Sprintf(
"beardrive: this folder syncs to %s (the project's hub page; files are at %s/<url-encoded path>). "+
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — 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 <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link.",
base, base, base)
if filter, err := syncer.LoadFilter(folder, proj.Include); err == nil {
if lines := staleLines(sess.Store, filter, time.Now().UTC()); len(lines) > 0 {
ctx += "\n\nbeardrive: possibly out-of-date files in this project —\n" +
strings.Join(lines, "\n") +
"\nIf you read one of these, treat it as possibly out of date and say so when you cite it."
}
}
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/<url-encoded path>). "+
"Link convention: whenever you mention a synced file's path in prose, append its gated hub link on an emoji, formatted exactly as: `<path>` [🔗](%s/<url-encoded path>) — 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 <file>` mints PUBLIC no-account links — use it only when the user explicitly asks for a public link.",
base, base, base),
"hookEventName": "UserPromptSubmit",
"additionalContext": ctx,
},
}
enc, err := json.Marshal(out)
@@ -82,3 +98,95 @@ func runHookSync(cmd *cobra.Command, folder, label string, emitContext bool) err
fmt.Fprintln(cmd.OutOrStdout(), string(enc))
return nil
}
// The staleness watchlist: files old enough to have rotted, sitting in a
// directory people are still editing. The hub's Dashboard already plots this
// judgment (reads × days-since-change) for a human who does not have the page
// open mid-task; these lines put the same judgment in front of the agent that
// is about to cite the file.
const (
staleAge = 90 * 24 * time.Hour // higher bar than the Dashboard's 30d: this injects on every turn
churnWindow = 30 * 24 * time.Hour // matches the Dashboard's window
maxStale = 5 // bounds the injected text to a few lines
)
// staleLines ranks the project's stale-but-surrounded-by-churn files from the
// local journals — never mtimes: a teammate who just ran `bdrive init` has
// today's mtime on every materialized file, so an mtime version would report a
// brand-new project to exactly the joiner it helps most. Best-effort: any
// error means no watchlist, never a failed turn.
func staleLines(st *store.Store, filter *syncer.Filter, now time.Time) []string {
ops, err := st.AllOps()
if err != nil {
return nil
}
live := journal.Replay(ops) // deleted paths drop out here
latest := make(map[string]journal.Op, len(live)) // newest put per live path
for _, op := range ops {
if op.Kind != journal.KindPut {
continue
}
if _, ok := live[op.Path]; !ok {
continue
}
if prev, ok := latest[op.Path]; !ok || journal.Less(prev, op) {
latest[op.Path] = op
}
}
churn := make(map[string]int) // dir -> files changed inside the churn window
for p, op := range latest {
if now.Sub(op.Time) < churnWindow {
churn[path.Dir(p)]++
}
}
type candidate struct {
path string
days int
who string
churn int
}
var cands []candidate
for p, op := range latest {
age := now.Sub(op.Time)
if age < staleAge {
continue
}
n := churn[path.Dir(p)]
if n < 1 || filter.Skip(p) { // out-of-scope files aren't on disk here
continue
}
who := op.User
if who == "" {
who = op.UserName
}
if who == "" {
who = op.Author
}
cands = append(cands, candidate{path: p, days: int(age.Hours() / 24), who: who, churn: n})
}
// Ties break on path: the same data must produce the same list every turn.
sort.Slice(cands, func(i, j int) bool {
a, b := cands[i], cands[j]
if a.days*a.churn != b.days*b.churn {
return a.days*a.churn > b.days*b.churn
}
return a.path < b.path
})
if len(cands) > maxStale {
cands = cands[:maxStale]
}
lines := make([]string, 0, len(cands))
for _, c := range cands {
by := ""
if c.who != "" {
by = fmt.Sprintf(" (by %s)", c.who)
}
lines = append(lines, fmt.Sprintf(" %s — last changed %dd ago%s; %d files near it changed in the last 30d",
c.path, c.days, by, c.churn))
}
return lines
}
+148
View File
@@ -2,12 +2,18 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/runbear-io/beardrive/internal/config"
"github.com/runbear-io/beardrive/internal/journal"
"github.com/runbear-io/beardrive/internal/store"
"github.com/runbear-io/beardrive/internal/syncer"
)
// `bdrive sync --hook` must emit the gated-link formula as Claude Code
@@ -49,6 +55,10 @@ func TestSyncHookMode(t *testing.T) {
t.Errorf("hook output missing %q:\n%s", want, got)
}
}
// Nothing is stale in an empty project: the watchlist block is absent.
if strings.Contains(got, "possibly out-of-date") {
t.Errorf("stale watchlist emitted for a project with no ops:\n%s", got)
}
// The session note was stamped for the daemon's follow-up scans.
vdir, err := config.VolumeDir(proj.ID)
@@ -64,6 +74,144 @@ func TestSyncHookMode(t *testing.T) {
}
}
// The stale-file watchlist rides along on the same additionalContext: files
// that haven't changed in 90d but sit in a directory people are still editing.
// Ages come from journal ops, never mtimes — the ops are seeded under a PEER
// device (scan compares against the materialization cache, not replayed state,
// so a file written to disk first would get a fresh put and the test would
// silently assert nothing).
func TestSyncHookStaleWatchlist(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
folder := t.TempDir()
folder, _ = filepath.EvalSymlinks(folder)
proj, err := config.SaveProject(folder, config.Project{
Volume: "wiki",
Remote: "https://hub.example.com/p/p-12345678",
})
if err != nil {
t.Fatal(err)
}
if _, _, err := config.ResolveMount(folder); err != nil {
t.Fatal(err)
}
vdir, err := config.VolumeDir(proj.ID)
if err != nil {
t.Fatal(err)
}
st, err := store.Open(vdir)
if err != nil {
t.Fatal(err)
}
shaOld, _, err := st.PutBlobBytes([]byte("old")) // the blob must exist or materialize skips the file
if err != nil {
t.Fatal(err)
}
shaNew, _, err := st.PutBlobBytes([]byte("new"))
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
if err := st.AppendOps("devPeer", []journal.Op{
{Seq: 1, Lamport: 1, Device: "devPeer", Kind: journal.KindPut, Path: "a/old.md",
Blob: shaOld, Size: 3, Mode: 0o644, Time: now.Add(-200 * 24 * time.Hour), User: "ken@acme.co"},
{Seq: 2, Lamport: 2, Device: "devPeer", Kind: journal.KindPut, Path: "a/recent.md",
Blob: shaNew, Size: 3, Mode: 0o644, Time: now.Add(-24 * time.Hour)},
{Seq: 3, Lamport: 3, Device: "devPeer", Kind: journal.KindPut, Path: "a/gone.md",
Blob: shaOld, Size: 3, Mode: 0o644, Time: now.Add(-300 * 24 * time.Hour)},
{Seq: 4, Lamport: 4, Device: "devPeer", Kind: journal.KindDelete, Path: "a/gone.md",
Time: now.Add(-10 * 24 * time.Hour)},
}); err != nil {
t.Fatal(err)
}
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)
}
var got struct {
Hook struct {
AdditionalContext string `json:"additionalContext"`
} `json:"hookSpecificOutput"`
}
if err := json.Unmarshal([]byte(out.String()), &got); err != nil {
t.Fatalf("hook output is not one JSON object: %v\n%s", err, out.String())
}
ctx := got.Hook.AdditionalContext
for _, want := range []string{
"possibly out-of-date files",
"a/old.md",
"200d ago",
"ken@acme.co",
"1 files near it changed",
"[🔗](", // the link formula is still there: appended, not replaced
"PUBLIC",
} {
if !strings.Contains(ctx, want) {
t.Errorf("additionalContext missing %q:\n%s", want, ctx)
}
}
if strings.Contains(ctx, "a/gone.md") {
t.Errorf("deleted path listed as stale:\n%s", ctx)
}
if strings.Contains(ctx, "a/recent.md —") {
t.Errorf("file inside the 90d bar listed as stale:\n%s", ctx)
}
// The cycle materialized a/old.md with today's mtime, and the reported
// age is still 200d — ages come from the journal, not the filesystem.
if _, err := os.Stat(filepath.Join(folder, "a", "old.md")); err != nil {
t.Fatalf("old.md was never materialized, so the mtime case is untested: %v", err)
}
}
// Ranking and the cap, straight against the helper: at most 5 entries,
// ordered by age × churn descending.
func TestStaleLinesRankAndCap(t *testing.T) {
st, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
var ops []journal.Op
seq := int64(0)
add := func(p string, ageDays int) {
seq++
ops = append(ops, journal.Op{Seq: seq, Lamport: seq, Device: "devPeer", Kind: journal.KindPut,
Path: p, Blob: "deadbeef", Size: 1, Time: now.Add(-time.Duration(ageDays) * 24 * time.Hour)})
}
// Seven stale files across two directories; d2 churns twice as hard, so a
// younger d2 file can outrank an older d1 one.
for i, age := range []int{100, 120, 140, 160, 180, 200, 220} {
add(fmt.Sprintf("d1/old%d.md", i), age)
}
add("d2/old.md", 95)
add("d1/recent.md", 1) // churn 1 for d1
add("d2/recent1.md", 1) // churn 2 for d2
add("d2/recent2.md", 2)
if err := st.AppendOps("devPeer", ops); err != nil {
t.Fatal(err)
}
filter, err := syncer.LoadFilter(t.TempDir(), nil)
if err != nil {
t.Fatal(err)
}
lines := staleLines(st, filter, now)
if len(lines) != maxStale {
t.Fatalf("got %d lines, want the %d cap:\n%s", len(lines), maxStale, strings.Join(lines, "\n"))
}
// age × churn: 220, 200, then d2/old.md at 95 × 2 = 190 jumping the
// 180d-old d1 file, then 180, 160. The two youngest d1 files fall off.
for i, want := range []string{"d1/old6.md", "d1/old5.md", "d2/old.md", "d1/old4.md", "d1/old3.md"} {
if !strings.Contains(lines[i], want) {
t.Errorf("line %d = %q, want %s", i, lines[i], want)
}
}
}
func TestSyncHookModeNoOps(t *testing.T) {
t.Setenv("BDRIVE_HOME", t.TempDir())
+1 -1
View File
@@ -18,7 +18,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
| Stop syncing | `bdrive stop [<folder>]` — pauses daemon *and* agent hooks; `bdrive init` resumes (`--forget` also unregisters) |
| Show/change which subfolders sync | `bdrive scope` / `bdrive scope add <dirs...>` / `bdrive scope rm <dirs...>` — edits the managed block of `.bdriveignore` rules that `init --only` writes (run from the mount root; never hand-write the negation syntax). The daemon applies it within seconds. `rm` stops syncing a folder but deletes nothing, locally or on the hub; removing the last entry is refused (that would flip to whole-folder sync — use `bdrive stop` instead) |
| Verify what actually leaves this machine | `bdrive scope --explain` — walks the folder and prints two sorted lists, `synced` and `not synced`, with counts; fully-excluded directories collapse to one counted line and nested mounts are marked as syncing through their own project. Pure read: safe with the daemon running and offline, takes no lock, makes no network call. Answers "what leaves from now on", NOT "what is already on the hub" — that's `bdrive forget` below. Output is stable, so `bdrive scope --explain > before.txt` + `diff` is a real audit |
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--prune` also removes from the hub whatever `.bdriveignore` now excludes (refuses when `.bdriveignore` narrows the scope with `!` rules — see below); `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula out) |
| One sync cycle now | `bdrive sync [<folder>]` — `--note <text>` stamps session context; `--prune` also removes from the hub whatever `.bdriveignore` now excludes (refuses when `.bdriveignore` narrows the scope with `!` rules — see below); `--hook <label>` is the Claude turn-start hook's plumbing (event JSON in, sync + note, gated-link formula + stale-file watchlist out) |
| Stop syncing a path **and** take it off the hub | `bdrive forget <path>...` — appends the rule to `.bdriveignore` (trailing `/` for a directory) and prunes in the same run. **Deletes nothing on disk**, here or on teammates' devices: they receive the rule with the removal and just stop tracking the path. Idempotent; a path outside the project errors and writes nothing. This is the ONLY way to clean up something that synced before you excluded it — plain `.bdriveignore` edits and `bdrive scope rm` leave the hub's copy in place |
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install` — merges pull/push/session-note/read-tracking hooks into each platform's USER config (`~/.claude/settings.json` and friends), once per machine, idempotently. `bdrive init` runs it automatically, so this is mainly for retries or `--agent`-targeting an undetected platform; bare `bdrive hooks` shows the status table; `bdrive hooks uninstall` removes only our entries |
| Install this skill on another agent (Codex, Gemini CLI, Hermes, Claude Code) | `bdrive skill install [<folder>]` — writes the binary's own copy of this skill to each detected platform's user-level skills dir (`~/.codex/skills/beardrive/SKILL.md` and friends), idempotently; bare `bdrive skill` shows the status table. Then the user asks that agent to set the folder up and it runs `init` + `hooks install` itself |
@@ -31,7 +31,10 @@ 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. The
same injection also lists up to 5 files that haven't changed in 90+ days while
files around them have, so the agent flags them as possibly out of date instead
of citing them with full confidence. See
[Set up with your agent](/start/setup/).
:::
+1 -1
View File
@@ -18,7 +18,7 @@ One binary, `bdrive` — the CLI, the sync daemon, and the web server.
| `bdrive forget <path>...` | Stop syncing a path and remove it from the hub. Adds the rule to `.bdriveignore` (which syncs) and prunes in one step. Local files are never touched, here or on teammates' devices |
| `bdrive url [path]` | Internal hub link for a file or folder — sign-in and membership required. `--sync` pushes first; no argument gives the project home. Computed locally |
| `bdrive share <file>` | Public URL for a synced file. `--list`, `--revoke`, `--expires` (the hub's Share dialog can also set an expiry on an existing link) |
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing |
| `bdrive sync [folder]` | Run one sync cycle now. Refuses folders this device never `init`ed and folders paused by `bdrive stop`. `--note <text>` stamps session context onto changes; `--note-ttl` (default 30m) bounds it. `--prune` also removes from the hub what `.bdriveignore` now excludes (files stay on disk everywhere). `--hook <label>` is agent-hook plumbing: it also hands the agent a watchlist of up to 5 files that haven't changed in 90+ days while their neighbors have |
| `bdrive hooks [install\|uninstall]` | Register turn-boundary sync hooks in each detected agent platform's user config — once per machine, covering every folder. Run automatically by `bdrive init`; idempotent; `--agent` overrides detection. `uninstall` removes only BearDrive's own hook entries |
| `bdrive skill [install]` | Install the `beardrive` skill into detected agent platforms so the agent can do setup itself. Run automatically by `bdrive init`; idempotent; `--agent` overrides detection |
| `bdrive hook-approve` | Hook plumbing: answers the beardrive plugin's `PreToolUse` hook, auto-approving bare `bdrive init\|login\|hooks\|status\|sync\|url` so setup costs no permission prompts. Anything with a shell operator is left to the normal prompt |