Skip to content

feat(hooks): turn-end receipt names files that never reached the hub (BEA-256) - #265

Open
ssowonny wants to merge 1 commit into
mainfrom
bea-256-ph-idea-before-the-agent-says-done-tell-it-which-files-never
Open

ssowonny wants to merge 1 commit into
mainfrom
bea-256-ph-idea-before-the-agent-says-done-tell-it-which-files-never

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • Agents used to say "done, wrote x.html 🔗" when the file had never left the laptop. Now a Claude Code Stop hook checks first.
  • If this session's files aren't on the hub, the agent is stopped once, with the file names and the reason: offline, the hub's refusal, paused, or reverted. When everything landed it prints nothing.
  • It fails open and never loops (stop_hook_active). A user's own bdrive sync . Stop hook is never overwritten or removed.
  • Known gap: files ignored by .bdriveignore are never journaled, so they're invisible to it. A read-only-folder revert usually happens in the daemon before Stop runs, so it's rarely reported.

Closes BEA-256.

flowchart LR
    A["Stop fires"] --> B{"stop_hook_active?"}
    B -- yes --> Z["exit 0, silent"]
    B -- no --> C["per mount: one Cycle under the volume flock<br/>(paused mount: DriftPaths only, never resumed)"]
    C --> D{"own ops past PushedOps<br/>with Session or note == this session?"}
    D -- none --> Z
    D -- some --> E["one JSON on stdout:<br/>decision: block, reason names the files"]
Loading

What it prints (from TestHookStopMultipleMounts):

BearDrive: not on the hub yet — projA/a.md (offline: Get "https://hub.example.com/api/p/p-aaaaaaaa/store/list?…": dial tcp: lookup hub.example.com: no such host); projB/b.md (offline: …). Retry bdrive sync, or tell the user these files are local only.

What can't break

  • Hook stdout is one JSON object or nothing. The tests capture the real os.Stdout, not a cobra buffer, so a stray Printf would fail them.
  • The guard stays pure shell. hookStopCommand is mountGuard() + bdrive sync . --hook-stop claude-code, and TestSec_Hooks_EveryHookCommandIsGuarded covers it.
  • The journal is only read. Apart from that, the Stop cycle is an ordinary Cycle under the flock, so it waits behind any in-flight async push. That wait is the point. The cost: if it runs past the 30s timeout, the turn ends with no receipt (fails open).
  • Paused mounts stay paused. No session is opened and nothing is enrolled. syncer.DriftPaths is a pure read, and the paused-mount test asserts mounts.json is byte-identical afterwards.

How it's built

  • cmd/bdrive/hookstop.go (new). It parses the Stop event and runs each target. Ops are matched on Op.Session == id or Op.Note == "<label> session <id>", because mid-turn writes come from the async push hook and daemon scans, which only carry the note. It dedupes by path (last op wins) and renders grouped by reason, capped at 20.
  • hooksync.go: the session-stamping block moved into stampHookSession, so the turn-start and turn-end hooks stamp identically. This is a pure refactor.
  • agenthooks: Stop is added to ourEvents. mergeJSONHooks gains a stopCmd slot, used by Claude only. Codex and Gemini pass "". Under Stop, "ours" is judged by --hook-stop alone, both on install convergence and in uninstall's ownHook.
  • syncer/drift.go: Drift now wraps a callback walker (walkDrift). DriftPaths returns the added and modified paths. Drift's counts are unchanged.
  • Docs: README (sync flag, hooks row, hooks prose), INSTALL_FOR_AGENTS.md §5, web/docs hooks page and CLI reference.

Tests run

  • go test ./...: all green. go vet ./...: clean.
  • New in cmd/bdrive/hookstop_test.go: active stop is silent and runs no cycle; offline blocks with b.md committed through the note-only push path; another session's and a human's ops aren't listed; pushed is silent and the op is stamped; refused push carries the hub's 403 text; paused mount; two mounts produce one object with projA/ and projB/ prefixes.
  • agenthooks: Claude Stop is present and not async, and Codex/Gemini have none. New TestInstallStopKeepsUsersOwnStopHook covers install twice plus uninstall. TestUninstallRemovesOnlyOurHooks covers our Stop.
  • syncer: TestDriftSeesUnscannedWork asserts DriftPaths.
  • No webapp change, so no e2e run or UI pass.

Architecture changes

architecture/cli-sync.md: Drift gains +DriftPaths; AgentHooks gains the turn-end: sync --hook-stop (claude) hook; new edge Commands --> Drift (the --hook-stop paused-mount path).

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Commands["<div style='text-align:left'><b>Commands</b><br/>cmd/bdrive</div>"]
    Drift["<div style='text-align:left'><b>Drift</b><br/>+Drift(folder, include, accepted, cache) added, modified, removed<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +DriftPaths(folder, include, accepted, cache) paths</span></div>"]
    walkFolder["walkFolder"]
    Filter["Filter"]
    AgentHooks["<div style='text-align:left'><b>AgentHooks</b><br/>Detect / Install / Uninstall / Registered<br/>ConfigPath = USER config<br/>turn-start: sync --hook<br/>post-edit: sync --note<br/>post-read: read-log<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ turn-end: sync --hook-stop (claude)</span></div>"]
    syncBlocked["<div style='text-align:left'><b>syncBlocked</b><br/>enrolled in mounts.json?<br/>volume not paused?</div>"]
    Drift -- "same predicate" --> walkFolder
    Drift -- "own fresh instance" --> Filter
    Commands -- "hooks install/uninstall" --> AgentHooks
    Commands -- "sync and read-log gate first" --> syncBlocked
    Commands -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ sync --hook-stop names a paused mount's unscanned paths</span>" --> Drift
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    linkStyle 4 stroke:#22c55e,stroke-width:2px
Loading

Build session

cd $(git worktree list | grep bea-256-ph-idea-before-the-agent-says-done-tell-it-which-files-never | awk '{print $1}') && claude --resume 5e1b208d-6815-4e02-a4d1-addbe9978f6c

(only works on the build machine)

🤖 Generated with Claude Code

…(BEA-256)

A Claude Code Stop hook now runs `bdrive sync . --hook-stop claude-code`:
one blocking cycle under the volume flock, then — only if this session's own
ops (matched on Op.Session or the session note) are still past PushedOps —
one {"decision":"block"} JSON naming them and why (offline, the hub's
refusal, paused, reverted). Fails open, and stop_hook_active stops a loop.

The Stop group is identified by --hook-stop, so a user's own
`bdrive sync .` Stop hook is neither converged over on install nor removed
on uninstall. syncer.DriftPaths reports a paused mount's unscanned files
without resuming it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant