syncade 0.7.3__tar.gz → 0.7.6__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 (483) hide show
  1. {syncade-0.7.3/src/syncade.egg-info → syncade-0.7.6}/PKG-INFO +1 -1
  2. {syncade-0.7.3 → syncade-0.7.6}/pyproject.toml +1 -1
  3. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/__init__.py +1 -1
  4. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/metrics_mode.py +18 -14
  5. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/update_mode.py +51 -56
  6. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config.py +6 -1
  7. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/metrics/aggregate.py +15 -12
  8. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/metrics/findings.py +129 -71
  9. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/metrics/schema.py +35 -17
  10. {syncade-0.7.3 → syncade-0.7.6/src/syncade.egg-info}/PKG-INFO +1 -1
  11. {syncade-0.7.3 → syncade-0.7.6}/src/syncade.egg-info/SOURCES.txt +1 -0
  12. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_update_post_condition.py +3 -123
  13. syncade-0.7.6/tests/cli/test_version_probe.py +158 -0
  14. {syncade-0.7.3 → syncade-0.7.6}/tests/conftest.py +11 -12
  15. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_aggregate.py +10 -2
  16. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_consensus_report.py +38 -18
  17. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_findings_capture.py +209 -78
  18. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_schema.py +92 -7
  19. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_unknown_panel.py +75 -15
  20. {syncade-0.7.3 → syncade-0.7.6}/tests/test_worktree_base_isolation.py +2 -2
  21. {syncade-0.7.3 → syncade-0.7.6}/LICENSE +0 -0
  22. {syncade-0.7.3 → syncade-0.7.6}/MANIFEST.in +0 -0
  23. {syncade-0.7.3 → syncade-0.7.6}/README.md +0 -0
  24. {syncade-0.7.3 → syncade-0.7.6}/docs/config-reference.md +0 -0
  25. {syncade-0.7.3 → syncade-0.7.6}/docs/how-to-use.md +0 -0
  26. {syncade-0.7.3 → syncade-0.7.6}/setup.cfg +0 -0
  27. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/__main__.py +0 -0
  28. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/__init__.py +0 -0
  29. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/anthropic.py +0 -0
  30. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/base.py +0 -0
  31. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/fake.py +0 -0
  32. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/fake_common.py +0 -0
  33. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/fake_producer_audit_draft.py +0 -0
  34. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/fake_reviewer_synth.py +0 -0
  35. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/openai.py +0 -0
  36. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/openai_parsing.py +0 -0
  37. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/producer.py +0 -0
  38. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/producer_anthropic.py +0 -0
  39. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/producer_openai.py +0 -0
  40. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/adapters/registry.py +0 -0
  41. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/auth_check.py +0 -0
  42. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/auth_preflight.py +0 -0
  43. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/base_resolution.py +0 -0
  44. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/billing.py +0 -0
  45. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/checks_config.py +0 -0
  46. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/__init__.py +0 -0
  47. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/auth_gate.py +0 -0
  48. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/config_keys.py +0 -0
  49. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/config_list.py +0 -0
  50. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/config_menu_rows.py +0 -0
  51. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/config_mode.py +0 -0
  52. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/config_overrides.py +0 -0
  53. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/config_tui.py +0 -0
  54. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/doctor_mode.py +0 -0
  55. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/gc_mode.py +0 -0
  56. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/install_skill.py +0 -0
  57. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/modes.py +0 -0
  58. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/parser.py +0 -0
  59. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/parser_types.py +0 -0
  60. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/paths.py +0 -0
  61. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/preflight_paths.py +0 -0
  62. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/resolve.py +0 -0
  63. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/resume_mode.py +0 -0
  64. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/skill_status.py +0 -0
  65. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/toml_writer.py +0 -0
  66. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/update_notice.py +0 -0
  67. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/cli/validate.py +0 -0
  68. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_auth.py +0 -0
  69. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_cold.py +0 -0
  70. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_gc.py +0 -0
  71. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_loader.py +0 -0
  72. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_loop.py +0 -0
  73. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_producer.py +0 -0
  74. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_retry.py +0 -0
  75. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_types.py +0 -0
  76. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/config_update.py +0 -0
  77. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/diff_filter.py +0 -0
  78. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/dispatcher.py +0 -0
  79. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/doctor.py +0 -0
  80. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/doctor_env.py +0 -0
  81. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/doctor_preview.py +0 -0
  82. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/doctor_types.py +0 -0
  83. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/exit_codes.py +0 -0
  84. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/findings.py +0 -0
  85. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/findings_json.py +0 -0
  86. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/gc.py +0 -0
  87. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/gc_execute.py +0 -0
  88. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/gc_protection.py +0 -0
  89. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/gc_types.py +0 -0
  90. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/gc_worktrees.py +0 -0
  91. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/git_object_id.py +0 -0
  92. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/git_preconditions.py +0 -0
  93. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/logging.py +0 -0
  94. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/metrics/__init__.py +0 -0
  95. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/__init__.py +0 -0
  96. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/_runs_dir.py +0 -0
  97. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/branch_advance.py +0 -0
  98. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/branch_guard.py +0 -0
  99. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/budget.py +0 -0
  100. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/escalation_coverage.py +0 -0
  101. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop.py +0 -0
  102. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop_dispatch_check.py +0 -0
  103. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop_finalize.py +0 -0
  104. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop_preflight.py +0 -0
  105. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop_resume.py +0 -0
  106. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop_rmtree.py +0 -0
  107. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/loop_round_step.py +0 -0
  108. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/prior_round.py +0 -0
  109. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/producer_phase.py +0 -0
  110. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/results.py +0 -0
  111. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/resume.py +0 -0
  112. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/resume_load.py +0 -0
  113. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/resume_plan.py +0 -0
  114. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/resume_target.py +0 -0
  115. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/resume_types.py +0 -0
  116. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/reviewer_template_failure.py +0 -0
  117. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/round.py +0 -0
  118. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/round_checks.py +0 -0
  119. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/round_no_changes.py +0 -0
  120. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/round_predispatch.py +0 -0
  121. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/orchestrator/verdict.py +0 -0
  122. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/__init__.py +0 -0
  123. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/_atomic.py +0 -0
  124. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/_clusters.py +0 -0
  125. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/_findings_verdict.py +0 -0
  126. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/_markdown.py +0 -0
  127. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/_validation.py +0 -0
  128. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/checks.py +0 -0
  129. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/decision_needed.py +0 -0
  130. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/findings_md.py +0 -0
  131. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/handoff.py +0 -0
  132. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/handoff_classify.py +0 -0
  133. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/last_reviewed.py +0 -0
  134. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/loop_manifest.py +0 -0
  135. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/loop_summary.py +0 -0
  136. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/loop_summary_text.py +0 -0
  137. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/producer.py +0 -0
  138. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/reviewer.py +0 -0
  139. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/round_manifest.py +0 -0
  140. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/run_init.py +0 -0
  141. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/run_summary.py +0 -0
  142. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/run_summary_next_steps.py +0 -0
  143. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/synth.py +0 -0
  144. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/persistence/test_run.py +0 -0
  145. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/presets.py +0 -0
  146. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/pricing_config.py +0 -0
  147. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/process.py +0 -0
  148. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/producer.py +0 -0
  149. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/producer_attempt.py +0 -0
  150. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/producer_escalation.py +0 -0
  151. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/producer_git.py +0 -0
  152. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/producer_result.py +0 -0
  153. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/prompts.py +0 -0
  154. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/prompts_loader.py +0 -0
  155. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/retry.py +0 -0
  156. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/run_inputs.py +0 -0
  157. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/run_status.py +0 -0
  158. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/selfcheck.py +0 -0
  159. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/skills/claude/README.md +0 -0
  160. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/skills/claude/SKILL.md +0 -0
  161. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/skills/codex/README.md +0 -0
  162. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/skills/codex/SKILL.md +0 -0
  163. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/snapshot.py +0 -0
  164. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/spec_audit.py +0 -0
  165. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/spec_audit_schema.py +0 -0
  166. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/spec_draft.py +0 -0
  167. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/spec_source.py +0 -0
  168. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesis.py +0 -0
  169. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesis_clusters.py +0 -0
  170. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesis_repair.py +0 -0
  171. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesis_schema.py +0 -0
  172. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/__init__.py +0 -0
  173. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/constants.py +0 -0
  174. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/driver.py +0 -0
  175. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/rendering.py +0 -0
  176. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/result.py +0 -0
  177. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/validation.py +0 -0
  178. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/synthesizer/workspace.py +0 -0
  179. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/presets/balanced.toml +0 -0
  180. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/presets/cheap.toml +0 -0
  181. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/presets/thorough.toml +0 -0
  182. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/producer.md +0 -0
  183. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/reviewer.md +0 -0
  184. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/reviewer_adversarial.md +0 -0
  185. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/reviewer_codex.md +0 -0
  186. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/spec_audit.md +0 -0
  187. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/spec_draft.md +0 -0
  188. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/templates/synthesizer.md +0 -0
  189. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/test_runner.py +0 -0
  190. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/test_runner_classify.py +0 -0
  191. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/transcript.py +0 -0
  192. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/update_check.py +0 -0
  193. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/usage.py +0 -0
  194. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/worktree.py +0 -0
  195. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/worktree_env.py +0 -0
  196. {syncade-0.7.3 → syncade-0.7.6}/src/syncade/worktree_paths.py +0 -0
  197. {syncade-0.7.3 → syncade-0.7.6}/src/syncade.egg-info/dependency_links.txt +0 -0
  198. {syncade-0.7.3 → syncade-0.7.6}/src/syncade.egg-info/entry_points.txt +0 -0
  199. {syncade-0.7.3 → syncade-0.7.6}/src/syncade.egg-info/requires.txt +0 -0
  200. {syncade-0.7.3 → syncade-0.7.6}/src/syncade.egg-info/top_level.txt +0 -0
  201. {syncade-0.7.3 → syncade-0.7.6}/tests/__init__.py +0 -0
  202. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/__init__.py +0 -0
  203. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/_anthropic_helpers.py +0 -0
  204. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/_openai_helpers.py +0 -0
  205. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_anthropic.py +0 -0
  206. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_anthropic_more.py +0 -0
  207. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_openai.py +0 -0
  208. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_openai_more.py +0 -0
  209. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_producer.py +0 -0
  210. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_producer_more.py +0 -0
  211. {syncade-0.7.3 → syncade-0.7.6}/tests/adapters/test_prompt_transport.py +0 -0
  212. {syncade-0.7.3 → syncade-0.7.6}/tests/auth_check/__init__.py +0 -0
  213. {syncade-0.7.3 → syncade-0.7.6}/tests/auth_check/test_probes.py +0 -0
  214. {syncade-0.7.3 → syncade-0.7.6}/tests/auth_check/test_run_auth_check.py +0 -0
  215. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/__init__.py +0 -0
  216. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/_helpers.py +0 -0
  217. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/_undo_helpers.py +0 -0
  218. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_basic_and_mutex.py +0 -0
  219. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_cli_library_divergence.py +0 -0
  220. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_keys.py +0 -0
  221. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_list.py +0 -0
  222. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_menu_rows.py +0 -0
  223. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_mode.py +0 -0
  224. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_overrides.py +0 -0
  225. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_tui.py +0 -0
  226. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_config_tui_pty.py +0 -0
  227. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_documented_exit_codes.py +0 -0
  228. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_documented_flags_exist.py +0 -0
  229. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_flags.py +0 -0
  230. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_gc.py +0 -0
  231. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_help_retention_contract.py +0 -0
  232. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_mode_exclusivity_matrix.py +0 -0
  233. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_mode_flag_guards.py +0 -0
  234. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_resume_reports_hard_kill.py +0 -0
  235. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_run_and_config.py +0 -0
  236. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_run_status_cli.py +0 -0
  237. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_scope_openspec_draft.py +0 -0
  238. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_toml_writer.py +0 -0
  239. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_undo_auto_init.py +0 -0
  240. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_undo_scope.py +0 -0
  241. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_update_mode.py +0 -0
  242. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_update_notice.py +0 -0
  243. {syncade-0.7.3 → syncade-0.7.6}/tests/cli/test_validate_before_mutate.py +0 -0
  244. {syncade-0.7.3 → syncade-0.7.6}/tests/config/__init__.py +0 -0
  245. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_auth_enforcement.py +0 -0
  246. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_auth_modes.py +0 -0
  247. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_auth_preflight.py +0 -0
  248. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_auth_report_scope.py +0 -0
  249. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_budget_config.py +0 -0
  250. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_cold_actors.py +0 -0
  251. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_config_checks.py +0 -0
  252. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_config_reference_drift.py +0 -0
  253. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_config_schema.py +0 -0
  254. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_gc_config.py +0 -0
  255. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_global_config_layer.py +0 -0
  256. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_presets.py +0 -0
  257. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_retry_config.py +0 -0
  258. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_reviewer_config.py +0 -0
  259. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_reviewer_permissions.py +0 -0
  260. {syncade-0.7.3 → syncade-0.7.6}/tests/config/test_worktree_base_config.py +0 -0
  261. {syncade-0.7.3 → syncade-0.7.6}/tests/dispatcher/__init__.py +0 -0
  262. {syncade-0.7.3 → syncade-0.7.6}/tests/dispatcher/_helpers.py +0 -0
  263. {syncade-0.7.3 → syncade-0.7.6}/tests/dispatcher/test_dispatch_core.py +0 -0
  264. {syncade-0.7.3 → syncade-0.7.6}/tests/dispatcher/test_dispatch_misc.py +0 -0
  265. {syncade-0.7.3 → syncade-0.7.6}/tests/dispatcher/test_dispatch_usage.py +0 -0
  266. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/__init__.py +0 -0
  267. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/_helpers.py +0 -0
  268. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/conftest.py +0 -0
  269. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/test_doctor.py +0 -0
  270. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/test_doctor_diff_size.py +0 -0
  271. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/test_doctor_malformed_diff.py +0 -0
  272. {syncade-0.7.3 → syncade-0.7.6}/tests/doctor/test_doctor_preview.py +0 -0
  273. {syncade-0.7.3 → syncade-0.7.6}/tests/findings/__init__.py +0 -0
  274. {syncade-0.7.3 → syncade-0.7.6}/tests/findings/_helpers.py +0 -0
  275. {syncade-0.7.3 → syncade-0.7.6}/tests/findings/test_parser_and_fields.py +0 -0
  276. {syncade-0.7.3 → syncade-0.7.6}/tests/findings/test_parser_resilience.py +0 -0
  277. {syncade-0.7.3 → syncade-0.7.6}/tests/findings/test_verdict_block_selection.py +0 -0
  278. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/__init__.py +0 -0
  279. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/_helpers.py +0 -0
  280. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_autoprune.py +0 -0
  281. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_execute.py +0 -0
  282. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_plan.py +0 -0
  283. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_plan_execution_races.py +0 -0
  284. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_reaping_scope.py +0 -0
  285. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_retention_tiers.py +0 -0
  286. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_robustness.py +0 -0
  287. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_self_orphan_protection.py +0 -0
  288. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_smoke.py +0 -0
  289. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_two_tier_retention.py +0 -0
  290. {syncade-0.7.3 → syncade-0.7.6}/tests/gc/test_worktree_age_bound.py +0 -0
  291. {syncade-0.7.3 → syncade-0.7.6}/tests/logging/__init__.py +0 -0
  292. {syncade-0.7.3 → syncade-0.7.6}/tests/logging/_helpers.py +0 -0
  293. {syncade-0.7.3 → syncade-0.7.6}/tests/logging/test_logging.py +0 -0
  294. {syncade-0.7.3 → syncade-0.7.6}/tests/logging/test_logging_summary.py +0 -0
  295. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/conftest.py +0 -0
  296. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_actor_spend.py +0 -0
  297. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_billing_reconciliation.py +0 -0
  298. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_billing_surfaces_agree.py +0 -0
  299. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_budget_metrics.py +0 -0
  300. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_cost_honesty.py +0 -0
  301. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_metrics_cli.py +0 -0
  302. {syncade-0.7.3 → syncade-0.7.6}/tests/metrics/test_metrics_mode.py +0 -0
  303. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/__init__.py +0 -0
  304. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/_helpers.py +0 -0
  305. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/_resume_fixtures.py +0 -0
  306. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/conftest.py +0 -0
  307. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_budget.py +0 -0
  308. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_budget_resume_e2e.py +0 -0
  309. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_budget_stop_message.py +0 -0
  310. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_budget_warning.py +0 -0
  311. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_checks_and_recording.py +0 -0
  312. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_contracts.py +0 -0
  313. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_cross_round.py +0 -0
  314. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_cross_round_more.py +0 -0
  315. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_default_branch_guard.py +0 -0
  316. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_diff_size_cap.py +0 -0
  317. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_dispatch_record.py +0 -0
  318. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_empty_diff.py +0 -0
  319. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_empty_diff_no_side_effects.py +0 -0
  320. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_escalation.py +0 -0
  321. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_last_reviewed_truthful.py +0 -0
  322. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_loop_audit_remediation.py +0 -0
  323. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_matrix_and_e2e.py +0 -0
  324. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_multiround.py +0 -0
  325. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_multiround_dirty.py +0 -0
  326. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_multiround_more.py +0 -0
  327. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_paraphrase_escalation.py +0 -0
  328. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_pipeline.py +0 -0
  329. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_provider_usage_limit.py +0 -0
  330. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_rename_boundary.py +0 -0
  331. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_resume.py +0 -0
  332. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_resume_base_oid.py +0 -0
  333. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_reviewer_blindness_is_structural.py +0 -0
  334. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_round_input_boundaries.py +0 -0
  335. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_run_layout.py +0 -0
  336. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_run_status_wiring.py +0 -0
  337. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_safety_warning_survives_quiet.py +0 -0
  338. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_snapshot_and_base.py +0 -0
  339. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_synth_phase.py +0 -0
  340. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_synth_phase_more.py +0 -0
  341. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_synth_provenance.py +0 -0
  342. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_synth_validation.py +0 -0
  343. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_template_robustness.py +0 -0
  344. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_termination_reason_drift.py +0 -0
  345. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_testleg.py +0 -0
  346. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_timeout_is_per_subprocess.py +0 -0
  347. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_usage_limit_message.py +0 -0
  348. {syncade-0.7.3 → syncade-0.7.6}/tests/orchestrator/test_worktree_cleanup.py +0 -0
  349. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/__init__.py +0 -0
  350. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/_helpers.py +0 -0
  351. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_atomic_writes.py +0 -0
  352. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_checks_surfacing.py +0 -0
  353. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_diff_size_recording.py +0 -0
  354. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_findings_md.py +0 -0
  355. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_findings_md_more.py +0 -0
  356. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_findings_md_verdict.py +0 -0
  357. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_handoff.py +0 -0
  358. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_loop_manifest_checks.py +0 -0
  359. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_loop_summary.py +0 -0
  360. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_loop_summary_more.py +0 -0
  361. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_md_safety.py +0 -0
  362. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_next_steps.py +0 -0
  363. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_producer_persist.py +0 -0
  364. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_reviewer.py +0 -0
  365. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_round_manifest.py +0 -0
  366. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_run_init.py +0 -0
  367. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_run_summary.py +0 -0
  368. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_run_summary_more.py +0 -0
  369. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_run_summary_producer.py +0 -0
  370. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_synth_persist.py +0 -0
  371. {syncade-0.7.3 → syncade-0.7.6}/tests/persistence/test_test_run.py +0 -0
  372. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/__init__.py +0 -0
  373. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/_helpers.py +0 -0
  374. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/test_producer.py +0 -0
  375. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/test_producer_confinement.py +0 -0
  376. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/test_producer_retry.py +0 -0
  377. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/test_producer_run.py +0 -0
  378. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/test_producer_timeout.py +0 -0
  379. {syncade-0.7.3 → syncade-0.7.6}/tests/producer/test_producer_usage.py +0 -0
  380. {syncade-0.7.3 → syncade-0.7.6}/tests/prompts/__init__.py +0 -0
  381. {syncade-0.7.3 → syncade-0.7.6}/tests/prompts/test_load_template.py +0 -0
  382. {syncade-0.7.3 → syncade-0.7.6}/tests/prompts/test_misc.py +0 -0
  383. {syncade-0.7.3 → syncade-0.7.6}/tests/prompts/test_producer.py +0 -0
  384. {syncade-0.7.3 → syncade-0.7.6}/tests/prompts/test_render_synthesizer.py +0 -0
  385. {syncade-0.7.3 → syncade-0.7.6}/tests/prompts/test_synthesizer.py +0 -0
  386. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/__init__.py +0 -0
  387. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/_helpers.py +0 -0
  388. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_budget_resume.py +0 -0
  389. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_checks_phase_failure.py +0 -0
  390. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_cli_malformed_rehydration.py +0 -0
  391. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_find_resolve_plan.py +0 -0
  392. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_plan_resume.py +0 -0
  393. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_round_trip.py +0 -0
  394. {syncade-0.7.3 → syncade-0.7.6}/tests/resume/test_tree_drift_and_escalation.py +0 -0
  395. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_config_section.py +0 -0
  396. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_install_skill.py +0 -0
  397. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_install_skill_multi_harness.py +0 -0
  398. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_install_skill_ownership.py +0 -0
  399. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_install_skill_refuses.py +0 -0
  400. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_skill_drift.py +0 -0
  401. {syncade-0.7.3 → syncade-0.7.6}/tests/skills/test_skill_status.py +0 -0
  402. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/__init__.py +0 -0
  403. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_anthropic_smoke.py +0 -0
  404. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_auth_check_smoke.py +0 -0
  405. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_codex_smoke.py +0 -0
  406. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_loop_smoke.py +0 -0
  407. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_orchestrator_smoke.py +0 -0
  408. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_orchestrator_smoke_more.py +0 -0
  409. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_producer_smoke.py +0 -0
  410. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_resume_reap_smoke.py +0 -0
  411. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_selfcheck_smoke.py +0 -0
  412. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_synthesizer_smoke.py +0 -0
  413. {syncade-0.7.3 → syncade-0.7.6}/tests/smoke/test_test_runner_smoke.py +0 -0
  414. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/__init__.py +0 -0
  415. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/_helpers.py +0 -0
  416. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/conftest.py +0 -0
  417. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/test_snapshot.py +0 -0
  418. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/test_snapshot_diff_identity.py +0 -0
  419. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/test_snapshot_dirty.py +0 -0
  420. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/test_snapshot_warning.py +0 -0
  421. {syncade-0.7.3 → syncade-0.7.6}/tests/snapshot/test_three_dot_diff.py +0 -0
  422. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/__init__.py +0 -0
  423. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/_helpers.py +0 -0
  424. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/test_cluster_repair.py +0 -0
  425. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/test_clusters.py +0 -0
  426. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/test_models.py +0 -0
  427. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/test_models_distinct_keying.py +0 -0
  428. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/test_parsing.py +0 -0
  429. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesis/test_unanimous_blocker_downgrade.py +0 -0
  430. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesizer/test_provenance_fabrication.py +0 -0
  431. {syncade-0.7.3 → syncade-0.7.6}/tests/synthesizer/test_provenance_repair.py +0 -0
  432. {syncade-0.7.3 → syncade-0.7.6}/tests/test_adapter_registry.py +0 -0
  433. {syncade-0.7.3 → syncade-0.7.6}/tests/test_adapters_fake.py +0 -0
  434. {syncade-0.7.3 → syncade-0.7.6}/tests/test_auto_init_policy.py +0 -0
  435. {syncade-0.7.3 → syncade-0.7.6}/tests/test_base_resolution.py +0 -0
  436. {syncade-0.7.3 → syncade-0.7.6}/tests/test_check_loc_script.py +0 -0
  437. {syncade-0.7.3 → syncade-0.7.6}/tests/test_checks.py +0 -0
  438. {syncade-0.7.3 → syncade-0.7.6}/tests/test_config_loader.py +0 -0
  439. {syncade-0.7.3 → syncade-0.7.6}/tests/test_cross_model_claim.py +0 -0
  440. {syncade-0.7.3 → syncade-0.7.6}/tests/test_diff_filter.py +0 -0
  441. {syncade-0.7.3 → syncade-0.7.6}/tests/test_diff_filter_binary.py +0 -0
  442. {syncade-0.7.3 → syncade-0.7.6}/tests/test_diff_filter_quoted_paths.py +0 -0
  443. {syncade-0.7.3 → syncade-0.7.6}/tests/test_escalation_coverage.py +0 -0
  444. {syncade-0.7.3 → syncade-0.7.6}/tests/test_exit_codes.py +0 -0
  445. {syncade-0.7.3 → syncade-0.7.6}/tests/test_git_choke_point.py +0 -0
  446. {syncade-0.7.3 → syncade-0.7.6}/tests/test_git_config_isolation.py +0 -0
  447. {syncade-0.7.3 → syncade-0.7.6}/tests/test_git_preconditions.py +0 -0
  448. {syncade-0.7.3 → syncade-0.7.6}/tests/test_last_reviewed.py +0 -0
  449. {syncade-0.7.3 → syncade-0.7.6}/tests/test_parse_repair_extra_keys.py +0 -0
  450. {syncade-0.7.3 → syncade-0.7.6}/tests/test_pricing.py +0 -0
  451. {syncade-0.7.3 → syncade-0.7.6}/tests/test_private_identifier_gate.py +0 -0
  452. {syncade-0.7.3 → syncade-0.7.6}/tests/test_process.py +0 -0
  453. {syncade-0.7.3 → syncade-0.7.6}/tests/test_process_teardown.py +0 -0
  454. {syncade-0.7.3 → syncade-0.7.6}/tests/test_producer_escalation.py +0 -0
  455. {syncade-0.7.3 → syncade-0.7.6}/tests/test_regression_acme.py +0 -0
  456. {syncade-0.7.3 → syncade-0.7.6}/tests/test_retry.py +0 -0
  457. {syncade-0.7.3 → syncade-0.7.6}/tests/test_run_status.py +0 -0
  458. {syncade-0.7.3 → syncade-0.7.6}/tests/test_run_status_signals.py +0 -0
  459. {syncade-0.7.3 → syncade-0.7.6}/tests/test_runner/__init__.py +0 -0
  460. {syncade-0.7.3 → syncade-0.7.6}/tests/test_runner/test_test_runner.py +0 -0
  461. {syncade-0.7.3 → syncade-0.7.6}/tests/test_runner/test_test_runner_more.py +0 -0
  462. {syncade-0.7.3 → syncade-0.7.6}/tests/test_safety_disclosures_survive_quiet.py +0 -0
  463. {syncade-0.7.3 → syncade-0.7.6}/tests/test_selfcheck.py +0 -0
  464. {syncade-0.7.3 → syncade-0.7.6}/tests/test_spec_audit.py +0 -0
  465. {syncade-0.7.3 → syncade-0.7.6}/tests/test_spec_draft.py +0 -0
  466. {syncade-0.7.3 → syncade-0.7.6}/tests/test_spec_source.py +0 -0
  467. {syncade-0.7.3 → syncade-0.7.6}/tests/test_streamed_capture.py +0 -0
  468. {syncade-0.7.3 → syncade-0.7.6}/tests/test_strip_list_semantics.py +0 -0
  469. {syncade-0.7.3 → syncade-0.7.6}/tests/test_synthesizer.py +0 -0
  470. {syncade-0.7.3 → syncade-0.7.6}/tests/test_synthesizer_passthrough.py +0 -0
  471. {syncade-0.7.3 → syncade-0.7.6}/tests/test_synthesizer_provenance.py +0 -0
  472. {syncade-0.7.3 → syncade-0.7.6}/tests/test_transcript.py +0 -0
  473. {syncade-0.7.3 → syncade-0.7.6}/tests/test_update_check.py +0 -0
  474. {syncade-0.7.3 → syncade-0.7.6}/tests/test_usage.py +0 -0
  475. {syncade-0.7.3 → syncade-0.7.6}/tests/test_usage_persistence.py +0 -0
  476. {syncade-0.7.3 → syncade-0.7.6}/tests/test_workflow_action_pins.py +0 -0
  477. {syncade-0.7.3 → syncade-0.7.6}/tests/test_worktree_env.py +0 -0
  478. {syncade-0.7.3 → syncade-0.7.6}/tests/test_worktree_strip_guard.py +0 -0
  479. {syncade-0.7.3 → syncade-0.7.6}/tests/worktree/__init__.py +0 -0
  480. {syncade-0.7.3 → syncade-0.7.6}/tests/worktree/_helpers.py +0 -0
  481. {syncade-0.7.3 → syncade-0.7.6}/tests/worktree/conftest.py +0 -0
  482. {syncade-0.7.3 → syncade-0.7.6}/tests/worktree/test_worktree.py +0 -0
  483. {syncade-0.7.3 → syncade-0.7.6}/tests/worktree/test_worktree_cleanup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: syncade
3
- Version: 0.7.3
3
+ Version: 0.7.6
4
4
  Summary: External blind multi-judge review orchestrator for AI-assisted coding
5
5
  Author-email: Syncade <build@syncade.ai>
6
6
  License-Expression: Apache-2.0
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
 
10
10
  [project]
11
11
  name = "syncade"
12
- version = "0.7.3"
12
+ version = "0.7.6"
13
13
  description = "External blind multi-judge review orchestrator for AI-assisted coding"
14
14
  readme = "README.md"
15
15
  requires-python = ">=3.11"
@@ -1,3 +1,3 @@
1
1
  """Syncade — external blind multi-judge review orchestrator."""
2
2
 
3
- __version__ = "0.7.3"
3
+ __version__ = "0.7.6"
@@ -255,11 +255,21 @@ def render_report(conn: sqlite3.Connection, last_n: int | None = None) -> str:
255
255
  ).fetchone()
256
256
  actor_rows = _actor_spend_rows(conn)
257
257
  incomplete_tokens = _cost_incomplete_tokens(conn)
258
+ manifest_only, unknown_only = conn.execute(
259
+ "SELECT "
260
+ "COALESCE(SUM(CASE WHEN counts_source='manifest' THEN 1 ELSE 0 END), 0), "
261
+ "COALESCE(SUM(CASE WHEN counts_source='unknown' THEN 1 ELSE 0 END), 0) "
262
+ "FROM rounds "
263
+ "WHERE NOT (counts_source='manifest' AND panel_source='recorded' AND panel_size=0 "
264
+ "AND blockers=0 AND minors=0 AND nits=0 AND dismissed=0)"
265
+ ).fetchone()
266
+ finding_note = " (lower bounds: incomplete round evidence)" if unknown_only else ""
258
267
 
259
268
  lines = [
260
269
  f"syncade metrics — {runs} runs",
261
270
  f" ship-rate: {ship}/{runs} ({round(100 * ship / runs)}%)",
262
- f" findings: {blockers} blockers, {minors} minors, {nits} nits, {dismissed} dismissed",
271
+ f" findings: {blockers} blockers, {minors} minors, {nits} nits, {dismissed} dismissed"
272
+ f"{finding_note}",
263
273
  f" rounds: {rounds} total",
264
274
  f" handoffs: {handoffs} decisions: {decisions}",
265
275
  f" producer: committed={p_committed} stalled={p_stalled}"
@@ -322,7 +332,7 @@ def render_report(conn: sqlite3.Connection, last_n: int | None = None) -> str:
322
332
  # OUTSIDE the `if reviewer_rows` block on purpose. Nested inside it, a corpus where EVERY
323
333
  # panel is unknown printed no warning at all — the one corpus that most needs it, silent.
324
334
  # panel_source='unknown' means no manifest had a `reviewers` key for that round. This is
325
- # independent of blockers_source: a manifest can record a blocker count without a reviewer
335
+ # independent of counts_source: a manifest can record finding counts without a reviewer
326
336
  # list, and a no-dispatch round (no_changes_to_review) has panel_source='recorded' and
327
337
  # panel_size=0 — explicitly empty, not unknown.
328
338
  unknown_rounds, total_rounds = conn.execute(
@@ -334,23 +344,17 @@ def render_report(conn: sqlite3.Connection, last_n: int | None = None) -> str:
334
344
  f" {unknown_rounds} of {total_rounds} rounds record no reviewer panel "
335
345
  f"(no manifest evidence; excluded from consensus)"
336
346
  )
337
- manifest_only, unknown_only = conn.execute(
338
- "SELECT "
339
- "COALESCE(SUM(CASE WHEN blockers_source='manifest' THEN 1 ELSE 0 END), 0), "
340
- "COALESCE(SUM(CASE WHEN blockers_source='unknown' THEN 1 ELSE 0 END), 0) "
341
- "FROM rounds "
342
- "WHERE NOT (panel_source='recorded' AND panel_size=0 AND blockers=0)"
343
- ).fetchone()
344
347
  if manifest_only:
345
348
  lines.append(
346
- f" {manifest_only} of {total_rounds} rounds have no synthesizer artifact; their "
347
- f"blocker counts come from round manifests and carry no provenance"
349
+ f" {manifest_only} of {total_rounds} rounds have finding counts from manifests "
350
+ f"but no usable synthesizer artifact evidence; no per-finding provenance is available"
348
351
  )
349
352
  if unknown_only:
350
353
  lines.append(
351
- f" {unknown_only} of {total_rounds} rounds have no blocker evidence "
352
- f"(killed/interrupted before any artifact or manifest); "
353
- f"excluded from the blocker curve"
354
+ f" {unknown_only} of {total_rounds} rounds have no finding-count evidence "
355
+ f"(killed/interrupted before a usable artifact or complete manifest); "
356
+ f"top-line findings are lower bounds and these rounds are excluded from the "
357
+ f"blocker curve"
354
358
  )
355
359
 
356
360
  consensus = consensus_stats(conn)
@@ -21,6 +21,7 @@ import site
21
21
  import subprocess
22
22
  import sys
23
23
  from dataclasses import dataclass
24
+ from importlib.metadata import Distribution
24
25
  from pathlib import Path
25
26
 
26
27
  import syncade
@@ -29,9 +30,6 @@ from syncade.process import SubprocessError, run_subprocess
29
30
  from syncade.update_check import _parse, _text, manifest_once
30
31
 
31
32
  _UPGRADE_TIMEOUT = 300.0
32
- #: Reading one version string out of a fresh interpreter. Generous, because a cold venv
33
- #: import is slower than it looks; short, because we already hold the operator's terminal.
34
- _VERSION_TIMEOUT = 60.0
35
33
  #: How far up from ``syncade/__init__.py`` an install marker can sit. A venv layout is
36
34
  #: ``<root>/lib/python3.x/site-packages/syncade/__init__.py`` — five parents to the root; the
37
35
  #: extra headroom costs nothing and covers layouts that nest one deeper.
@@ -59,6 +57,21 @@ def _in_user_site() -> bool:
59
57
  return user_site in Path(syncade.__file__).resolve().parents
60
58
 
61
59
 
60
+ def _scoped_dist_infos() -> list[Path]:
61
+ """The dist-info directories beside the package that supplied this process's code."""
62
+ try:
63
+ site_packages = Path(syncade.__file__).resolve().parent.parent
64
+ return sorted(
65
+ entry
66
+ for entry in site_packages.iterdir()
67
+ if entry.name.casefold().startswith("syncade-")
68
+ and entry.name.casefold().endswith(".dist-info")
69
+ and entry.is_dir()
70
+ )
71
+ except (OSError, RuntimeError, TypeError):
72
+ return []
73
+
74
+
62
75
  def _read_scoped_installer() -> str:
63
76
  """Read INSTALLER from the dist-info directory that BELONGS to this syncade install.
64
77
 
@@ -71,27 +84,44 @@ def _read_scoped_installer() -> str:
71
84
  is empty string — the same as not found. First-match would misclassify in that state,
72
85
  which contradicts the PROVE-or-refuse posture the rest of this module maintains.
73
86
  """
74
- site_packages = Path(syncade.__file__).resolve().parent.parent
75
87
  found: list[str] = []
76
- try:
77
- for entry in site_packages.iterdir():
78
- if (
79
- entry.name.startswith("syncade-")
80
- and entry.name.endswith(".dist-info")
81
- and entry.is_dir()
82
- ):
83
- try:
84
- found.append((entry / "INSTALLER").read_text(encoding="utf-8").strip())
85
- except OSError:
86
- pass # No INSTALLER file — this dist-info cannot prove anything.
87
- except OSError:
88
- pass
88
+ for entry in _scoped_dist_infos():
89
+ try:
90
+ found.append((entry / "INSTALLER").read_text(encoding="utf-8").strip())
91
+ except OSError:
92
+ pass # No INSTALLER file — this dist-info cannot prove anything.
89
93
  if not found:
90
94
  return ""
91
95
  # Contradictory entries in a broken packaging state are not proof of anything.
92
96
  return found[0] if len(set(found)) == 1 else ""
93
97
 
94
98
 
99
+ def _read_scoped_version() -> str | None:
100
+ """Read ``Version`` from this syncade install's one adjacent dist-info.
101
+
102
+ The package directory is already the authoritative identity: it supplied the code running
103
+ this function. Reading its sibling metadata after the package manager returns sees the new
104
+ on-disk version without resolving the name ``syncade`` through ``sys.path`` again. A missing,
105
+ malformed, or duplicate dist-info is ambiguous and therefore unverifiable, never first-match.
106
+ """
107
+ candidates = _scoped_dist_infos()
108
+ if len(candidates) != 1:
109
+ return None
110
+ try:
111
+ if not (candidates[0] / "METADATA").is_file():
112
+ return None
113
+ metadata = Distribution.at(candidates[0]).metadata
114
+ names = metadata.get_all("Name") or []
115
+ versions = metadata.get_all("Version") or []
116
+ except Exception: # noqa: BLE001 — broken install metadata degrades to "unverifiable".
117
+ return None
118
+ if len(names) != 1 or names[0].strip().casefold() != "syncade":
119
+ return None
120
+ if len(versions) != 1:
121
+ return None
122
+ return versions[0].strip()
123
+
124
+
95
125
  def _pip_or_unknown(where: Path | None) -> InstallMethod:
96
126
  """``pip`` when the distribution records pip as its installer, else ``unknown``.
97
127
 
@@ -239,49 +269,14 @@ def _installed_version() -> str | None:
239
269
  """The version on disk NOW, or ``None`` when it cannot be established.
240
270
 
241
271
  It CANNOT be ``syncade.__version__``: this process imported that before the upgrade ran, so
242
- it is by definition the old number. A fresh interpreter re-reads the metadata the upgrade
243
- just rewrote, and ``sys.executable`` is the tool venv's own python under both uv and pipx —
244
- precisely the environment that changed.
272
+ it is by definition the old number. The value is read from the dist-info tree that belongs
273
+ to this install, so the answer follows the package on disk instead of any ambient metadata
274
+ discovery or subprocess import state.
245
275
 
246
276
  ``None`` means "could not verify", which is deliberately NOT the same as "did not change":
247
277
  the caller must not turn an unreadable version into a failure report.
248
278
  """
249
- try:
250
- result = run_subprocess(
251
- # `-I` (isolated) is load-bearing, not tidiness. Without it the probe inherits
252
- # PYTHONPATH and the user site dir, so a `sitecustomize` on that path runs inside
253
- # the answer and can emit stdout before OR after the version print.
254
- # `-I` keeps the venv's own site-packages, so `importlib.metadata` still finds
255
- # the install (verified) — but it does NOT suppress `sitecustomize.py` that lives
256
- # in those site-packages. An atexit hook registered there (syncade ships such a
257
- # shim for worktree imports) can append a version-shaped line AFTER the real one.
258
- # `os._exit(0)` bypasses all atexit handlers: the process terminates immediately
259
- # after `os.write`, so nothing can append to our stdout (reproduced: a hostile
260
- # sitecustomize turned "0.7.0" into "0.7.0\n99.0.0" before this fix).
261
- # `os.write` is used instead of `print` because `os._exit` skips stdio flushing;
262
- # an unbuffered syscall guarantees the version byte reaches the pipe.
263
- [
264
- sys.executable,
265
- "-I",
266
- "-c",
267
- "import importlib.metadata as m, os; "
268
- "os.write(1, (m.version('syncade') + '\\n').encode()); "
269
- "os._exit(0)",
270
- ],
271
- timeout=_VERSION_TIMEOUT,
272
- )
273
- except SubprocessError:
274
- return None
275
- if result.returncode != 0:
276
- return None
277
- raw = result.stdout.strip()
278
- if not raw:
279
- return None
280
- # sitecustomize from PYTHONPATH (suppressed by -I) could emit noise before -c runs;
281
- # the venv's own sitecustomize can still do so. Take the LAST line — atexit is now
282
- # impossible (os._exit), so the last line is what os.write put there — and validate it
283
- # parses as a version. Noise that accidentally ends in a dotted string still fails _parse.
284
- candidate = raw.splitlines()[-1].strip()
279
+ candidate = _read_scoped_version()
285
280
  return candidate if _parse(candidate) is not None else None
286
281
 
287
282
 
@@ -327,6 +327,11 @@ def _default_reviewers() -> list[ReviewerConfig]:
327
327
  # model="claude-opus-4-6", adversarial_lens=True)
328
328
 
329
329
 
330
+ def _default_worktree_base() -> Path:
331
+ """Resolve the worktree default when a config instance is created."""
332
+ return DEFAULT_WORKTREE_BASE
333
+
334
+
330
335
  class SyncadeConfig(BaseModel):
331
336
  """Top-level ``.syncade/config.toml`` schema.
332
337
 
@@ -363,7 +368,7 @@ class SyncadeConfig(BaseModel):
363
368
  # ``[worktree]`` block — Q5); default reproduces worktree.DEFAULT_WORKTREE_BASE.
364
369
  # ``--worktree-base`` overrides per-invocation; threaded into the review run and doctor preview.
365
370
  worktree_base: Path = Field(
366
- default=DEFAULT_WORKTREE_BASE,
371
+ default_factory=_default_worktree_base,
367
372
  description=(
368
373
  "Base directory under which each run's per-reviewer/producer/test git worktrees are "
369
374
  f"created (default ``{DEFAULT_WORKTREE_BASE}``). Overridable per-run with "
@@ -16,6 +16,7 @@ import sys
16
16
  from pathlib import Path
17
17
 
18
18
  from syncade.metrics.findings import (
19
+ read_finding_artifacts,
19
20
  read_findings,
20
21
  read_rounds,
21
22
  )
@@ -137,6 +138,9 @@ def _load_init(run_dir: Path) -> dict:
137
138
  def read_run(run_dir: Path | str) -> tuple[RunRow, list[ReviewerStatRow]] | None:
138
139
  """Map one run directory to a :class:`RunRow` + per-reviewer stats.
139
140
 
141
+ Finding totals on the returned row are provisional loop-manifest summaries. ``backfill()``
142
+ replaces the complete vector from normalized round evidence before persisting the row.
143
+
140
144
  Returns ``None`` when ``loop-manifest.json`` is present but unparseable — a
141
145
  *corrupt* run, which the caller skips with a warning rather than counting it as
142
146
  a bogus UNKNOWN run. A *missing* or *parseable-but-partial* manifest is still
@@ -510,7 +514,8 @@ def backfill(conn: sqlite3.Connection, runs_root: Path | str) -> None:
510
514
 
511
515
  Upserts every present run, refreshes each run's reviewer set (so a changed
512
516
  roster drops stale rows), and prunes runs no longer in ``.syncade/runs/`` — so
513
- the DB stays a faithful derived view even after ``--gc`` slims runs.
517
+ the DB stays a faithful derived view even after ``--gc`` slims runs. This is the final
518
+ authority for persisted run totals; ``read_run()`` alone returns provisional summaries.
514
519
  """
515
520
  present: list[str] = []
516
521
  for d in _candidate_run_dirs(runs_root):
@@ -522,6 +527,15 @@ def backfill(conn: sqlite3.Connection, runs_root: Path | str) -> None:
522
527
  )
523
528
  continue
524
529
  row, stats = parsed
530
+ artifacts = read_finding_artifacts(d)
531
+ findings, provenance = read_findings(d, row.run_id, artifacts)
532
+ round_rows = read_rounds(d, row.run_id, artifacts)
533
+ row.blockers, row.minors, row.nits, row.dismissed = (
534
+ sum(r.blockers for r in round_rows),
535
+ sum(r.minors for r in round_rows),
536
+ sum(r.nits for r in round_rows),
537
+ sum(r.dismissed for r in round_rows),
538
+ )
525
539
  upsert_run(conn, row)
526
540
  conn.execute("DELETE FROM reviewer_stats WHERE run_id = ?", (row.run_id,))
527
541
  conn.execute("DELETE FROM actor_stats WHERE run_id = ?", (row.run_id,))
@@ -533,24 +547,13 @@ def backfill(conn: sqlite3.Connection, runs_root: Path | str) -> None:
533
547
  for stat in read_actor_stats(d):
534
548
  upsert_actor_stat(conn, stat)
535
549
 
536
- findings, provenance = read_findings(d, row.run_id)
537
550
  for finding in findings:
538
551
  upsert_finding(conn, finding)
539
552
  for entry in provenance:
540
553
  upsert_finding_provenance(conn, entry)
541
554
 
542
- # ONE derivation, no branch. `rounds` already resolved artifacts-vs-manifest per round
543
- # and recorded which it used, so the run total is a sum rather than a choice between two
544
- # counts that could disagree. What stood here was a reached-set supplement plus a
545
- # conditional derive-or-preserve — three rounds of patching the same question, which is
546
- # what the single authority replaces.
547
- round_rows = read_rounds(d, row.run_id)
548
555
  for round_row in round_rows:
549
556
  upsert_round(conn, round_row)
550
- conn.execute(
551
- "UPDATE runs SET blockers = ? WHERE run_id = ?",
552
- (sum(r.blockers for r in round_rows), row.run_id),
553
- )
554
557
 
555
558
  present.append(row.run_id)
556
559
  _prune_absent(conn, present)
@@ -11,12 +11,9 @@ physical lines against a 600 ceiling; extraction there would have breached it on
11
11
  rebuild on schema drift exactly like ``runs``. The derived-view rule is intact: nothing here is
12
12
  the only copy of anything.
13
13
 
14
- **The count-agreement hazard is the reason this module has a public
15
- :func:`count_disagreements`.** ``runs.blockers`` sums each round manifest's
16
- ``active_blocker_count``; these rows come from the synthesizer artifact. Two paths to one fact
17
- WILL drift, so the disagreement is measured and reported rather than assumed away — and the
18
- verdict on what to do about it is the caller's, because a hard failure would make one
19
- inconsistent legacy run poison a whole backfill.
14
+ **The count-agreement hazard is the reason this module owns the per-round authority.** Every
15
+ finding count travels as one vector with one recorded source; consumers sum those rows rather
16
+ than choosing independently between artifacts and manifests.
20
17
  """
21
18
 
22
19
  from __future__ import annotations
@@ -27,6 +24,11 @@ from pathlib import Path
27
24
  from syncade.metrics.schema import FindingProvenanceRow, FindingRow, RoundRow
28
25
 
29
26
  _SYNTH_ARTIFACT = "synthesizer.parsed.json"
27
+ _KNOWN_EMPTY_REASONS = {"no_changes_to_review", "producer_emptied_diff"}
28
+ _KNOWN_FINDING_SEVERITIES = {"blocker", "minor", "nit"}
29
+ _CountVector = tuple[int, int, int, int]
30
+ _ArtifactEvidence = tuple[list[dict[str, object]], _CountVector]
31
+ _RoundArtifacts = dict[int, tuple[Path, _ArtifactEvidence | None]]
30
32
 
31
33
 
32
34
  def round_dirs(run_dir: Path) -> list[tuple[int, Path]]:
@@ -54,8 +56,47 @@ def _text(value: object) -> str:
54
56
  return value if isinstance(value, str) else ""
55
57
 
56
58
 
59
+ def _normalize_artifact(container: object) -> _ArtifactEvidence | None:
60
+ """One complete finding list and its count vector, or no artifact evidence."""
61
+ if not isinstance(container, dict):
62
+ return None
63
+ consolidated = container.get("consolidated_findings")
64
+ if not isinstance(consolidated, list):
65
+ return None
66
+ normalized: list[dict[str, object]] = []
67
+ active = {severity: 0 for severity in _KNOWN_FINDING_SEVERITIES}
68
+ dismissed = 0
69
+ for finding in consolidated:
70
+ if not isinstance(finding, dict):
71
+ return None
72
+ severity = finding.get("severity")
73
+ is_dismissed = finding.get("dismissed")
74
+ if not isinstance(severity, str) or severity not in active:
75
+ return None
76
+ if type(is_dismissed) is not bool:
77
+ return None
78
+ normalized.append(finding)
79
+ if is_dismissed:
80
+ dismissed += 1
81
+ else:
82
+ active[severity] += 1
83
+ return normalized, (active["blocker"], active["minor"], active["nit"], dismissed)
84
+
85
+
86
+ def read_finding_artifacts(run_dir: Path | str) -> _RoundArtifacts:
87
+ """Parse and count each round's count-critical artifact exactly once."""
88
+ artifacts: _RoundArtifacts = {}
89
+ for round_index, round_dir in round_dirs(Path(run_dir)):
90
+ try:
91
+ data = json.loads((round_dir / _SYNTH_ARTIFACT).read_text(encoding="utf-8"))
92
+ except (OSError, ValueError):
93
+ data = None
94
+ artifacts[round_index] = (round_dir, _normalize_artifact(data))
95
+ return artifacts
96
+
97
+
57
98
  def read_findings(
58
- run_dir: Path | str, run_id: str
99
+ run_dir: Path | str, run_id: str, artifacts: _RoundArtifacts
59
100
  ) -> tuple[list[FindingRow], list[FindingProvenanceRow]]:
60
101
  """Every consolidated finding in a run, with its provenance. Never raises.
61
102
 
@@ -71,26 +112,14 @@ def read_findings(
71
112
  _loop = {}
72
113
  _loop_rounds = _loop.get("rounds") if isinstance(_loop, dict) else None
73
114
 
74
- for round_index, round_dir in round_dirs(run_dir):
115
+ for round_index, (round_dir, evidence) in sorted(artifacts.items()):
75
116
  # A reviewer absent from the round's PANEL cannot have raised a finding in it. Ghost
76
117
  # provenance inflates consensus, and the ratio is the number the whole feature exists
77
118
  # to report. An unrecorded panel (empty) cannot convict anyone, so it filters nothing.
78
119
  _panel, _ = _round_panel(_loop_rounds, round_dir, round_index)
79
- try:
80
- data = json.loads((round_dir / _SYNTH_ARTIFACT).read_text(encoding="utf-8"))
81
- except (OSError, ValueError):
82
- continue
83
- if not isinstance(data, dict):
84
- continue
85
- consolidated = data.get("consolidated_findings")
86
- if not isinstance(consolidated, list):
87
- continue
88
- # All-or-nothing: a partially malformed list degrades to 'manifest' in read_rounds().
89
- # Persisting the valid-looking subset here would leave metrics.db with contradictory
90
- # round/run counts versus findings/provenance rows — the count mismatch this whole
91
- # table exists to prevent, reintroduced by the reader.
92
- if not all(isinstance(f, dict) for f in consolidated):
120
+ if evidence is None:
93
121
  continue
122
+ consolidated, _counts = evidence
94
123
 
95
124
  for idx, finding in enumerate(consolidated):
96
125
  findings.append(
@@ -99,10 +128,7 @@ def read_findings(
99
128
  round=round_index,
100
129
  idx=idx,
101
130
  severity=_text(finding.get("severity")),
102
- # `is True`, not truthiness: `dismissed="false"` is a TRUTHY string and
103
- # would mark an ACTIVE blocker dismissed, quietly shrinking every count
104
- # derived from it. Anything that is not a real boolean True is active.
105
- dismissed=1 if finding.get("dismissed") is True else 0,
131
+ dismissed=1 if finding["dismissed"] else 0,
106
132
  file=_text(finding.get("file")),
107
133
  description=_text(finding.get("description")),
108
134
  )
@@ -148,9 +174,9 @@ def _round_panel(loop_rounds: object, round_dir: Path, round_index: int) -> tupl
148
174
  like ``no_changes_to_review`` records ``reviewers: []``) and ``'unknown'`` when no
149
175
  manifest had the key and the panel composition cannot be determined.
150
176
 
151
- Panel provenance is independent from blocker-count provenance: a manifest can carry an
177
+ Panel provenance is independent from finding-count provenance: a manifest can carry
152
178
  ``active_blocker_count`` without a ``reviewers`` list, giving
153
- ``blockers_source='manifest'`` and ``panel_source='unknown'``.
179
+ ``counts_source='manifest'`` and ``panel_source='unknown'``.
154
180
  """
155
181
  names: set[str] = set()
156
182
  recorded = False
@@ -176,15 +202,27 @@ def _round_panel(loop_rounds: object, round_dir: Path, round_index: int) -> tupl
176
202
  return names, "recorded" if recorded else "unknown"
177
203
 
178
204
 
179
- def _synth_count(container: object) -> int | None:
180
- """``synthesizer.active_blocker_count`` from a manifest-shaped dict, if it is a real int."""
205
+ _COUNT_KEYS = (
206
+ "active_blocker_count",
207
+ "active_minor_count",
208
+ "active_nit_count",
209
+ "dismissed_count",
210
+ )
211
+
212
+
213
+ def _synth_counts(container: object) -> _CountVector | None:
214
+ """The complete synthesizer count vector from a manifest-shaped dict, if valid."""
181
215
  synth = container.get("synthesizer") if isinstance(container, dict) else None
182
- count = synth.get("active_blocker_count") if isinstance(synth, dict) else None
183
- return count if isinstance(count, int) and not isinstance(count, bool) else None
216
+ if not isinstance(synth, dict):
217
+ return None
218
+ values = tuple(synth.get(key) for key in _COUNT_KEYS)
219
+ if not all(type(value) is int and value >= 0 for value in values):
220
+ return None
221
+ return values
184
222
 
185
223
 
186
- def _manifest_blockers(round_dir: Path, loop_rounds: object, round_index: int) -> int | None:
187
- """A manifest-recorded blocker count for this round, or ``None``.
224
+ def _manifest_counts(round_dir: Path, loop_rounds: object, round_index: int) -> _CountVector | None:
225
+ """A complete manifest-recorded count vector for this round, or ``None``.
188
226
 
189
227
  BOTH manifests are consulted, in that order, for the same reason the round set is a union:
190
228
  either alone is incomplete. The round manifest exists for interrupted runs the loop manifest
@@ -196,24 +234,24 @@ def _manifest_blockers(round_dir: Path, loop_rounds: object, round_index: int) -
196
234
  data = json.loads((round_dir / _ROUND_MANIFEST).read_text(encoding="utf-8"))
197
235
  except (OSError, ValueError):
198
236
  data = None
199
- count = _synth_count(data)
200
- if count is not None:
201
- return count
237
+ counts = _synth_counts(data)
238
+ if counts is not None:
239
+ return counts
202
240
  if isinstance(loop_rounds, list) and round_index < len(loop_rounds):
203
- return _synth_count(loop_rounds[round_index])
241
+ return _synth_counts(loop_rounds[round_index])
204
242
  return None
205
243
 
206
244
 
207
- def read_rounds(run_dir: Path | str, run_id: str) -> list[RoundRow]:
245
+ def read_rounds(run_dir: Path | str, run_id: str, artifacts: _RoundArtifacts) -> list[RoundRow]:
208
246
  """One row per round that happened — THE authority, replacing three disagreeing derivations.
209
247
 
210
- Precedence for the blocker count is explicit and recorded, never inferred:
248
+ Precedence for the complete finding-count vector is explicit and recorded, never inferred:
211
249
 
212
250
  1. ``artifacts`` — the synthesizer artifact parsed and every entry was well-formed.
213
- 2. ``manifest`` — the artifact is missing or malformed but a round manifest recorded a
214
- count. The round ran and found something; only the evidence of WHAT is gone.
251
+ 2. ``manifest`` — the artifact is missing or malformed but a manifest recorded all four
252
+ counts. The round ran and found something; only the evidence of WHAT is gone.
215
253
  3. ``unknown`` — the round ran and nothing credible says what it found. Counted as a round,
216
- contributing zero blockers, because inventing either number is worse.
254
+ contributing zeroes by convention, because inventing counts is worse.
217
255
 
218
256
  A partially-malformed artifact degrades to ``manifest``/``unknown`` rather than silently
219
257
  reporting the entries that happened to parse: dropping three of five findings and calling
@@ -229,7 +267,7 @@ def read_rounds(run_dir: Path | str, run_id: str) -> list[RoundRow]:
229
267
  # The union of BOTH kinds of evidence, because either alone is incomplete: a directory can
230
268
  # exist for a round the manifest never summarised (interrupted), and a manifest can prove a
231
269
  # round ran whose directory is absent. Taking one source was the mistake this table replaces.
232
- on_disk = dict(round_dirs(run_dir))
270
+ on_disk = {index: entry[0] for index, entry in artifacts.items()}
233
271
  indices = set(on_disk)
234
272
  if isinstance(loop_rounds, list):
235
273
  indices |= set(range(len(loop_rounds)))
@@ -238,13 +276,31 @@ def read_rounds(run_dir: Path | str, run_id: str) -> list[RoundRow]:
238
276
  for round_index in sorted(indices):
239
277
  round_dir = on_disk.get(round_index, run_dir / f"round-{round_index}")
240
278
  panel, panel_source = _round_panel(loop_rounds, round_dir, round_index)
241
- blockers, source = _round_blockers(round_dir, loop_rounds, round_index)
279
+ known_no_dispatch = (
280
+ isinstance(loop_rounds, list)
281
+ and round_index == len(loop_rounds) - 1
282
+ and isinstance(loop, dict)
283
+ and loop.get("termination_reason") in _KNOWN_EMPTY_REASONS
284
+ and panel_source == "recorded"
285
+ and not panel
286
+ )
287
+ counts, source = _round_counts(
288
+ round_dir,
289
+ loop_rounds,
290
+ round_index,
291
+ artifacts.get(round_index, (round_dir, None))[1],
292
+ known_no_dispatch=known_no_dispatch,
293
+ )
294
+ blockers, minors, nits, dismissed = counts
242
295
  rows.append(
243
296
  RoundRow(
244
297
  run_id=run_id,
245
298
  round=round_index,
246
299
  blockers=blockers,
247
- blockers_source=source,
300
+ minors=minors,
301
+ nits=nits,
302
+ dismissed=dismissed,
303
+ counts_source=source,
248
304
  panel_size=len(panel),
249
305
  panel_source=panel_source,
250
306
  )
@@ -252,23 +308,22 @@ def read_rounds(run_dir: Path | str, run_id: str) -> list[RoundRow]:
252
308
  return rows
253
309
 
254
310
 
255
- def _round_blockers(round_dir: Path, loop_rounds: object, round_index: int) -> tuple[int, str]:
256
- try:
257
- data = json.loads((round_dir / _SYNTH_ARTIFACT).read_text(encoding="utf-8"))
258
- except (OSError, ValueError):
259
- data = None
260
- consolidated = data.get("consolidated_findings") if isinstance(data, dict) else None
261
- if isinstance(consolidated, list) and all(isinstance(f, dict) for f in consolidated):
262
- return (
263
- sum(
264
- 1
265
- for f in consolidated
266
- if f.get("severity") == "blocker" and f.get("dismissed") is not True
267
- ),
268
- "artifacts",
269
- )
270
- fallback = _manifest_blockers(round_dir, loop_rounds, round_index)
271
- return (fallback, "manifest") if fallback is not None else (0, "unknown")
311
+ def _round_counts(
312
+ round_dir: Path,
313
+ loop_rounds: object,
314
+ round_index: int,
315
+ artifact: _ArtifactEvidence | None,
316
+ *,
317
+ known_no_dispatch: bool,
318
+ ) -> tuple[_CountVector, str]:
319
+ if artifact is not None:
320
+ return artifact[1], "artifacts"
321
+ fallback = _manifest_counts(round_dir, loop_rounds, round_index)
322
+ if fallback is not None:
323
+ return fallback, "manifest"
324
+ if known_no_dispatch:
325
+ return (0, 0, 0, 0), "manifest"
326
+ return (0, 0, 0, 0), "unknown"
272
327
 
273
328
 
274
329
  def active_blockers(findings: list[FindingRow]) -> int:
@@ -297,7 +352,7 @@ def consensus_stats(conn) -> tuple[int, int] | None:
297
352
  """
298
353
  WITH multi_rounds AS (
299
354
  SELECT run_id, round FROM rounds
300
- WHERE panel_size > 1 AND blockers_source = 'artifacts'
355
+ WHERE panel_size > 1 AND counts_source = 'artifacts'
301
356
  ),
302
357
  active AS (
303
358
  SELECT f.run_id, f.round, f.idx FROM findings f
@@ -329,30 +384,33 @@ def blocker_curve(conn) -> list[tuple[int, int, int]]:
329
384
  convergence; it is SURVIVORSHIP, since fewer runs reach each round. Returning both forces a
330
385
  renderer to show density, which this repo published wrongly once already.
331
386
 
332
- ``blockers_source='unknown'`` rounds are EXCLUDED. Their blocker count is 0 by convention
387
+ ``counts_source='unknown'`` rounds are EXCLUDED. Their blocker count is 0 by convention
333
388
  (no evidence either way), not by measurement — including them fabricates zeroes in the curve
334
389
  for killed/interrupted runs that left no manifest or artifact. Manifest-backed rounds ARE
335
390
  included: they have a measured count, just no per-finding provenance.
336
391
  """
337
392
  rows = conn.execute(
338
393
  "SELECT round, COALESCE(SUM(blockers), 0), COUNT(*) FROM rounds "
339
- "WHERE blockers_source != 'unknown' "
394
+ "WHERE counts_source != 'unknown' AND NOT (panel_source = 'recorded' "
395
+ "AND panel_size = 0 AND blockers = 0 AND minors = 0 AND nits = 0 AND dismissed = 0) "
340
396
  "GROUP BY round ORDER BY round"
341
397
  ).fetchall()
342
398
  return [(int(r), int(n), int(d)) for r, n, d in rows]
343
399
 
344
400
 
345
401
  def incomplete_rounds(conn) -> int:
346
- """Rounds whose blocker count is not artifact-backed — reported, never silently zeroed.
402
+ """Rounds whose finding counts are not artifact-backed — reported, never silently zeroed.
347
403
 
348
404
  Excludes deliberate no-dispatch rounds (panel explicitly recorded as empty with no blockers):
349
405
  those have no synthesizer by design, not because one is missing. The signature is
350
- ``panel_source='recorded' AND panel_size=0 AND blockers=0`` — a round where a manifest
351
- explicitly recorded zero reviewers and zero blockers, such as ``no_changes_to_review``.
406
+ ``panel_source='recorded' AND panel_size=0`` plus an all-zero vector — a round where a
407
+ manifest explicitly recorded zero reviewers and zero findings, such as
408
+ ``no_changes_to_review``.
352
409
  """
353
410
  return int(
354
411
  conn.execute(
355
- "SELECT COUNT(*) FROM rounds WHERE blockers_source != 'artifacts' "
356
- "AND NOT (panel_source = 'recorded' AND panel_size = 0 AND blockers = 0)"
412
+ "SELECT COUNT(*) FROM rounds WHERE counts_source != 'artifacts' "
413
+ "AND NOT (counts_source = 'manifest' AND panel_source = 'recorded' AND panel_size = 0 "
414
+ "AND blockers = 0 AND minors = 0 AND nits = 0 AND dismissed = 0)"
357
415
  ).fetchone()[0]
358
416
  )