specsmith 0.5.0.dev226__tar.gz → 0.5.0.dev228__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 (175) hide show
  1. {specsmith-0.5.0.dev226/src/specsmith.egg-info → specsmith-0.5.0.dev228}/PKG-INFO +1 -1
  2. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/pyproject.toml +1 -1
  3. specsmith-0.5.0.dev228/src/specsmith/agent/chat_runner.py +337 -0
  4. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/cli.py +42 -10
  5. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228/src/specsmith.egg-info}/PKG-INFO +1 -1
  6. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith.egg-info/SOURCES.txt +2 -0
  7. specsmith-0.5.0.dev228/tests/test_chat_diff_decision.py +158 -0
  8. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/LICENSE +0 -0
  9. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/README.md +0 -0
  10. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/setup.cfg +0 -0
  11. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/__init__.py +0 -0
  12. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/belief.py +0 -0
  13. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/certainty.py +0 -0
  14. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/failure_graph.py +0 -0
  15. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/py.typed +0 -0
  16. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/recovery.py +0 -0
  17. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/session.py +0 -0
  18. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/stress_tester.py +0 -0
  19. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/epistemic/trace.py +0 -0
  20. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/__init__.py +0 -0
  21. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/__main__.py +0 -0
  22. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/__init__.py +0 -0
  23. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/broker.py +0 -0
  24. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/cleanup.py +0 -0
  25. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/events.py +0 -0
  26. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/indexer.py +0 -0
  27. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/mcp.py +0 -0
  28. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/memory.py +0 -0
  29. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/orchestrator.py +0 -0
  30. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/repl.py +0 -0
  31. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/router.py +0 -0
  32. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/rules.py +0 -0
  33. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/safety.py +0 -0
  34. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/tools.py +0 -0
  35. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/agent/verifier.py +0 -0
  36. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/architect.py +0 -0
  37. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/auditor.py +0 -0
  38. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/auth.py +0 -0
  39. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/commands/__init__.py +0 -0
  40. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/compressor.py +0 -0
  41. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/config.py +0 -0
  42. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/console_utils.py +0 -0
  43. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/credit_analyzer.py +0 -0
  44. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/credits.py +0 -0
  45. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/differ.py +0 -0
  46. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/doctor.py +0 -0
  47. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/epistemic/__init__.py +0 -0
  48. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/epistemic/belief.py +0 -0
  49. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/epistemic/certainty.py +0 -0
  50. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/epistemic/failure_graph.py +0 -0
  51. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/epistemic/recovery.py +0 -0
  52. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/epistemic/stress_tester.py +0 -0
  53. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/executor.py +0 -0
  54. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/exporter.py +0 -0
  55. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/__init__.py +0 -0
  56. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/app.py +0 -0
  57. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/main_window.py +0 -0
  58. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/session_tab.py +0 -0
  59. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/theme.py +0 -0
  60. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/__init__.py +0 -0
  61. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/chat_view.py +0 -0
  62. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/input_bar.py +0 -0
  63. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/provider_bar.py +0 -0
  64. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/token_meter.py +0 -0
  65. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/tool_panel.py +0 -0
  66. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/widgets/update_checker.py +0 -0
  67. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/gui/worker.py +0 -0
  68. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/importer.py +0 -0
  69. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/__init__.py +0 -0
  70. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/agent_skill.py +0 -0
  71. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/aider.py +0 -0
  72. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/base.py +0 -0
  73. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/claude_code.py +0 -0
  74. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/copilot.py +0 -0
  75. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/cursor.py +0 -0
  76. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/gemini.py +0 -0
  77. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/integrations/windsurf.py +0 -0
  78. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/languages.py +0 -0
  79. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/ledger.py +0 -0
  80. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/patent.py +0 -0
  81. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/phase.py +0 -0
  82. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/plugins.py +0 -0
  83. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/profiles.py +0 -0
  84. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/rate_limits.py +0 -0
  85. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/releaser.py +0 -0
  86. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/requirements.py +0 -0
  87. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/requirements_parser.py +0 -0
  88. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/retrieval.py +0 -0
  89. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/scaffolder.py +0 -0
  90. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/serve.py +0 -0
  91. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/session.py +0 -0
  92. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/skills.py +0 -0
  93. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/agents.md.j2 +0 -0
  94. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
  95. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
  96. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/contributing.md.j2 +0 -0
  97. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
  98. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
  99. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/license-MIT.j2 +0 -0
  100. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
  101. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/community/security.md.j2 +0 -0
  102. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  103. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
  104. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
  105. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  106. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  107. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/editorconfig.j2 +0 -0
  108. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/gitattributes.j2 +0 -0
  109. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/gitignore.j2 +0 -0
  110. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/go/go.mod.j2 +0 -0
  111. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/go/main.go.j2 +0 -0
  112. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
  113. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
  114. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  115. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
  116. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
  117. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
  118. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  119. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  120. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
  121. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
  122. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  123. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/js/package.json.j2 +0 -0
  124. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/ledger.md.j2 +0 -0
  125. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/python/cli.py.j2 +0 -0
  126. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/python/init.py.j2 +0 -0
  127. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
  128. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/readme.md.j2 +0 -0
  129. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
  130. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/rust/main.rs.j2 +0 -0
  131. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
  132. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
  133. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  134. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  135. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  136. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  137. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
  138. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/tool_installer.py +0 -0
  139. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/toolrules.py +0 -0
  140. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/tools.py +0 -0
  141. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/trace.py +0 -0
  142. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/updater.py +0 -0
  143. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/upgrader.py +0 -0
  144. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/validator.py +0 -0
  145. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/vcs/__init__.py +0 -0
  146. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/vcs/base.py +0 -0
  147. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/vcs/bitbucket.py +0 -0
  148. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/vcs/github.py +0 -0
  149. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/vcs/gitlab.py +0 -0
  150. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/vcs_commands.py +0 -0
  151. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/wireframes.py +0 -0
  152. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith/workspace.py +0 -0
  153. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith.egg-info/dependency_links.txt +0 -0
  154. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith.egg-info/entry_points.txt +0 -0
  155. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith.egg-info/requires.txt +0 -0
  156. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/src/specsmith.egg-info/top_level.txt +0 -0
  157. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_CMD_001.py +0 -0
  158. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_auditor.py +0 -0
  159. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_chat_stdin_protocol.py +0 -0
  160. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_cli.py +0 -0
  161. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_cli_workflows_history_drive.py +0 -0
  162. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_compressor.py +0 -0
  163. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_e2e_nexus.py +0 -0
  164. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_epistemic.py +0 -0
  165. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_importer.py +0 -0
  166. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_integrations.py +0 -0
  167. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_nexus.py +0 -0
  168. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_phase1_4_new.py +0 -0
  169. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_rate_limits.py +0 -0
  170. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_scaffolder.py +0 -0
  171. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_skill_marketplace.py +0 -0
  172. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_smoke.py +0 -0
  173. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_tools.py +0 -0
  174. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_validator.py +0 -0
  175. {specsmith-0.5.0.dev226 → specsmith-0.5.0.dev228}/tests/test_vcs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.5.0.dev226
3
+ Version: 0.5.0.dev228
4
4
  Summary: Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands.
5
5
  Author: BitConcepts
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specsmith"
7
- version = "0.5.0.dev226"
7
+ version = "0.5.0.dev228"
8
8
  description = "Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,337 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Real LLM-backed runner for `specsmith chat` (REQ-108, REQ-112-118).
4
+
5
+ This module replaces the deterministic stub that previously lived inside
6
+ `chat_cmd`. It selects the first available provider (Ollama → Anthropic →
7
+ OpenAI → Gemini) and streams the model's response as `token` events
8
+ through the supplied :class:`EventEmitter`. Output is then parsed for
9
+ ``Files changed:`` and ``Test results:`` sections so the verifier can
10
+ emit a real verdict.
11
+
12
+ The runner is deliberately defensive: any provider error (missing SDK,
13
+ unreachable endpoint, network failure) returns ``None`` so the caller
14
+ can fall back to the deterministic stub. This keeps the test suite
15
+ green on machines that have no LLM configured at all.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ from dataclasses import dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any
25
+ from urllib.error import URLError
26
+ from urllib.request import Request, urlopen
27
+
28
+ from specsmith.agent.events import EventEmitter
29
+ from specsmith.agent.verifier import (
30
+ VerifierVerdict,
31
+ report_from_chat_sections,
32
+ score,
33
+ )
34
+
35
+ DEFAULT_OLLAMA_HOST = "http://127.0.0.1:11434"
36
+ DEFAULT_OLLAMA_MODEL = os.environ.get("SPECSMITH_OLLAMA_MODEL", "qwen2.5:7b")
37
+ SYSTEM_PROMPT = (
38
+ "You are Nexus, the local-first agentic developer assistant inside "
39
+ "Specsmith. Always end your response with the canonical contract:\n"
40
+ "Plan:\n"
41
+ "Files changed:\n"
42
+ "Test results:\n"
43
+ "Next action:\n"
44
+ )
45
+
46
+
47
+ @dataclass
48
+ class ChatRunResult:
49
+ """Return value of :func:`run_chat`."""
50
+
51
+ provider: str
52
+ summary: str
53
+ files_changed: list[str] = field(default_factory=list)
54
+ verdict: VerifierVerdict | None = None
55
+ raw_text: str = ""
56
+
57
+ def to_dict(self) -> dict[str, Any]:
58
+ return {
59
+ "provider": self.provider,
60
+ "summary": self.summary,
61
+ "files_changed": list(self.files_changed),
62
+ "confidence": self.verdict.confidence if self.verdict else 0.0,
63
+ "equilibrium": self.verdict.equilibrium if self.verdict else False,
64
+ }
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Public entry point
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ def run_chat(
73
+ utterance: str,
74
+ *,
75
+ project_dir: Path,
76
+ profile: str,
77
+ session_id: str,
78
+ emitter: EventEmitter,
79
+ msg_block: str,
80
+ history: list[dict[str, Any]] | None = None,
81
+ confidence_target: float = 0.7,
82
+ rules_prefix: str = "",
83
+ ) -> ChatRunResult | None:
84
+ """Drive a real LLM turn. Return ``None`` if no provider is reachable."""
85
+ history = history or []
86
+ messages = _build_messages(utterance, history, rules_prefix)
87
+
88
+ # Order matters: Ollama first because it's local-first and free.
89
+ for provider in (_run_ollama, _run_anthropic, _run_openai, _run_gemini):
90
+ try:
91
+ full_text = provider(messages, emitter, msg_block)
92
+ except Exception: # noqa: BLE001 - any failure → next provider
93
+ continue
94
+ if full_text is None:
95
+ continue
96
+ return _finalize(full_text, provider.__name__, project_dir, confidence_target)
97
+ return None
98
+
99
+
100
+ def _finalize(
101
+ full_text: str,
102
+ provider_fn_name: str,
103
+ project_dir: Path,
104
+ confidence_target: float,
105
+ ) -> ChatRunResult:
106
+ sections = _parse_output_contract(full_text)
107
+ files_changed = _split_files_list(sections.get("files_changed", ""))
108
+ report = report_from_chat_sections(sections, files_changed=files_changed)
109
+ verdict = score(report, confidence_target=confidence_target)
110
+ summary = (sections.get("plan") or full_text.strip()[:200]).strip() or verdict.summary
111
+ return ChatRunResult(
112
+ provider=provider_fn_name.removeprefix("_run_"),
113
+ summary=summary,
114
+ files_changed=files_changed,
115
+ verdict=verdict,
116
+ raw_text=full_text,
117
+ )
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Provider drivers — each returns the full assembled text or None
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def _run_ollama(
126
+ messages: list[dict[str, str]],
127
+ emitter: EventEmitter,
128
+ block_id: str,
129
+ ) -> str | None:
130
+ """Stream from a local Ollama daemon using only stdlib."""
131
+ host = os.environ.get("OLLAMA_HOST", DEFAULT_OLLAMA_HOST).rstrip("/")
132
+ model = os.environ.get("SPECSMITH_OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
133
+
134
+ if not _ollama_alive(host):
135
+ return None
136
+
137
+ payload = json.dumps({"model": model, "messages": messages, "stream": True}).encode("utf-8")
138
+ req = Request( # noqa: S310 - URL is a hardcoded localhost default
139
+ f"{host}/api/chat",
140
+ data=payload,
141
+ headers={"Content-Type": "application/json"},
142
+ method="POST",
143
+ )
144
+
145
+ pieces: list[str] = []
146
+ with urlopen(req, timeout=120) as resp: # noqa: S310
147
+ for raw_line in resp:
148
+ line = raw_line.decode("utf-8", errors="replace").strip()
149
+ if not line:
150
+ continue
151
+ try:
152
+ obj = json.loads(line)
153
+ except ValueError:
154
+ continue
155
+ chunk = ((obj.get("message") or {}).get("content")) or ""
156
+ if chunk:
157
+ emitter.token(block_id, chunk)
158
+ pieces.append(chunk)
159
+ if obj.get("done"):
160
+ break
161
+ return "".join(pieces) if pieces else None
162
+
163
+
164
+ def _ollama_alive(host: str) -> bool:
165
+ try:
166
+ with urlopen(f"{host}/api/tags", timeout=2): # noqa: S310
167
+ return True
168
+ except (URLError, TimeoutError, OSError):
169
+ return False
170
+
171
+
172
+ def _run_anthropic(
173
+ messages: list[dict[str, str]],
174
+ emitter: EventEmitter,
175
+ block_id: str,
176
+ ) -> str | None:
177
+ """Use the anthropic SDK if installed and a key is configured."""
178
+ if not os.environ.get("ANTHROPIC_API_KEY"):
179
+ return None
180
+ try:
181
+ import anthropic
182
+ except ImportError:
183
+ return None
184
+
185
+ system = "\n".join(m["content"] for m in messages if m["role"] == "system")
186
+ user_msgs = [m for m in messages if m["role"] != "system"]
187
+ client = anthropic.Anthropic()
188
+ pieces: list[str] = []
189
+ with client.messages.stream(
190
+ model=os.environ.get("ANTHROPIC_MODEL", "claude-haiku-4-5"),
191
+ max_tokens=2048,
192
+ system=system,
193
+ messages=[{"role": m["role"], "content": m["content"]} for m in user_msgs],
194
+ ) as stream:
195
+ for event in stream:
196
+ text = getattr(getattr(event, "delta", None), "text", None)
197
+ if text:
198
+ emitter.token(block_id, text)
199
+ pieces.append(text)
200
+ return "".join(pieces) if pieces else None
201
+
202
+
203
+ def _run_openai(
204
+ messages: list[dict[str, str]],
205
+ emitter: EventEmitter,
206
+ block_id: str,
207
+ ) -> str | None:
208
+ """Use the openai SDK if installed and a key is configured."""
209
+ if not os.environ.get("OPENAI_API_KEY"):
210
+ return None
211
+ try:
212
+ from openai import OpenAI
213
+ except ImportError:
214
+ return None
215
+
216
+ client = OpenAI()
217
+ stream = client.chat.completions.create(
218
+ model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
219
+ messages=messages,
220
+ stream=True,
221
+ )
222
+ pieces: list[str] = []
223
+ for chunk in stream:
224
+ text = (chunk.choices[0].delta.content or "") if chunk.choices else ""
225
+ if text:
226
+ emitter.token(block_id, text)
227
+ pieces.append(text)
228
+ return "".join(pieces) if pieces else None
229
+
230
+
231
+ def _run_gemini(
232
+ messages: list[dict[str, str]],
233
+ emitter: EventEmitter,
234
+ block_id: str,
235
+ ) -> str | None:
236
+ """Use google-genai SDK if installed and a key is configured."""
237
+ if not os.environ.get("GOOGLE_API_KEY"):
238
+ return None
239
+ try:
240
+ from google import genai
241
+ except ImportError:
242
+ return None
243
+
244
+ client = genai.Client()
245
+ prompt = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
246
+ pieces: list[str] = []
247
+ for chunk in client.models.generate_content_stream(
248
+ model=os.environ.get("GEMINI_MODEL", "gemini-2.5-flash"),
249
+ contents=prompt,
250
+ ):
251
+ text = getattr(chunk, "text", "") or ""
252
+ if text:
253
+ emitter.token(block_id, text)
254
+ pieces.append(text)
255
+ return "".join(pieces) if pieces else None
256
+
257
+
258
+ # ---------------------------------------------------------------------------
259
+ # Helpers
260
+ # ---------------------------------------------------------------------------
261
+
262
+
263
+ def _build_messages(
264
+ utterance: str,
265
+ history: list[dict[str, Any]],
266
+ rules_prefix: str,
267
+ ) -> list[dict[str, str]]:
268
+ system = SYSTEM_PROMPT
269
+ if rules_prefix:
270
+ system = f"{system}\n\nProject rules:\n{rules_prefix}"
271
+ msgs: list[dict[str, str]] = [{"role": "system", "content": system}]
272
+ for turn in history[-10:]:
273
+ text = str(turn.get("utterance") or turn.get("text") or "").strip()
274
+ if text:
275
+ msgs.append({"role": "user", "content": text})
276
+ msgs.append({"role": "user", "content": utterance})
277
+ return msgs
278
+
279
+
280
+ def _parse_output_contract(text: str) -> dict[str, str]:
281
+ """Extract canonical Nexus output sections from free-form text.
282
+
283
+ The contract is `Plan:`, `Commands to run:`, `Files changed:`,
284
+ `Diff:`, `Test results:`, `Next action:`. Sections that don't
285
+ appear are returned as empty strings.
286
+ """
287
+ keys = [
288
+ ("plan", "Plan:"),
289
+ ("commands_to_run", "Commands to run:"),
290
+ ("files_changed", "Files changed:"),
291
+ ("diff", "Diff:"),
292
+ ("test_results", "Test results:"),
293
+ ("next_action", "Next action:"),
294
+ ]
295
+ out: dict[str, str] = {key: "" for key, _ in keys}
296
+ lower = text.lower()
297
+ bounds: list[tuple[str, int]] = []
298
+ for key, header in keys:
299
+ idx = lower.find(header.lower())
300
+ if idx >= 0:
301
+ bounds.append((key, idx + len(header)))
302
+ bounds.sort(key=lambda b: b[1])
303
+ for i, (key, start) in enumerate(bounds):
304
+ if i + 1 < len(bounds):
305
+ next_key, next_pos = bounds[i + 1]
306
+ end = next_pos - len(_section_header(next_key))
307
+ else:
308
+ end = len(text)
309
+ out[key] = text[start:end].strip()
310
+ return out
311
+
312
+
313
+ def _section_header(key: str) -> str:
314
+ return {
315
+ "plan": "Plan:",
316
+ "commands_to_run": "Commands to run:",
317
+ "files_changed": "Files changed:",
318
+ "diff": "Diff:",
319
+ "test_results": "Test results:",
320
+ "next_action": "Next action:",
321
+ }[key]
322
+
323
+
324
+ def _split_files_list(text: str) -> list[str]:
325
+ items: list[str] = []
326
+ for raw in text.splitlines():
327
+ line = raw.strip()
328
+ if not line:
329
+ continue
330
+ if line.startswith(("-", "*", "+")):
331
+ line = line[1:].strip()
332
+ if line:
333
+ items.append(line)
334
+ return items
335
+
336
+
337
+ __all__ = ["ChatRunResult", "run_chat"]
@@ -5258,6 +5258,7 @@ def chat_cmd(
5258
5258
  events instead of executing tool calls (REQ-115).
5259
5259
  """
5260
5260
  import json as _json
5261
+ import os
5261
5262
  import uuid as _uuid
5262
5263
 
5263
5264
  from specsmith.agent.events import EventEmitter
@@ -5363,10 +5364,38 @@ def chat_cmd(
5363
5364
  emitter.tool_call(msg_block, "execute_with_governance", {"utterance": utterance})
5364
5365
  emitter.plan_step(plan_block, "s2", "complete")
5365
5366
 
5366
- # Verifier sketch (deterministic, no LLM needed for this stub):
5367
- summary = (
5368
- f"Preflight intent={real_intent.value}, matched_reqs={len(scope.matched_requirements)}."
5369
- )
5367
+ # Real LLM turn try Ollama / Anthropic / OpenAI / Gemini via
5368
+ # specsmith.agent.chat_runner. Any failure (no provider, network
5369
+ # error, missing SDK) returns ``None`` so we fall back to the
5370
+ # deterministic stub below. This keeps the test suite green on
5371
+ # machines without an LLM configured at all.
5372
+ real_result = None
5373
+ if os.environ.get("SPECSMITH_DISABLE_REAL_CHAT", "").lower() not in ("1", "true", "yes"):
5374
+ try:
5375
+ from specsmith.agent.chat_runner import run_chat as _run_chat
5376
+
5377
+ real_result = _run_chat(
5378
+ utterance,
5379
+ project_dir=root,
5380
+ profile=profile,
5381
+ session_id=sid,
5382
+ emitter=emitter,
5383
+ msg_block=msg_block,
5384
+ history=history,
5385
+ rules_prefix=rules_prefix,
5386
+ )
5387
+ except Exception: # noqa: BLE001 - real chat is best-effort
5388
+ real_result = None
5389
+
5390
+ if real_result is not None:
5391
+ verdict = real_result.verdict
5392
+ summary = real_result.summary or (verdict.summary if verdict else "")
5393
+ else:
5394
+ # Verifier sketch (deterministic, no LLM needed for this stub):
5395
+ verdict = None
5396
+ summary = (
5397
+ f"Preflight intent={real_intent.value}, matched_reqs={len(scope.matched_requirements)}."
5398
+ )
5370
5399
  if reviewer_comment:
5371
5400
  summary += f" reviewer_comment={reviewer_comment!r}"
5372
5401
  emitter.plan_step(plan_block, "s3", "complete", summary=summary)
@@ -5383,13 +5412,13 @@ def chat_cmd(
5383
5412
  for req in scope.matched_requirements[:3]:
5384
5413
  diff_block = emitter.diff(
5385
5414
  path=f"docs/{req.req_id}.md",
5386
- diff=f"--- {req.req_id} (review)\n+++ {req.req_id} (proposed)\n",
5415
+ body=f"--- {req.req_id} (review)\n+++ {req.req_id} (proposed)\n",
5387
5416
  )
5388
5417
  decision = _read_stdin_decision("diff_decision", decision_timeout)
5389
- verdict = (decision or {}).get("decision", "timeout")
5418
+ decision_status = (decision or {}).get("decision", "timeout")
5390
5419
  comment = (decision or {}).get("comment", "")
5391
- emitter.block_complete(diff_block, status=verdict)
5392
- if verdict != "accept" and comment:
5420
+ emitter.block_complete(diff_block, status=decision_status)
5421
+ if decision_status != "accept" and comment:
5393
5422
  extra_comment = comment
5394
5423
  break
5395
5424
 
@@ -5397,9 +5426,12 @@ def chat_cmd(
5397
5426
  if extra_comment:
5398
5427
  final_summary += f" reviewer_comment={extra_comment!r}"
5399
5428
 
5429
+ final_confidence = (
5430
+ verdict.confidence if real_result is not None and verdict is not None else 0.7
5431
+ )
5400
5432
  emitter.task_complete(
5401
- success=True,
5402
- confidence=0.7,
5433
+ success=real_result is None or (verdict is not None and verdict.equilibrium),
5434
+ confidence=final_confidence,
5403
5435
  summary=final_summary,
5404
5436
  profile=profile,
5405
5437
  session_id=sid,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.5.0.dev226
3
+ Version: 0.5.0.dev228
4
4
  Summary: Applied Epistemic Engineering toolkit — AEE agent sessions, execution profiles, FPGA/HDL governance, tool installer, 50+ CLI commands.
5
5
  Author: BitConcepts
6
6
  License-Expression: MIT
@@ -59,6 +59,7 @@ src/specsmith.egg-info/requires.txt
59
59
  src/specsmith.egg-info/top_level.txt
60
60
  src/specsmith/agent/__init__.py
61
61
  src/specsmith/agent/broker.py
62
+ src/specsmith/agent/chat_runner.py
62
63
  src/specsmith/agent/cleanup.py
63
64
  src/specsmith/agent/events.py
64
65
  src/specsmith/agent/indexer.py
@@ -152,6 +153,7 @@ src/specsmith/vcs/github.py
152
153
  src/specsmith/vcs/gitlab.py
153
154
  tests/test_CMD_001.py
154
155
  tests/test_auditor.py
156
+ tests/test_chat_diff_decision.py
155
157
  tests/test_chat_stdin_protocol.py
156
158
  tests/test_cli.py
157
159
  tests/test_cli_workflows_history_drive.py
@@ -0,0 +1,158 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Tests for `specsmith chat --interactive` inline diff-decision flow.
4
+
5
+ The diff-decision protocol is the second half of the interactive contract
6
+ (``test_chat_stdin_protocol`` covers tool_decision). When the broker
7
+ matches a requirement, the chat command emits one diff block per matched
8
+ REQ and consumes a ``diff_decision`` JSON line from stdin per block. A
9
+ non-accept verdict with a ``comment`` field becomes the next-retry
10
+ reviewer comment (REQ-116).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ from click.testing import CliRunner
19
+
20
+ from specsmith.cli import main
21
+
22
+ REQUIREMENTS_MD = """# Requirements
23
+
24
+ ## 1. Hello world greeter
25
+
26
+ - **ID:** REQ-001
27
+ - **Description:** Implement a hello world greeter so the agent can introduce itself.
28
+ """
29
+
30
+
31
+ def _seed_project(tmp_path: Path) -> None:
32
+ """Write the minimum REQUIREMENTS.md so the broker has a scope match."""
33
+ (tmp_path / "REQUIREMENTS.md").write_text(REQUIREMENTS_MD, encoding="utf-8")
34
+
35
+
36
+ def _events(output: str) -> list[dict]:
37
+ return [
38
+ json.loads(line)
39
+ for line in output.splitlines()
40
+ if line.startswith("{") and '"type"' in line
41
+ ]
42
+
43
+
44
+ def test_chat_interactive_diff_decision_emits_diff_block(tmp_path: Path) -> None:
45
+ """A matched REQ should produce a diff block_start whose status accepts."""
46
+ _seed_project(tmp_path)
47
+ runner = CliRunner()
48
+ decisions = (
49
+ json.dumps({"type": "tool_decision", "decision": "approve"})
50
+ + "\n"
51
+ + json.dumps({"type": "diff_decision", "decision": "accept"})
52
+ + "\n"
53
+ )
54
+ res = runner.invoke(
55
+ main,
56
+ [
57
+ "chat",
58
+ "add hello world greeter",
59
+ "--project-dir",
60
+ str(tmp_path),
61
+ "--profile",
62
+ "safe",
63
+ "--interactive",
64
+ "--decision-timeout",
65
+ "5",
66
+ ],
67
+ input=decisions,
68
+ env={"SPECSMITH_DISABLE_REAL_CHAT": "1"},
69
+ )
70
+ assert res.exit_code == 0, res.output
71
+ events = _events(res.output)
72
+ diff_blocks = [e for e in events if e.get("type") == "block_start" and e.get("kind") == "diff"]
73
+ assert diff_blocks, "expected at least one diff block_start"
74
+ diff_completes = [
75
+ e
76
+ for e in events
77
+ if e.get("type") == "block_complete" and e.get("block_id") == diff_blocks[0]["block_id"]
78
+ ]
79
+ assert diff_completes, "diff block should have been completed"
80
+ assert diff_completes[-1]["payload"].get("status") == "accept"
81
+
82
+
83
+ def test_chat_interactive_diff_decision_reject_threads_comment(tmp_path: Path) -> None:
84
+ """A non-accept verdict with a comment should fold into the final summary."""
85
+ _seed_project(tmp_path)
86
+ runner = CliRunner()
87
+ decisions = (
88
+ json.dumps({"type": "tool_decision", "decision": "approve"})
89
+ + "\n"
90
+ + json.dumps(
91
+ {
92
+ "type": "diff_decision",
93
+ "decision": "reject",
94
+ "comment": "use uppercase greeting",
95
+ }
96
+ )
97
+ + "\n"
98
+ )
99
+ res = runner.invoke(
100
+ main,
101
+ [
102
+ "chat",
103
+ "add hello world greeter",
104
+ "--project-dir",
105
+ str(tmp_path),
106
+ "--profile",
107
+ "safe",
108
+ "--interactive",
109
+ "--decision-timeout",
110
+ "5",
111
+ ],
112
+ input=decisions,
113
+ env={"SPECSMITH_DISABLE_REAL_CHAT": "1"},
114
+ )
115
+ assert res.exit_code == 0, res.output
116
+ events = _events(res.output)
117
+ diff_completes = [
118
+ e
119
+ for e in events
120
+ if e.get("type") == "block_complete" and e.get("payload", {}).get("status") == "reject"
121
+ ]
122
+ assert diff_completes, "expected the diff block to be completed with reject status"
123
+ completes = [e for e in events if e.get("type") == "task_complete"]
124
+ assert completes
125
+ assert "use uppercase greeting" in completes[-1].get("summary", "")
126
+
127
+
128
+ def test_chat_interactive_diff_decision_timeout_uses_timeout_status(
129
+ tmp_path: Path,
130
+ ) -> None:
131
+ """No diff_decision on stdin should mark the diff block as timeout."""
132
+ _seed_project(tmp_path)
133
+ runner = CliRunner()
134
+ decisions = json.dumps({"type": "tool_decision", "decision": "approve"}) + "\n"
135
+ res = runner.invoke(
136
+ main,
137
+ [
138
+ "chat",
139
+ "add hello world greeter",
140
+ "--project-dir",
141
+ str(tmp_path),
142
+ "--profile",
143
+ "safe",
144
+ "--interactive",
145
+ "--decision-timeout",
146
+ "1",
147
+ ],
148
+ input=decisions,
149
+ env={"SPECSMITH_DISABLE_REAL_CHAT": "1"},
150
+ )
151
+ assert res.exit_code == 0, res.output
152
+ events = _events(res.output)
153
+ diff_completes = [
154
+ e
155
+ for e in events
156
+ if e.get("type") == "block_complete" and e.get("payload", {}).get("status") == "timeout"
157
+ ]
158
+ assert diff_completes, "expected at least one diff block to time out"