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.
- {graphite_code-1.0.1 → graphite_code-1.1.0}/CHANGELOG.md +126 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/PKG-INFO +2 -2
- {graphite_code-1.0.1 → graphite_code-1.1.0}/README.md +1 -1
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/__init__.py +1 -1
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/channel.py +282 -29
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/cli.py +25 -1
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/doctor_probes.py +28 -2
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/mcp_server.py +29 -9
- graphite_code-1.1.0/tests/test__win32_ctypes.py +38 -0
- graphite_code-1.1.0/tests/test_channel_attribution.py +254 -0
- graphite_code-1.1.0/tests/test_channel_log_integrity.py +216 -0
- graphite_code-1.1.0/tests/test_cli_channel_attribution.py +84 -0
- graphite_code-1.1.0/tests/test_cluster.py +123 -0
- graphite_code-1.1.0/tests/test_context_builder.py +312 -0
- graphite_code-1.1.0/tests/test_dependency_install.py +242 -0
- graphite_code-1.1.0/tests/test_doctor_probes.py +390 -0
- graphite_code-1.1.0/tests/test_llm_probe.py +261 -0
- graphite_code-1.1.0/tests/test_mcp_server.py +205 -0
- graphite_code-1.1.0/tests/test_mcp_server_channel_attribution.py +191 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_mutation_tests.py +19 -2
- graphite_code-1.1.0/tests/test_ollama_executor.py +272 -0
- graphite_code-1.1.0/tests/test_probe_process.py +207 -0
- graphite_code-1.1.0/tests/test_process_contracts.py +74 -0
- graphite_code-1.1.0/tests/test_route_pool_execution.py +363 -0
- graphite_code-1.1.0/tests/test_ts_bridge.py +241 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_typescript_activation.py +38 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/.gitignore +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/ARCHITECTURE.md +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/CONTRIBUTING.md +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/LICENSE +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/RELEASING.md +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/pyproject.toml +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/__main__.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/_cleanup_worker.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/_win32_ctypes.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/activation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/agent_hooks.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/agent_settings.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/analyze.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/answer_contract.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/bootstrap.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/buildlock.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/cache.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/cluster.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/config.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/context.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/daemon.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/daemon_health.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/daemon_launch.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/debt.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/dependency_install.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/detach.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/doctor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/engine_identity.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/__init__.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/html.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/json.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/export/md.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/extract/__init__.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/extract/ast.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/freshness.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/git.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/graph.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/graph_io.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/health.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/hook_entry.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/hookinstall.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/hookshim.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/incident_ledger.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/ingest.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/init.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/io.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/launchd_agent.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/listing.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/llm.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/llm_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/mcp.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/natural_query.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/overlays.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/probe_process.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/probe_workspace.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/process_contracts.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/provider_observer.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/py.typed +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/query.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/query_plan.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/replacement_audit.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/resolve.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/review.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/__init__.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/approval.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/classifier.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/claude_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/claude_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/cli_identity.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/codex_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/codex_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/context_builder.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/contracts.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/diff_policy.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/edit_apply.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/effort.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle_operator.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle_service.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/lifecycle_storage.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/ollama_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/ollama_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/openrouter_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/openrouter_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/policy.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/probe_runner.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/process_runner.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/profiles.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/prompt.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/registry.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/route_pool.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/route_pool_execution.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/schema_validation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/service.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/settings.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/shadow.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/storage.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/telemetry.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/worktree.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/zai_edit.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/zai_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/routing/zai_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/savings.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/systemd_unit.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/ts_bridge.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/ts_resolver.mjs +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/typescript_activation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/usage_ledger.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/validation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/watch.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/windows_job.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/windows_startup.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/src/graphite/windows_task.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/conftest.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/data/sqloracle/blindspots.py.txt +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/data/sqloracle/round69.py.txt +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fake_clis/fake_cli.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/provider_lifecycle_schema_v1.sql +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/routing_schema_v2_ca77600.sql +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/routing_schema_v3_94eb333.sql +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/fixtures/routing_schema_v4_lifecycle_migration.sql +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_activation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_activation_backstop.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_agent_hook_activation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_agent_hooks.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_agent_settings.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_answer_contract.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_arrow_and_constructor_binding.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_atomic_write.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_bootstrap.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_build_detach.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_buildlock.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_engine_identity.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_entry_pruning.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_file_set.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cache_pruning.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_call_graph.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_cli.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_core.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_lock.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_mcp.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_register.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_report.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_channel_status.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_class_field_arrow_binding.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cli_channel.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cli_reference.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cli_version.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_compare_dist.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_configuration_reference.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_context.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cwd_relative_config.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_cycle_search.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_activation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_build_lock.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_child_lock.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_engine_staleness.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_health.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_health_activation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_launch.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_daemon_service_cli.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_debt.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_destructured_binding.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_determinism.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_dispatch_evidence.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor_agent_hooks.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor_hooks.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_doctor_managed_docs.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_documentation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_edit_apply.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_engine_identity.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_exit_codes_reference.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_external_calls.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_git_security.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_go_rust.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_graph_io.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_graph_provider_isolation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hardening.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_health.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hook_entry.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hook_template.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hookinstall.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_hookshim.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_html_security.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_incident_ledger.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init_activation_doctrine.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init_hooks.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_init_validation_exit.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_launchd_agent.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_lifecycle_operator.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_listing.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_listing_surfaces.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_llm.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_mcp.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_method_dispatch_scope.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_monorepo.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_natural_query.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_node_identity.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_overlays.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_path_leak_fixture.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_probe_diagnostics.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_probe_workspace.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_claude_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_codex_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_lifecycle.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_lifecycle_service.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_lifecycle_storage.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_observer.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_ollama_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_openrouter_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_provider_probe_runner.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_public_surface.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_published_schemas.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_python_resolver.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_query_plan.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_reliability.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_replacement_audit.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_resolve.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_review.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_route_pool.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_approval.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_benchmark.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_classifier.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_claude_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_cli.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_cli_contracts.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_cli_recovery.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_codex_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_context.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_contracts.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_diff_policy.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_openrouter_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_policy.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_process_runner.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_profiles.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_registry.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_schema_validation.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_security.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_service.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_shadow.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_storage.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_telemetry.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_worktree.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_zai_executor.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_routing_zai_probe.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_savings.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_search.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_smoke.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_sqloracle.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_synthetic_repo.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_systemd_unit.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_typescript_resolver.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_usage_ledger.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_verify_artifact.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_verify_published_release.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_watch.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_windows_startup.py +0 -0
- {graphite_code-1.0.1 → graphite_code-1.1.0}/tests/test_windows_task.py +0 -0
- {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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
68
|
-
# perfectly healthy holder can legitimately sit for
|
|
69
|
-
# waiter is willing to wait. A
|
|
70
|
-
#
|
|
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
|
|
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"
|
|
448
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
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
|
-
|
|
1223
|
-
if
|
|
1224
|
-
anomalies.append({"round": number, "kind": "
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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 += [
|