mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Merge pull request #20 from runbear-io/fix/read-heat-coverage
Read heat: capture grep matches and shell-command reads, not just the Read tool
This commit is contained in:
@@ -123,7 +123,7 @@ beardrive uses each provider's standard credential chain — nothing beardrive-s
|
||||
| `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 |
|
||||
| `bdrive hooks [install]` | Register turn-boundary sync hooks with detected agent platforms (Claude Code, Codex, Gemini CLI, Hermes) — pull each turn, push after edits, session-note stamping, agent-read tracking; 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; drained on the next sync. Registered by `bdrive hooks install` |
|
||||
| `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` |
|
||||
| `bdrive status [folder]` | Projects, daemon state, pending changes |
|
||||
| `bdrive log [folder] [-p path] [-n N]` | Change history: account, device, time, file |
|
||||
| `bdrive web [folder \| storage-root-url]` | Web server: viewer (rendered markdown, downloads, history), uploads, multi-project sync hub |
|
||||
|
||||
+184
-7
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -21,8 +23,12 @@ func readLogCmd() *cobra.Command {
|
||||
Use: "read-log [folder]",
|
||||
Short: "Record agent file reads from a hook event (JSON on stdin)",
|
||||
Long: `Record which project files an agent just read, from the hook event JSON
|
||||
piped on stdin (any platform's PostToolUse-style payload: file paths are
|
||||
found wherever they appear in the event).
|
||||
piped on stdin (any platform's PostToolUse-style payload). Coverage is
|
||||
tool-aware: native read tools report their file paths directly, grep-style
|
||||
search tools count the files their matches came from, and shell commands
|
||||
count the existing files named as arguments (a "cat notes.md" or
|
||||
"grep -n foo wiki/a.md" is a read). Listing tools (glob, ls) are ignored —
|
||||
seeing a file's name is not reading it.
|
||||
|
||||
Reads are queued locally in the volume store and drained to the hub on the
|
||||
next sync, where they show up as agent traffic in the project's read heat.
|
||||
@@ -39,7 +45,7 @@ to run it by hand.`,
|
||||
return nil // not a beardrive project: fast no-op
|
||||
}
|
||||
data, _ := io.ReadAll(io.LimitReader(cmd.InOrStdin(), 1<<20))
|
||||
paths := extractEventPaths(data)
|
||||
paths := extractEventPaths(data, folder)
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -83,14 +89,63 @@ var (
|
||||
eventPathListKeys = map[string]bool{"paths": true, "file_paths": true}
|
||||
)
|
||||
|
||||
// extractEventPaths pulls candidate file paths out of an arbitrary hook
|
||||
// event JSON. The hook only fires on read-tool matchers, so any path-shaped
|
||||
// field is a read; non-project paths are filtered by the caller.
|
||||
func extractEventPaths(data []byte) []string {
|
||||
// Tool families, matched on the lowercased tool name from the event. Shell
|
||||
// and search tools carry no read-path fields — their reads are mined from
|
||||
// the command line / the match results instead — and listing tools are
|
||||
// dropped entirely: seeing a file's name is not reading it. (A grep-style
|
||||
// event must NOT fall through to the generic key walk: its `path` field is
|
||||
// the search SCOPE, usually a directory.)
|
||||
var (
|
||||
shellTools = map[string]bool{"bash": true, "shell": true, "run_shell_command": true, "execute_command": true}
|
||||
matchTools = map[string]bool{"grep": true, "search_file_content": true, "search": true, "ripgrep": true}
|
||||
listTools = map[string]bool{"glob": true, "ls": true, "list_directory": true, "find_files": true}
|
||||
)
|
||||
|
||||
const maxMinedPaths = 200 // bound stat() work; the hook runs on every tool call
|
||||
|
||||
// extractEventPaths pulls the file paths an agent just read out of a hook
|
||||
// event JSON, dispatching on the tool that fired: read tools report their
|
||||
// paths in well-known fields, shell commands and search results are mined
|
||||
// heuristically (existing regular files only). Non-project paths are
|
||||
// filtered by the caller.
|
||||
func extractEventPaths(data []byte, folder string) []string {
|
||||
var root any
|
||||
if json.Unmarshal(data, &root) != nil {
|
||||
return nil
|
||||
}
|
||||
switch tool := eventToolName(root); {
|
||||
case listTools[tool]:
|
||||
return nil
|
||||
case shellTools[tool]:
|
||||
return statFiles(commandTokens(collectKeyStrings(root, "command")), folder)
|
||||
case matchTools[tool]:
|
||||
return statFiles(matchCandidates(root), folder)
|
||||
}
|
||||
return keyWalkPaths(root)
|
||||
}
|
||||
|
||||
// eventToolName finds the tool that fired, wherever the platform puts it
|
||||
// (Claude/Hermes tool_name, Gemini tool.name).
|
||||
func eventToolName(root any) string {
|
||||
m, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if s, ok := m["tool_name"].(string); ok {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
if t, ok := m["tool"].(map[string]any); ok {
|
||||
if s, ok := t["name"].(string); ok {
|
||||
return strings.ToLower(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// keyWalkPaths is the read-tool extraction: any path-shaped field anywhere
|
||||
// in the event is a read (no existence check — a just-read file can already
|
||||
// be gone by hook time).
|
||||
func keyWalkPaths(root any) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
add := func(s string) {
|
||||
@@ -129,3 +184,125 @@ func extractEventPaths(data []byte) []string {
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
// collectKeyStrings gathers every string value stored under the given key
|
||||
// anywhere in the event (e.g. all "command" fields of a shell tool call).
|
||||
func collectKeyStrings(root any, key string) []string {
|
||||
var out []string
|
||||
var walk func(v any)
|
||||
walk = func(v any) {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
for k, val := range t {
|
||||
if k == key {
|
||||
if s, ok := val.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
walk(val)
|
||||
}
|
||||
case []any:
|
||||
for _, it := range t {
|
||||
walk(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(root)
|
||||
return out
|
||||
}
|
||||
|
||||
var cmdSplitRe = regexp.MustCompile(`\|\||&&|[|;\n]`)
|
||||
|
||||
// commandTokens pulls read-candidate tokens out of shell command lines:
|
||||
// per pipeline segment, redirection targets are cut (an "echo x > f" is a
|
||||
// write, not a read), flags are dropped, quotes stripped. Which tokens are
|
||||
// real files is decided by statFiles.
|
||||
func commandTokens(commands []string) []string {
|
||||
var out []string
|
||||
for _, command := range commands {
|
||||
for _, seg := range cmdSplitRe.Split(command, -1) {
|
||||
if i := strings.IndexByte(seg, '>'); i >= 0 {
|
||||
seg = seg[:i]
|
||||
}
|
||||
for _, tok := range strings.Fields(seg) {
|
||||
tok = strings.Trim(tok, `"'`+"`")
|
||||
if tok == "" || strings.HasPrefix(tok, "-") {
|
||||
continue
|
||||
}
|
||||
out = append(out, tok)
|
||||
if len(out) >= maxMinedPaths {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// matchCandidates mines a search tool's result for the files the matches
|
||||
// came from: every string in the response, line by line, both whole ("a
|
||||
// filenames list") and up to the first colon ("path:12:matched text").
|
||||
func matchCandidates(root any) []string {
|
||||
m, ok := root.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, key := range []string{"tool_response", "tool_output", "response", "result", "output"} {
|
||||
var strs []string
|
||||
var walk func(v any)
|
||||
walk = func(v any) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
strs = append(strs, t)
|
||||
case map[string]any:
|
||||
for _, val := range t {
|
||||
walk(val)
|
||||
}
|
||||
case []any:
|
||||
for _, it := range t {
|
||||
walk(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(m[key])
|
||||
for _, s := range strs {
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
if i := strings.IndexByte(line, ':'); i > 0 {
|
||||
out = append(out, line[:i])
|
||||
}
|
||||
if len(out) >= maxMinedPaths {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// statFiles keeps the candidates that are existing regular files (absolute,
|
||||
// or relative to the mount folder) — the guard that turns heuristic tokens
|
||||
// into trustworthy reads.
|
||||
func statFiles(candidates []string, folder string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, c := range candidates {
|
||||
abs := c
|
||||
if !filepath.IsAbs(abs) {
|
||||
abs = filepath.Join(folder, c)
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
if fi, err := os.Stat(abs); err == nil && fi.Mode().IsRegular() {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -11,8 +12,21 @@ import (
|
||||
)
|
||||
|
||||
// Real hook payload shapes from the supported platforms — read-log must find
|
||||
// the file paths wherever each platform puts them.
|
||||
// the file paths wherever each platform puts them. Shell and search events
|
||||
// are mined heuristically, so the folder holds real files to stat against.
|
||||
func TestExtractEventPaths(t *testing.T) {
|
||||
folder := t.TempDir()
|
||||
for _, f := range []string{"wiki/a.md", "wiki/b.md", "notes.md", "out.md"} {
|
||||
p := filepath.Join(folder, f)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
abs := func(rel string) string { return filepath.Join(folder, rel) }
|
||||
|
||||
cases := map[string]struct {
|
||||
payload string
|
||||
want []string
|
||||
@@ -46,9 +60,37 @@ func TestExtractEventPaths(t *testing.T) {
|
||||
`plain text`,
|
||||
nil,
|
||||
},
|
||||
// Shell commands: existing files named as arguments count as reads;
|
||||
// flags, missing files, and redirect targets don't.
|
||||
"bash cat and grep": {
|
||||
`{"tool_name":"Bash","tool_input":{"command":"cat wiki/a.md && grep -n foo notes.md missing.md > out.md"}}`,
|
||||
[]string{"wiki/a.md", "notes.md"},
|
||||
},
|
||||
"bash pipeline with quotes": {
|
||||
`{"tool_name":"Bash","tool_input":{"command":"tail -20 'wiki/b.md' | head -5"}}`,
|
||||
[]string{"wiki/b.md"},
|
||||
},
|
||||
// Search tools: reads are the files the MATCHES came from, mined
|
||||
// from the response; the scope dir in tool_input.path must not leak.
|
||||
"grep content lines": {
|
||||
`{"tool_name":"Grep","tool_input":{"pattern":"foo","path":"` + folder + `"},
|
||||
"tool_response":{"content":"wiki/a.md:3:foo bar\nwiki/b.md:9:foo"}}`,
|
||||
[]string{"wiki/a.md", "wiki/b.md"},
|
||||
},
|
||||
"grep filenames list": {
|
||||
`{"tool_name":"Grep","tool_input":{"pattern":"foo","path":"` + folder + `"},
|
||||
"tool_response":{"filenames":["` + abs("notes.md") + `"]}}`,
|
||||
[]string{abs("notes.md")},
|
||||
},
|
||||
// Listing tools: seeing a file's name is not reading it.
|
||||
"glob ignored": {
|
||||
`{"tool_name":"Glob","tool_input":{"pattern":"**/*.md"},
|
||||
"tool_response":{"filenames":["` + abs("wiki/a.md") + `"]}}`,
|
||||
nil,
|
||||
},
|
||||
}
|
||||
for name, c := range cases {
|
||||
got := extractEventPaths([]byte(c.payload))
|
||||
got := extractEventPaths([]byte(c.payload), folder)
|
||||
if strings.Join(got, ",") != strings.Join(c.want, ",") {
|
||||
t.Errorf("%s: paths = %v, want %v", name, got, c.want)
|
||||
}
|
||||
|
||||
@@ -13,10 +13,15 @@
|
||||
//
|
||||
// The hook syncs the project and stamps changes with "<agent> session <id>"
|
||||
// (see `bdrive sync --note`), so hub history links every change to the agent
|
||||
// session that made it. A third hook on each platform's read-tool matcher
|
||||
// runs `bdrive read-log`, queueing agent file reads for the hub's read
|
||||
// heatmap (drained on the next sync — the hook itself never touches the
|
||||
// network). Hooks are fast no-ops outside bdrive projects.
|
||||
// session that made it. A third hook runs `bdrive read-log` on each
|
||||
// platform's read-shaped tools — native file reads, grep-style searches
|
||||
// (the files the matches came from), and shell commands (the existing files
|
||||
// they name) — queueing agent file reads for the hub's read heatmap
|
||||
// (drained on the next sync — the hook itself never touches the network).
|
||||
// Listing tools (glob, ls) are deliberately unmatched: seeing a file's name
|
||||
// is not reading it. Hooks are fast no-ops outside bdrive projects, and
|
||||
// reinstalling upgrades a registered hook's matcher in place when coverage
|
||||
// grows.
|
||||
package agenthooks
|
||||
|
||||
import (
|
||||
@@ -84,18 +89,22 @@ var platforms = map[string]platform{
|
||||
label: "claude-code",
|
||||
projectDir: ".claude",
|
||||
install: func(folder string) (string, bool, error) {
|
||||
// Reads happen through more than the Read tool: Grep consumes
|
||||
// the files its matches come from, and Bash reads whatever
|
||||
// files the command names (`read-log` mines both payloads).
|
||||
// Glob stays unmatched on purpose — listing names isn't reading.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".claude", "settings.json"),
|
||||
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "Read", "claude-code", 30, true)
|
||||
"UserPromptSubmit", "PostToolUse", "Write|Edit|MultiEdit", "Read|Grep|Bash", "claude-code", 30, true)
|
||||
},
|
||||
},
|
||||
"codex": {
|
||||
label: "codex",
|
||||
projectDir: ".codex",
|
||||
install: func(folder string) (string, bool, error) {
|
||||
// Codex reads mostly happen through shell commands, so the
|
||||
// read_file matcher is best-effort coverage.
|
||||
// Codex reads mostly happen through shell commands; read-log
|
||||
// mines the command line for the files it names.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".codex", "hooks.json"),
|
||||
"UserPromptSubmit", "PostToolUse", "apply_patch", "read_file", "codex", 30, false)
|
||||
"UserPromptSubmit", "PostToolUse", "apply_patch", "read_file|shell", "codex", 30, false)
|
||||
},
|
||||
note: "run /hooks inside Codex once to trust the project's .codex layer",
|
||||
},
|
||||
@@ -105,7 +114,8 @@ var platforms = map[string]platform{
|
||||
install: func(folder string) (string, bool, error) {
|
||||
// Gemini uses its own event names and millisecond timeouts.
|
||||
return mergeJSONHooks(filepath.Join(folder, ".gemini", "settings.json"),
|
||||
"BeforeAgent", "AfterTool", "write_file|replace|edit", "read_file|read_many_files", "gemini", 30000, false)
|
||||
"BeforeAgent", "AfterTool", "write_file|replace|edit",
|
||||
"read_file|read_many_files|search_file_content|run_shell_command", "gemini", 30000, false)
|
||||
},
|
||||
},
|
||||
"hermes": {
|
||||
@@ -214,16 +224,24 @@ func mergeJSONHooks(path, pullEvent, pushEvent, pushMatcher, readMatcher, label
|
||||
|
||||
changed := false
|
||||
for _, g := range []struct {
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
matcher string // non-empty: keep an already-registered group's matcher current
|
||||
}{
|
||||
{pullEvent, pull, marker},
|
||||
{pushEvent, push, marker},
|
||||
{pushEvent, read, readMarker},
|
||||
{pullEvent, pull, marker, ""},
|
||||
{pushEvent, push, marker, pushMatcher},
|
||||
{pushEvent, read, readMarker, readMatcher},
|
||||
} {
|
||||
arr, _ := hooks[g.event].([]any)
|
||||
if containsMarker(arr, g.marker) {
|
||||
if grp := findMarkerGroup(arr, g.marker); grp != nil {
|
||||
// Already registered — but upgrade a stale matcher in place so
|
||||
// coverage improvements reach existing projects on reinstall
|
||||
// (e.g. the read hook growing from "Read" to "Read|Grep|Bash").
|
||||
if g.matcher != "" && grp["matcher"] != g.matcher {
|
||||
grp["matcher"] = g.matcher
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
hooks[g.event] = append(arr, g.group)
|
||||
@@ -256,18 +274,23 @@ func installHermes(string) (string, bool, error) {
|
||||
}
|
||||
cmd := hookCommand("hermes")
|
||||
groups := []struct {
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
event string
|
||||
group map[string]any
|
||||
marker string
|
||||
matcher string
|
||||
}{
|
||||
{"pre_llm_call", map[string]any{"command": cmd, "timeout": 30}, marker},
|
||||
{"post_tool_call", map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30}, marker},
|
||||
{"post_tool_call", map[string]any{"matcher": "read_file", "command": readHookCommand(), "timeout": 30}, readMarker},
|
||||
{"pre_llm_call", map[string]any{"command": cmd, "timeout": 30}, marker, ""},
|
||||
{"post_tool_call", map[string]any{"matcher": "write_file|patch", "command": cmd, "timeout": 30}, marker, "write_file|patch"},
|
||||
{"post_tool_call", map[string]any{"matcher": "read_file|grep|bash", "command": readHookCommand(), "timeout": 30}, readMarker, "read_file|grep|bash"},
|
||||
}
|
||||
changed := false
|
||||
for _, g := range groups {
|
||||
arr, _ := hooks[g.event].([]any)
|
||||
if containsMarker(arr, g.marker) {
|
||||
if grp := findMarkerGroup(arr, g.marker); grp != nil {
|
||||
if g.matcher != "" && grp["matcher"] != g.matcher {
|
||||
grp["matcher"] = g.matcher
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
hooks[g.event] = append(arr, g.group)
|
||||
@@ -289,6 +312,18 @@ func containsMarker(v any, m string) bool {
|
||||
return err == nil && strings.Contains(string(data), m)
|
||||
}
|
||||
|
||||
// findMarkerGroup returns the hook group in the array that carries the
|
||||
// marker (so its matcher can be upgraded in place), or nil.
|
||||
func findMarkerGroup(arr []any, m string) map[string]any {
|
||||
for _, it := range arr {
|
||||
grp, ok := it.(map[string]any)
|
||||
if ok && containsMarker(grp, m) {
|
||||
return grp
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeConfig(path string, marshal func() ([]byte, error)) error {
|
||||
data, err := marshal()
|
||||
if err != nil {
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestInstallJSONPlatforms(t *testing.T) {
|
||||
t.Fatal("claude push hook should be async")
|
||||
}
|
||||
// …and the read hook, on its own matcher.
|
||||
if !strings.Contains(string(raw), "bdrive read-log") || !strings.Contains(string(raw), `"matcher":"Read"`) {
|
||||
if !strings.Contains(string(raw), "bdrive read-log") || !strings.Contains(string(raw), `"matcher":"Read|Grep|Bash"`) {
|
||||
t.Fatalf("claude read hook missing: %s", raw)
|
||||
}
|
||||
|
||||
@@ -88,13 +88,13 @@ func TestInstallJSONPlatforms(t *testing.T) {
|
||||
if strings.Contains(string(cx), "async") {
|
||||
t.Fatal("codex should not get the claude-only async field")
|
||||
}
|
||||
if !strings.Contains(string(cx), "bdrive read-log") || !strings.Contains(string(cx), `"matcher":"read_file"`) {
|
||||
if !strings.Contains(string(cx), "bdrive read-log") || !strings.Contains(string(cx), `"matcher":"read_file|shell"`) {
|
||||
t.Fatalf("codex read hook missing: %s", cx)
|
||||
}
|
||||
|
||||
// Gemini: its own event names and ms timeout.
|
||||
gm, _ := json.Marshal(readJSON(t, filepath.Join(folder, ".gemini", "settings.json")))
|
||||
for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000", "bdrive read-log", "read_file|read_many_files"} {
|
||||
for _, want := range []string{"BeforeAgent", "AfterTool", "gemini session $s", "30000", "bdrive read-log", "read_file|read_many_files|search_file_content|run_shell_command"} {
|
||||
if !strings.Contains(string(gm), want) {
|
||||
t.Fatalf("gemini hooks missing %q: %s", want, gm)
|
||||
}
|
||||
@@ -172,6 +172,47 @@ func TestInstallUpgradesSyncOnlyConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A config registered when the read hook only matched "Read" gets its
|
||||
// matcher upgraded in place on re-install — coverage improvements must
|
||||
// reach existing projects without duplicating groups.
|
||||
func TestInstallUpgradesReadMatcher(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
folder := t.TempDir()
|
||||
old := `{"hooks":{
|
||||
"UserPromptSubmit":[{"hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]}],
|
||||
"PostToolUse":[
|
||||
{"matcher":"Write|Edit|MultiEdit","hooks":[{"type":"command","command":"sh -c 'bdrive sync .'"}]},
|
||||
{"matcher":"Read","hooks":[{"type":"command","command":"sh -c 'bdrive read-log .'"}]}]}}`
|
||||
os.MkdirAll(filepath.Join(folder, ".claude"), 0o755)
|
||||
os.WriteFile(filepath.Join(folder, ".claude", "settings.json"), []byte(old), 0o644)
|
||||
|
||||
results, err := Install(folder, []string{"claude"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !results[0].Changed {
|
||||
t.Fatal("matcher upgrade reported unchanged")
|
||||
}
|
||||
cfg := readJSON(t, filepath.Join(folder, ".claude", "settings.json"))
|
||||
hooks := cfg["hooks"].(map[string]any)
|
||||
if got := len(hooks["PostToolUse"].([]any)); got != 2 {
|
||||
t.Fatalf("PostToolUse groups = %d, want push + read (no duplicates)", got)
|
||||
}
|
||||
raw, _ := json.Marshal(cfg)
|
||||
if !strings.Contains(string(raw), `"matcher":"Read|Grep|Bash"`) {
|
||||
t.Fatalf("read matcher not upgraded: %s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"matcher":"Read"`+",") {
|
||||
t.Fatalf("old matcher left behind: %s", raw)
|
||||
}
|
||||
|
||||
// And it settles: the next install is a no-op.
|
||||
results, _ = Install(folder, []string{"claude"})
|
||||
if results[0].Changed {
|
||||
t.Fatal("re-install after upgrade reported a change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallHermesYAML(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
|
||||
@@ -58,9 +58,10 @@ Follow these steps:
|
||||
Hermes — by their config dirs in the project or home) and idempotently
|
||||
merges beardrive's sync hooks into each platform's own hook config, so
|
||||
files pull at every turn start, push after edits, every change is
|
||||
stamped with the agent session that made it, and agent file reads feed
|
||||
the hub's read heatmap (queued locally by `bdrive read-log`, reported
|
||||
on the next sync). Tell the user which platforms got hooks; if Codex is
|
||||
stamped with the agent session that made it, and agent file reads — native
|
||||
reads, grep matches, and files named in shell commands — feed the hub's
|
||||
read heatmap (queued locally by `bdrive read-log`, reported on the next
|
||||
sync). Tell the user which platforms got hooks; if Codex is
|
||||
among them, mention they must run `/hooks` inside Codex once to trust
|
||||
the project's `.codex` layer.
|
||||
|
||||
|
||||
@@ -97,10 +97,11 @@ team's latest files), push right after edits (artifacts land on the server
|
||||
seconds after they're created — daemon or no daemon), and stamp every
|
||||
change with the agent session that made it (`bdrive sync --note "<agent>
|
||||
session <id>"` — visible in `bdrive log` and the hub's history views).
|
||||
A third hook on each platform's read tool (`bdrive read-log`) queues which
|
||||
files the agent read, so the hub's read heatmap can show admins what the
|
||||
team's agents actually consume — reads are reported on the next sync,
|
||||
never from the hook itself. They are fast no-ops in folders without
|
||||
A third hook (`bdrive read-log`) queues which files the agent read — via
|
||||
the native read tool, grep-style searches (the files the matches came
|
||||
from), or shell commands that name project files — so the hub's read
|
||||
heatmap can show admins what the team's agents actually consume. Reads
|
||||
are reported on the next sync, never from the hook itself. They are fast no-ops in folders without
|
||||
`.bdrive/`.
|
||||
|
||||
Tell the user which platforms got hooks (`bdrive hooks` shows the status
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Read",
|
||||
"matcher": "Read|Grep|Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Queue the file reads from a Read tool call for the hub's read heatmap.
|
||||
# Queue agent file reads for the hub's read heatmap: Read tool calls, the
|
||||
# files Grep matches came from, and the files a Bash command names.
|
||||
# `bdrive read-log` parses the hook's stdin JSON itself and only appends to
|
||||
# a local spool (drained on the next sync) — no network, no locking, so this
|
||||
# is safe to run on every Read in every project. Fast no-op outside
|
||||
# beardrive projects.
|
||||
# is safe to run on every matched tool call in every project. Fast no-op
|
||||
# outside beardrive projects.
|
||||
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
|
||||
[ -d .bdrive ] || exit 0
|
||||
command -v bdrive >/dev/null 2>&1 || exit 0
|
||||
|
||||
@@ -18,7 +18,7 @@ Use this skill whenever the user is working with the `bdrive` CLI: initializing
|
||||
| Stop syncing | `bdrive stop [<folder>]` (`--forget` also unregisters) |
|
||||
| One sync cycle now | `bdrive sync [<folder>]` |
|
||||
| Register agent sync hooks (Claude Code, Codex, Gemini CLI, Hermes) | `bdrive hooks install [<folder>]` — auto-detects the platforms in use and merges pull/push/session-note/read-tracking hooks into each one's own hook config, idempotently; bare `bdrive hooks` shows the status table |
|
||||
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally; drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
|
||||
| Record agent file reads (hook plumbing) | `bdrive read-log [<folder>]` — parses a hook event JSON from stdin and queues in-project reads locally (native reads, grep matches, and files named in shell commands); drained to the hub on the next sync as agent traffic in the read heatmap. Registered automatically by `bdrive hooks install`; rarely run by hand |
|
||||
| Mounts + daemon + pending state | `bdrive status [<folder>]` |
|
||||
| Change history | `bdrive log [<folder>] [-p path] [-n N]` |
|
||||
| This device's identity | `bdrive whoami` |
|
||||
@@ -133,18 +133,24 @@ agent platform it detects (by config dir, in the project or home):
|
||||
|
||||
| Platform | Config it writes | Pull / push / read events |
|
||||
|---|---|---|
|
||||
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) / `PostToolUse` (Read) |
|
||||
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) / `PostToolUse` (read_file, best-effort) — user must `/hooks`-trust the layer once |
|
||||
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) / `AfterTool` (read_file\|read_many_files) |
|
||||
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) / `post_tool_call` (read_file) |
|
||||
| Claude Code (& Cowork) | `<project>/.claude/settings.json` | `UserPromptSubmit` / `PostToolUse` (Write\|Edit) / `PostToolUse` (Read\|Grep\|Bash) |
|
||||
| Codex (ChatGPT) | `<project>/.codex/hooks.json` | `UserPromptSubmit` / `PostToolUse` (apply_patch) / `PostToolUse` (read_file\|shell, best-effort) — user must `/hooks`-trust the layer once |
|
||||
| Gemini CLI | `<project>/.gemini/settings.json` | `BeforeAgent` / `AfterTool` (write_file\|replace) / `AfterTool` (read_file\|read_many_files\|search\|shell) |
|
||||
| Hermes | `~/.hermes/config.yaml` (per-user) | `pre_llm_call` / `post_tool_call` (write_file\|patch) / `post_tool_call` (read_file\|grep\|bash) |
|
||||
|
||||
Every platform pipes hook JSON with a `session_id`, so one hook command
|
||||
serves all four: it syncs the project (fast no-op outside bdrive folders)
|
||||
and stamps changes with `<agent> session <id>`. The read hook runs `bdrive
|
||||
read-log`, which queues the read locally (no network) for the hub's read
|
||||
heatmap. Merging is idempotent and preserves existing hooks — each hook
|
||||
carries its own marker, so configs from before the read hook gain just the
|
||||
missing group on re-install; `--agent claude,codex,gemini,hermes` overrides
|
||||
heatmap — coverage is tool-aware: native read tools report their paths,
|
||||
grep-style searches count the files their matches came from, and shell
|
||||
commands count the existing files they name (`cat notes.md`, `tail wiki/log.md`);
|
||||
listing tools (glob, ls) are deliberately excluded — seeing a name isn't
|
||||
reading. Merging is idempotent and preserves existing hooks — each hook
|
||||
carries its own marker, configs from before the read hook gain just the
|
||||
missing group on re-install, and a registered hook's matcher is upgraded
|
||||
in place when coverage grows (re-run `bdrive hooks install` after upgrading
|
||||
the binary); `--agent claude,codex,gemini,hermes` overrides
|
||||
detection; bare `bdrive hooks` prints the detection/registration table.
|
||||
Project-level configs ride the repo, so hooks reach the whole team.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user