graphite-code 1.2.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
graphite/__init__.py CHANGED
@@ -38,4 +38,4 @@ __all__ = ["__version__"]
38
38
  #: Coarse and hand-maintained by construction. It is a release label, not
39
39
  #: evidence that a given fix is present -- to answer that, survey for the marker
40
40
  #: the fix introduced, or compare `graphite --version` fingerprints.
41
- __version__ = "1.2.0"
41
+ __version__ = "1.2.1"
graphite/agent_hooks.py CHANGED
@@ -21,7 +21,9 @@ from .config import Config
21
21
  from .freshness import check_graph_freshness
22
22
  from .graph_io import load_validated_graph_bundle
23
23
  from .health import _edge_language, is_placeholder, persisted_resolution
24
+ from .hook_entry import MARKER as _SETUP_MARKER
24
25
  from .incident_ledger import record_incident, repo_ledger_dir
26
+ from .ingest import enclosing_git_root
25
27
 
26
28
  SESSION_CONTRACT = (
27
29
  "graphite-first: this repo uses a shared code graph. For cross-file questions "
@@ -34,22 +36,64 @@ SESSION_CONTRACT = (
34
36
  _FRESHNESS_BUDGET_SECONDS = 2.0
35
37
 
36
38
 
37
- def _payload_root(payload: dict[str, Any]) -> Path:
39
+ def _payload_cwd(payload: dict[str, Any]) -> Path:
40
+ """The directory the session is in. It follows a `cd` in the shell."""
38
41
  cwd = payload.get("cwd")
39
42
  if isinstance(cwd, str) and cwd:
40
43
  return Path(cwd)
41
44
  return Path.cwd()
42
45
 
43
46
 
47
+ def _repo_root(cwd: Path) -> Path:
48
+ """The repository a session in `cwd` belongs to: where its graph is.
49
+
50
+ Taking the session's directory for the root made the graph "missing" one
51
+ `cd` below it, and switched the gate off there (#80). The repository is the
52
+ nearest directory upward that holds `.git`, the one place a build of it can
53
+ run (`ingest.enclosing_git_root`).
54
+
55
+ That directory is used only where graphite is already set up: it has a
56
+ graph, or the `GRAPHITE.md` that `init` commits. Anywhere else `cwd` stands,
57
+ as it did before, so a session that wanders into a subfolder of some other
58
+ checkout does not hand the daemon that whole project to build.
59
+ """
60
+ top = enclosing_git_root(cwd)
61
+ if top is None or top == cwd:
62
+ return cwd
63
+ if (top / "graph-out" / "graph.json").is_file() or (top / _SETUP_MARKER).is_file():
64
+ return top
65
+ return cwd
66
+
67
+
68
+ def _where_to_run(root: Path, cwd: Path) -> str:
69
+ """One sentence for a session below the repository root, else nothing.
70
+
71
+ The commands these hooks print are run where the agent is, and the CLI
72
+ looks for the graph in its own working directory. Below the root `query`,
73
+ `context` and `check .` report the graph missing and `build .` is refused,
74
+ so a status or a refusal that names no directory sends the agent to a
75
+ command that fails.
76
+ """
77
+ if cwd == root:
78
+ return ""
79
+ return f" This session is below the repository root: run graphite from `{root}`."
80
+
81
+
44
82
  def _activate(root: Path) -> None:
45
83
  """Register this repo as open, so the daemon supervises it and leaves the
46
84
  rest of the machine alone.
47
85
 
86
+ A directory below a checkout's root is never registered. No build can run
87
+ there (`ingest` refuses it), so the daemon would fail on it at every
88
+ discovery cycle for as long as the marker lived.
89
+
48
90
  Guarded separately from the caller's own try/except: activation is
49
91
  bookkeeping, and this module is fail-open by contract -- a broken registry
50
92
  must never cost the agent its session context or its graph-first reminder.
51
93
  """
52
94
  try:
95
+ if enclosing_git_root(root) not in (None, root):
96
+ return
53
97
  mark_active(root, "claude")
54
98
  except Exception:
55
99
  return
@@ -97,7 +141,8 @@ def _freshness_within_budget(root: Path) -> tuple[str, str | None]:
97
141
 
98
142
  def handle_session_start(payload: dict[str, Any]) -> dict[str, Any] | None:
99
143
  try:
100
- root = _payload_root(payload)
144
+ cwd = _payload_cwd(payload)
145
+ root = _repo_root(cwd)
101
146
  _activate(root)
102
147
  if (root / "graph-out" / "graph.json").is_file():
103
148
  state, reason = _freshness_within_budget(root)
@@ -121,7 +166,7 @@ def handle_session_start(payload: dict[str, Any]) -> dict[str, Any] | None:
121
166
  return {
122
167
  "hookSpecificOutput": {
123
168
  "hookEventName": "SessionStart",
124
- "additionalContext": f"{SESSION_CONTRACT}\n{status}",
169
+ "additionalContext": f"{SESSION_CONTRACT}\n{status}{_where_to_run(root, cwd)}",
125
170
  }
126
171
  }
127
172
  except Exception:
@@ -435,14 +480,22 @@ def _shell_path(text: str, msys: bool) -> Path:
435
480
  return Path(text)
436
481
 
437
482
 
438
- def _cd_target(start: Path, arguments: list[str], msys: bool) -> Path | None:
439
- """The directory `cd <arguments>` leaves the shell in, or None when unknown."""
483
+ def _cd_target(start: Path | None, arguments: list[str], msys: bool) -> Path | None:
484
+ """The directory `cd <arguments>` leaves the shell in, or None when unknown.
485
+
486
+ `start` is None when the hook does not know where the shell is. From there
487
+ only an absolute path leads somewhere known.
488
+ """
440
489
  if len(arguments) != 1 or os.environ.get("CDPATH"):
441
490
  return None # with `CDPATH` set, `cd docs` may land somewhere other than `./docs`
442
491
  text = arguments[0]
443
492
  if text.startswith("-") or _SHELL_EXPANSION_CHARS.intersection(text):
444
493
  return None
445
- target = start / _shell_path(text, msys) # an absolute path replaces `start`
494
+ target = _shell_path(text, msys)
495
+ if start is not None:
496
+ target = start / target # an absolute path replaces `start`
497
+ elif not target.is_absolute():
498
+ return None
446
499
  try:
447
500
  return target if target.is_dir() else None
448
501
  except OSError: # pragma: no cover - a path the platform cannot stat
@@ -542,7 +595,29 @@ def _search_in(name: str, rest: list[str], base: Path | None, msys: bool) -> _Se
542
595
  return _Search(positional[0], _located(positional[1:], base, msys), fold_case)
543
596
 
544
597
 
545
- def _bash_searches(command: str, root: Path, msys: bool = False) -> list[_Search]:
598
+ def _unplaced(search: _Search, cwd: Path) -> _Search:
599
+ """`search` when the hook does not know which directory it runs in.
600
+
601
+ An absolute path says where it is. A relative one that names a file in the
602
+ session's directory is still one file's text, as it is at the root:
603
+ `ls && grep x notes.md`. A directory, a path that is not there, and a
604
+ search that names no path could be anywhere in the repository. The search
605
+ is then one of all of it, and no path is left to earn an exemption.
606
+ """
607
+ placed: list[str] = []
608
+ for text in search.paths:
609
+ path = Path(text)
610
+ if not path.is_absolute():
611
+ path = cwd / path
612
+ if not path.is_file():
613
+ return search._replace(paths=[])
614
+ placed.append(str(path))
615
+ return search._replace(paths=placed)
616
+
617
+
618
+ def _bash_searches(
619
+ command: str, root: Path, msys: bool = False, cwd: Path | None = None
620
+ ) -> list[_Search]:
546
621
  """Every search `command` runs that is not downstream of a pipe.
547
622
 
548
623
  All of them, not the first: `grep x notes.md ; grep -rn <symbol> src/` is
@@ -557,12 +632,23 @@ def _bash_searches(command: str, root: Path, msys: bool = False) -> list[_Search
557
632
  `cd docs && ls && grep ...` is not followed. In every such case, and when
558
633
  the directory is not one the hook can find, paths resolve against the
559
634
  repository root as they did before `cd` was read at all.
635
+
636
+ `cwd` is the directory the shell starts in when that is below the root
637
+ (#80). The line's first command runs there, and so does a search reached
638
+ through searches alone. Once a `cd` or a command the hook does not model
639
+ has run, the shell may be anywhere. At the root that costs nothing, since
640
+ the root holds every directory it could be in. Below the root the session's
641
+ directory holds only itself: from a `docs/` with no code in it,
642
+ `cd ../src ; grep` would pass as a search of `docs/`. There the search
643
+ keeps only the paths that do not depend on where the shell is (`_unplaced`).
560
644
  """
561
645
  searches: list[_Search] = []
562
- base: Path | None = None
646
+ base: Path | None = cwd # None is the repository root
647
+ placed = True # `base` is where the shell is
648
+ moved = False # a `cd`, or a command the hook does not model, has run
563
649
  for head in _bash_command_heads(command):
564
650
  if head.before != "&&":
565
- base = None
651
+ base, placed = (None, False) if moved else (cwd, True)
566
652
  argv = head.argv
567
653
  while argv and _ENV_ASSIGNMENT_RE.match(argv[0]):
568
654
  argv = argv[1:]
@@ -574,16 +660,22 @@ def _bash_searches(command: str, root: Path, msys: bool = False) -> list[_Search
574
660
  name = name[:-4]
575
661
  rest = argv[1:]
576
662
  if name in _CD_COMMANDS:
577
- base = _cd_target(base or root, rest, msys) if head.after == "&&" else None
663
+ moved = True
664
+ # At the root an unplaced shell is read as being at the root. Below
665
+ # it, only an absolute path leads somewhere known.
666
+ start = base or (root if cwd is None else None)
667
+ base = _cd_target(start, rest, msys) if head.after == "&&" else None
668
+ placed = base is not None
578
669
  continue
579
670
  if name == "git" and rest and rest[0] == "grep":
580
671
  name, rest = "grep", rest[1:]
581
672
  if name not in _SEARCH_COMMANDS:
582
- base = None # `popd`, `builtin cd ..`, a function: the shell may have moved
673
+ # `popd`, `builtin cd ..`, a function: the shell may have moved
674
+ base, placed, moved = None, False, True
583
675
  continue
584
676
  found = _search_in(name, rest, base, msys)
585
677
  if found is not None:
586
- searches.append(found)
678
+ searches.append(found if placed or cwd is None else _unplaced(found, cwd))
587
679
  return searches
588
680
 
589
681
 
@@ -692,7 +784,7 @@ def _denial_for(root: Path, searches: list[_Search]) -> str | None:
692
784
  return None
693
785
 
694
786
 
695
- def _strict_denial(payload: dict[str, Any], root: Path) -> str | None:
787
+ def _strict_denial(payload: dict[str, Any], root: Path, cwd: Path | None = None) -> str | None:
696
788
  try:
697
789
  tool_input = payload.get("tool_input")
698
790
  if not isinstance(tool_input, dict):
@@ -702,6 +794,10 @@ def _strict_denial(payload: dict[str, Any], root: Path) -> str | None:
702
794
  return None
703
795
  target = tool_input.get("path")
704
796
  paths = [target] if isinstance(target, str) and target else []
797
+ if cwd is not None:
798
+ # Below the root: with no `path` the tool searches the session's
799
+ # directory, and a relative one starts there.
800
+ paths = _located(paths, cwd, False)
705
801
  # The Grep tool matches case exactly unless `-i` is set. A value that
706
802
  # is not plainly "off" is read as on: unsure folds.
707
803
  # The tool is built on ripgrep, so it is read as `rg` with no flags of
@@ -747,7 +843,7 @@ def _cursor_number(value: Any, cast: type) -> Any:
747
843
  def handle_stop(payload: dict[str, Any]) -> dict[str, Any] | None:
748
844
  """Turn-end savings summary; silent unless graphite was used this turn."""
749
845
  try:
750
- root = _payload_root(payload)
846
+ root = _repo_root(_payload_cwd(payload))
751
847
  # Stop fires once per assistant turn, which makes it the heartbeat that
752
848
  # keeps a working session inside the activation TTL. It must run before
753
849
  # the session_id guard below, which returns early on most turns.
@@ -821,9 +917,14 @@ def handle_pre_tool_use(payload: dict[str, Any], mode: str) -> dict[str, Any] |
821
917
  tool_name = payload.get("tool_name")
822
918
  if tool_name not in ("Grep", "Glob", "Bash", "PowerShell"):
823
919
  return None
824
- root = _payload_root(payload)
920
+ cwd = _payload_cwd(payload)
921
+ root = _repo_root(cwd)
825
922
  if not (root / "graph-out" / "graph.json").is_file():
826
923
  return None
924
+ # Two directories, not one: the graph is at `root`, and a search's
925
+ # relative paths start where the shell is.
926
+ below = None if cwd == root else cwd
927
+ where = _where_to_run(root, cwd)
827
928
  denial: str | None = None
828
929
  if tool_name in ("Bash", "PowerShell"):
829
930
  # The matcher now routes EVERY Bash call here, so anything that is
@@ -835,13 +936,13 @@ def handle_pre_tool_use(payload: dict[str, Any], mode: str) -> dict[str, Any] |
835
936
  command = tool_input.get("command") if isinstance(tool_input, dict) else None
836
937
  # On Windows the Bash tool is Git Bash, which spells drives `/c/...`.
837
938
  msys = tool_name == "Bash" and os.name == "nt"
838
- searches = _bash_searches(command, root, msys) if isinstance(command, str) else []
939
+ searches = _bash_searches(command, root, msys, below) if isinstance(command, str) else []
839
940
  if not searches:
840
941
  return None
841
942
  if mode == "strict":
842
943
  denial = _denial_for(root, searches)
843
944
  elif mode == "strict" and tool_name == "Grep":
844
- denial = _strict_denial(payload, root)
945
+ denial = _strict_denial(payload, root, below)
845
946
  if denial is not None:
846
947
  health = persisted_resolution(
847
948
  root,
@@ -858,19 +959,19 @@ def handle_pre_tool_use(payload: dict[str, Any], mode: str) -> dict[str, Any] |
858
959
  "hookSpecificOutput": {
859
960
  "hookEventName": "PreToolUse",
860
961
  "permissionDecision": "deny",
861
- "permissionDecisionReason": denial,
962
+ "permissionDecisionReason": denial + where,
862
963
  }
863
964
  }
864
965
  return {
865
966
  "hookSpecificOutput": {
866
967
  "hookEventName": "PreToolUse",
867
- "additionalContext": PRE_TOOL_REMINDER + STRICT_SUSPENSION_NOTE,
968
+ "additionalContext": PRE_TOOL_REMINDER + STRICT_SUSPENSION_NOTE + where,
868
969
  }
869
970
  }
870
971
  return {
871
972
  "hookSpecificOutput": {
872
973
  "hookEventName": "PreToolUse",
873
- "additionalContext": PRE_TOOL_REMINDER,
974
+ "additionalContext": PRE_TOOL_REMINDER + where,
874
975
  }
875
976
  }
876
977
  except Exception:
graphite/ingest.py CHANGED
@@ -157,6 +157,19 @@ def _normalize_git_path(root: Path, value: str) -> str | None:
157
157
  return native_path.as_posix()
158
158
 
159
159
 
160
+ def enclosing_git_root(path: Path) -> Path | None:
161
+ """The nearest directory, from `path` upward, that holds `.git`.
162
+
163
+ `.git` is a directory in a clone and a file in a linked worktree or a
164
+ submodule, so it is tested for existence only. None means `path` is in no
165
+ repository at all.
166
+ """
167
+ for directory in (path, *path.parents):
168
+ if os.path.lexists(directory / ".git"):
169
+ return directory
170
+ return None
171
+
172
+
160
173
  def _git_ls_files(
161
174
  root: Path,
162
175
  *,
@@ -164,9 +177,9 @@ def _git_ls_files(
164
177
  is_eligible: Callable[[str], bool] | None = None,
165
178
  ) -> list[str] | None:
166
179
  """Return bounded Git files, or None only when *root* is not a Git repository."""
167
- git_dir = root / ".git"
168
- if not os.path.lexists(git_dir):
169
- if any(os.path.lexists(ancestor / ".git") for ancestor in root.parents):
180
+ enclosing = enclosing_git_root(root)
181
+ if enclosing != root:
182
+ if enclosing is not None:
170
183
  raise IngestError("nested Git roots are unsupported")
171
184
  return None
172
185
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphite-code
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Local-first, zero-LLM knowledge graph extraction for codebases.
5
5
  Project-URL: Homepage, https://github.com/jared0565/graphite
6
6
  Project-URL: Repository, https://github.com/jared0565/graphite
@@ -48,7 +48,7 @@ Description-Content-Type: text/markdown
48
48
 
49
49
  Local-first, deterministic knowledge graph extraction for codebases — zero-LLM, daemon-maintained, agent-agnostic.
50
50
 
51
- **Status: 1.2.0, production/stable.** Published on PyPI as
51
+ **Status: 1.2.1, production/stable.** Published on PyPI as
52
52
  [`graphite-code`](https://pypi.org/project/graphite-code/) with PEP 740
53
53
  attestations; supported on Windows, Linux and macOS with Python 3.11–3.14
54
54
  (every cell gates CI). What 1.x promises — CLI, JSON outputs, `graph.json`,
@@ -1,9 +1,9 @@
1
- graphite/__init__.py,sha256=goLupZ0EbigwFOj7ww3tIeXtkxbC4xFt9ZkWt1j1WXo,2181
1
+ graphite/__init__.py,sha256=qZMmOAr0ZddEL5nGkmdWPpCtYeSJuYsNahTqQSuDWo8,2181
2
2
  graphite/__main__.py,sha256=Bp5bcXE6M0AnuNW1LjOXOEvGG5r0fXZoeRb_k5Yxphs,149
3
3
  graphite/_cleanup_worker.py,sha256=oIXOPa1jHVP8ePRSwn27nZEHOAc4DSBrjeH2Ds9wuq8,19619
4
4
  graphite/_win32_ctypes.py,sha256=YLH4kpKjL7zUUOs9E8C-Kjdy45PJBd6eRUeKwiKdSV4,1476
5
5
  graphite/activation.py,sha256=Jcgk8AFvqbMNQiPAa2MTzSO0BAYRC5FX6MsZUjbDe1I,5249
6
- graphite/agent_hooks.py,sha256=qWpDiXDnBmc-_XI7DDraJbnSsfiW0giCOHZNgONp9pY,37299
6
+ graphite/agent_hooks.py,sha256=m9nkRRkfqgfioYFxwG4TDEF2JhAL_U6zXWXLkl3OasE,42073
7
7
  graphite/agent_settings.py,sha256=Ec95ilPhkPOM3vW67pdsjYivnCyc5uCqWfCBu-DLI3A,11085
8
8
  graphite/analyze.py,sha256=BwiZ4lkQ8Oo8WvnIGLI03YSNUpynYvV46tVbzHH5iWQ,9304
9
9
  graphite/answer_contract.py,sha256=ON2i3QRc1OpyM6_k3Y6wCw-CvdD2LyYqfayA6h_nxrI,27509
@@ -33,7 +33,7 @@ graphite/hook_entry.py,sha256=0lfCZB9-B_9QBKagLhV_VIMVFcrOeAwpLcFXUjhMMpg,2655
33
33
  graphite/hookinstall.py,sha256=1fCjMeQsKVfOmrJm42p94SKL14hZ31R-WANZPZzkzmM,9344
34
34
  graphite/hookshim.py,sha256=4to9xkNOWPjbk1Es97KTTknjJM9gdHkbEDjCQq8ntec,3771
35
35
  graphite/incident_ledger.py,sha256=wHW-P_PJNollM_L9bAXHaxFeLb89wMXs7XX0VrH7aSw,8654
36
- graphite/ingest.py,sha256=JGIptEfOBlLarvTV3jRqxBajzZf67Sin2QGmDx7wv-Q,8778
36
+ graphite/ingest.py,sha256=hQoRDcaIZkaCg9GA6JKuJsq-Ntnup1-f0jrPhRFX1fs,9176
37
37
  graphite/init.py,sha256=TD7996Wp51-anUi1KjTyWyeMlXyR14BWnMDzK3uSZ0Y,41241
38
38
  graphite/io.py,sha256=YBXfMcPsv1Lzun8ByW4BQlfgMm54akckLFrUhwWtleI,2996
39
39
  graphite/launchd_agent.py,sha256=rmKhhCbB-9FDU_3A1b2OlZxKpxZqdgtYnLbzkFMs_rI,4529
@@ -112,8 +112,8 @@ graphite/routing/zai_edit.py,sha256=Kra2tI9GkntQAhV1vNHlFlp4sY-ksq2-bY4N9fEfLEc,
112
112
  graphite/routing/zai_executor.py,sha256=cHUl7DbWRUhYg9Lwm3OiP6H3dI70y_b8iAWCyfgOPi0,6636
113
113
  graphite/routing/zai_probe.py,sha256=HL2vM8s2J4-n8Bp3l7QLpWLEk-PhZrt_rLc2cY65hvU,4771
114
114
  graphite/ts_resolver.mjs,sha256=wiKDWkJIC9__3wL49ty4W8hRc5biisNNtFhH1aFA47A,10822
115
- graphite_code-1.2.0.dist-info/METADATA,sha256=v-VnmbarHZ6lSQeIXEXbMsQfciV8zZoVh8UOkBy8B_w,48402
116
- graphite_code-1.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
117
- graphite_code-1.2.0.dist-info/entry_points.txt,sha256=-BpYAYFyn58DYi-hrFW9hMz6mTwNsYI44s9gaef4MiM,87
118
- graphite_code-1.2.0.dist-info/licenses/LICENSE,sha256=mfotJ1Lk0VVUgoEPOIR7-x5cvYiz2UkuVH7e3fhoDiY,1066
119
- graphite_code-1.2.0.dist-info/RECORD,,
115
+ graphite_code-1.2.1.dist-info/METADATA,sha256=hPtv7G5SJsHx021OGAhswsIgr1v93-VEOVdw_ynaYY8,48402
116
+ graphite_code-1.2.1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
117
+ graphite_code-1.2.1.dist-info/entry_points.txt,sha256=-BpYAYFyn58DYi-hrFW9hMz6mTwNsYI44s9gaef4MiM,87
118
+ graphite_code-1.2.1.dist-info/licenses/LICENSE,sha256=mfotJ1Lk0VVUgoEPOIR7-x5cvYiz2UkuVH7e3fhoDiY,1066
119
+ graphite_code-1.2.1.dist-info/RECORD,,