graphite-code 1.0.1__tar.gz → 1.1.0__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 (289) hide show
  1. {graphite_code-1.0.1 → graphite_code-1.1.0}/CHANGELOG.md +126 -0
  2. {graphite_code-1.0.1 → graphite_code-1.1.0}/PKG-INFO +2 -2
  3. {graphite_code-1.0.1 → graphite_code-1.1.0}/README.md +1 -1
  4. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/__init__.py +1 -1
  5. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/channel.py +282 -29
  6. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/cli.py +25 -1
  7. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/doctor_probes.py +28 -2
  8. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/mcp_server.py +29 -9
  9. graphite_code-1.1.0/tests/test__win32_ctypes.py +38 -0
  10. graphite_code-1.1.0/tests/test_channel_attribution.py +254 -0
  11. graphite_code-1.1.0/tests/test_channel_log_integrity.py +216 -0
  12. graphite_code-1.1.0/tests/test_cli_channel_attribution.py +84 -0
  13. graphite_code-1.1.0/tests/test_cluster.py +123 -0
  14. graphite_code-1.1.0/tests/test_context_builder.py +312 -0
  15. graphite_code-1.1.0/tests/test_dependency_install.py +242 -0
  16. graphite_code-1.1.0/tests/test_doctor_probes.py +390 -0
  17. graphite_code-1.1.0/tests/test_llm_probe.py +261 -0
  18. graphite_code-1.1.0/tests/test_mcp_server.py +205 -0
  19. graphite_code-1.1.0/tests/test_mcp_server_channel_attribution.py +191 -0
  20. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_mutation_tests.py +19 -2
  21. graphite_code-1.1.0/tests/test_ollama_executor.py +272 -0
  22. graphite_code-1.1.0/tests/test_probe_process.py +207 -0
  23. graphite_code-1.1.0/tests/test_process_contracts.py +74 -0
  24. graphite_code-1.1.0/tests/test_route_pool_execution.py +363 -0
  25. graphite_code-1.1.0/tests/test_ts_bridge.py +241 -0
  26. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_typescript_activation.py +38 -0
  27. {graphite_code-1.0.1 → graphite_code-1.1.0}/.gitignore +0 -0
  28. {graphite_code-1.0.1 → graphite_code-1.1.0}/ARCHITECTURE.md +0 -0
  29. {graphite_code-1.0.1 → graphite_code-1.1.0}/CONTRIBUTING.md +0 -0
  30. {graphite_code-1.0.1 → graphite_code-1.1.0}/LICENSE +0 -0
  31. {graphite_code-1.0.1 → graphite_code-1.1.0}/RELEASING.md +0 -0
  32. {graphite_code-1.0.1 → graphite_code-1.1.0}/pyproject.toml +0 -0
  33. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/__main__.py +0 -0
  34. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/_cleanup_worker.py +0 -0
  35. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/_win32_ctypes.py +0 -0
  36. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/activation.py +0 -0
  37. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/agent_hooks.py +0 -0
  38. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/agent_settings.py +0 -0
  39. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/analyze.py +0 -0
  40. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/answer_contract.py +0 -0
  41. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/bootstrap.py +0 -0
  42. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/buildlock.py +0 -0
  43. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/cache.py +0 -0
  44. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/cluster.py +0 -0
  45. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/config.py +0 -0
  46. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/context.py +0 -0
  47. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/daemon.py +0 -0
  48. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/daemon_health.py +0 -0
  49. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/daemon_launch.py +0 -0
  50. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/debt.py +0 -0
  51. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/dependency_install.py +0 -0
  52. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/detach.py +0 -0
  53. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/doctor.py +0 -0
  54. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/engine_identity.py +0 -0
  55. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/__init__.py +0 -0
  56. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/html.py +0 -0
  57. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/json.py +0 -0
  58. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/md.py +0 -0
  59. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/extract/__init__.py +0 -0
  60. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/extract/ast.py +0 -0
  61. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/freshness.py +0 -0
  62. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/git.py +0 -0
  63. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/graph.py +0 -0
  64. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/graph_io.py +0 -0
  65. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/health.py +0 -0
  66. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/hook_entry.py +0 -0
  67. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/hookinstall.py +0 -0
  68. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/hookshim.py +0 -0
  69. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/incident_ledger.py +0 -0
  70. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/ingest.py +0 -0
  71. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/init.py +0 -0
  72. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/io.py +0 -0
  73. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/launchd_agent.py +0 -0
  74. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/listing.py +0 -0
  75. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/llm.py +0 -0
  76. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/llm_probe.py +0 -0
  77. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/mcp.py +0 -0
  78. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/natural_query.py +0 -0
  79. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/overlays.py +0 -0
  80. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/probe_process.py +0 -0
  81. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/probe_workspace.py +0 -0
  82. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/process_contracts.py +0 -0
  83. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/provider_observer.py +0 -0
  84. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/py.typed +0 -0
  85. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/query.py +0 -0
  86. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/query_plan.py +0 -0
  87. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/replacement_audit.py +0 -0
  88. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/resolve.py +0 -0
  89. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/review.py +0 -0
  90. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/__init__.py +0 -0
  91. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/approval.py +0 -0
  92. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/classifier.py +0 -0
  93. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/claude_executor.py +0 -0
  94. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/claude_probe.py +0 -0
  95. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/cli_identity.py +0 -0
  96. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/codex_executor.py +0 -0
  97. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/codex_probe.py +0 -0
  98. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/context_builder.py +0 -0
  99. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/contracts.py +0 -0
  100. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/diff_policy.py +0 -0
  101. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/edit_apply.py +0 -0
  102. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/effort.py +0 -0
  103. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle.py +0 -0
  104. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle_operator.py +0 -0
  105. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle_service.py +0 -0
  106. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle_storage.py +0 -0
  107. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/ollama_executor.py +0 -0
  108. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/ollama_probe.py +0 -0
  109. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/openrouter_executor.py +0 -0
  110. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/openrouter_probe.py +0 -0
  111. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/policy.py +0 -0
  112. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/probe_runner.py +0 -0
  113. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/process_runner.py +0 -0
  114. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/profiles.py +0 -0
  115. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/prompt.py +0 -0
  116. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/registry.py +0 -0
  117. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/route_pool.py +0 -0
  118. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/route_pool_execution.py +0 -0
  119. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/schema_validation.py +0 -0
  120. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/service.py +0 -0
  121. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/settings.py +0 -0
  122. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/shadow.py +0 -0
  123. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/storage.py +0 -0
  124. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/telemetry.py +0 -0
  125. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/worktree.py +0 -0
  126. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/zai_edit.py +0 -0
  127. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/zai_executor.py +0 -0
  128. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/zai_probe.py +0 -0
  129. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/savings.py +0 -0
  130. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/systemd_unit.py +0 -0
  131. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/ts_bridge.py +0 -0
  132. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/ts_resolver.mjs +0 -0
  133. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/typescript_activation.py +0 -0
  134. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/usage_ledger.py +0 -0
  135. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/validation.py +0 -0
  136. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/watch.py +0 -0
  137. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/windows_job.py +0 -0
  138. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/windows_startup.py +0 -0
  139. {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/windows_task.py +0 -0
  140. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/conftest.py +0 -0
  141. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/data/sqloracle/blindspots.py.txt +0 -0
  142. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/data/sqloracle/round69.py.txt +0 -0
  143. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fake_clis/fake_cli.py +0 -0
  144. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/provider_lifecycle_schema_v1.sql +0 -0
  145. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/routing_schema_v2_ca77600.sql +0 -0
  146. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/routing_schema_v3_94eb333.sql +0 -0
  147. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/routing_schema_v4_lifecycle_migration.sql +0 -0
  148. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_activation.py +0 -0
  149. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_activation_backstop.py +0 -0
  150. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_agent_hook_activation.py +0 -0
  151. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_agent_hooks.py +0 -0
  152. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_agent_settings.py +0 -0
  153. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_answer_contract.py +0 -0
  154. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_arrow_and_constructor_binding.py +0 -0
  155. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_atomic_write.py +0 -0
  156. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_bootstrap.py +0 -0
  157. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_build_detach.py +0 -0
  158. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_buildlock.py +0 -0
  159. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_engine_identity.py +0 -0
  160. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_entry_pruning.py +0 -0
  161. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_file_set.py +0 -0
  162. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_pruning.py +0 -0
  163. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_call_graph.py +0 -0
  164. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_cli.py +0 -0
  165. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_core.py +0 -0
  166. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_lock.py +0 -0
  167. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_mcp.py +0 -0
  168. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_register.py +0 -0
  169. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_report.py +0 -0
  170. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_status.py +0 -0
  171. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_class_field_arrow_binding.py +0 -0
  172. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cli_channel.py +0 -0
  173. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cli_reference.py +0 -0
  174. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cli_version.py +0 -0
  175. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_compare_dist.py +0 -0
  176. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_configuration_reference.py +0 -0
  177. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_context.py +0 -0
  178. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cwd_relative_config.py +0 -0
  179. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cycle_search.py +0 -0
  180. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon.py +0 -0
  181. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_activation.py +0 -0
  182. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_build_lock.py +0 -0
  183. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_child_lock.py +0 -0
  184. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_engine_staleness.py +0 -0
  185. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_health.py +0 -0
  186. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_health_activation.py +0 -0
  187. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_launch.py +0 -0
  188. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_service_cli.py +0 -0
  189. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_debt.py +0 -0
  190. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_destructured_binding.py +0 -0
  191. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_determinism.py +0 -0
  192. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_dispatch_evidence.py +0 -0
  193. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor.py +0 -0
  194. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor_agent_hooks.py +0 -0
  195. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor_hooks.py +0 -0
  196. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor_managed_docs.py +0 -0
  197. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_documentation.py +0 -0
  198. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_edit_apply.py +0 -0
  199. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_engine_identity.py +0 -0
  200. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_exit_codes_reference.py +0 -0
  201. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_external_calls.py +0 -0
  202. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_git_security.py +0 -0
  203. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_go_rust.py +0 -0
  204. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_graph_io.py +0 -0
  205. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_graph_provider_isolation.py +0 -0
  206. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hardening.py +0 -0
  207. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_health.py +0 -0
  208. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hook_entry.py +0 -0
  209. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hook_template.py +0 -0
  210. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hookinstall.py +0 -0
  211. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hookshim.py +0 -0
  212. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_html_security.py +0 -0
  213. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_incident_ledger.py +0 -0
  214. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init.py +0 -0
  215. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init_activation_doctrine.py +0 -0
  216. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init_hooks.py +0 -0
  217. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init_validation_exit.py +0 -0
  218. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_launchd_agent.py +0 -0
  219. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_lifecycle_operator.py +0 -0
  220. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_listing.py +0 -0
  221. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_listing_surfaces.py +0 -0
  222. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_llm.py +0 -0
  223. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_mcp.py +0 -0
  224. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_method_dispatch_scope.py +0 -0
  225. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_monorepo.py +0 -0
  226. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_natural_query.py +0 -0
  227. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_node_identity.py +0 -0
  228. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_overlays.py +0 -0
  229. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_path_leak_fixture.py +0 -0
  230. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_probe_diagnostics.py +0 -0
  231. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_probe_workspace.py +0 -0
  232. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_claude_probe.py +0 -0
  233. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_codex_probe.py +0 -0
  234. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_lifecycle.py +0 -0
  235. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_lifecycle_service.py +0 -0
  236. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_lifecycle_storage.py +0 -0
  237. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_observer.py +0 -0
  238. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_ollama_probe.py +0 -0
  239. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_openrouter_probe.py +0 -0
  240. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_probe_runner.py +0 -0
  241. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_public_surface.py +0 -0
  242. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_published_schemas.py +0 -0
  243. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_python_resolver.py +0 -0
  244. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_query_plan.py +0 -0
  245. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_reliability.py +0 -0
  246. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_replacement_audit.py +0 -0
  247. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_resolve.py +0 -0
  248. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_review.py +0 -0
  249. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_route_pool.py +0 -0
  250. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_approval.py +0 -0
  251. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_benchmark.py +0 -0
  252. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_classifier.py +0 -0
  253. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_claude_executor.py +0 -0
  254. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_cli.py +0 -0
  255. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_cli_contracts.py +0 -0
  256. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_cli_recovery.py +0 -0
  257. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_codex_executor.py +0 -0
  258. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_context.py +0 -0
  259. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_contracts.py +0 -0
  260. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_diff_policy.py +0 -0
  261. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_executor.py +0 -0
  262. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_openrouter_executor.py +0 -0
  263. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_policy.py +0 -0
  264. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_process_runner.py +0 -0
  265. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_profiles.py +0 -0
  266. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_registry.py +0 -0
  267. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_schema_validation.py +0 -0
  268. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_security.py +0 -0
  269. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_service.py +0 -0
  270. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_shadow.py +0 -0
  271. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_storage.py +0 -0
  272. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_telemetry.py +0 -0
  273. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_worktree.py +0 -0
  274. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_zai_executor.py +0 -0
  275. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_zai_probe.py +0 -0
  276. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_savings.py +0 -0
  277. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_search.py +0 -0
  278. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_smoke.py +0 -0
  279. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_sqloracle.py +0 -0
  280. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_synthetic_repo.py +0 -0
  281. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_systemd_unit.py +0 -0
  282. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_typescript_resolver.py +0 -0
  283. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_usage_ledger.py +0 -0
  284. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_verify_artifact.py +0 -0
  285. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_verify_published_release.py +0 -0
  286. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_watch.py +0 -0
  287. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_windows_startup.py +0 -0
  288. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_windows_task.py +0 -0
  289. {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_zai_edit.py +0 -0
@@ -13,6 +13,132 @@ machine-checkable identity; the version is for humans.
13
13
 
14
14
  [Keep a Changelog]: https://keepachangelog.com/en/1.1.0/
15
15
 
16
+ ## [Unreleased]
17
+
18
+ ## [1.1.0] — 2026-09-27
19
+
20
+ A minor release. The agent channel's read surfaces gain fields, which is an
21
+ addition under `docs/compatibility.md`. The channel's audit trail also
22
+ closes three holes: status events taken on trust, deletions nobody saw, and
23
+ an audit gate that could fail open. The graph itself does not change. On
24
+ this repository, measured against the deployed 1.0.1 engine, 7659 of
25
+ 7659 node ids survive, 21788 of 21788 edge triples are unchanged, every
26
+ relation count is equal, and `analysis.cycles` is identical. Only the
27
+ engine fingerprint moves, because graphite's own bytes did.
28
+
29
+ ### Added
30
+
31
+ **Every channel status now says who recorded it, and where each recipient
32
+ stands.** `graphite_channel_list`, `graphite_channel_read` and `graphite
33
+ channel list --json` carry `status_actor`, `status_at` and
34
+ `status_verification` beside `status`, plus `recipients` (each recipient's
35
+ own latest status and its verification, or `null`) and `unreceipted`
36
+ (recipients with no event of any kind). `status` keeps its
37
+ meaning: the newest event by any agent. `graphite_channel_read` also returns
38
+ `history`, where every event carries its actor, time, commit and
39
+ verification grade. `graphite channel show N` prints the same history after
40
+ the body. Measured on the live channel on 2026-09-25: the one folded word
41
+ misstated at least one recipient in 39 of the 212 rounds that carry events.
42
+ Round 256 read `acknowledged` while one of its two recipients had recorded
43
+ nothing. The read tool's description now says that reading records nothing,
44
+ and that `graphite_channel_inbox` registers receipt.
45
+
46
+ ### Fixed
47
+
48
+ **The channel report now verifies status events against their commits, as it
49
+ already did for rounds.** A status event's `actor` was taken on trust. An
50
+ event written by hand under another agent's trailer, rewritten after its
51
+ commit, or edited and left uncommitted was invisible, because the forged
52
+ actor was a participant and the file was tracked. Each event is now graded
53
+ from one `git log` over `status/`. It is `verified` only when it has exactly
54
+ one commit, a clean working copy, and a trailer naming its actor.
55
+ `status_modified`, `status_discrepancy` and `status_uncommitted` are
56
+ anomalies and fail `ok`. On the live channel all 408 events verified, with
57
+ a planted-forgery control flagged. A comment had claimed this check existed.
58
+ The read surfaces carry the same grade, so an actor is never shown bare:
59
+ `status_verification` sits beside `status_actor`, each recipient's entry has
60
+ its own `verification`, and the human report marks a failing attribution
61
+ `UNVERIFIED`. This closes aramid llm-review finding `53368ee`.
62
+ One trust limit remains, and it is stated here because it bounds what
63
+ "verified" means. Every agent commits under the operator's git identity, so
64
+ a trailer proves which agent label was attached, not which process
65
+ committed. A hand-made commit under the claimed actor's own trailer still
66
+ grades `verified`. Closing that needs signed commits.
67
+
68
+ **The channel report now sees deletions, and a status event's number is
69
+ never reused.** The report graded only the events and rounds still on disk,
70
+ and a new event took `len(files) + 1`. An agent could therefore delete its
71
+ own `blocked` event, commit the deletion, write a `done` into the same slot,
72
+ and the report still read OK. A round file removed the same way vanished
73
+ from the audit view entirely. The next number is now one past the highest
74
+ the round has ever used, on disk or in history, and a history git cannot
75
+ read refuses the write instead of passing for an empty one. Any status file
76
+ or round that history or the index knows but the working tree lacks is
77
+ reported as `status_deleted` or `round_deleted`, and a hole in a round's
78
+ 1..N sequence is reported as `status_seq_gap`. All three fail `ok`. The live channel's history
79
+ holds no deletions or renames, so none fire there. This closes aramid
80
+ llm-review finding `5db5889`.
81
+
82
+ **Installing the channel's audit gate now fails closed.**
83
+ `ensure_channel_hook` set `core.hooksPath` with `check=False` and always
84
+ reported `changed: True`. A gate git never armed (a locked `.git/config`, or a
85
+ higher-precedence override) was therefore reported as in place, and later
86
+ commits would have passed with no agent trailer. The write now raises on
87
+ failure. The value git will actually use is read back, and anything other
88
+ than `.githooks` raises `hook_not_armed`. `changed` reports the observed
89
+ before-and-after state. This closes aramid llm-review finding `0e87805`.
90
+
91
+ **Stalling is judged per recipient.** A co-recipient's `done` no longer
92
+ hides a recipient that was handed the round and never followed up. A
93
+ withdrawn or superseded round never stalls. Rows gain `stalled_recipients`.
94
+ The report lists recipients with no receipt per agent under `unreceipted`.
95
+ They are visible but do not fail `ok`, because several registered agents
96
+ have never called inbox, and a check that is always red is one nobody
97
+ reads.
98
+
99
+ **Reading the channel no longer rewrites its git index.** The report's
100
+ `git status` refreshed stale stat data by rewriting `.git/index` under
101
+ `index.lock`, the lock a concurrent broker `git add` needs. Channel git now
102
+ runs with `GIT_OPTIONAL_LOCKS=0`. A test pins the index bytes across a
103
+ report and a read with stale stat data.
104
+
105
+ **A PEP 508 marker of the shape `(A) or (B)` was mis-parsed by the doctor's
106
+ MCP distribution walk.** The marker evaluator unwrapped outer parentheses by
107
+ checking only that the text started with `(` and ended with `)`, so
108
+ `(extra == "a") or (python_version == "3.14")` was stripped to
109
+ `extra == "a") or (python_version == "3.14"`. An `extra` marker of that shape
110
+ then failed closed to "does not apply"; a plain marker of that shape raised
111
+ and aborted the whole distribution walk, which is `probe_mcp`'s manifest
112
+ source. Outer parentheses are now unwrapped only when the first `(` is closed
113
+ by the last character, counting depth outside quoted values. Found by the
114
+ new `tests/test_doctor_probes.py`; no installed distribution on the reference
115
+ machine carried the shape, which is why the walk had never failed.
116
+
117
+ ### Changed
118
+
119
+ **`scripts/mutation_tests.py` targets `scripts/` changes too.** The
120
+ certifying launcher for aramid's mutation gate diffed `src/` only, so a
121
+ mutant in the launcher itself skipped the targeted stage and its confirm ran
122
+ the whole suite (465 s on the gate's first live drain, 2026-09-04, where the
123
+ targeted stage kills in seconds). A changed `scripts/<stem>.py` now targets
124
+ `tests/test_<stem>.py` and `tests/test_<stem>_*.py` like a changed source
125
+ module.
126
+
127
+ **Twelve modules gained a `tests/test_<stem>.py`.** aramid's mutation stage 1
128
+ runs `tests/test_<module>.py` when it exists and otherwise `pytest -k
129
+ <module>`; a run that selects nothing is booked a stage-1 survivor and every
130
+ such mutant goes to a full-suite confirm, of which an item gets three. Nine
131
+ modules had a `-k` fallback that collected zero tests (`doctor_probes`,
132
+ `dependency_install`, `probe_process`, `ts_bridge`, `_win32_ctypes`,
133
+ `process_contracts`, `routing/ollama_executor`, `routing/context_builder`,
134
+ `routing/route_pool_execution`) and three collected one (`mcp_server`,
135
+ `llm_probe`, `cluster`). Each now has a stage-1 file that pins its pure
136
+ helpers directly; every file was proven able to fail by a hand mutant of its
137
+ module. Three quirks were pinned as-is rather than changed: a cluster's
138
+ kind label is `f"{kind}s"` verbatim (`classs`), a blank path in a TypeScript
139
+ bridge edge normalises to `.` instead of being dropped, and an empty MCP
140
+ `node_id` fuzzy-matches the first node.
141
+
16
142
  ## [1.0.1] — 2026-09-05
17
143
 
18
144
  A patch release. Measured against the deployed 1.0.0 engine on this repository,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: graphite-code
3
- Version: 1.0.1
3
+ Version: 1.1.0
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.0.1, production/stable.** Published on PyPI as
51
+ **Status: 1.1.0, 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.0.1, production/stable.** Published on PyPI as
5
+ **Status: 1.1.0, 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.0.1"
41
+ __version__ = "1.1.0"
@@ -64,10 +64,11 @@ _LOCK_TIMEOUT_SECONDS = 30.0
64
64
  # would strand every other agent behind a holder that is already doomed.
65
65
  #
66
66
  # Note this holds per CALL, not per critical section. `register_agent` runs
67
- # `ensure_channel_hook`'s config call plus a three-call `_commit`, so a
68
- # perfectly healthy holder can legitimately sit for ~80s -- well over the 30s a
69
- # waiter is willing to wait. A `lock_timeout` is therefore not by itself
70
- # evidence of a wedge.
67
+ # `ensure_channel_hook`'s three config calls (read, set, verify) plus a
68
+ # three-call `_commit`, so a perfectly healthy holder can legitimately sit for
69
+ # ~120s -- well over the 30s a waiter is willing to wait. (A status write is
70
+ # four: the sequence read plus `_commit`.) A `lock_timeout` is therefore not by
71
+ # itself evidence of a wedge.
71
72
  _GIT_TIMEOUT_SECONDS = 20.0
72
73
 
73
74
  #: How long before an unreleased lock is assumed abandoned.
@@ -79,7 +80,7 @@ _GIT_TIMEOUT_SECONDS = 20.0
79
80
  #: checking. The `pid` and `host` in the record are DIAGNOSTIC ONLY -- they tell
80
81
  #: an operator who to go look at; they are never probed.
81
82
  #:
82
- #: 300s is ~3.7x the ~80s worst-case LEGITIMATE hold derived above. The margin
83
+ #: 300s is 2.5x the ~120s worst-case LEGITIMATE hold derived above. The margin
83
84
  #: is lopsided on purpose: breaking a live lock puts two writers in the critical
84
85
  #: section, which is the exact failure this lock exists to prevent, and a torn
85
86
  #: status directory is worse than a wedge that clears itself five minutes later.
@@ -438,14 +439,29 @@ exit 1
438
439
 
439
440
 
440
441
  def ensure_channel_hook(root: Path) -> dict:
441
- """Install/refresh the audit gate and point `core.hooksPath` at it."""
442
+ """Install/refresh the audit gate and point `core.hooksPath` at it.
443
+
444
+ Fails CLOSED. The config call used to run with `check=False` and the result
445
+ always read `changed: True`, so a gate git never armed (a locked
446
+ `.git/config`, a higher-precedence override) was reported as in place --
447
+ and every later commit would have gone through with no trailer. `changed`
448
+ now describes what was observed before and after, not what was attempted.
449
+ """
442
450
  hooks = root / ".githooks"
443
451
  hooks.mkdir(parents=True, exist_ok=True)
444
452
  path = hooks / "commit-msg"
453
+ before = path.read_text(encoding="utf-8") if path.exists() else None
454
+ armed_before = _git(root, "config", "--get", "core.hooksPath", check=False).strip()
445
455
  path.write_text(COMMIT_MSG_HOOK, encoding="utf-8", newline="\n")
446
456
  path.chmod(0o755)
447
- _git(root, "config", "core.hooksPath", ".githooks", check=False)
448
- return {"path": str(path), "changed": True}
457
+ _git(root, "config", "core.hooksPath", ".githooks")
458
+ armed = _git(root, "config", "--get", "core.hooksPath", check=False).strip()
459
+ if armed != ".githooks":
460
+ raise ChannelError(
461
+ "hook_not_armed",
462
+ f"core.hooksPath reads {armed!r}, not '.githooks': the channel's audit gate is not armed",
463
+ )
464
+ return {"path": str(path), "changed": before != COMMIT_MSG_HOOK or armed_before != ".githooks"}
449
465
 
450
466
 
451
467
  def _committed_agents(root: Path) -> set[str]:
@@ -699,6 +715,12 @@ def _git(root: Path, *args: str, check: bool = True) -> str:
699
715
  `errors="replace"` because git also echoes paths, and a byte sequence that
700
716
  is not valid UTF-8 must not be able to take the audit surface down. It also
701
717
  makes the decode total, which is what keeps `result.stdout` a `str`.
718
+
719
+ `GIT_OPTIONAL_LOCKS=0`: reads run git here too (the report, `read`'s
720
+ history), beside other agents' brokers writing. A plain `git status`
721
+ refreshes stale stat data by REWRITING the index under `index.lock`, and a
722
+ writer's `git add` that meets that lock fails. Only optional locks are
723
+ skipped; `add` and `commit` still take the ones they need.
702
724
  """
703
725
  try:
704
726
  result = subprocess.run( # noqa: S603
@@ -710,6 +732,7 @@ def _git(root: Path, *args: str, check: bool = True) -> str:
710
732
  check=False,
711
733
  stdin=subprocess.DEVNULL,
712
734
  timeout=_GIT_TIMEOUT_SECONDS,
735
+ env={**os.environ, "GIT_OPTIONAL_LOCKS": "0"},
713
736
  )
714
737
  except subprocess.TimeoutExpired:
715
738
  raise ChannelError(
@@ -721,6 +744,21 @@ def _git(root: Path, *args: str, check: bool = True) -> str:
721
744
  return result.stdout
722
745
 
723
746
 
747
+ def _log(root: Path, *args: str) -> str:
748
+ """`git log` over the channel, where a channel with no commits yet HAS a
749
+ history: an empty one.
750
+
751
+ git exits 128 on `log` before the first commit, and that read as a failure
752
+ -- `channel list` errored on a fresh channel once list and read graded
753
+ events against history. Every OTHER failure still raises: the next status
754
+ number is read from history, and a failed read taken for an empty one
755
+ hands a deleted event's number out again.
756
+ """
757
+ if not _git(root, "rev-parse", "--verify", "--quiet", "HEAD", check=False).strip():
758
+ return ""
759
+ return _git(root, "log", *args)
760
+
761
+
724
762
  def _commit(root: Path, paths: list[str], subject: str, agent: str) -> str:
725
763
  message = f"{subject}\n\n{trailer(agent)}\n"
726
764
  _git(root, "add", "--", *paths)
@@ -993,6 +1031,86 @@ def current_status(root: Path, number: int) -> dict | None:
993
1031
  return events[-1] if events else None
994
1032
 
995
1033
 
1034
+ def status_view(entry: Round, events: list[dict], grader: EventGrader) -> dict:
1035
+ """What a reader needs beside the folded status: who set it, and where EACH
1036
+ recipient stands -- each with whether that attribution checks out.
1037
+
1038
+ The fold alone is one word across every actor. On a shared round it spoke
1039
+ for recipients who had recorded nothing -- round 256 read `acknowledged`
1040
+ while one of its two recipients had no event at all. And an actor is only
1041
+ what the event file CLAIMS: a forged file names a real recipient, so the
1042
+ name travels with its grade against the file's own commit (aramid
1043
+ llm-review 53368eee), never bare.
1044
+ """
1045
+ current = events[-1] if events else None
1046
+ latest: dict[str, dict] = {}
1047
+ for event in events:
1048
+ actor = event.get("actor")
1049
+ if isinstance(actor, str):
1050
+ latest[actor] = event
1051
+
1052
+ def grade(event: dict) -> str | None:
1053
+ return grader.grade(entry.number, event)[0] if entry.number is not None else None
1054
+
1055
+ return {
1056
+ "status": current.get("status") if current else None,
1057
+ "status_actor": current.get("actor") if current else None,
1058
+ "status_at": current.get("at") if current else None,
1059
+ "status_verification": grade(current) if current else None,
1060
+ "recipients": {
1061
+ agent: (
1062
+ {
1063
+ **{key: latest[agent].get(key) for key in ("status", "at", "seq")},
1064
+ "verification": grade(latest[agent]),
1065
+ }
1066
+ if agent in latest
1067
+ else None
1068
+ )
1069
+ for agent in entry.to
1070
+ },
1071
+ # No event of any kind: the broker never handed it over (no inbox call)
1072
+ # and the recipient never asserted anything either.
1073
+ "unreceipted": [agent for agent in entry.to if agent not in latest],
1074
+ }
1075
+
1076
+
1077
+ def status_history(root: Path, number: int, grader: EventGrader | None = None) -> list[dict]:
1078
+ """Every event for one round, oldest first, each graded against its commit."""
1079
+ events = status_events(root, number)
1080
+ if not events:
1081
+ return []
1082
+ grader = grader or EventGrader.load(root, f"{STATUS_DIRNAME}/{number:03d}")
1083
+ history = []
1084
+ for event in events:
1085
+ verification, created = grader.grade(number, event)
1086
+ item = {key: event.get(key) for key in ("seq", "status", "actor", "broker", "at", "reason")}
1087
+ if event.get("malformed"):
1088
+ item["malformed"] = True
1089
+ item.update(file=event["file"], commit=created, verification=verification)
1090
+ history.append(item)
1091
+ return history
1092
+
1093
+
1094
+ def _seq_of(name: str) -> int | None:
1095
+ """The sequence number a status file's name carries (`0003-done.json` -> 3)."""
1096
+ head = name[:4]
1097
+ return int(head) if head.isdigit() else None
1098
+
1099
+
1100
+ def _next_seq(root: Path, number: int) -> int:
1101
+ """One past the highest number this round has EVER used, on disk or in history.
1102
+
1103
+ It used to be `len(files) + 1`, which hands a deleted event's number to the
1104
+ next writer: delete your own `blocked`, write `done` into its slot, and the
1105
+ log reads as though `blocked` never happened.
1106
+ """
1107
+ prefix = f"{STATUS_DIRNAME}/{number:03d}"
1108
+ names = [path.name for path in (root / prefix).glob("*.json")]
1109
+ history = _log(root, "--format=", "--name-only", "--", prefix)
1110
+ names += [Path(line.strip()).name for line in history.splitlines() if line.strip()]
1111
+ return max((seq for seq in map(_seq_of, names) if seq is not None), default=0) + 1
1112
+
1113
+
996
1114
  def _write_status_event(
997
1115
  root: Path,
998
1116
  number: int,
@@ -1005,7 +1123,7 @@ def _write_status_event(
1005
1123
  with _Lock(root) as lock:
1006
1124
  directory = _status_dir(root, number)
1007
1125
  directory.mkdir(parents=True, exist_ok=True)
1008
- seq = len(list(directory.glob("*.json"))) + 1
1126
+ seq = _next_seq(root, number)
1009
1127
  name = f"{seq:04d}-{status}.json"
1010
1128
  target = directory / name
1011
1129
  if target.exists():
@@ -1023,8 +1141,8 @@ def _write_status_event(
1023
1141
  "reason": reason,
1024
1142
  }
1025
1143
  target.write_text(json.dumps(event, indent=2, sort_keys=True) + "\n", encoding="utf-8")
1026
- # Committed under the actor's trailer so the report can compare the two
1027
- # and catch an event whose recorded actor is not who committed it.
1144
+ # Committed under the actor's trailer so `_verify_event` can compare the
1145
+ # two and catch an event whose recorded actor is not who committed it.
1028
1146
  event["commit"] = _commit(
1029
1147
  root,
1030
1148
  [f"{STATUS_DIRNAME}/{number:03d}/{name}"],
@@ -1116,12 +1234,13 @@ FAILING_VERIFICATIONS = frozenset({"uncommitted", "modified", "discrepancy"})
1116
1234
  _TRAILER = re.compile(r"^Co-Authored-By:\s*(\S+-agent)\s*<", re.MULTILINE)
1117
1235
 
1118
1236
 
1119
- def _tracked(root: Path) -> set[str]:
1120
- return {line.strip() for line in _git(root, "ls-files").splitlines() if line.strip()}
1237
+ def _tracked(root: Path, *pathspec: str) -> set[str]:
1238
+ out = _git(root, "ls-files", "--", *pathspec)
1239
+ return {line.strip() for line in out.splitlines() if line.strip()}
1121
1240
 
1122
1241
 
1123
1242
  def _commits_for(root: Path, rel: str) -> list[str]:
1124
- out = _git(root, "log", "--format=%H", "--", rel)
1243
+ out = _log(root, "--format=%H", "--", rel)
1125
1244
  return [line.strip() for line in out.splitlines() if line.strip()]
1126
1245
 
1127
1246
 
@@ -1130,16 +1249,81 @@ def _commit_agent(root: Path, sha: str) -> str | None:
1130
1249
  return match.group(1) if match else None
1131
1250
 
1132
1251
 
1133
- def _dirty(root: Path) -> set[str]:
1252
+ def _dirty(root: Path, *pathspec: str) -> set[str]:
1134
1253
  """Tracked files with uncommitted working-tree changes.
1135
1254
 
1136
1255
  Without this, editing a tracked round and simply not committing would grade
1137
1256
  `verified`: the file is tracked and still has exactly one commit.
1138
1257
  """
1139
- out = _git(root, "status", "--porcelain", "--untracked-files=no")
1258
+ out = _git(root, "status", "--porcelain", "--untracked-files=no", "--", *pathspec)
1140
1259
  return {line[3:].strip().strip('"') for line in out.splitlines() if line.strip()}
1141
1260
 
1142
1261
 
1262
+ def _event_commits(root: Path, pathspec: str) -> dict[str, list[tuple[str, str | None]]]:
1263
+ """Every commit touching each file under `pathspec`, newest first, paired
1264
+ with the agent its trailer names.
1265
+
1266
+ ONE `git log` for the whole tree: grading 400 status events one file at a
1267
+ time would be 800 git calls on every report.
1268
+ """
1269
+ out = _log(root, "--format=%x1e%H%x1f%B%x1f", "--name-only", "--", pathspec)
1270
+ commits: dict[str, list[tuple[str, str | None]]] = {}
1271
+ for chunk in out.split("\x1e"):
1272
+ if not chunk.strip():
1273
+ continue
1274
+ sha, body, files = chunk.split("\x1f", 2)
1275
+ match = _TRAILER.search(body)
1276
+ agent = match.group(1) if match else None
1277
+ for line in files.splitlines():
1278
+ if line.strip():
1279
+ commits.setdefault(line.strip(), []).append((sha.strip(), agent))
1280
+ return commits
1281
+
1282
+
1283
+ def _verify_event(
1284
+ rel: str,
1285
+ actor: object,
1286
+ tracked: set[str],
1287
+ dirty: set[str],
1288
+ commits: dict[str, list[tuple[str, str | None]]],
1289
+ ) -> tuple[str, str | None]:
1290
+ """Grade one status event the way `_verify` grades a round: `(grade, sha)`.
1291
+
1292
+ Recipient membership alone cannot catch a forged status -- the forged actor
1293
+ usually IS a recipient. The event's own commit can: an event is written once,
1294
+ by the broker, under its actor's trailer, so a second commit is an edit and a
1295
+ different trailer is someone else speaking for that actor.
1296
+ """
1297
+ history = commits.get(rel, [])
1298
+ if rel not in tracked or not history:
1299
+ return "uncommitted", None
1300
+ created_sha, created_agent = history[-1]
1301
+ if rel in dirty or len(history) > 1:
1302
+ return "modified", created_sha
1303
+ if created_agent != actor:
1304
+ return "discrepancy", created_sha
1305
+ return "verified", created_sha
1306
+
1307
+
1308
+ @dataclass(frozen=True)
1309
+ class EventGrader:
1310
+ """Grades status events against their own commits, from ONE `ls-files`,
1311
+ `status` and `log` over a pathspec: grading round by round from `channel
1312
+ list` would be three git calls per round."""
1313
+
1314
+ tracked: set[str]
1315
+ dirty: set[str]
1316
+ commits: dict[str, list[tuple[str, str | None]]]
1317
+
1318
+ @classmethod
1319
+ def load(cls, root: Path, pathspec: str = STATUS_DIRNAME) -> EventGrader:
1320
+ return cls(_tracked(root, pathspec), _dirty(root, pathspec), _event_commits(root, pathspec))
1321
+
1322
+ def grade(self, number: int, event: dict) -> tuple[str, str | None]:
1323
+ rel = f"{STATUS_DIRNAME}/{number:03d}/{event['file']}"
1324
+ return _verify_event(rel, event.get("actor"), self.tracked, self.dirty, self.commits)
1325
+
1326
+
1143
1327
  def _verify(root: Path, rel: str, tracked: set[str], dirty: set[str]) -> str:
1144
1328
  """Grade one file against its ORIGINAL committed content, not its current text.
1145
1329
 
@@ -1174,6 +1358,16 @@ def _verify(root: Path, rel: str, tracked: set[str], dirty: set[str]) -> str:
1174
1358
  return "verified"
1175
1359
 
1176
1360
 
1361
+ def _deleted(root: Path, ever: set[str]) -> list[str]:
1362
+ """Paths history or the index knows that the working tree no longer has.
1363
+
1364
+ Grading only what is present is blind to the cheapest tampering of all:
1365
+ removal. A committed deletion leaves the file in history; an uncommitted one
1366
+ leaves it in the index. Either way the audit view must show it.
1367
+ """
1368
+ return sorted(rel for rel in ever if not (root / rel).exists())
1369
+
1370
+
1177
1371
  def _parse_at(value: str | None) -> datetime | None:
1178
1372
  try:
1179
1373
  return datetime.strptime(str(value), "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
@@ -1192,25 +1386,53 @@ def build_report(
1192
1386
  now = now or datetime.now(timezone.utc)
1193
1387
  tracked = _tracked(root)
1194
1388
  dirty = _dirty(root)
1389
+ event_commits = _event_commits(root, STATUS_DIRNAME)
1390
+ grader = EventGrader(tracked, dirty, event_commits)
1195
1391
  entries = list_rounds(root)
1196
1392
  numbers = {e.number for e in entries if e.number is not None}
1197
1393
 
1198
1394
  rows: list[dict] = []
1199
1395
  anomalies: list[dict] = []
1396
+ unreceipted: dict[str, list[int]] = {}
1397
+
1398
+ status_prefix = f"{STATUS_DIRNAME}/"
1399
+ for rel in _deleted(root, set(event_commits) | {p for p in tracked if p.startswith(status_prefix)}):
1400
+ parts = rel.split("/")
1401
+ number = int(parts[1]) if len(parts) == 3 and parts[1].isdigit() else None
1402
+ anomalies.append({"round": number, "kind": "status_deleted", "detail": parts[-1]})
1403
+ round_history = _log(root, "--format=", "--name-only", "--", ROUNDS_DIRNAME)
1404
+ ever_rounds = {line.strip() for line in round_history.splitlines() if line.strip()}
1405
+ ever_rounds |= {p for p in tracked if p.startswith(f"{ROUNDS_DIRNAME}/")}
1406
+ for rel in _deleted(root, ever_rounds):
1407
+ match = _ROUND_IN_NAME.search(rel)
1408
+ anomalies.append(
1409
+ {"round": int(match.group(1)) if match else None, "kind": "round_deleted", "detail": rel}
1410
+ )
1200
1411
  for entry in entries:
1201
1412
  rel = entry.path.relative_to(root).as_posix()
1202
1413
  verification = _verify(root, rel, tracked, dirty)
1203
1414
  events = status_events(root, entry.number) if entry.number is not None else []
1204
1415
  current = events[-1] if events else None
1205
-
1206
- stalled = False
1207
- if current and current.get("status") in {"delivered", "acknowledged"}:
1208
- at = _parse_at(current.get("at"))
1209
- stalled = at is not None and (now - at) > timedelta(days=stale_days)
1416
+ view = status_view(entry, events, grader)
1417
+
1418
+ # Per recipient: another recipient's `done` must not hide one that was
1419
+ # handed the round and never followed up. An author's withdrawal ends it.
1420
+ stalled_recipients: list[str] = []
1421
+ if not current or current.get("status") not in {"withdrawn", "superseded"}:
1422
+ for agent, state in view["recipients"].items():
1423
+ if state and state["status"] in {"delivered", "acknowledged"}:
1424
+ at = _parse_at(state["at"])
1425
+ if at is not None and (now - at) > timedelta(days=stale_days):
1426
+ stalled_recipients.append(agent)
1427
+ for agent in view["unreceipted"]:
1428
+ if entry.number is not None:
1429
+ unreceipted.setdefault(agent, []).append(entry.number)
1210
1430
 
1211
1431
  participants = {entry.author, *entry.to} - {None}
1212
1432
  for event in events:
1213
1433
  number = entry.number
1434
+ if number is None: # never taken: events are only read for numbered rounds
1435
+ continue
1214
1436
  if event.get("malformed"):
1215
1437
  anomalies.append({"round": number, "kind": "malformed_status", "detail": event.get("file")})
1216
1438
  continue
@@ -1219,9 +1441,16 @@ def build_report(
1219
1441
  anomalies.append(
1220
1442
  {"round": number, "kind": "status_actor_not_participant", "detail": actor}
1221
1443
  )
1222
- event_rel = f"{STATUS_DIRNAME}/{number:03d}/{event['file']}"
1223
- if event_rel not in tracked:
1224
- anomalies.append({"round": number, "kind": "status_uncommitted", "detail": event["file"]})
1444
+ grade, _sha = grader.grade(number, event)
1445
+ if grade != "verified":
1446
+ anomalies.append({"round": number, "kind": f"status_{grade}", "detail": event["file"]})
1447
+ # Numbers are handed out 1..N and never reused, so a hole is an event
1448
+ # that existed and is gone -- or one written by hand, skipping ahead.
1449
+ seqs = sorted(seq for seq in (_seq_of(e["file"]) for e in events) if seq is not None)
1450
+ if seqs != list(range(1, len(seqs) + 1)):
1451
+ anomalies.append(
1452
+ {"round": entry.number, "kind": "status_seq_gap", "detail": ",".join(map(str, seqs))}
1453
+ )
1225
1454
  seen = [e.get("status") for e in events]
1226
1455
  if "done" in seen and "delivered" not in seen[: seen.index("done")]:
1227
1456
  anomalies.append({"round": entry.number, "kind": "done_without_delivery", "detail": None})
@@ -1240,11 +1469,10 @@ def build_report(
1240
1469
  "posted": entry.posted,
1241
1470
  "supersedes": entry.supersedes,
1242
1471
  "verification": verification,
1243
- "status": current.get("status") if current else None,
1244
- "status_actor": current.get("actor") if current else None,
1245
- "status_at": current.get("at") if current else None,
1472
+ **view,
1246
1473
  "reason": current.get("reason") if current else None,
1247
- "stalled": stalled,
1474
+ "stalled": bool(stalled_recipients),
1475
+ "stalled_recipients": stalled_recipients,
1248
1476
  }
1249
1477
  )
1250
1478
 
@@ -1266,6 +1494,10 @@ def build_report(
1266
1494
  "stalled": [r for r in rows if r["stalled"]],
1267
1495
  "anomalies": anomalies,
1268
1496
  "by_agent": by_agent,
1497
+ # Visible, NOT failing: registered agents that never call inbox would
1498
+ # otherwise hold the report permanently red, and a check that is always
1499
+ # red is one nobody reads.
1500
+ "unreceipted": dict(sorted(unreceipted.items())),
1269
1501
  # Who may write at all. Without this the operator can see what was said
1270
1502
  # but not who is able to say anything -- and an agent that was onboarded
1271
1503
  # but never registered is silently mute rather than visibly absent.
@@ -1294,8 +1526,23 @@ def render_report(data: dict) -> str:
1294
1526
  f"{label:<10} {row['author'] or '(legacy)':<16} -> {recipients:<16} "
1295
1527
  f"{status:<13} {row['verification'].upper():<12} {row['title']}"
1296
1528
  )
1529
+ if row["status"] is not None:
1530
+ # The actor is what the event file claims; say so when its commit disagrees.
1531
+ checked = row.get("status_verification")
1532
+ flag = "" if checked == "verified" else f" UNVERIFIED: {str(checked).upper()}"
1533
+ lines.append(f"{'':<10} set by {row['status_actor']} at {row['status_at']}{flag}")
1534
+ states = row.get("recipients") or {}
1535
+ # One line per recipient wherever the folded word misleads: it names a
1536
+ # status some recipient never recorded.
1537
+ if any((state or {}).get("status") != row["status"] for state in states.values()):
1538
+ for agent, state in states.items():
1539
+ own = f"{state['status'].upper():<13} {state['at']}" if state else "NO RECEIPT"
1540
+ if state and state.get("verification") != "verified":
1541
+ own += f" UNVERIFIED: {str(state.get('verification')).upper()}"
1542
+ lines.append(f"{'':<10} {agent:<28} {own}")
1297
1543
  if row["stalled"]:
1298
- lines.append(f"{'':<10} ^ STALLED — {status.lower()} since {row['status_at']}, no follow-up")
1544
+ who = ", ".join(row["stalled_recipients"])
1545
+ lines.append(f"{'':<10} ^ STALLED — {who}: delivered or acknowledged, no follow-up")
1299
1546
  if row["reason"]:
1300
1547
  lines.append(f"{'':<10} \"{row['reason']}\"")
1301
1548
 
@@ -1306,6 +1553,12 @@ def render_report(data: dict) -> str:
1306
1553
  if not registry:
1307
1554
  lines.append(" (none — no agent can post; graphite channel register <repo> <name>-agent)")
1308
1555
 
1556
+ unreceipted = data.get("unreceipted") or {}
1557
+ if unreceipted:
1558
+ lines += ["", "No receipt recorded — addressed, but no event of any kind (never called inbox):"]
1559
+ for agent, rounds in unreceipted.items():
1560
+ lines.append(f" {agent:<28} {len(rounds):>3} round(s): {', '.join(map(str, rounds))}")
1561
+
1309
1562
  legacy = data["counts"]["legacy"]
1310
1563
  if legacy:
1311
1564
  lines += [