graphite-code 1.2.0__tar.gz → 1.2.1__tar.gz

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.
Files changed (298) hide show
  1. {graphite_code-1.2.0 → graphite_code-1.2.1}/CHANGELOG.md +69 -0
  2. {graphite_code-1.2.0 → graphite_code-1.2.1}/PKG-INFO +2 -2
  3. {graphite_code-1.2.0 → graphite_code-1.2.1}/README.md +1 -1
  4. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/__init__.py +1 -1
  5. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/agent_hooks.py +121 -20
  6. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/ingest.py +16 -3
  7. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_agent_hooks.py +319 -0
  8. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_hardening.py +17 -0
  9. {graphite_code-1.2.0 → graphite_code-1.2.1}/.gitignore +0 -0
  10. {graphite_code-1.2.0 → graphite_code-1.2.1}/ARCHITECTURE.md +0 -0
  11. {graphite_code-1.2.0 → graphite_code-1.2.1}/CONTRIBUTING.md +0 -0
  12. {graphite_code-1.2.0 → graphite_code-1.2.1}/LICENSE +0 -0
  13. {graphite_code-1.2.0 → graphite_code-1.2.1}/RELEASING.md +0 -0
  14. {graphite_code-1.2.0 → graphite_code-1.2.1}/pyproject.toml +0 -0
  15. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/__main__.py +0 -0
  16. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/_cleanup_worker.py +0 -0
  17. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/_win32_ctypes.py +0 -0
  18. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/activation.py +0 -0
  19. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/agent_settings.py +0 -0
  20. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/analyze.py +0 -0
  21. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/answer_contract.py +0 -0
  22. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/bootstrap.py +0 -0
  23. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/buildlock.py +0 -0
  24. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/cache.py +0 -0
  25. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/channel.py +0 -0
  26. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/cli.py +0 -0
  27. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/cluster.py +0 -0
  28. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/config.py +0 -0
  29. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/context.py +0 -0
  30. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/daemon.py +0 -0
  31. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/daemon_health.py +0 -0
  32. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/daemon_launch.py +0 -0
  33. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/debt.py +0 -0
  34. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/dependency_install.py +0 -0
  35. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/detach.py +0 -0
  36. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/doctor.py +0 -0
  37. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/doctor_probes.py +0 -0
  38. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/engine_identity.py +0 -0
  39. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/export/__init__.py +0 -0
  40. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/export/html.py +0 -0
  41. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/export/json.py +0 -0
  42. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/export/md.py +0 -0
  43. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/extract/__init__.py +0 -0
  44. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/extract/ast.py +0 -0
  45. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/extract/pyscope.py +0 -0
  46. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/freshness.py +0 -0
  47. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/git.py +0 -0
  48. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/graph.py +0 -0
  49. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/graph_io.py +0 -0
  50. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/health.py +0 -0
  51. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/hook_entry.py +0 -0
  52. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/hookinstall.py +0 -0
  53. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/hookshim.py +0 -0
  54. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/incident_ledger.py +0 -0
  55. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/init.py +0 -0
  56. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/io.py +0 -0
  57. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/launchd_agent.py +0 -0
  58. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/listing.py +0 -0
  59. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/llm.py +0 -0
  60. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/llm_probe.py +0 -0
  61. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/mcp.py +0 -0
  62. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/mcp_server.py +0 -0
  63. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/natural_query.py +0 -0
  64. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/overlays.py +0 -0
  65. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/probe_process.py +0 -0
  66. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/probe_workspace.py +0 -0
  67. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/process_contracts.py +0 -0
  68. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/programs.py +0 -0
  69. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/provider_observer.py +0 -0
  70. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/py.typed +0 -0
  71. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/query.py +0 -0
  72. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/query_plan.py +0 -0
  73. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/replacement_audit.py +0 -0
  74. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/resolve.py +0 -0
  75. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/review.py +0 -0
  76. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/__init__.py +0 -0
  77. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/approval.py +0 -0
  78. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/classifier.py +0 -0
  79. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/claude_executor.py +0 -0
  80. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/claude_probe.py +0 -0
  81. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/cli_identity.py +0 -0
  82. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/codex_executor.py +0 -0
  83. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/codex_probe.py +0 -0
  84. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/context_builder.py +0 -0
  85. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/contracts.py +0 -0
  86. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/diff_policy.py +0 -0
  87. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/edit_apply.py +0 -0
  88. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/effort.py +0 -0
  89. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/lifecycle.py +0 -0
  90. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/lifecycle_operator.py +0 -0
  91. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/lifecycle_service.py +0 -0
  92. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/lifecycle_storage.py +0 -0
  93. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/ollama_executor.py +0 -0
  94. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/ollama_probe.py +0 -0
  95. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/openrouter_executor.py +0 -0
  96. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/openrouter_probe.py +0 -0
  97. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/policy.py +0 -0
  98. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/probe_runner.py +0 -0
  99. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/process_runner.py +0 -0
  100. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/profiles.py +0 -0
  101. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/prompt.py +0 -0
  102. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/registry.py +0 -0
  103. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/route_pool.py +0 -0
  104. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/route_pool_execution.py +0 -0
  105. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/schema_validation.py +0 -0
  106. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/service.py +0 -0
  107. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/settings.py +0 -0
  108. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/shadow.py +0 -0
  109. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/storage.py +0 -0
  110. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/telemetry.py +0 -0
  111. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/worktree.py +0 -0
  112. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/zai_edit.py +0 -0
  113. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/zai_executor.py +0 -0
  114. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/routing/zai_probe.py +0 -0
  115. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/savings.py +0 -0
  116. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/systemd_unit.py +0 -0
  117. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/ts_bridge.py +0 -0
  118. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/ts_resolver.mjs +0 -0
  119. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/typescript_activation.py +0 -0
  120. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/usage_ledger.py +0 -0
  121. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/validation.py +0 -0
  122. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/watch.py +0 -0
  123. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/windows_job.py +0 -0
  124. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/windows_startup.py +0 -0
  125. {graphite_code-1.2.0 → graphite_code-1.2.1}/src/graphite/windows_task.py +0 -0
  126. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/conftest.py +0 -0
  127. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/data/sqloracle/blindspots.py.txt +0 -0
  128. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/data/sqloracle/round69.py.txt +0 -0
  129. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/fake_clis/fake_cli.py +0 -0
  130. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/fixtures/provider_lifecycle_schema_v1.sql +0 -0
  131. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/fixtures/routing_schema_v2_ca77600.sql +0 -0
  132. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/fixtures/routing_schema_v3_94eb333.sql +0 -0
  133. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/fixtures/routing_schema_v4_lifecycle_migration.sql +0 -0
  134. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test__win32_ctypes.py +0 -0
  135. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_activation.py +0 -0
  136. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_activation_backstop.py +0 -0
  137. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_agent_hook_activation.py +0 -0
  138. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_agent_settings.py +0 -0
  139. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_answer_contract.py +0 -0
  140. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_arrow_and_constructor_binding.py +0 -0
  141. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_atomic_write.py +0 -0
  142. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_bootstrap.py +0 -0
  143. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_build_detach.py +0 -0
  144. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_buildlock.py +0 -0
  145. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cache_engine_identity.py +0 -0
  146. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cache_entry_pruning.py +0 -0
  147. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cache_file_set.py +0 -0
  148. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cache_pruning.py +0 -0
  149. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_call_graph.py +0 -0
  150. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_case_ambiguous_answer.py +0 -0
  151. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_attribution.py +0 -0
  152. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_cli.py +0 -0
  153. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_core.py +0 -0
  154. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_kind.py +0 -0
  155. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_lock.py +0 -0
  156. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_log_integrity.py +0 -0
  157. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_mcp.py +0 -0
  158. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_register.py +0 -0
  159. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_report.py +0 -0
  160. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_channel_status.py +0 -0
  161. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_class_field_arrow_binding.py +0 -0
  162. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cli_channel.py +0 -0
  163. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cli_channel_attribution.py +0 -0
  164. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cli_reference.py +0 -0
  165. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cli_version.py +0 -0
  166. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cluster.py +0 -0
  167. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_compare_dist.py +0 -0
  168. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_configuration_reference.py +0 -0
  169. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_context.py +0 -0
  170. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_context_builder.py +0 -0
  171. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cwd_relative_config.py +0 -0
  172. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_cycle_search.py +0 -0
  173. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon.py +0 -0
  174. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_activation.py +0 -0
  175. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_build_lock.py +0 -0
  176. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_child_lock.py +0 -0
  177. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_engine_staleness.py +0 -0
  178. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_health.py +0 -0
  179. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_health_activation.py +0 -0
  180. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_launch.py +0 -0
  181. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_daemon_service_cli.py +0 -0
  182. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_debt.py +0 -0
  183. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_dependency_install.py +0 -0
  184. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_destructured_binding.py +0 -0
  185. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_determinism.py +0 -0
  186. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_dispatch_evidence.py +0 -0
  187. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_doctor.py +0 -0
  188. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_doctor_agent_hooks.py +0 -0
  189. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_doctor_hooks.py +0 -0
  190. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_doctor_managed_docs.py +0 -0
  191. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_doctor_probes.py +0 -0
  192. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_documentation.py +0 -0
  193. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_edit_apply.py +0 -0
  194. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_engine_identity.py +0 -0
  195. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_exit_codes_reference.py +0 -0
  196. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_external_calls.py +0 -0
  197. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_git_security.py +0 -0
  198. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_go_rust.py +0 -0
  199. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_graph_io.py +0 -0
  200. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_graph_provider_isolation.py +0 -0
  201. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_health.py +0 -0
  202. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_hook_entry.py +0 -0
  203. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_hook_template.py +0 -0
  204. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_hookinstall.py +0 -0
  205. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_hookshim.py +0 -0
  206. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_html_security.py +0 -0
  207. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_incident_ledger.py +0 -0
  208. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_init.py +0 -0
  209. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_init_activation_doctrine.py +0 -0
  210. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_init_hooks.py +0 -0
  211. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_init_validation_exit.py +0 -0
  212. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_launchd_agent.py +0 -0
  213. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_lifecycle_operator.py +0 -0
  214. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_listing.py +0 -0
  215. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_listing_surfaces.py +0 -0
  216. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_llm.py +0 -0
  217. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_llm_probe.py +0 -0
  218. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_mcp.py +0 -0
  219. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_mcp_server.py +0 -0
  220. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_mcp_server_channel_attribution.py +0 -0
  221. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_method_dispatch_scope.py +0 -0
  222. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_monorepo.py +0 -0
  223. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_mutation_tests.py +0 -0
  224. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_natural_query.py +0 -0
  225. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_node_identity.py +0 -0
  226. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_ollama_executor.py +0 -0
  227. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_overlays.py +0 -0
  228. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_path_leak_fixture.py +0 -0
  229. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_probe_diagnostics.py +0 -0
  230. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_probe_process.py +0 -0
  231. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_probe_workspace.py +0 -0
  232. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_process_contracts.py +0 -0
  233. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_programs.py +0 -0
  234. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_claude_probe.py +0 -0
  235. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_codex_probe.py +0 -0
  236. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_lifecycle.py +0 -0
  237. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_lifecycle_service.py +0 -0
  238. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_lifecycle_storage.py +0 -0
  239. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_observer.py +0 -0
  240. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_ollama_probe.py +0 -0
  241. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_openrouter_probe.py +0 -0
  242. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_provider_probe_runner.py +0 -0
  243. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_public_surface.py +0 -0
  244. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_published_schemas.py +0 -0
  245. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_pyscope.py +0 -0
  246. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_pyscopeoracle.py +0 -0
  247. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_python_import_bindings.py +0 -0
  248. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_python_resolver.py +0 -0
  249. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_python_scope_identity.py +0 -0
  250. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_query_plan.py +0 -0
  251. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_reliability.py +0 -0
  252. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_replacement_audit.py +0 -0
  253. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_resolve.py +0 -0
  254. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_review.py +0 -0
  255. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_route_pool.py +0 -0
  256. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_route_pool_execution.py +0 -0
  257. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_approval.py +0 -0
  258. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_benchmark.py +0 -0
  259. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_classifier.py +0 -0
  260. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_claude_executor.py +0 -0
  261. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_cli.py +0 -0
  262. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_cli_contracts.py +0 -0
  263. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_cli_recovery.py +0 -0
  264. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_codex_executor.py +0 -0
  265. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_context.py +0 -0
  266. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_contracts.py +0 -0
  267. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_diff_policy.py +0 -0
  268. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_executor.py +0 -0
  269. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_openrouter_executor.py +0 -0
  270. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_policy.py +0 -0
  271. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_process_runner.py +0 -0
  272. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_profiles.py +0 -0
  273. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_registry.py +0 -0
  274. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_schema_validation.py +0 -0
  275. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_security.py +0 -0
  276. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_service.py +0 -0
  277. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_shadow.py +0 -0
  278. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_storage.py +0 -0
  279. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_telemetry.py +0 -0
  280. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_worktree.py +0 -0
  281. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_zai_executor.py +0 -0
  282. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_routing_zai_probe.py +0 -0
  283. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_savings.py +0 -0
  284. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_search.py +0 -0
  285. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_smoke.py +0 -0
  286. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_sqloracle.py +0 -0
  287. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_synthetic_repo.py +0 -0
  288. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_systemd_unit.py +0 -0
  289. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_ts_bridge.py +0 -0
  290. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_typescript_activation.py +0 -0
  291. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_typescript_resolver.py +0 -0
  292. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_usage_ledger.py +0 -0
  293. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_verify_artifact.py +0 -0
  294. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_verify_published_release.py +0 -0
  295. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_watch.py +0 -0
  296. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_windows_startup.py +0 -0
  297. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_windows_task.py +0 -0
  298. {graphite_code-1.2.0 → graphite_code-1.2.1}/tests/test_zai_edit.py +0 -0
@@ -15,6 +15,75 @@ machine-checkable identity; the version is for humans.
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
+ ## [1.2.1] — 2026-10-11
19
+
20
+ A patch release. It carries one fix, to the agent hooks (#80). No stable
21
+ surface in `docs/compatibility.md` changes: nothing under `docs/reference/` or
22
+ `docs/schemas/` moves, `graphite agent-hook` still always exits 0, and the
23
+ wording of a hook's message is not a stable surface. The graph itself does not
24
+ change: on this repository, measured against the deployed 1.2.0 engine,
25
+ 8581 of 8581 node ids survive, 23612 of 23612 edge triples are unchanged,
26
+ every relation count is equal, and `analysis` is identical. Only the engine
27
+ fingerprint moves, because graphite's own bytes did.
28
+
29
+ ### Fixed
30
+
31
+ **The agent hooks find the repository from a subfolder (#80).** A hook payload
32
+ carries the session's directory, and in Claude Code that follows a `cd` in the
33
+ shell. All three hooks took it for the repository root. One directory below the
34
+ root, with the graph fresh at the root:
35
+
36
+ - `session-start` said `Graph status: missing`, the status that licenses a full
37
+ fallback from graph-first. The remedy it printed, `python -m graphite build .`,
38
+ is refused there ("nested Git roots are unsupported").
39
+ - `pre-tool-use` wrote nothing: no refusal in strict mode and no reminder. A
40
+ `cd` into any subfolder switched the gate off.
41
+ - `stop` created a `.graphite/local/` in the subfolder for its cursor and looked
42
+ for the turn's usage there.
43
+ - The subfolder was registered as an open project. The daemon cannot build
44
+ one, so it logged `project_snapshot_failed` for it at every discovery cycle,
45
+ and the repository's own marker was no longer renewed.
46
+
47
+ The repository is now the nearest directory upward that holds `.git`, the test
48
+ the build already uses to refuse a nested path (`ingest.enclosing_git_root`).
49
+ It is taken only where graphite is set up: a `graph-out/graph.json`, or the
50
+ `GRAPHITE.md` that `init` commits. In a subfolder of any other checkout, and in
51
+ a directory outside every checkout, the hooks read the session's directory as
52
+ they did. A repository inside the repository, with its own `.git`, stays its
53
+ own root.
54
+
55
+ One reading changes inside a checkout that is set up. A folder there that has
56
+ its own `GRAPHITE.md` or `graph-out/` but no `.git` was read as a project of
57
+ its own: `Graph status: missing`, or `unknown` over a leftover graph, with a
58
+ `build .` that is refused there. It is now read as part of the checkout.
59
+
60
+ Below the root:
61
+
62
+ - The graph, the freshness check, the usage ledger and the activation marker
63
+ are the repository's.
64
+ - A search's relative paths, and a search that names no path, belong to the
65
+ directory the shell is in. `grep <symbol> core.py` beside the file is one
66
+ file. A search with no path in a `docs/` that holds no code is text. `..` and
67
+ `../src` are judged as the directories they are.
68
+ - Once the command line has run a `cd` the hook does not follow, or any command
69
+ it does not model, the shell may be anywhere in the repository. A search then
70
+ keeps an absolute path, and a relative one that names a file in the session's
71
+ directory. With a directory, or no path, it is judged as a search of the
72
+ whole repository: from a `docs/` with no code, `cd ../src ; grep -rn <symbol> .`
73
+ is refused. At the root this case resolves paths against the root, as before.
74
+ - Every status, refusal and reminder ends by naming the root: "This session is
75
+ below the repository root: run graphite from `<root>`." The CLI still looks
76
+ for the graph in its own working directory (#81), so the commands those
77
+ messages print work from the root only.
78
+
79
+ The hooks no longer register a directory below a checkout's root with the
80
+ daemon, whether or not graphite is set up in that checkout.
81
+
82
+ Nothing changes for a session at the repository root. Measured on the 1.2.0
83
+ wheel and on this change: 32 strict-hook arms from the #72 work (25 refused, 7
84
+ allowed) and 12 hook arms on a checkout with `.git` and `GRAPHITE.md` read the
85
+ same on both.
86
+
18
87
  ## [1.2.0] — 2026-10-10
19
88
 
20
89
  A minor release. Query and search results, channel rounds and Python
@@ -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`,
@@ -2,7 +2,7 @@
2
2
 
3
3
  Local-first, deterministic knowledge graph extraction for codebases — zero-LLM, daemon-maintained, agent-agnostic.
4
4
 
5
- **Status: 1.2.0, production/stable.** Published on PyPI as
5
+ **Status: 1.2.1, production/stable.** Published on PyPI as
6
6
  [`graphite-code`](https://pypi.org/project/graphite-code/) with PEP 740
7
7
  attestations; supported on Windows, Linux and macOS with Python 3.11–3.14
8
8
  (every cell gates CI). What 1.x promises — CLI, JSON outputs, `graph.json`,
@@ -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"
@@ -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:
@@ -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: