graphite-code 1.1.0__tar.gz → 1.1.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 (291) hide show
  1. {graphite_code-1.1.0 → graphite_code-1.1.1}/CHANGELOG.md +109 -0
  2. {graphite_code-1.1.0 → graphite_code-1.1.1}/PKG-INFO +2 -2
  3. {graphite_code-1.1.0 → graphite_code-1.1.1}/README.md +1 -1
  4. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/__init__.py +1 -1
  5. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/channel.py +97 -10
  6. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/daemon_health.py +4 -1
  7. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/doctor.py +6 -22
  8. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/doctor_probes.py +9 -1
  9. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/git.py +8 -29
  10. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/hookinstall.py +6 -1
  11. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/init.py +4 -1
  12. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/mcp_server.py +12 -4
  13. graphite_code-1.1.1/src/graphite/programs.py +190 -0
  14. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/process_runner.py +6 -0
  15. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/service.py +24 -12
  16. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/ts_bridge.py +16 -1
  17. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/windows_startup.py +6 -1
  18. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/windows_task.py +12 -4
  19. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_core.py +224 -0
  20. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_lock.py +55 -1
  21. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_log_integrity.py +44 -0
  22. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_mcp.py +26 -0
  23. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_register.py +26 -0
  24. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_health.py +31 -0
  25. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_doctor.py +72 -3
  26. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_hookinstall.py +32 -0
  27. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_init.py +32 -0
  28. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_mutation_tests.py +116 -0
  29. graphite_code-1.1.1/tests/test_programs.py +403 -0
  30. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_process_runner.py +21 -0
  31. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_service.py +111 -0
  32. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_ts_bridge.py +63 -5
  33. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_windows_startup.py +21 -0
  34. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_windows_task.py +42 -2
  35. {graphite_code-1.1.0 → graphite_code-1.1.1}/.gitignore +0 -0
  36. {graphite_code-1.1.0 → graphite_code-1.1.1}/ARCHITECTURE.md +0 -0
  37. {graphite_code-1.1.0 → graphite_code-1.1.1}/CONTRIBUTING.md +0 -0
  38. {graphite_code-1.1.0 → graphite_code-1.1.1}/LICENSE +0 -0
  39. {graphite_code-1.1.0 → graphite_code-1.1.1}/RELEASING.md +0 -0
  40. {graphite_code-1.1.0 → graphite_code-1.1.1}/pyproject.toml +0 -0
  41. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/__main__.py +0 -0
  42. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/_cleanup_worker.py +0 -0
  43. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/_win32_ctypes.py +0 -0
  44. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/activation.py +0 -0
  45. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/agent_hooks.py +0 -0
  46. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/agent_settings.py +0 -0
  47. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/analyze.py +0 -0
  48. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/answer_contract.py +0 -0
  49. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/bootstrap.py +0 -0
  50. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/buildlock.py +0 -0
  51. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/cache.py +0 -0
  52. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/cli.py +0 -0
  53. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/cluster.py +0 -0
  54. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/config.py +0 -0
  55. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/context.py +0 -0
  56. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/daemon.py +0 -0
  57. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/daemon_launch.py +0 -0
  58. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/debt.py +0 -0
  59. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/dependency_install.py +0 -0
  60. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/detach.py +0 -0
  61. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/engine_identity.py +0 -0
  62. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/export/__init__.py +0 -0
  63. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/export/html.py +0 -0
  64. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/export/json.py +0 -0
  65. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/export/md.py +0 -0
  66. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/extract/__init__.py +0 -0
  67. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/extract/ast.py +0 -0
  68. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/freshness.py +0 -0
  69. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/graph.py +0 -0
  70. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/graph_io.py +0 -0
  71. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/health.py +0 -0
  72. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/hook_entry.py +0 -0
  73. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/hookshim.py +0 -0
  74. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/incident_ledger.py +0 -0
  75. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/ingest.py +0 -0
  76. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/io.py +0 -0
  77. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/launchd_agent.py +0 -0
  78. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/listing.py +0 -0
  79. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/llm.py +0 -0
  80. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/llm_probe.py +0 -0
  81. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/mcp.py +0 -0
  82. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/natural_query.py +0 -0
  83. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/overlays.py +0 -0
  84. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/probe_process.py +0 -0
  85. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/probe_workspace.py +0 -0
  86. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/process_contracts.py +0 -0
  87. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/provider_observer.py +0 -0
  88. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/py.typed +0 -0
  89. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/query.py +0 -0
  90. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/query_plan.py +0 -0
  91. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/replacement_audit.py +0 -0
  92. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/resolve.py +0 -0
  93. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/review.py +0 -0
  94. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/__init__.py +0 -0
  95. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/approval.py +0 -0
  96. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/classifier.py +0 -0
  97. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/claude_executor.py +0 -0
  98. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/claude_probe.py +0 -0
  99. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/cli_identity.py +0 -0
  100. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/codex_executor.py +0 -0
  101. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/codex_probe.py +0 -0
  102. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/context_builder.py +0 -0
  103. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/contracts.py +0 -0
  104. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/diff_policy.py +0 -0
  105. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/edit_apply.py +0 -0
  106. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/effort.py +0 -0
  107. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/lifecycle.py +0 -0
  108. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/lifecycle_operator.py +0 -0
  109. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/lifecycle_service.py +0 -0
  110. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/lifecycle_storage.py +0 -0
  111. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/ollama_executor.py +0 -0
  112. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/ollama_probe.py +0 -0
  113. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/openrouter_executor.py +0 -0
  114. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/openrouter_probe.py +0 -0
  115. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/policy.py +0 -0
  116. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/probe_runner.py +0 -0
  117. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/profiles.py +0 -0
  118. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/prompt.py +0 -0
  119. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/registry.py +0 -0
  120. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/route_pool.py +0 -0
  121. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/route_pool_execution.py +0 -0
  122. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/schema_validation.py +0 -0
  123. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/settings.py +0 -0
  124. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/shadow.py +0 -0
  125. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/storage.py +0 -0
  126. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/telemetry.py +0 -0
  127. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/worktree.py +0 -0
  128. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/zai_edit.py +0 -0
  129. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/zai_executor.py +0 -0
  130. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/routing/zai_probe.py +0 -0
  131. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/savings.py +0 -0
  132. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/systemd_unit.py +0 -0
  133. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/ts_resolver.mjs +0 -0
  134. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/typescript_activation.py +0 -0
  135. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/usage_ledger.py +0 -0
  136. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/validation.py +0 -0
  137. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/watch.py +0 -0
  138. {graphite_code-1.1.0 → graphite_code-1.1.1}/src/graphite/windows_job.py +0 -0
  139. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/conftest.py +0 -0
  140. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/data/sqloracle/blindspots.py.txt +0 -0
  141. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/data/sqloracle/round69.py.txt +0 -0
  142. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/fake_clis/fake_cli.py +0 -0
  143. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/fixtures/provider_lifecycle_schema_v1.sql +0 -0
  144. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/fixtures/routing_schema_v2_ca77600.sql +0 -0
  145. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/fixtures/routing_schema_v3_94eb333.sql +0 -0
  146. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/fixtures/routing_schema_v4_lifecycle_migration.sql +0 -0
  147. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test__win32_ctypes.py +0 -0
  148. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_activation.py +0 -0
  149. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_activation_backstop.py +0 -0
  150. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_agent_hook_activation.py +0 -0
  151. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_agent_hooks.py +0 -0
  152. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_agent_settings.py +0 -0
  153. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_answer_contract.py +0 -0
  154. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_arrow_and_constructor_binding.py +0 -0
  155. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_atomic_write.py +0 -0
  156. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_bootstrap.py +0 -0
  157. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_build_detach.py +0 -0
  158. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_buildlock.py +0 -0
  159. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cache_engine_identity.py +0 -0
  160. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cache_entry_pruning.py +0 -0
  161. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cache_file_set.py +0 -0
  162. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cache_pruning.py +0 -0
  163. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_call_graph.py +0 -0
  164. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_attribution.py +0 -0
  165. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_cli.py +0 -0
  166. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_report.py +0 -0
  167. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_channel_status.py +0 -0
  168. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_class_field_arrow_binding.py +0 -0
  169. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cli_channel.py +0 -0
  170. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cli_channel_attribution.py +0 -0
  171. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cli_reference.py +0 -0
  172. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cli_version.py +0 -0
  173. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cluster.py +0 -0
  174. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_compare_dist.py +0 -0
  175. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_configuration_reference.py +0 -0
  176. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_context.py +0 -0
  177. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_context_builder.py +0 -0
  178. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cwd_relative_config.py +0 -0
  179. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_cycle_search.py +0 -0
  180. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon.py +0 -0
  181. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_activation.py +0 -0
  182. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_build_lock.py +0 -0
  183. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_child_lock.py +0 -0
  184. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_engine_staleness.py +0 -0
  185. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_health_activation.py +0 -0
  186. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_launch.py +0 -0
  187. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_daemon_service_cli.py +0 -0
  188. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_debt.py +0 -0
  189. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_dependency_install.py +0 -0
  190. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_destructured_binding.py +0 -0
  191. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_determinism.py +0 -0
  192. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_dispatch_evidence.py +0 -0
  193. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_doctor_agent_hooks.py +0 -0
  194. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_doctor_hooks.py +0 -0
  195. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_doctor_managed_docs.py +0 -0
  196. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_doctor_probes.py +0 -0
  197. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_documentation.py +0 -0
  198. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_edit_apply.py +0 -0
  199. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_engine_identity.py +0 -0
  200. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_exit_codes_reference.py +0 -0
  201. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_external_calls.py +0 -0
  202. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_git_security.py +0 -0
  203. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_go_rust.py +0 -0
  204. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_graph_io.py +0 -0
  205. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_graph_provider_isolation.py +0 -0
  206. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_hardening.py +0 -0
  207. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_health.py +0 -0
  208. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_hook_entry.py +0 -0
  209. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_hook_template.py +0 -0
  210. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_hookshim.py +0 -0
  211. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_html_security.py +0 -0
  212. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_incident_ledger.py +0 -0
  213. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_init_activation_doctrine.py +0 -0
  214. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_init_hooks.py +0 -0
  215. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_init_validation_exit.py +0 -0
  216. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_launchd_agent.py +0 -0
  217. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_lifecycle_operator.py +0 -0
  218. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_listing.py +0 -0
  219. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_listing_surfaces.py +0 -0
  220. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_llm.py +0 -0
  221. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_llm_probe.py +0 -0
  222. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_mcp.py +0 -0
  223. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_mcp_server.py +0 -0
  224. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_mcp_server_channel_attribution.py +0 -0
  225. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_method_dispatch_scope.py +0 -0
  226. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_monorepo.py +0 -0
  227. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_natural_query.py +0 -0
  228. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_node_identity.py +0 -0
  229. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_ollama_executor.py +0 -0
  230. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_overlays.py +0 -0
  231. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_path_leak_fixture.py +0 -0
  232. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_probe_diagnostics.py +0 -0
  233. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_probe_process.py +0 -0
  234. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_probe_workspace.py +0 -0
  235. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_process_contracts.py +0 -0
  236. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_claude_probe.py +0 -0
  237. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_codex_probe.py +0 -0
  238. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_lifecycle.py +0 -0
  239. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_lifecycle_service.py +0 -0
  240. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_lifecycle_storage.py +0 -0
  241. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_observer.py +0 -0
  242. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_ollama_probe.py +0 -0
  243. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_openrouter_probe.py +0 -0
  244. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_provider_probe_runner.py +0 -0
  245. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_public_surface.py +0 -0
  246. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_published_schemas.py +0 -0
  247. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_python_resolver.py +0 -0
  248. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_query_plan.py +0 -0
  249. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_reliability.py +0 -0
  250. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_replacement_audit.py +0 -0
  251. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_resolve.py +0 -0
  252. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_review.py +0 -0
  253. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_route_pool.py +0 -0
  254. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_route_pool_execution.py +0 -0
  255. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_approval.py +0 -0
  256. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_benchmark.py +0 -0
  257. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_classifier.py +0 -0
  258. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_claude_executor.py +0 -0
  259. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_cli.py +0 -0
  260. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_cli_contracts.py +0 -0
  261. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_cli_recovery.py +0 -0
  262. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_codex_executor.py +0 -0
  263. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_context.py +0 -0
  264. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_contracts.py +0 -0
  265. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_diff_policy.py +0 -0
  266. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_executor.py +0 -0
  267. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_openrouter_executor.py +0 -0
  268. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_policy.py +0 -0
  269. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_profiles.py +0 -0
  270. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_registry.py +0 -0
  271. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_schema_validation.py +0 -0
  272. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_security.py +0 -0
  273. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_shadow.py +0 -0
  274. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_storage.py +0 -0
  275. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_telemetry.py +0 -0
  276. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_worktree.py +0 -0
  277. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_zai_executor.py +0 -0
  278. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_routing_zai_probe.py +0 -0
  279. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_savings.py +0 -0
  280. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_search.py +0 -0
  281. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_smoke.py +0 -0
  282. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_sqloracle.py +0 -0
  283. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_synthetic_repo.py +0 -0
  284. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_systemd_unit.py +0 -0
  285. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_typescript_activation.py +0 -0
  286. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_typescript_resolver.py +0 -0
  287. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_usage_ledger.py +0 -0
  288. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_verify_artifact.py +0 -0
  289. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_verify_published_release.py +0 -0
  290. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_watch.py +0 -0
  291. {graphite_code-1.1.0 → graphite_code-1.1.1}/tests/test_zai_edit.py +0 -0
@@ -15,6 +15,115 @@ machine-checkable identity; the version is for humans.
15
15
 
16
16
  ## [Unreleased]
17
17
 
18
+ ## [1.1.1] — 2026-10-03
19
+
20
+ A patch release. Security fixes ship in the next patch release of the current
21
+ minor (`docs/compatibility.md`, `SECURITY.md`). The graph itself does not
22
+ change: on this repository, measured against the deployed 1.1.0 engine,
23
+ 7797 of 7797 node ids survive, 22154 of 22154 edge triples are
24
+ unchanged, every relation count is equal, and `analysis.cycles` is identical.
25
+ Only the engine fingerprint moves, because graphite's own bytes did.
26
+
27
+ ### Security
28
+
29
+ **graphite no longer launches a program by bare name, so a program sitting in
30
+ a repository root cannot run in place of the real one.** On Windows a bare
31
+ name is looked up in the launching process's current directory before PATH.
32
+ That covers a list-form `subprocess.run(["git", ...])`, cmd.exe and
33
+ `shutil.which`, unless `NoDefaultCurrentDirectoryInExePath` is set. Claude
34
+ Code sets that variable, but the daemon at login and a commit from a terminal
35
+ do not.
36
+
37
+ graphite's builds, hooks and channel broker run with a repository root as
38
+ their current directory. A `node.exe` committed to a TypeScript repo's root
39
+ ran on every build, and the build still exited 0. This was measured against
40
+ the released 1.1.0 wheel; channel round 304 reported it.
41
+
42
+ Every launch now goes through `graphite.programs.resolve_program`, which:
43
+
44
+ - reads only absolute PATH entries;
45
+ - skips a PATH directory inside the target repository, or one that IS the
46
+ current directory, judged both as written and as resolved, so a committed
47
+ symlink cannot choose which outside binary runs (aramid llm-review
48
+ `1d8d8cf`);
49
+ - refuses any candidate that resolves into one of those;
50
+ - on Windows, tries `.exe` as CreateProcess does, unless the caller asks for
51
+ more.
52
+
53
+ Windows' own tools come from the system directory the OS reports. The
54
+ sites changed are:
55
+
56
+ - the TypeScript bridge's `node` (every build);
57
+ - the channel broker's `git`;
58
+ - the channel's commit-msg hook, which fails closed when no git is found;
59
+ - the `git` calls in `init` and `hookinstall`;
60
+ - `daemon-health`'s `powershell.exe`;
61
+ - the four `schtasks.exe` calls;
62
+ - the Startup launcher's `powershell.exe`;
63
+ - routing's `claude`/`codex` lookup, which keeps `.cmd` shims, excludes the
64
+ repository and the task worktree whatever the current directory (aramid
65
+ llm-review `30e2473`), and sets the CLI's environment (see below);
66
+ - `doctor`'s `graphite-mcp` check;
67
+ - the mutation-test launcher's `git`.
68
+
69
+ Resolving the first program is not enough when that program is a `.cmd` shim.
70
+ An npm-installed CLI with no `node.exe` beside it runs bare `node` through
71
+ cmd.exe, which searches the task worktree first. A `node.exe` committed to the
72
+ repo would then run before the CLI's own sandbox. Routing now sets
73
+ `NoDefaultCurrentDirectoryInExePath=1` in every CLI's environment, which turns
74
+ that search off for the whole chain. The commit-msg hook also has its own status
75
+ when it cannot find git. It used to fall through to "this commit names no agent".
76
+
77
+ Two older private copies of the same PATH walk, in `git` and `doctor`, now
78
+ delegate to the shared resolver. A test walks every launch call in `src/`,
79
+ `scripts/` and the hook's embedded program, and fails on a program named by a
80
+ literal or on `shell=True`.
81
+
82
+ The current directory is refused itself, not its subtree, because that is
83
+ what Windows searches. Refusing everything beneath it would refuse every tool
84
+ on drive C for a run started from `C:\`, and `~/.local/bin` or an
85
+ nvm-installed `node` for a run started from `$HOME`. A pre-release build
86
+ measured exactly that. The repository's own subtree is refused through the
87
+ repository each caller passes.
88
+
89
+ Artifacts already installed keep the old command until they are regenerated:
90
+ - the Startup `.vbs` (`graphite daemon-install-startup-windows`);
91
+ - the channel's commit-msg hook (rewritten by `graphite channel register`).
92
+
93
+ ### Fixed
94
+
95
+ **`doctor --deep`'s LLM probe no longer spends the provider's timeout on
96
+ starting its worker.** One number was both the provider timeout and the worker
97
+ process's whole budget. The interpreter's start was therefore paid from the
98
+ provider's time, and a loaded machine reported `timeout` for a provider that
99
+ was only misconfigured: 2 s read `timeout` 7 times in 10 under 36 CPU burners.
100
+
101
+ The worker already enforces the provider timeout itself. Its process budget is
102
+ now that timeout plus `_LLM_WORKER_START_ALLOWANCE_SECONDS` (30 s), which only
103
+ bounds a worker that never answers. A 0.1 s provider timeout, below any
104
+ interpreter's start, now reaches `configuration`.
105
+
106
+ **The channel reports an abandoned lock on every path that can break one.**
107
+ These come from aramid llm-review findings:
108
+ - **`1810f9e`:** `inbox()`, the path every agent takes at session start, was
109
+ the one writer that dropped the recovery. `graphite_channel_inbox` now
110
+ carries `lock_recovered` when a delivery had to break a lock, and stays
111
+ silent otherwise.
112
+ - **`9751aab`:** a residue scan that failed read as `[]`, the same as a clean
113
+ one. It now reads `null`, meaning "we could not look". A wedged git, the
114
+ likeliest cause of an abandoned lock, is also what makes the scan fail.
115
+ - **`9a23b84`:** the recovery report no longer returns the dead holder's host
116
+ name. Nothing decides on it: identity is pid + start time, and staleness is
117
+ the clock.
118
+
119
+ **The channel's commit-msg hook is installed with exactly `0o755`, pinned on
120
+ every platform.** aramid's mutation drain found `0o755 -> 0o756`, a
121
+ world-writable audit hook on POSIX, surviving (`e37afd86`). On Windows,
122
+ `os.chmod` honours only the write bit, so no on-disk check there could see it.
123
+ A test now pins the argument, which kills the mutant on Windows too. A
124
+ POSIX-only test checks the on-disk property: executable, and not group- or
125
+ world-writable.
126
+
18
127
  ## [1.1.0] — 2026-09-27
19
128
 
20
129
  A minor release. The agent channel's read surfaces gain fields, which is an
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphite-code
3
- Version: 1.1.0
3
+ Version: 1.1.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.1.0, production/stable.** Published on PyPI as
51
+ **Status: 1.1.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.1.0, production/stable.** Published on PyPI as
5
+ **Status: 1.1.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.1.0"
41
+ __version__ = "1.1.1"
@@ -34,6 +34,8 @@ from datetime import datetime, timedelta, timezone
34
34
  from pathlib import Path
35
35
  from typing import Callable
36
36
 
37
+ from .programs import resolve_program
38
+
37
39
  CHANNEL_DIRNAME = ".agent-channel"
38
40
  REGISTRY_FILENAME = "agents.json"
39
41
  ROUNDS_DIRNAME = "rounds"
@@ -260,7 +262,7 @@ else
260
262
  fi
261
263
 
262
264
  "$PY" -I - "$MSG_FILE" "$ROOT" <<'PYEOF'
263
- import json, re, subprocess, sys
265
+ import json, os, re, subprocess, sys
264
266
 
265
267
  # Distinguishable so the shell can name the ACTUAL reason. Sentinel objects
266
268
  # rather than strings: a registry whose contents happened to be `"absent"` would
@@ -269,6 +271,50 @@ ABSENT = object()
269
271
  CORRUPT = object()
270
272
 
271
273
 
274
+ class GitUnavailable(Exception):
275
+ """No usable git outside the channel. Its own exit status, so the shell can
276
+ name it: the catch-all's 1 means "names no agent", which is not the fault."""
277
+
278
+
279
+ def _git_executable(root):
280
+ """git by absolute path, never from the channel or the current directory.
281
+
282
+ git starts this hook in the channel's root, and Windows looked a bare `git`
283
+ up in the current directory before PATH (channel round 304). Inlined from
284
+ `graphite.programs.resolve_program`: `-I` cannot import graphite. Raises
285
+ rather than returning nothing, so the gate fails CLOSED -- an unreadable
286
+ registry must never read as an absent one, which is the bootstrap branch.
287
+ """
288
+ names = ("git.exe",) if os.name == "nt" else ("git",)
289
+ # The channel's whole subtree is refused; the cwd only itself, which is
290
+ # what Windows searches -- refusing everything beneath a cwd above git's
291
+ # install would refuse git.
292
+ channel = os.path.normcase(os.path.realpath(root))
293
+ cwd = os.path.normcase(os.path.realpath(os.getcwd()))
294
+
295
+ def refused(directory):
296
+ folded = os.path.normcase(directory)
297
+ return folded == cwd or folded == channel or folded.startswith(channel.rstrip(os.sep) + os.sep)
298
+
299
+ for raw in os.environ.get("PATH", "").split(os.pathsep):
300
+ if not raw or not os.path.isabs(raw):
301
+ continue
302
+ # The directory itself, as written and as resolved: a symlink inside
303
+ # the channel must not choose which outside git runs (1d8d8cf).
304
+ if refused(os.path.normpath(raw)) or refused(os.path.realpath(raw)):
305
+ continue
306
+ for name in names:
307
+ candidate = os.path.realpath(os.path.join(raw, name))
308
+ if not os.path.isfile(candidate):
309
+ continue
310
+ if os.name != "nt" and not os.access(candidate, os.X_OK):
311
+ continue
312
+ if refused(os.path.dirname(candidate)):
313
+ continue
314
+ return candidate
315
+ raise GitUnavailable("git was not found on PATH outside the channel")
316
+
317
+
272
318
  def _registry_at(root, spec):
273
319
  """Parse `agents.json` at a git revision spec.
274
320
 
@@ -282,7 +328,7 @@ def _registry_at(root, spec):
282
328
  is what the next commit will inherit as authority.
283
329
  """
284
330
  result = subprocess.run(
285
- ["git", "-C", root, "show", spec],
331
+ [_git_executable(root), "-C", root, "show", spec],
286
332
  capture_output=True,
287
333
  text=True,
288
334
  # Explicit, for the reason `_git` documents: `text=True` alone decodes
@@ -346,6 +392,8 @@ try:
346
392
  # Parses, authorises nobody, and is not the empty object a fresh channel
347
393
  # seeds. Nothing legitimate produces this, and the gate must not guess.
348
394
  sys.exit(3)
395
+ except GitUnavailable:
396
+ sys.exit(4)
349
397
  except Exception:
350
398
  # `SystemExit` derives from BaseException, so the exits above pass through
351
399
  # this handler untouched -- their codes survive.
@@ -405,6 +453,18 @@ EOF
405
453
  exit 1
406
454
  fi
407
455
 
456
+ if [ "$STATUS" -eq 4 ]; then
457
+ # `echo`, not `cat`, for the reason the missing-interpreter branch gives: an
458
+ # environment that has lost git may have lost the rest of its toolchain.
459
+ echo "[agent-channel] BLOCKED: git was not found on PATH outside the channel." >&2
460
+ echo "" >&2
461
+ echo "The audit gate reads the registry from HEAD with git, and will not run a" >&2
462
+ echo "git that sits inside the channel or the current directory. It cannot" >&2
463
+ echo "verify this commit, so it is refused rather than waved through. This is" >&2
464
+ echo "NOT a problem with your commit message. Put git on PATH, then commit again." >&2
465
+ exit 1
466
+ fi
467
+
408
468
  # The banner heredocs stay QUOTED. An earlier revision unquoted them to
409
469
  # interpolate `$PY`, which turned the audit gate's own output into a shell
410
470
  # expansion surface: any `$`, backtick or `$(...)` that later reaches this text
@@ -721,10 +781,18 @@ def _git(root: Path, *args: str, check: bool = True) -> str:
721
781
  refreshes stale stat data by REWRITING the index under `index.lock`, and a
722
782
  writer's `git add` that meets that lock fails. Only optional locks are
723
783
  skipped; `add` and `commit` still take the ones they need.
784
+
785
+ git is launched by ABSOLUTE path, from outside both the channel and the
786
+ directory this process stands in (channel round 304). The broker's current
787
+ directory is the agent's repository, and Windows looked a bare `git` up
788
+ there before PATH. See `graphite.programs`.
724
789
  """
790
+ git = resolve_program("git", exclude=(root,))
791
+ if git is None:
792
+ raise ChannelError("git_unavailable", "git was not found on PATH outside the repository")
725
793
  try:
726
794
  result = subprocess.run( # noqa: S603
727
- ["git", "-C", str(root), *args], # noqa: S607
795
+ [str(git), "-C", str(root), *args],
728
796
  capture_output=True,
729
797
  text=True,
730
798
  encoding="utf-8",
@@ -766,8 +834,9 @@ def _commit(root: Path, paths: list[str], subject: str, agent: str) -> str:
766
834
  return _git(root, "rev-parse", "--short", "HEAD").strip()
767
835
 
768
836
 
769
- def _uncommitted_residue(root: Path) -> list[str]:
770
- """Channel files an abandoned holder left behind uncommitted.
837
+ def _uncommitted_residue(root: Path) -> list[str] | None:
838
+ """Channel files an abandoned holder left behind uncommitted, or None when
839
+ the scan itself failed.
771
840
 
772
841
  Automatic recovery makes this MORE important, not less. `status_events`
773
842
  reads from DISK, so an orphaned `status/NNN/*.json` written by a holder that
@@ -776,7 +845,10 @@ def _uncommitted_residue(root: Path) -> list[str]:
776
845
  look; once the lock clears itself, nobody is looking unless we say so.
777
846
 
778
847
  Best-effort by construction: this runs while we hold nothing, and a failure
779
- to scan must never block a recovery that is otherwise safe.
848
+ to scan must never block a recovery that is otherwise safe. But it must not
849
+ read as a clean scan either (aramid llm-review 9751aab): `[]` says "the dead
850
+ holder left nothing", `None` says "we could not look". A wedged git -- the
851
+ likeliest cause of an abandoned lock -- is also what makes this fail.
780
852
  """
781
853
  try:
782
854
  # `--untracked-files=all`: bare `--porcelain` collapses a wholly
@@ -784,7 +856,7 @@ def _uncommitted_residue(root: Path) -> list[str]:
784
856
  # operator needs in order to go and look at it.
785
857
  porcelain = _git(root, "status", "--porcelain", "--untracked-files=all", check=False)
786
858
  except ChannelError:
787
- return []
859
+ return None
788
860
  residue = set()
789
861
  for line in porcelain.splitlines():
790
862
  path = line[3:].strip().strip('"')
@@ -893,9 +965,12 @@ class _Lock:
893
965
  started = (first or {}).get("started_at")
894
966
  if isinstance(started, (int, float)) and not isinstance(started, bool):
895
967
  held_for = round(self._clock() - float(started), 1)
968
+ # No `host`: it is in the record for the operator reading the lock file,
969
+ # but nothing here decides on it -- identity is (pid, started_at) and
970
+ # staleness is the clock -- so it does not go to every caller (aramid
971
+ # llm-review 9a23b84).
896
972
  self.recovered = {
897
973
  "pid": (first or {}).get("pid"),
898
- "host": (first or {}).get("host"),
899
974
  "held_seconds": held_for,
900
975
  "residue": _uncommitted_residue(self.root),
901
976
  }
@@ -1194,13 +1269,23 @@ def record_status(
1194
1269
  # --- inbox: notification and handover ---------------------------------------
1195
1270
 
1196
1271
 
1197
- def inbox(root: Path, project_root: Path) -> list[Round]:
1272
+ def inbox(
1273
+ root: Path,
1274
+ project_root: Path,
1275
+ *,
1276
+ on_recovered: Callable[[dict], None] | None = None,
1277
+ ) -> list[Round]:
1198
1278
  """Hand over rounds addressed to the caller that it has not been given yet.
1199
1279
 
1200
1280
  The `delivered` event is written HERE, by the broker, as the message goes
1201
1281
  out. The agent cannot assert it and cannot decline it, so an agent that
1202
1282
  ignores its inbox leaves a visible `delivered` with no follow-up rather than
1203
1283
  an absence indistinguishable from never having been told.
1284
+
1285
+ `on_recovered` receives each abandoned lock a delivery had to break. Every
1286
+ other writer returns that to its caller; this one returns rounds, and used
1287
+ to drop it -- on the busiest path, since every agent calls this at session
1288
+ start (aramid llm-review 1810f9e).
1204
1289
  """
1205
1290
  root = require_channel(root)
1206
1291
  agent = derive_identity(root, project_root)
@@ -1217,7 +1302,9 @@ def inbox(root: Path, project_root: Path) -> list[Round]:
1217
1302
  pending.append((entry.number, entry))
1218
1303
 
1219
1304
  for number, _entry in pending:
1220
- _write_status_event(root, number, "delivered", agent, broker=True)
1305
+ event = _write_status_event(root, number, "delivered", agent, broker=True)
1306
+ if on_recovered is not None and "lock_recovered" in event:
1307
+ on_recovered(event["lock_recovered"])
1221
1308
  return [entry for _number, entry in pending]
1222
1309
 
1223
1310
 
@@ -12,6 +12,7 @@ from pathlib import Path
12
12
  from typing import Any, Callable, TypeGuard
13
13
 
14
14
  from .daemon import DaemonStatusInvalidError, DaemonStatusTooLargeError, read_daemon_status
15
+ from .programs import system_program
15
16
  from .routing.lifecycle import LifecycleReasonCode, ProviderLifecycleState
16
17
  from .windows_task import DEFAULT_TASK_NAME
17
18
  from .windows_startup import startup_status
@@ -285,7 +286,9 @@ def check_daemon_process(base: Path) -> dict[str, Any]:
285
286
  )
286
287
  try:
287
288
  result = subprocess.run(
288
- ["powershell.exe", "-NoProfile", "-Command", ps],
289
+ # By absolute path: a bare `powershell.exe` is looked up in the
290
+ # current directory before System32 (channel round 304).
291
+ [str(system_program("WindowsPowerShell", "v1.0", "powershell.exe")), "-NoProfile", "-Command", ps],
289
292
  capture_output=True,
290
293
  text=True,
291
294
  # `errors` but deliberately NO `encoding`: Windows PowerShell 5.1
@@ -5,7 +5,6 @@ import importlib.util
5
5
  import json
6
6
  import math
7
7
  import os
8
- import shutil
9
8
  import subprocess
10
9
  import sys
11
10
  import threading
@@ -25,6 +24,7 @@ from .hookinstall import DEFAULT_HOOKS_DIRNAME, hook_shim_present, hooks_dir
25
24
  from .init import gitignored_managed_paths, managed_doc_paths
26
25
  from .hookshim import TRIGGERS
27
26
  from .llm import canonical_provider_name
27
+ from .programs import resolve_program
28
28
  from .validation import validate_graph_bundle
29
29
 
30
30
  DoctorStatus = Literal["ready", "optional", "degraded", "blocked"]
@@ -469,7 +469,10 @@ def check_daemon(root: Path, daemon_base: Path) -> DoctorCheck:
469
469
 
470
470
  def check_mcp() -> DoctorCheck:
471
471
  package = importlib.util.find_spec("mcp") is not None
472
- command = shutil.which("graphite-mcp") is not None
472
+ # Not `shutil.which`: on Windows it answers from the current directory
473
+ # first, so a `graphite-mcp.exe` sitting in the repo read as installed
474
+ # (channel round 304).
475
+ command = resolve_program("graphite-mcp", extensions=(".exe", ".cmd", ".bat", ".com")) is not None
473
476
  details = {"python_package": package, "command": command}
474
477
  if package and command:
475
478
  return DoctorCheck("mcp", "MCP", "ready", "MCP integration is available.", details)
@@ -498,26 +501,7 @@ def check_typescript(root: Path, *, timeout_seconds: float = 5.0) -> DoctorCheck
498
501
 
499
502
 
500
503
  def _resolve_node_executable(root: Path, *, platform_name: str | None = None) -> Path | None:
501
- selected_platform = os.name if platform_name is None else platform_name
502
- name = "node.exe" if selected_platform == "nt" else "node"
503
- resolved_root = root.resolve()
504
- for raw_directory in os.environ.get("PATH", "").split(os.pathsep):
505
- directory = Path(raw_directory)
506
- if not raw_directory or not directory.is_absolute():
507
- continue
508
- try:
509
- candidate = (directory / name).resolve(strict=True)
510
- if not candidate.is_file():
511
- continue
512
- if selected_platform != "nt" and not os.access(candidate, os.X_OK):
513
- continue
514
- try:
515
- candidate.relative_to(resolved_root)
516
- except ValueError:
517
- return candidate
518
- except OSError:
519
- continue
520
- return None
504
+ return resolve_program("node", exclude=(root,), platform_name=platform_name)
521
505
 
522
506
 
523
507
  def _node_environment() -> dict[str, str]:
@@ -53,6 +53,14 @@ _LLM_CATEGORIES = frozenset(
53
53
  )
54
54
  _LLM_OUTPUT_LIMIT_BYTES = 4096
55
55
  _LLM_TIMEOUT_MAX_SECONDS = 60.0
56
+ #: Added to the provider timeout to make the WORKER's process budget. The two
57
+ #: used to be one number, so the worker's interpreter start was spent from the
58
+ #: provider's time, and a loaded machine read `timeout` for a provider that was
59
+ #: merely misconfigured (2 s read `timeout` 7 in 10 under 36 CPU burners). The
60
+ #: worker enforces the provider timeout itself and reports `timeout` when the
61
+ #: provider is slow, so this only bounds a worker that never answers. Generous
62
+ #: on purpose: it costs nothing unless the worker hangs.
63
+ _LLM_WORKER_START_ALLOWANCE_SECONDS = 30.0
56
64
  _REQUIRED_MCP_TOOLS = frozenset(
57
65
  {"graphite_query", "graphite_summary", "graphite_community", "graphite_refresh"}
58
66
  )
@@ -2100,7 +2108,7 @@ def probe_llm(
2100
2108
  [str(python), "-I", "-S", "-B", str(worker)],
2101
2109
  cwd=python.parent,
2102
2110
  stdin=stdin,
2103
- timeout_seconds=timeout_seconds,
2111
+ timeout_seconds=timeout_seconds + _LLM_WORKER_START_ALLOWANCE_SECONDS,
2104
2112
  max_output_bytes=_LLM_OUTPUT_LIMIT_BYTES,
2105
2113
  check=False,
2106
2114
  )
@@ -9,6 +9,8 @@ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Sequence
11
11
 
12
+ from .programs import resolve_program
13
+
12
14
 
13
15
  DEFAULT_GIT_STDOUT_MAX_BYTES = 16 * 1024 * 1024
14
16
  _MINIMUM_GIT_VERSION = (2, 38, 0)
@@ -376,35 +378,12 @@ def _close_stdout(process: subprocess.Popen[bytes]) -> bool:
376
378
  def _resolve_git_executable(
377
379
  resolved_project_root: Path, *, platform_name: str | None = None
378
380
  ) -> Path:
379
- selected_platform = os.name if platform_name is None else platform_name
380
- executable_name = "git.exe" if selected_platform == "nt" else "git"
381
-
382
- for raw_directory in os.environ.get("PATH", "").split(os.pathsep):
383
- if not raw_directory:
384
- continue
385
- directory = Path(raw_directory)
386
- if not directory.is_absolute():
387
- continue
388
- candidate = directory / executable_name
389
- try:
390
- if not candidate.is_file():
391
- continue
392
- resolved_candidate = candidate.resolve(strict=True)
393
- if not resolved_candidate.is_file():
394
- continue
395
- if selected_platform != "nt" and not os.access(resolved_candidate, os.X_OK):
396
- continue
397
- try:
398
- resolved_candidate.relative_to(resolved_project_root)
399
- except ValueError:
400
- pass
401
- else:
402
- continue
403
- except OSError:
404
- continue
405
- return resolved_candidate
406
-
407
- raise GitUnavailableError("Git executable was not found")
381
+ resolved = resolve_program(
382
+ "git", exclude=(resolved_project_root,), platform_name=platform_name
383
+ )
384
+ if resolved is None:
385
+ raise GitUnavailableError("Git executable was not found")
386
+ return resolved
408
387
 
409
388
 
410
389
  def _isolated_environment() -> dict[str, str]:
@@ -26,6 +26,7 @@ from pathlib import Path
26
26
 
27
27
  from .config import default_projects_root
28
28
  from .hookshim import CHAINED_SUFFIX, MARKER_START, TRIGGERS, render_trigger_shim
29
+ from .programs import require_program
29
30
 
30
31
  DEFAULT_HOOKS_DIRNAME = ".githooks"
31
32
 
@@ -40,8 +41,12 @@ def _git(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
40
41
  # locale codec, and git echoes repository paths and branch names that are
41
42
  # not required to be Latin-1. A failure decodes on subprocess's reader
42
43
  # thread, so it yields `stdout is None` rather than raising.
44
+ #
45
+ # By absolute path, from outside the repo (channel round 304): `init` runs
46
+ # from the root of the repo being onboarded, and Windows looked a bare `git`
47
+ # up there before PATH. A missing git still raises FileNotFoundError.
43
48
  return subprocess.run(
44
- ["git", "-C", str(root), *args],
49
+ [str(require_program("git", exclude=(root,))), "-C", str(root), *args],
45
50
  capture_output=True,
46
51
  text=True,
47
52
  encoding="utf-8",
@@ -19,6 +19,7 @@ from .git import GitError, GitRunner
19
19
  from . import hookinstall
20
20
  from .hookinstall import DEFAULT_HOOKS_DIRNAME
21
21
  from .io import atomic_write_text
22
+ from .programs import require_program
22
23
 
23
24
  # Bump whenever GRAPHITE_DOC, SHARED_POINTER, or CURSOR_POINTER changes;
24
25
  # test_template_change_requires_doc_version_bump pins the pairing. Files
@@ -320,8 +321,10 @@ def _is_git_repo(root: Path) -> bool:
320
321
  """Ground truth, not a `.git`-exists heuristic: `--is-inside-work-tree`
321
322
  also answers correctly for a repo `root` nested inside a larger one,
322
323
  matching how `hookinstall`'s own `git -C root ...` calls resolve."""
324
+ # By absolute path, from outside the repo (channel round 304); see
325
+ # `hookinstall._git`.
323
326
  result = subprocess.run(
324
- ["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
327
+ [str(require_program("git", exclude=(root,))), "-C", str(root), "rev-parse", "--is-inside-work-tree"],
325
328
  capture_output=True, text=True,
326
329
  # See `channel._git`: `text=True` alone uses the locale codec, and a
327
330
  # decode failure returns None instead of raising -- which here would
@@ -172,8 +172,10 @@ class GraphiteMCPServer:
172
172
  def channel_inbox_tool(self) -> dict[str, Any]:
173
173
  from .channel import inbox
174
174
 
175
- return self._channel_call(
176
- lambda root: {
175
+ def handover(root: Path) -> dict[str, Any]:
176
+ recovered: list[dict] = []
177
+ rounds = inbox(root, self.project_root, on_recovered=recovered.append)
178
+ result: dict[str, Any] = {
177
179
  "ok": True,
178
180
  "rounds": [
179
181
  {
@@ -183,10 +185,16 @@ class GraphiteMCPServer:
183
185
  "posted": entry.posted,
184
186
  "body": entry.body,
185
187
  }
186
- for entry in inbox(root, self.project_root)
188
+ for entry in rounds
187
189
  ],
188
190
  }
189
- )
191
+ # Present only when a lock was broken, like `post` and `status`: a
192
+ # quiet key on every response is noise nobody reads.
193
+ if recovered:
194
+ result["lock_recovered"] = recovered
195
+ return result
196
+
197
+ return self._channel_call(handover)
190
198
 
191
199
  def channel_status_tool(
192
200
  self, *, number: int, status: str, reason: str | None = None