mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
fix(hooks): read heat captures grep matches and shell-command reads, not just Read
Field report: an agent session read a dozen-plus files via Bash (grep/cat/tail/find) and Grep, but read heat showed almost nothing — the read-log hook only matched the native Read tool. - matchers broadened per platform: claude Read|Grep|Bash, codex read_file|shell, gemini +search_file_content|run_shell_command, hermes read_file|grep|bash; plugin hooks.json matches Read|Grep|Bash - read-log is now tool-aware: shell events mine the command line for existing files it names (redirect targets and flags excluded), grep events mine the response for the files the matches came from (content lines and filename lists), and listing tools (Glob, ls) are deliberately ignored — seeing a file's name is not reading it - hooks install upgrades a registered hook's stale matcher in place, so re-running it after a binary upgrade rolls coverage out to existing projects instead of being skipped by the idempotency marker - docs: SKILL.md platform table + read-heat wording, install/init commands, README command table Note from the same report, verified not a bug: read-log resolves the mount via the folder's own .bdrive/config.json (config.ResolveMount), so a stale duplicate registry entry cannot swallow reads. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
co-authored by
Claude Fable 5
parent
4bb8a91f96
commit
476113ebdc
@@ -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