Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `DOCKET_HOOKS=off` silences the pre-edit hook and the session briefing's feature, update and merge notices. The briefing still prints the ledger.

## [0.30.0] - 2026-10-21

### Changed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ docket context --query "database" --file src/db.py --max-chars 4000
Docket measures budget in **characters** instead of tokens. It keeps whole
records, reports what was omitted, and includes a command to retrieve more.

Set `DOCKET_HOOKS=off` in the environment to let an agent work with docket unimpeded: the edit hook stops asking about ledger writes and adding guidance, and the session briefing drops its feature, update and merge notices but still prints the ledger.

## Learn more

- [Why Docket?](docs/why.md): what the structure buys you and when simpler notes are enough.
Expand Down
11 changes: 10 additions & 1 deletion docket/cli/context_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import argparse
import json
import os
import subprocess
import sys
import time
Expand Down Expand Up @@ -77,7 +78,14 @@ def _print_context(text: str, args: argparse.Namespace, notice: str | None = Non
)


def hooks_off() -> bool:
"""DOCKET_HOOKS=off silences the nags; the ledger briefing still prints."""
return os.environ.get("DOCKET_HOOKS") == "off"


def _no_feature_hint() -> str:
if hooks_off():
return ""
# Uncapped: a capped list could be filled by .docket/ paths and hide a
# change elsewhere.
dirty = any(not p.startswith(".docket/") for p in uncommitted_paths())
Expand Down Expand Up @@ -172,7 +180,8 @@ def cmd_context(args: argparse.Namespace) -> int:
except ConfigError as exc:
print(f"docket: {exc}", file=sys.stderr)
return 1
line = "\n".join(filter(None, (update_line(), _merge_notice()))) or None
notices = () if hooks_off() else (update_line(), _merge_notice())
line = "\n".join(filter(None, notices)) or None
# Validate against the loaded minimum, not a literal. A config that raises
# budget.minimum would otherwise let a too-small value through and surface
# as an uncaught ValueError from the renderer.
Expand Down
5 changes: 5 additions & 0 deletions hooks/guard_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
the ledger, a variable holding the path or other indirection can still write
it; the hook prevents accidents and does not enforce append-only.

DOCKET_HOOKS=off is the human's opt-out: the hook exits silently for every
call, ledger writes included.

A hook that fails open costs a session nothing and a hook that fails closed
costs it everything.
"""
Expand Down Expand Up @@ -515,6 +518,8 @@ def feature_nudge(payload: dict) -> str:


def main() -> int:
if os.environ.get("DOCKET_HOOKS") == "off":
return 0
try:
payload = json.load(sys.stdin)
except (json.JSONDecodeError, ValueError):
Expand Down
8 changes: 8 additions & 0 deletions tests/test_feature_hint.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock

sys.path.insert(0, str(Path(__file__).parent.parent))
# Someone running with the hooks off must still get the default behavior under test.
os.environ.pop("DOCKET_HOOKS", None)
from docket.cli import main
from docket.cli.autoscope import uncommitted_paths
from docket.cli.context_cmd import _NO_FEATURE_HINT, _feature_block
Expand Down Expand Up @@ -40,6 +43,11 @@ def test_a_dirty_tree_with_no_feature_store_gets_the_hint(self):
(self.root / "work.txt").write_text("x\n", encoding="utf-8")
self.assertEqual(self.block(), _NO_FEATURE_HINT)

def test_hooks_off_silences_the_hint(self):
(self.root / "work.txt").write_text("x\n", encoding="utf-8")
with mock.patch.dict(os.environ, {"DOCKET_HOOKS": "off"}):
self.assertEqual(self.block(), "")

def test_a_staged_change_gets_the_hint(self):
(self.root / "work.txt").write_text("x\n", encoding="utf-8")
git(self.root, "add", "work.txt")
Expand Down
30 changes: 30 additions & 0 deletions tests/test_guard_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import unittest
from pathlib import Path

# Someone running with the hooks off must still get the default behavior under test.
os.environ.pop("DOCKET_HOOKS", None)

GUARD = Path(__file__).parent.parent / "hooks" / "guard_ledger.py"
CWD = "/home/user/project"
# Every variable the hook reads a session id from; a test run inside Claude
Expand Down Expand Up @@ -479,6 +482,33 @@ def test_it_joins_the_governing_records_on_the_same_edit(self):
self.assertIn("feature start", context)


class HooksOffTests(unittest.TestCase):
def test_off_silences_a_ledger_write_and_the_nudge(self):
with tempfile.TemporaryDirectory() as tmp:
env = {
**os.environ,
"DOCKET_HOOKS": "off",
"XDG_STATE_HOME": tmp,
"CLAUDE_SESSION_ID": "s",
}
calls = [("Edit", {"file_path": ".docket/ledger.jsonl"})]
calls += [("Edit", {"file_path": f"a/{n}.py"}) for n in ("one", "two", "three")]
for tool, tool_input in calls:
payload = json.dumps({"tool_name": tool, "tool_input": tool_input, "cwd": tmp})
done = subprocess.run(
[sys.executable, str(GUARD)],
input=payload,
capture_output=True,
text=True,
env=env,
)
self.assertEqual((done.returncode, done.stdout), (0, ""))

def test_any_other_value_leaves_the_guard_on(self):
env = {**os.environ, "DOCKET_HOOKS": "on"}
self.assertEqual(decide("Edit", {"file_path": ".docket/ledger.jsonl"}, env=env), "ask")


class RobustnessTests(unittest.TestCase):
def test_malformed_input_allows(self):
result = subprocess.run(
Expand Down
Loading