mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Field report follow-up: an agent with a stale skill copy couldn't find the gated URL after creating a file. Instructions rot; hook output is computed fresh from the binary every turn. Claude Code's turn-start pull hook now runs 'bdrive sync --hook claude-code', which: - pulls as before, and stamps the session note from the event JSON (replacing the sh/sed pipeline for the pull leg) - emits the project's gated-link formula as UserPromptSubmit additionalContext: whenever the agent mentions a synced file path in prose, it appends the hub link on an emoji — `<path>` [🔗](<url>) — path plain (it's the local path), hyperlink on the emoji only; code blocks stay plain; bdrive share stays explicit-opt-in-public Blind-tested: an agent given only the injected context decorated every path mention correctly, kept the code-block command plain, and checked files were synced before linking. - hooks install now CONVERGES marker-identified groups to the current shape (command/matcher/flags), so improvements reach existing projects on reinstall instead of being frozen by the idempotency marker; hermes same - plugin: UserPromptSubmit → beardrive-pull.sh (stdout passes through); version 0.3.0 - SKILL 'Share what you make' generalized to 'Link what you mention' (URL formula documented for non-Claude platforms); install.md pointer template updated Never fails the turn: every error path in --hook mode is a silent successful exit; offline still emits (links serve online teammates). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
100 lines
2.9 KiB
Go
100 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/runbear-io/beardrive/internal/config"
|
|
"github.com/runbear-io/beardrive/internal/store"
|
|
)
|
|
|
|
// `bdrive sync --hook` must emit the gated-link formula as Claude Code
|
|
// hook JSON, stamp the session note, and stay a silent no-op everywhere
|
|
// else — a hook must never fail the turn.
|
|
func TestSyncHookMode(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", // unreachable: cycle degrades offline, formula still valid
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
c := syncCmd()
|
|
var out bytes.Buffer
|
|
c.SetOut(&out)
|
|
c.SetIn(strings.NewReader(`{"session_id":"sess-42","prompt":"hello"}`))
|
|
c.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c.Execute(); err != nil {
|
|
t.Fatalf("hook mode must never fail: %v", err)
|
|
}
|
|
got := out.String()
|
|
for _, want := range []string{
|
|
`"hookSpecificOutput"`,
|
|
`"hookEventName":"UserPromptSubmit"`,
|
|
"https://hub.example.com/p-12345678", // base URL: remote minus /p
|
|
"[🔗](", // the emoji-link convention
|
|
"code blocks", // paths in code blocks stay plain
|
|
"PUBLIC", // bdrive share stays opt-in
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("hook output missing %q:\n%s", want, got)
|
|
}
|
|
}
|
|
|
|
// The session note was stamped for the daemon's follow-up scans.
|
|
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("note = %q, want the stamped session", note)
|
|
}
|
|
}
|
|
|
|
func TestSyncHookModeNoOps(t *testing.T) {
|
|
t.Setenv("BDRIVE_HOME", t.TempDir())
|
|
|
|
// Not a mount: silent success, no output.
|
|
c := syncCmd()
|
|
var out bytes.Buffer
|
|
c.SetOut(&out)
|
|
c.SetIn(strings.NewReader(`{"session_id":"x"}`))
|
|
c.SetArgs([]string{t.TempDir(), "--hook", "claude-code"})
|
|
if err := c.Execute(); err != nil {
|
|
t.Fatalf("non-mount must be a silent no-op: %v", err)
|
|
}
|
|
if out.Len() != 0 {
|
|
t.Fatalf("non-mount emitted output: %s", out.String())
|
|
}
|
|
|
|
// Garbage stdin: still sync, still emit, never fail.
|
|
folder := t.TempDir()
|
|
folder, _ = filepath.EvalSymlinks(folder)
|
|
if _, err := config.SaveProject(folder, config.Project{
|
|
Volume: "wiki", Remote: "https://hub.example.com/p/p-12345678",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c2 := syncCmd()
|
|
out.Reset()
|
|
c2.SetOut(&out)
|
|
c2.SetIn(strings.NewReader("not json at all"))
|
|
c2.SetArgs([]string{folder, "--hook", "claude-code"})
|
|
if err := c2.Execute(); err != nil {
|
|
t.Fatalf("garbage stdin must not fail: %v", err)
|
|
}
|
|
if !strings.Contains(out.String(), `"hookSpecificOutput"`) {
|
|
t.Fatalf("formula not emitted on garbage stdin: %s", out.String())
|
|
}
|
|
}
|