From fab3f5eaf1197d94daa84d92b2b5ee4ca73f5b30 Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 22:53:43 +0300 Subject: [PATCH 1/3] feat(context): point agents at feature tracking No project outside this repository had started a feature: the docket-feature description named no trigger, the docket skill never mentioned features, and the briefing said nothing until one existed. The docket-feature description now names its triggers, the docket skill points to it, and docket context prints a one-line hint when no feature is open and the working tree has uncommitted changes outside .docket/. The global-ledger guard test no longer assumes ~/.claude, so it passes when CLAUDE_CONFIG_DIR points elsewhere, and a second test covers that case. Signed-off-by: NovusEdge --- CHANGELOG.md | 8 +++ docket/cli/autoscope.py | 39 +++++++++--- docket/cli/context_cmd.py | 27 ++++++-- docs/features.md | 2 + skills/docket-feature/SKILL.md | 7 ++- skills/docket/SKILL.md | 1 + tests/test_feature_hint.py | 111 +++++++++++++++++++++++++++++++++ tests/test_guard_ledger.py | 16 +++-- 8 files changed, 190 insertions(+), 21 deletions(-) create mode 100644 tests/test_feature_hint.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c88eb6..8dda2d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `docket context` prints a one-line hint above the records when no feature is open and the working tree has uncommitted changes outside `.docket/`. The hint tells the agent to load the docket-feature skill if the work continues past the session. A clean tree, changes only under `.docket/`, or any git failure prints nothing. + +### Changed + +- The docket-feature skill description now names its triggers (work that outlasts the session or goes to another agent, resuming such work, closing it), and the docket skill points to it. + ## [0.26.0] - 2026-10-07 ### Changed diff --git a/docket/cli/autoscope.py b/docket/cli/autoscope.py index 8b3c844..059a558 100644 --- a/docket/cli/autoscope.py +++ b/docket/cli/autoscope.py @@ -11,8 +11,18 @@ _AUTO_SCOPE_LIMIT = 50 -def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: - """Changed and untracked paths, as the working tree's proxy for a task. +def uncommitted_paths() -> tuple[str, ...]: + """Every modified, staged or untracked path, uncapped, in repository-root form. + + Empty outside a repository or when git fails. No last-commit fallback, so + a clean tree is empty. + """ + found = _uncommitted() + return tuple(found[1]) if found else () + + +def _uncommitted() -> tuple[str, list[str]] | None: + """The repository root and the interleaved uncommitted paths, or None on any git failure. Both commands run from the repository root. git ls-files lists only what sits under the current directory, and git diff prints root-relative paths, @@ -25,9 +35,9 @@ def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=3 ) except (OSError, subprocess.SubprocessError): - return () + return None if top.returncode != 0: - return () + return None root = top.stdout.strip() groups: list[list[str]] = [] @@ -42,7 +52,7 @@ def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: # docket context in that repository. done = subprocess.run(command, cwd=root, capture_output=True, timeout=3) except (OSError, subprocess.SubprocessError): - return () + return None # A repository with no commits has no HEAD, so the diff fails while # ls-files still reports every untracked file. Skip the failed command # and keep what the other one found. @@ -58,12 +68,23 @@ def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: # Interleave the two sources. Taking the head of a concatenated list let a # branch with more than `limit` modified files starve every untracked one, # which is the work in progress. - paths: list[str] = [] + paths: dict[str, None] = {} for index in range(max((len(group) for group in groups), default=0)): for group in groups: - if index < len(group) and group[index] not in paths: - paths.append(group[index]) + if index < len(group): + paths.setdefault(group[index]) + return root, list(paths) + +def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: + """Changed and untracked paths, as the working tree's proxy for a task. + + A clean tree falls back to the files of the last commit. + """ + found = _uncommitted() + if found is None: + return () + root, paths = found if not paths: # A clean tree says nothing about the task. The last commit does, and it # is the likeliest starting point for the next piece of work. --root @@ -99,4 +120,4 @@ def auto_scope_files(limit: int = _AUTO_SCOPE_LIMIT) -> tuple[str, ...]: return tuple(paths[:limit]) -__all__ = ["auto_scope_files"] +__all__ = ["auto_scope_files", "uncommitted_paths"] diff --git a/docket/cli/context_cmd.py b/docket/cli/context_cmd.py index 4f0571e..ef075b5 100644 --- a/docket/cli/context_cmd.py +++ b/docket/cli/context_cmd.py @@ -14,7 +14,7 @@ import time from docket import ROOT, env, version -from docket.cli.autoscope import auto_scope_files +from docket.cli.autoscope import auto_scope_files, uncommitted_paths from docket.context_model import positions from docket.env import read from docket.ledger import SCHEMA, LedgerError, SchemaTooOld, project @@ -70,12 +70,29 @@ def _print_context(text: str, args: argparse.Namespace, notice: str | None = Non return 0 +_NO_FEATURE_HINT = ( + "# feature: none open, and the working tree has uncommitted changes outside .docket/. " + "If that work continues past this session, load the docket-feature skill and start a feature." +) + + +def _no_feature_hint() -> str: + # 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()) + return _NO_FEATURE_HINT if dirty else "" + + def _feature_block(root, entries=None) -> str: - """The active feature's brief, or an empty string when nothing applies. + """The active feature's brief, or the no-feature hint, or an empty string. + + With no feature store or no open feature, the result is a one-line hint + when git reports uncommitted changes outside .docket/, and empty + otherwise. An open feature on any branch suppresses the hint. This runs on every session start through the hook. A repository with no feature store, no git, or a store that will not read must still get a - briefing, so every failure here degrades to no header. + briefing, so every failure here degrades to no header and no hint. ``entries`` takes the projected ledger the caller already holds. Reading and projecting it again here doubled that work on every session start. @@ -85,14 +102,14 @@ def _feature_block(root, entries=None) -> str: try: path = env.features_path() if not path.exists(): - return "" + return _no_feature_hint() current = feature_project.project(features.read(path)) branch = env.branch(root) open_now = [f for f in current if f["state"] not in features.TERMINAL_STATES] on_branch = [f for f in open_now if f["branch"] == branch] if branch else [] candidates = on_branch or open_now if not candidates: - return "" + return _no_feature_hint() feature = candidates[-1] if entries is None: entries = project(read(env.ledger_path()), validated=True) diff --git a/docs/features.md b/docs/features.md index fa8d6dc..e0800e6 100644 --- a/docs/features.md +++ b/docs/features.md @@ -170,6 +170,8 @@ budget is available for records. If the repository has no feature store or no Git metadata, or the feature store cannot be read, the briefing omits the header and continues. +When no feature is open on any branch, or the repository has no feature store, and `git` reports a modified, staged or untracked path outside `.docket/` (ignored files do not count), the briefing prints one hint line in the header's place: `# feature: none open, and the working tree has uncommitted changes outside .docket/. If that work continues past this session, load the docket-feature skill and start a feature.` A clean tree, or changes only under `.docket/`, prints nothing. Any git failure also prints nothing. + ## Archival Run `docket feature gc` to move closed features' events into diff --git a/skills/docket-feature/SKILL.md b/skills/docket-feature/SKILL.md index baf8467..77afc9b 100644 --- a/skills/docket-feature/SKILL.md +++ b/skills/docket-feature/SKILL.md @@ -1,6 +1,6 @@ --- name: docket-feature -description: Track a piece of work in flight against the ledger, from start to done, and know when a feature is worth declaring. +description: Use when starting work that will outlast this session or go to another agent, when resuming work an earlier session left uncommitted or unfinished, when the briefing shows a feature or a feature hint, or before merging, squashing or rebasing a branch that carries a feature. --- # docket-feature @@ -17,6 +17,11 @@ is worth declaring: a change that touches several files, crosses a module boundary, or another agent might pick up later. Do not start one for a one-line fix or a change you will finish and commit in this turn. +The session briefing opens with a `# feature: none open` line when the working +tree carries uncommitted changes and no feature is open. Those changes came +from an earlier session. Start a feature for them if the work continues past +this one; commit a leftover edit instead if it is already finished. + ## Starting work ```sh diff --git a/skills/docket/SKILL.md b/skills/docket/SKILL.md index e2c960b..814ad10 100644 --- a/skills/docket/SKILL.md +++ b/skills/docket/SKILL.md @@ -128,6 +128,7 @@ Evidence is recorder-supplied provenance; docket does not check it. Re-run `dock ## Elsewhere +- Work that outlasts this session, or that another agent will pick up: load the docket-feature skill before starting or resuming it. - Graph export and `graph --web`, for a person to read: [reading your ledger](../../docs/reading.md#export-the-graph). - Ledger location, `docket init`, `DOCKET_HOME`: [environment](../../docs/environment.md). - A ledger older than this docket needs `docket migrate`; add `--rewrite FILE...` for tracked, clean files that cite ledger IDs, and never guess record types from prose. After a migration an old ID can name a different record: `docket list --where was:ID` finds the record that carried it. [Migrating](../../docs/ledger.md#migrating-to-schema-3). diff --git a/tests/test_feature_hint.py b/tests/test_feature_hint.py new file mode 100644 index 0000000..df97b61 --- /dev/null +++ b/tests/test_feature_hint.py @@ -0,0 +1,111 @@ +import io +import os +import subprocess +import sys +import tempfile +import unittest +from contextlib import redirect_stdout +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +from docket.cli import main +from docket.cli.autoscope import uncommitted_paths +from docket.cli.context_cmd import _NO_FEATURE_HINT, _feature_block + + +def git(root, *args): + subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True) + + +class NoFeatureHintTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name).resolve() + git(self.root, "init", "-b", "main") + git(self.root, "config", "user.email", "t@example.test") + git(self.root, "config", "user.name", "t") + (self.root / ".docket").mkdir() + (self.root / ".docket" / "ledger.jsonl").write_text("", encoding="utf-8") + self.cwd = Path.cwd() + os.chdir(self.root) + + def tearDown(self): + os.chdir(self.cwd) + self.tmp.cleanup() + + def block(self): + return _feature_block(self.root, []) + + 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_a_staged_change_gets_the_hint(self): + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + git(self.root, "add", "work.txt") + git(self.root, "commit", "-m", "base") + (self.root / "work.txt").write_text("y\n", encoding="utf-8") + git(self.root, "add", "work.txt") + self.assertEqual(self.block(), _NO_FEATURE_HINT) + + def test_a_gitignored_file_gets_no_hint(self): + (self.root / ".gitignore").write_text("out/\n", encoding="utf-8") + git(self.root, "add", ".gitignore") + git(self.root, "commit", "-m", "base") + (self.root / "out").mkdir() + (self.root / "out" / "build.bin").write_text("x\n", encoding="utf-8") + self.assertEqual(self.block(), "") + + def test_changes_only_under_docket_get_no_hint(self): + self.assertEqual(self.block(), "") + + def test_a_clean_tree_gets_no_hint(self): + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + git(self.root, "add", "-A") + git(self.root, "commit", "-m", "base") + self.assertEqual(self.block(), "") + + def test_many_docket_paths_cannot_hide_a_change_elsewhere(self): + (self.root / ".docket" / "archive").mkdir() + for i in range(60): + (self.root / ".docket" / "archive" / f"a{i}.jsonl").write_text("", encoding="utf-8") + (self.root / "zzz.txt").write_text("x\n", encoding="utf-8") + self.assertEqual(self.block(), _NO_FEATURE_HINT) + self.assertGreater(len(uncommitted_paths()), 50) + + def test_an_open_feature_prints_its_brief_and_no_hint(self): + with redirect_stdout(io.StringIO()): + code = main(["feature", "start", "one", "--text", "do the thing", "--path", "a/**"]) + self.assertEqual(code, 0) + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + block = self.block() + self.assertIn("one", block) + self.assertNotIn("none open", block) + + def test_a_paused_feature_counts_as_open(self): + with redirect_stdout(io.StringIO()): + main(["feature", "start", "one", "--text", "t", "--path", "a/**"]) + main(["feature", "amend", "one", "--status", "paused"]) + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + self.assertNotEqual(self.block(), _NO_FEATURE_HINT) + + def test_outside_a_repository_there_is_no_hint_and_no_error(self): + with tempfile.TemporaryDirectory() as bare: + os.chdir(bare) + try: + self.assertEqual(_feature_block(Path(bare), []), "") + finally: + os.chdir(self.root) + + def test_a_missing_git_binary_degrades_to_no_hint(self): + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + old = os.environ["PATH"] + os.environ["PATH"] = "" + try: + self.assertEqual(self.block(), "") + finally: + os.environ["PATH"] = old + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_guard_ledger.py b/tests/test_guard_ledger.py index 0d89c2b..7e26171 100644 --- a/tests/test_guard_ledger.py +++ b/tests/test_guard_ledger.py @@ -10,12 +10,12 @@ CWD = "/home/user/project" -def decide(tool, tool_input, cwd=CWD): +def decide(tool, tool_input, cwd=CWD, env=None): payload = json.dumps( {"hook_event_name": "PreToolUse", "tool_name": tool, "tool_input": tool_input, "cwd": cwd} ) result = subprocess.run( - [sys.executable, str(GUARD)], input=payload, capture_output=True, text=True + [sys.executable, str(GUARD)], input=payload, capture_output=True, text=True, env=env ) if not result.stdout.strip(): return "allow" @@ -60,10 +60,14 @@ def test_an_ordinary_file_is_untouched(self): self.assertEqual(decide("Edit", {"file_path": path}), "allow") def test_a_global_ledger_asks(self): - self.assertEqual( - decide("Write", {"file_path": str(Path.home() / ".claude/docket/x/ledger.jsonl")}), - "ask", - ) + env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + path = Path.home() / ".claude/docket/x/ledger.jsonl" + self.assertEqual(decide("Write", {"file_path": str(path)}, env=env), "ask") + + def test_a_global_ledger_under_claude_config_dir_asks(self): + env = dict(os.environ, CLAUDE_CONFIG_DIR="/home/user/.claude-work") + path = "/home/user/.claude-work/docket/x/ledger.jsonl" + self.assertEqual(decide("Write", {"file_path": path}, env=env), "ask") class BashTests(unittest.TestCase): From b6435642ce582888204d586627e0056d3d6e7bca Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 22:56:09 +0300 Subject: [PATCH 2/3] chore(ledger): record the feature-discovery decision and feature Signed-off-by: NovusEdge --- .docket/features.jsonl | 2 ++ .docket/ledger.jsonl | 1 + 2 files changed, 3 insertions(+) diff --git a/.docket/features.jsonl b/.docket/features.jsonl index 76581d4..e1bc0e3 100644 --- a/.docket/features.jsonl +++ b/.docket/features.jsonl @@ -5,3 +5,5 @@ {"id":"f5","event":"done","slug":"web-view","status":"","text":"","paths":[],"intends":[],"include":[],"exclude":[],"cleared":[],"base":"","branch":"","ts":"2026-10-03T13:05:20+00:00","session":"","author":"claude-code","intentional":[".gitattributes","CHANGELOG.md","docket/cli/__init__.py","docket/cli/completion.py","docket/cli/export.py","docket/cli/graph.py","docket/cli/web.py","docket/graph_export.py","docket/web/__init__.py","docket/web/index.html","docket/web/server.py","docket/web/vendor/NOTICE","docket/web/vendor/viz-global.js","docs/commands.md","docs/reading.md","graph/main.go","graph/main_test.go","graph/model.go","graph/pane.go","graph/webcmd.go","skills/docket/SKILL.md","tests/test_graph_cli.py","tests/test_graph_export.py","tests/test_web.py"],"unintentional":[],"renamed_out":[],"held":[],"failed":[],"unanswered":["c38","c40","c42","c41","c12","c15","c16","c17"],"keys":{},"schema":2} {"id":"f6","event":"start","slug":"layout-controls","status":"active","text":"graph layout: engine, direction, grouping and focus in the web view and export","paths":["docket/graph_layout.py","docket/graph_export.py","docket/cli/**","docket/web/**","tests/test_graph_*.py","tests/test_web.py","docs/reading.md","docs/commands.md","skills/docket/SKILL.md","CHANGELOG.md*"],"intends":[],"include":[],"exclude":[],"cleared":[],"base":"089f399d95359f6105682e2fb1422203a210becf","branch":"main","ts":"2026-10-03T13:38:32+00:00","session":"","author":"claude-code","intentional":[],"unintentional":[],"renamed_out":[],"held":[],"failed":[],"unanswered":[],"keys":{},"schema":2} {"id":"f7","event":"done","slug":"layout-controls","status":"","text":"","paths":[],"intends":[],"include":[],"exclude":[],"cleared":[],"base":"","branch":"","ts":"2026-10-03T14:12:38+00:00","session":"","author":"claude-code","intentional":["CHANGELOG.md","docket/cli/__init__.py","docket/cli/export.py","docket/cli/web.py","docket/graph_export.py","docket/graph_layout.py","docket/web/index.html","docket/web/server.py","docs/commands.md","docs/reading.md","skills/docket/SKILL.md","tests/test_graph_cli.py","tests/test_graph_export.py","tests/test_graph_layout.py","tests/test_web.py"],"unintentional":[],"renamed_out":[],"held":[],"failed":[],"unanswered":["c38","c40","c42","c41","c12","c15","c16","c17"],"keys":{},"schema":2} +{"id":"f8","event":"start","slug":"feature-discovery","status":"active","text":"Agents find feature tracking: the docket-feature skill triggers on multi-session work, the docket skill points to it, and the session briefing hints when uncommitted work has no open feature","paths":["skills/**","docket/cli/context_cmd.py","tests/**","docs/**"],"intends":[],"include":[],"exclude":[],"cleared":[],"base":"2c73dcf66f562bd6e0ab51771be9318f4dfd564d","branch":"feature-discovery","ts":"2026-10-07T19:14:31+00:00","session":"","author":"claude-code","intentional":[],"unintentional":[],"renamed_out":[],"held":[],"failed":[],"unanswered":[],"keys":{},"schema":2} +{"id":"f9","event":"done","slug":"feature-discovery","status":"","text":"","paths":[],"intends":[],"include":[],"exclude":[],"cleared":[],"base":"","branch":"","ts":"2026-10-07T19:54:08+00:00","session":"","author":"claude-code","intentional":["docket/cli/context_cmd.py","docs/features.md","skills/docket-feature/SKILL.md","skills/docket/SKILL.md","tests/test_feature_hint.py","tests/test_guard_ledger.py"],"unintentional":["CHANGELOG.md","docket/cli/autoscope.py"],"renamed_out":[],"held":[],"failed":[],"unanswered":[],"keys":{},"schema":2} diff --git a/.docket/ledger.jsonl b/.docket/ledger.jsonl index 7e99db3..caf080a 100644 --- a/.docket/ledger.jsonl +++ b/.docket/ledger.jsonl @@ -248,3 +248,4 @@ {"author": "claude-code", "branch": "per-kind-ids", "corrects": "d138", "fields": {"alternatives": ["Resolve old ids through an explicit marker, an old id followed by @v2, wherever an id is accepted."], "rationale": "Nearly every id in this repository's docs is an illustrative example inside a command or a supports expression, and rewriting through the map would scramble them."}, "id": "d138.1", "kind": "correction", "reason": "drop example ids that the per-kind renumber would rewrite as real citations", "schema": 3, "session": "", "ts": "2026-10-07T11:50:53+00:00"} {"author": "claude-code", "branch": "per-kind-ids", "corrects": "d129", "fields": {"alternatives": ["Resolve old ids through an explicit marker, an old id followed by @v2, wherever an id is accepted."], "rationale": "Old and new ids share one format, so the same bare id names different records before and after the renumber. A resolver can be added later if old citations turn out to matter; one that scripts already use is hard to remove."}, "id": "d129.1", "kind": "correction", "reason": "drop example ids that the per-kind renumber would rewrite as real citations", "schema": 3, "session": "", "ts": "2026-10-07T11:50:53+00:00"} {"alternatives": ["Forward only: keep existing ids and count new records per kind from each kind's current maximum.", "Write the old-to-new map to a separate file under .docket/."], "answers": [], "author": "claude-code", "branch": "per-kind-ids", "choice": "A schema 2 to 3 migration renumbers claims, decisions and questions each in file order, so each kind counts from 1: 46 claims, 126 decisions and 12 questions on this ledger at 184 records. Every migrated record carries its schema-2 id in a field, which replaces the separate old-to-new map file the September design wrote to .docket/.", "cost_if_wrong": "Every id cited outside the ledger changes, and every revision token agents hold goes stale.", "decided_by": "user", "depends_on": [], "evidence": [], "id": "d153", "kind": "decision", "migrated_from": "d212", "pinned": false, "rationale": "Only a full renumber removes the gaps already in the chains, and keeping the old id on each record lets the ledger itself trace old ids with no side file to lose.", "revisit": "", "schema": 3, "scope": ["docket/ledger.py", "docket/migrate.py"], "session": "", "state": "adopted", "supersede_reason": "restate", "supersedes": ["d127"], "supports": [], "text": "Schema 3 renumbers every ledger record per kind, retired records included, and each record keeps its schema-2 id in a field.", "ts": "2026-10-07T11:51:30+00:00"} +{"schema":3,"kind":"decision","id":"d154","text":"Agents reach feature tracking from the skill listing, the docket skill, and the session briefing.","state":"adopted","ts":"2026-10-07T19:14:43+00:00","author":"claude-code","session":"","branch":"feature-discovery","scope":["skills/**","docket/cli/context_cmd.py"],"rationale":"Across the nimblefox projects no agent had ever started a feature: the description named no trigger, the docket skill never mentioned features, and the briefing is silent until a feature exists. Uncommitted changes at session start are work carried over from an earlier session, which is the skill's own threshold for starting one.","supports":[],"depends_on":[],"answers":[],"supersedes":[],"evidence":[],"revisit":"","cost_if_wrong":"Agents start features for small carried-over edits, and the store fills with features that never reach done.","pinned":false,"choice":"The docket-feature description names its triggers (work that outlasts the session or goes to another agent, resuming such work, closing it); the docket skill's Elsewhere list names the docket-feature skill without command text; docket context adds one hint line when no feature is open and the working tree has uncommitted changes outside .docket/.","alternatives":["Print the hint on every briefing that has no open feature","Move feature guidance back into the docket skill"],"decided_by":""} From 0cec1adeb91e227b054538f8dd676eecfa27c67e Mon Sep 17 00:00:00 2001 From: NovusEdge Date: Wed, 7 Oct 2026 23:07:51 +0300 Subject: [PATCH 3/3] fix(context): treat a failed diff with a resolving HEAD as a git failure Only an unborn branch falls back to the cached diff, so staged new files still count. Guard ledger tests also drop DOCKET_HOME. Signed-off-by: NovusEdge --- docket/cli/autoscope.py | 31 ++++++++++++++++++++++++------- tests/test_feature_hint.py | 13 +++++++++++++ tests/test_guard_ledger.py | 5 +++-- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/docket/cli/autoscope.py b/docket/cli/autoscope.py index 059a558..19c559c 100644 --- a/docket/cli/autoscope.py +++ b/docket/cli/autoscope.py @@ -41,24 +41,41 @@ def _uncommitted() -> tuple[str, list[str]] | None: root = top.stdout.strip() groups: list[list[str]] = [] - for command in ( + commands = [ ["git", "diff", "--name-only", "-z", "HEAD"], ["git", "ls-files", "--others", "--exclude-standard", "-z"], - ): + ] + for command in commands: try: # Bytes, not text: the locale codec decodes strictly, and a path # carrying an invalid byte would raise UnicodeDecodeError, which is # neither OSError nor SubprocessError and would kill every # docket context in that repository. done = subprocess.run(command, cwd=root, capture_output=True, timeout=3) + if done.returncode != 0 and command[1] == "diff": + # A repository with no commits has no HEAD, so the diff fails. + # Only that case is benign: the index then holds every tracked + # file, so the cached diff lists the staged ones. A diff that + # fails with HEAD resolving (corrupt object, broken index) is a + # real failure and must not be passed off as a clean tree. + head = subprocess.run( + ["git", "rev-parse", "--verify", "-q", "HEAD"], + cwd=root, + capture_output=True, + timeout=3, + ) + if head.returncode == 0: + return None + done = subprocess.run( + ["git", "diff", "--cached", "--name-only", "-z"], + cwd=root, + capture_output=True, + timeout=3, + ) except (OSError, subprocess.SubprocessError): return None - # A repository with no commits has no HEAD, so the diff fails while - # ls-files still reports every untracked file. Skip the failed command - # and keep what the other one found. if done.returncode != 0: - groups.append([]) - continue + return None text = done.stdout.decode("utf-8", errors="surrogateescape") # git collapses an untracked nested repository to a directory entry # with a trailing slash. A scope matches files, so such an entry can diff --git a/tests/test_feature_hint.py b/tests/test_feature_hint.py index df97b61..e076dbb 100644 --- a/tests/test_feature_hint.py +++ b/tests/test_feature_hint.py @@ -48,6 +48,19 @@ def test_a_staged_change_gets_the_hint(self): git(self.root, "add", "work.txt") self.assertEqual(self.block(), _NO_FEATURE_HINT) + def test_a_staged_new_file_before_the_first_commit_gets_the_hint(self): + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + git(self.root, "add", "work.txt") + self.assertEqual(self.block(), _NO_FEATURE_HINT) + + def test_a_head_pointing_at_a_missing_object_gets_no_hint(self): + (self.root / "work.txt").write_text("x\n", encoding="utf-8") + git(self.root, "add", "work.txt") + git(self.root, "commit", "-m", "base") + (self.root / "other.txt").write_text("y\n", encoding="utf-8") + (self.root / ".git" / "refs" / "heads" / "main").write_text("1" * 40 + "\n") + self.assertEqual(self.block(), "") + def test_a_gitignored_file_gets_no_hint(self): (self.root / ".gitignore").write_text("out/\n", encoding="utf-8") git(self.root, "add", ".gitignore") diff --git a/tests/test_guard_ledger.py b/tests/test_guard_ledger.py index 7e26171..cb46365 100644 --- a/tests/test_guard_ledger.py +++ b/tests/test_guard_ledger.py @@ -60,12 +60,13 @@ def test_an_ordinary_file_is_untouched(self): self.assertEqual(decide("Edit", {"file_path": path}), "allow") def test_a_global_ledger_asks(self): - env = {k: v for k, v in os.environ.items() if k != "CLAUDE_CONFIG_DIR"} + env = {k: v for k, v in os.environ.items() if k not in ("CLAUDE_CONFIG_DIR", "DOCKET_HOME")} path = Path.home() / ".claude/docket/x/ledger.jsonl" self.assertEqual(decide("Write", {"file_path": str(path)}, env=env), "ask") def test_a_global_ledger_under_claude_config_dir_asks(self): - env = dict(os.environ, CLAUDE_CONFIG_DIR="/home/user/.claude-work") + env = {k: v for k, v in os.environ.items() if k != "DOCKET_HOME"} + env["CLAUDE_CONFIG_DIR"] = "/home/user/.claude-work" path = "/home/user/.claude-work/docket/x/ledger.jsonl" self.assertEqual(decide("Write", {"file_path": path}, env=env), "ask")