diff --git a/CHANGELOG.md b/CHANGELOG.md index a2293a6..6c700af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index fbffab8..b7d6427 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docket/cli/context_cmd.py b/docket/cli/context_cmd.py index 5825a40..7007afb 100644 --- a/docket/cli/context_cmd.py +++ b/docket/cli/context_cmd.py @@ -9,6 +9,7 @@ import argparse import json +import os import subprocess import sys import time @@ -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()) @@ -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. diff --git a/hooks/guard_ledger.py b/hooks/guard_ledger.py index 606c61c..88f9110 100755 --- a/hooks/guard_ledger.py +++ b/hooks/guard_ledger.py @@ -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. """ @@ -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): diff --git a/tests/test_feature_hint.py b/tests/test_feature_hint.py index e076dbb..e45a681 100644 --- a/tests/test_feature_hint.py +++ b/tests/test_feature_hint.py @@ -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 @@ -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") diff --git a/tests/test_guard_ledger.py b/tests/test_guard_ledger.py index 0c40c1b..ff2ff36 100644 --- a/tests/test_guard_ledger.py +++ b/tests/test_guard_ledger.py @@ -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 @@ -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(