specsmith 0.10.0.dev238__tar.gz → 0.10.0.dev239__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 (196) hide show
  1. {specsmith-0.10.0.dev238/src/specsmith.egg-info → specsmith-0.10.0.dev239}/PKG-INFO +20 -1
  2. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/README.md +19 -0
  3. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/pyproject.toml +1 -1
  4. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/chat_runner.py +151 -30
  5. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/profiles.py +87 -1
  6. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/runner.py +41 -6
  7. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/cli.py +63 -9
  8. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239/src/specsmith.egg-info}/PKG-INFO +20 -1
  9. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_chat_runner_openai_compat.py +6 -3
  10. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/LICENSE +0 -0
  11. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/setup.cfg +0 -0
  12. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/__init__.py +0 -0
  13. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/belief.py +0 -0
  14. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/certainty.py +0 -0
  15. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/failure_graph.py +0 -0
  16. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/py.typed +0 -0
  17. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/recovery.py +0 -0
  18. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/session.py +0 -0
  19. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/stress_tester.py +0 -0
  20. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/epistemic/trace.py +0 -0
  21. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/__init__.py +0 -0
  22. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/__main__.py +0 -0
  23. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/__init__.py +0 -0
  24. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/broker.py +0 -0
  25. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/cleanup.py +0 -0
  26. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/core.py +0 -0
  27. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/endpoints.py +0 -0
  28. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/events.py +0 -0
  29. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/fallback.py +0 -0
  30. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/indexer.py +0 -0
  31. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/mcp.py +0 -0
  32. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/memory.py +0 -0
  33. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/orchestrator.py +0 -0
  34. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/repl.py +0 -0
  35. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/router.py +0 -0
  36. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/rules.py +0 -0
  37. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/safety.py +0 -0
  38. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/suggester.py +0 -0
  39. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/tools.py +0 -0
  40. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/verifier.py +0 -0
  41. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/agent/voice.py +0 -0
  42. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/architect.py +0 -0
  43. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/auditor.py +0 -0
  44. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/auth.py +0 -0
  45. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/block_export.py +0 -0
  46. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/commands/__init__.py +0 -0
  47. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/compressor.py +0 -0
  48. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/config.py +0 -0
  49. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/console_utils.py +0 -0
  50. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/credit_analyzer.py +0 -0
  51. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/credits.py +0 -0
  52. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/differ.py +0 -0
  53. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/doctor.py +0 -0
  54. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/drive.py +0 -0
  55. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/epistemic/__init__.py +0 -0
  56. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/epistemic/belief.py +0 -0
  57. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/epistemic/certainty.py +0 -0
  58. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/epistemic/failure_graph.py +0 -0
  59. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/epistemic/recovery.py +0 -0
  60. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/epistemic/stress_tester.py +0 -0
  61. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/executor.py +0 -0
  62. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/exporter.py +0 -0
  63. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/__init__.py +0 -0
  64. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/app.py +0 -0
  65. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/main_window.py +0 -0
  66. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/session_tab.py +0 -0
  67. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/theme.py +0 -0
  68. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/__init__.py +0 -0
  69. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/chat_view.py +0 -0
  70. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/input_bar.py +0 -0
  71. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/provider_bar.py +0 -0
  72. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/token_meter.py +0 -0
  73. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/tool_panel.py +0 -0
  74. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/widgets/update_checker.py +0 -0
  75. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/gui/worker.py +0 -0
  76. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/history_search.py +0 -0
  77. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/importer.py +0 -0
  78. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/__init__.py +0 -0
  79. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/agent_skill.py +0 -0
  80. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/aider.py +0 -0
  81. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/base.py +0 -0
  82. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/claude_code.py +0 -0
  83. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/copilot.py +0 -0
  84. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/cursor.py +0 -0
  85. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/gemini.py +0 -0
  86. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/integrations/windsurf.py +0 -0
  87. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/languages.py +0 -0
  88. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/ledger.py +0 -0
  89. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/patent.py +0 -0
  90. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/phase.py +0 -0
  91. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/plugins.py +0 -0
  92. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/profiles.py +0 -0
  93. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/rate_limits.py +0 -0
  94. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/releaser.py +0 -0
  95. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/requirements.py +0 -0
  96. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/requirements_parser.py +0 -0
  97. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/retrieval.py +0 -0
  98. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/scaffolder.py +0 -0
  99. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/serve.py +0 -0
  100. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/session.py +0 -0
  101. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/skills.py +0 -0
  102. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/agents.md.j2 +0 -0
  103. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
  104. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
  105. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/contributing.md.j2 +0 -0
  106. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
  107. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
  108. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/license-MIT.j2 +0 -0
  109. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
  110. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/community/security.md.j2 +0 -0
  111. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  112. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
  113. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
  114. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  115. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  116. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/editorconfig.j2 +0 -0
  117. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/gitattributes.j2 +0 -0
  118. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/gitignore.j2 +0 -0
  119. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/go/go.mod.j2 +0 -0
  120. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/go/main.go.j2 +0 -0
  121. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
  122. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
  123. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  124. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
  125. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
  126. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
  127. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  128. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  129. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
  130. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
  131. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  132. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/js/package.json.j2 +0 -0
  133. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/ledger.md.j2 +0 -0
  134. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/python/cli.py.j2 +0 -0
  135. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/python/init.py.j2 +0 -0
  136. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
  137. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/readme.md.j2 +0 -0
  138. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
  139. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/rust/main.rs.j2 +0 -0
  140. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
  141. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
  142. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  143. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  144. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  145. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  146. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
  147. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/tool_installer.py +0 -0
  148. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/toolrules.py +0 -0
  149. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/tools.py +0 -0
  150. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/trace.py +0 -0
  151. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/updater.py +0 -0
  152. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/upgrader.py +0 -0
  153. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/validator.py +0 -0
  154. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/vcs/__init__.py +0 -0
  155. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/vcs/base.py +0 -0
  156. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/vcs/bitbucket.py +0 -0
  157. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/vcs/github.py +0 -0
  158. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/vcs/gitlab.py +0 -0
  159. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/vcs_commands.py +0 -0
  160. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/wireframes.py +0 -0
  161. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith/workspace.py +0 -0
  162. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith.egg-info/SOURCES.txt +0 -0
  163. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith.egg-info/dependency_links.txt +0 -0
  164. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith.egg-info/entry_points.txt +0 -0
  165. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith.egg-info/requires.txt +0 -0
  166. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/src/specsmith.egg-info/top_level.txt +0 -0
  167. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_CMD_001.py +0 -0
  168. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_agent_profiles.py +0 -0
  169. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_agent_runner_ready.py +0 -0
  170. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_auditor.py +0 -0
  171. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_chat_diff_decision.py +0 -0
  172. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_chat_stdin_protocol.py +0 -0
  173. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_cli.py +0 -0
  174. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_cli_workflows_history_drive.py +0 -0
  175. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_compressor.py +0 -0
  176. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_e2e_nexus.py +0 -0
  177. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_endpoints_cli.py +0 -0
  178. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_endpoints_store.py +0 -0
  179. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_epistemic.py +0 -0
  180. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_fallback_chain.py +0 -0
  181. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_importer.py +0 -0
  182. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_integrations.py +0 -0
  183. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_mcp_client.py +0 -0
  184. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_nexus.py +0 -0
  185. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_phase1_4_new.py +0 -0
  186. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_phase34_completion.py +0 -0
  187. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_rate_limits.py +0 -0
  188. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_scaffolder.py +0 -0
  189. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_skill_marketplace.py +0 -0
  190. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_smoke.py +0 -0
  191. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_suggester.py +0 -0
  192. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_tools.py +0 -0
  193. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_validator.py +0 -0
  194. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_vcs.py +0 -0
  195. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_warp_parity.py +0 -0
  196. {specsmith-0.10.0.dev238 → specsmith-0.10.0.dev239}/tests/test_warp_parity_followup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.10.0.dev238
3
+ Version: 0.10.0.dev239
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
@@ -88,6 +88,25 @@ specsmith treats belief systems like code: codable, testable, and deployable. It
88
88
  epistemically-governed projects, stress-tests requirements as BeliefArtifacts, runs
89
89
  cryptographically-sealed trace vaults, and orchestrates AI agents under formal AEE governance.
90
90
 
91
+ **0.10.0 — Multi-Agent + BYOE.** A `/plan` goes to the architect, `/fix`
92
+ goes to the coder, `/review` goes to a reviewer that runs on a different
93
+ model family. Each *profile* is a `(provider, model, endpoint?, fallback_chain)`
94
+ bundle stored in `~/.specsmith/agents.json`; an *activity routing table*
95
+ maps slash commands and AEE phases to profiles; **BYOE endpoints**
96
+ (`~/.specsmith/endpoints.json`) let you point a profile at any
97
+ OpenAI-v1-compatible backend you self-host (vLLM, llama.cpp `server`,
98
+ LM Studio, TGI, ...). Cross-family **diversity guard**, capability
99
+ filtering, transient-failure fallback chains, and TraceVault decision
100
+ seals on every `/agent` pin are wired in by default. See
101
+ [`docs/site/agents.md`](docs/site/agents.md) for the five-minute walkthrough.
102
+
103
+ ```bash
104
+ specsmith agents preset apply default # frontier coder + cross-family reviewer
105
+ specsmith endpoints add --id home-vllm \
106
+ --base-url http://10.0.0.4:8000/v1 --auth bearer-keyring
107
+ specsmith run --agent opus-reviewer # one-shot per-session pin
108
+ ```
109
+
91
110
  It also co-installs the standalone `epistemic` Python library for direct use in any project:
92
111
 
93
112
  ```python
@@ -16,6 +16,25 @@ specsmith treats belief systems like code: codable, testable, and deployable. It
16
16
  epistemically-governed projects, stress-tests requirements as BeliefArtifacts, runs
17
17
  cryptographically-sealed trace vaults, and orchestrates AI agents under formal AEE governance.
18
18
 
19
+ **0.10.0 — Multi-Agent + BYOE.** A `/plan` goes to the architect, `/fix`
20
+ goes to the coder, `/review` goes to a reviewer that runs on a different
21
+ model family. Each *profile* is a `(provider, model, endpoint?, fallback_chain)`
22
+ bundle stored in `~/.specsmith/agents.json`; an *activity routing table*
23
+ maps slash commands and AEE phases to profiles; **BYOE endpoints**
24
+ (`~/.specsmith/endpoints.json`) let you point a profile at any
25
+ OpenAI-v1-compatible backend you self-host (vLLM, llama.cpp `server`,
26
+ LM Studio, TGI, ...). Cross-family **diversity guard**, capability
27
+ filtering, transient-failure fallback chains, and TraceVault decision
28
+ seals on every `/agent` pin are wired in by default. See
29
+ [`docs/site/agents.md`](docs/site/agents.md) for the five-minute walkthrough.
30
+
31
+ ```bash
32
+ specsmith agents preset apply default # frontier coder + cross-family reviewer
33
+ specsmith endpoints add --id home-vllm \
34
+ --base-url http://10.0.0.4:8000/v1 --auth bearer-keyring
35
+ specsmith run --agent opus-reviewer # one-shot per-session pin
36
+ ```
37
+
19
38
  It also co-installs the standalone `epistemic` Python library for direct use in any project:
20
39
 
21
40
  ```python
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "specsmith"
7
- version = "0.10.0.dev238"
7
+ version = "0.10.0.dev239"
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"
@@ -53,6 +53,14 @@ class ChatRunResult:
53
53
  files_changed: list[str] = field(default_factory=list)
54
54
  verdict: VerifierVerdict | None = None
55
55
  raw_text: str = ""
56
+ # C1: per-turn token + cost accounting. Populated by the provider
57
+ # driver when it can read counters from the response (Ollama and
58
+ # Anthropic both expose them). Falls back to a deterministic char-
59
+ # based heuristic so the TokenMeter chip is never zero on Ollama or
60
+ # OpenAI-compat endpoints that don't surface usage in streaming mode.
61
+ tokens_in: int = 0
62
+ tokens_out: int = 0
63
+ cost_usd: float = 0.0
56
64
 
57
65
  def to_dict(self) -> dict[str, Any]:
58
66
  return {
@@ -61,6 +69,9 @@ class ChatRunResult:
61
69
  "files_changed": list(self.files_changed),
62
70
  "confidence": self.verdict.confidence if self.verdict else 0.0,
63
71
  "equilibrium": self.verdict.equilibrium if self.verdict else False,
72
+ "tokens_in": int(self.tokens_in),
73
+ "tokens_out": int(self.tokens_out),
74
+ "cost_usd": float(self.cost_usd),
64
75
  }
65
76
 
66
77
 
@@ -103,44 +114,99 @@ def run_chat(
103
114
  endpoint = None
104
115
  if endpoint is not None:
105
116
  try:
106
- full_text = _run_openai_compat(messages, emitter, msg_block, endpoint=endpoint)
117
+ full_text, usage = _run_openai_compat(
118
+ messages, emitter, msg_block, endpoint=endpoint
119
+ )
107
120
  except Exception: # noqa: BLE001 - degrade to auto-detect
108
- full_text = None
121
+ full_text, usage = None, _UsageDelta()
109
122
  if full_text is not None:
110
- return _finalize(full_text, "openai_compat", project_dir, confidence_target)
123
+ return _finalize(
124
+ full_text,
125
+ "openai_compat",
126
+ project_dir,
127
+ confidence_target,
128
+ messages=messages,
129
+ usage=usage,
130
+ )
111
131
 
112
132
  # Order matters: Ollama first because it's local-first and free.
113
133
  for provider in (_run_ollama, _run_anthropic, _run_openai, _run_gemini):
114
134
  try:
115
- full_text = provider(messages, emitter, msg_block)
135
+ full_text, usage = provider(messages, emitter, msg_block)
116
136
  except Exception: # noqa: BLE001 - any failure → next provider
117
137
  continue
118
138
  if full_text is None:
119
139
  continue
120
- return _finalize(full_text, provider.__name__, project_dir, confidence_target)
140
+ return _finalize(
141
+ full_text,
142
+ provider.__name__,
143
+ project_dir,
144
+ confidence_target,
145
+ messages=messages,
146
+ usage=usage,
147
+ )
121
148
  return None
122
149
 
123
150
 
151
+ @dataclass
152
+ class _UsageDelta:
153
+ """Per-turn token + cost counters reported by a provider driver.
154
+
155
+ All fields default to ``0`` so callers can construct a zero-value
156
+ instance without caring whether the provider supports usage tracking.
157
+ """
158
+
159
+ tokens_in: int = 0
160
+ tokens_out: int = 0
161
+ cost_usd: float = 0.0
162
+
163
+
124
164
  def _finalize(
125
165
  full_text: str,
126
166
  provider_fn_name: str,
127
167
  project_dir: Path,
128
168
  confidence_target: float,
169
+ *,
170
+ messages: list[dict[str, str]] | None = None,
171
+ usage: _UsageDelta | None = None,
129
172
  ) -> ChatRunResult:
130
173
  sections = _parse_output_contract(full_text)
131
174
  files_changed = _split_files_list(sections.get("files_changed", ""))
132
175
  report = report_from_chat_sections(sections, files_changed=files_changed)
133
176
  verdict = score(report, confidence_target=confidence_target)
134
177
  summary = (sections.get("plan") or full_text.strip()[:200]).strip() or verdict.summary
178
+
179
+ # C1: when the provider didn't report exact counts, estimate from text.
180
+ # The four-chars-per-token rule of thumb is OpenAI's published guidance
181
+ # and matches Ollama / Anthropic / Gemini within ~10% across the model
182
+ # families we ship today — close enough for the TokenMeter chip and
183
+ # the ``credits record`` ledger event.
184
+ if usage is None:
185
+ usage = _UsageDelta()
186
+ if usage.tokens_in == 0 and messages is not None:
187
+ usage.tokens_in = _estimate_tokens("\n".join(m.get("content", "") for m in messages))
188
+ if usage.tokens_out == 0:
189
+ usage.tokens_out = _estimate_tokens(full_text)
190
+
135
191
  return ChatRunResult(
136
192
  provider=provider_fn_name.removeprefix("_run_"),
137
193
  summary=summary,
138
194
  files_changed=files_changed,
139
195
  verdict=verdict,
140
196
  raw_text=full_text,
197
+ tokens_in=int(usage.tokens_in),
198
+ tokens_out=int(usage.tokens_out),
199
+ cost_usd=float(usage.cost_usd),
141
200
  )
142
201
 
143
202
 
203
+ def _estimate_tokens(text: str) -> int:
204
+ """Rough char→token heuristic (4 chars/token, floor at 1 if non-empty)."""
205
+ if not text:
206
+ return 0
207
+ return max(1, len(text) // 4)
208
+
209
+
144
210
  # ---------------------------------------------------------------------------
145
211
  # Provider drivers — each returns the full assembled text or None
146
212
  # ---------------------------------------------------------------------------
@@ -150,13 +216,14 @@ def _run_ollama(
150
216
  messages: list[dict[str, str]],
151
217
  emitter: EventEmitter,
152
218
  block_id: str,
153
- ) -> str | None:
219
+ ) -> tuple[str | None, _UsageDelta]:
154
220
  """Stream from a local Ollama daemon using only stdlib."""
155
221
  host = os.environ.get("OLLAMA_HOST", DEFAULT_OLLAMA_HOST).rstrip("/")
156
222
  model = os.environ.get("SPECSMITH_OLLAMA_MODEL", DEFAULT_OLLAMA_MODEL)
223
+ usage = _UsageDelta()
157
224
 
158
225
  if not _ollama_alive(host):
159
- return None
226
+ return None, usage
160
227
 
161
228
  payload = json.dumps({"model": model, "messages": messages, "stream": True}).encode("utf-8")
162
229
  req = Request( # noqa: S310 - URL is a hardcoded localhost default
@@ -181,8 +248,13 @@ def _run_ollama(
181
248
  emitter.token(block_id, chunk)
182
249
  pieces.append(chunk)
183
250
  if obj.get("done"):
251
+ # C1: Ollama exposes prompt_eval_count + eval_count on the
252
+ # final ``done`` message. Cost is zero for local models.
253
+ usage.tokens_in = int(obj.get("prompt_eval_count") or 0)
254
+ usage.tokens_out = int(obj.get("eval_count") or 0)
255
+ usage.cost_usd = 0.0
184
256
  break
185
- return "".join(pieces) if pieces else None
257
+ return ("".join(pieces) if pieces else None), usage
186
258
 
187
259
 
188
260
  def _ollama_alive(host: str) -> bool:
@@ -197,14 +269,15 @@ def _run_anthropic(
197
269
  messages: list[dict[str, str]],
198
270
  emitter: EventEmitter,
199
271
  block_id: str,
200
- ) -> str | None:
272
+ ) -> tuple[str | None, _UsageDelta]:
201
273
  """Use the anthropic SDK if installed and a key is configured."""
274
+ usage = _UsageDelta()
202
275
  if not os.environ.get("ANTHROPIC_API_KEY"):
203
- return None
276
+ return None, usage
204
277
  try:
205
278
  import anthropic
206
279
  except ImportError:
207
- return None
280
+ return None, usage
208
281
 
209
282
  system = "\n".join(m["content"] for m in messages if m["role"] == "system")
210
283
  user_msgs = [m for m in messages if m["role"] != "system"]
@@ -221,35 +294,54 @@ def _run_anthropic(
221
294
  if text:
222
295
  emitter.token(block_id, text)
223
296
  pieces.append(text)
224
- return "".join(pieces) if pieces else None
297
+ # C1: pull final usage off the SDK's `final_message`. Cost is the
298
+ # caller's problem (rate-limit module knows the model price); we
299
+ # report tokens here and let the credits ledger compute USD.
300
+ try:
301
+ final = stream.get_final_message()
302
+ usage.tokens_in = int(getattr(final.usage, "input_tokens", 0) or 0)
303
+ usage.tokens_out = int(getattr(final.usage, "output_tokens", 0) or 0)
304
+ except Exception: # noqa: BLE001 - usage is best-effort
305
+ pass
306
+ return ("".join(pieces) if pieces else None), usage
225
307
 
226
308
 
227
309
  def _run_openai(
228
310
  messages: list[dict[str, str]],
229
311
  emitter: EventEmitter,
230
312
  block_id: str,
231
- ) -> str | None:
313
+ ) -> tuple[str | None, _UsageDelta]:
232
314
  """Use the openai SDK if installed and a key is configured."""
315
+ usage = _UsageDelta()
233
316
  if not os.environ.get("OPENAI_API_KEY"):
234
- return None
317
+ return None, usage
235
318
  try:
236
319
  from openai import OpenAI
237
320
  except ImportError:
238
- return None
321
+ return None, usage
239
322
 
240
323
  client = OpenAI()
324
+ # ``stream_options.include_usage`` makes the final SSE chunk carry a
325
+ # populated ``usage`` block (otherwise streaming responses emit it as
326
+ # ``None``). Older SDK versions silently ignore unknown kwargs.
241
327
  stream = client.chat.completions.create(
242
328
  model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"),
243
329
  messages=messages,
244
330
  stream=True,
331
+ stream_options={"include_usage": True},
245
332
  )
246
333
  pieces: list[str] = []
247
334
  for chunk in stream:
248
- text = (chunk.choices[0].delta.content or "") if chunk.choices else ""
249
- if text:
250
- emitter.token(block_id, text)
251
- pieces.append(text)
252
- return "".join(pieces) if pieces else None
335
+ if chunk.choices:
336
+ text = chunk.choices[0].delta.content or ""
337
+ if text:
338
+ emitter.token(block_id, text)
339
+ pieces.append(text)
340
+ usage_obj = getattr(chunk, "usage", None)
341
+ if usage_obj is not None:
342
+ usage.tokens_in = int(getattr(usage_obj, "prompt_tokens", 0) or 0)
343
+ usage.tokens_out = int(getattr(usage_obj, "completion_tokens", 0) or 0)
344
+ return ("".join(pieces) if pieces else None), usage
253
345
 
254
346
 
255
347
  def _run_openai_compat(
@@ -258,7 +350,7 @@ def _run_openai_compat(
258
350
  block_id: str,
259
351
  *,
260
352
  endpoint: Any,
261
- ) -> str | None:
353
+ ) -> tuple[str | None, _UsageDelta]:
262
354
  """Stream from a user-registered OpenAI-v1-compatible endpoint (REQ-142).
263
355
 
264
356
  Uses raw stdlib HTTP so the openai SDK is not a hard dependency for
@@ -266,13 +358,14 @@ def _run_openai_compat(
266
358
  Server-Sent-Events ``data:`` lines, and forwards each ``content``
267
359
  delta as a ``token`` event on ``block_id``.
268
360
  """
361
+ usage = _UsageDelta()
269
362
  base_url = endpoint.base_url.rstrip("/")
270
363
  url = f"{base_url}/chat/completions"
271
364
  model = endpoint.default_model or os.environ.get("SPECSMITH_OPENAI_COMPAT_MODEL", "")
272
365
  if not model:
273
366
  # The endpoint did not pin a default model and the env override is
274
367
  # absent. We cannot fabricate one; fall back to the auto-detect chain.
275
- return None
368
+ return None, usage
276
369
 
277
370
  headers: dict[str, str] = {
278
371
  "Content-Type": "application/json",
@@ -281,11 +374,20 @@ def _run_openai_compat(
281
374
  try:
282
375
  token = endpoint.resolve_token()
283
376
  except Exception: # noqa: BLE001 - fall back to auto-detect chain
284
- return None
377
+ return None, usage
285
378
  if token:
286
379
  headers["Authorization"] = f"Bearer {token}"
287
380
 
288
- body = json.dumps({"model": model, "messages": messages, "stream": True}).encode("utf-8")
381
+ body = json.dumps(
382
+ {
383
+ "model": model,
384
+ "messages": messages,
385
+ "stream": True,
386
+ # Many vLLM/llama.cpp builds honour OpenAI's stream_options;
387
+ # the request is harmless if they don't.
388
+ "stream_options": {"include_usage": True},
389
+ }
390
+ ).encode("utf-8")
289
391
  req = Request(url, data=body, headers=headers, method="POST") # noqa: S310 - user-supplied
290
392
 
291
393
  ctx = None
@@ -313,6 +415,10 @@ def _run_openai_compat(
313
415
  except ValueError:
314
416
  continue
315
417
  choices = obj.get("choices") or []
418
+ usage_obj = obj.get("usage")
419
+ if usage_obj:
420
+ usage.tokens_in = int(usage_obj.get("prompt_tokens") or 0)
421
+ usage.tokens_out = int(usage_obj.get("completion_tokens") or 0)
316
422
  if not choices:
317
423
  continue
318
424
  delta = (choices[0] or {}).get("delta") or {}
@@ -321,35 +427,50 @@ def _run_openai_compat(
321
427
  emitter.token(block_id, chunk)
322
428
  pieces.append(chunk)
323
429
  except (URLError, TimeoutError, OSError):
324
- return None
325
- return "".join(pieces) if pieces else None
430
+ return None, usage
431
+ return ("".join(pieces) if pieces else None), usage
326
432
 
327
433
 
328
434
  def _run_gemini(
329
435
  messages: list[dict[str, str]],
330
436
  emitter: EventEmitter,
331
437
  block_id: str,
332
- ) -> str | None:
438
+ ) -> tuple[str | None, _UsageDelta]:
333
439
  """Use google-genai SDK if installed and a key is configured."""
440
+ usage = _UsageDelta()
334
441
  if not os.environ.get("GOOGLE_API_KEY"):
335
- return None
442
+ return None, usage
336
443
  try:
337
444
  from google import genai
338
445
  except ImportError:
339
- return None
446
+ return None, usage
340
447
 
341
448
  client = genai.Client()
342
449
  prompt = "\n".join(f"{m['role']}: {m['content']}" for m in messages)
343
450
  pieces: list[str] = []
451
+ last_chunk: Any = None
344
452
  for chunk in client.models.generate_content_stream(
345
453
  model=os.environ.get("GEMINI_MODEL", "gemini-2.5-flash"),
346
454
  contents=prompt,
347
455
  ):
456
+ last_chunk = chunk
348
457
  text = getattr(chunk, "text", "") or ""
349
458
  if text:
350
459
  emitter.token(block_id, text)
351
460
  pieces.append(text)
352
- return "".join(pieces) if pieces else None
461
+ # Gemini exposes ``usage_metadata`` on the final chunk. Field names
462
+ # vary across SDK versions; we accept the union.
463
+ meta = getattr(last_chunk, "usage_metadata", None) if last_chunk else None
464
+ if meta is not None:
465
+ usage.tokens_in = int(
466
+ getattr(meta, "prompt_token_count", 0) or getattr(meta, "input_token_count", 0) or 0
467
+ )
468
+ usage.tokens_out = int(
469
+ getattr(meta, "candidates_token_count", 0)
470
+ or getattr(meta, "output_token_count", 0)
471
+ or 0
472
+ )
473
+ return ("".join(pieces) if pieces else None), usage
353
474
 
354
475
 
355
476
  # ---------------------------------------------------------------------------
@@ -56,6 +56,33 @@ VALID_ROLES = (
56
56
  "generalist",
57
57
  )
58
58
 
59
+ # Provider “family” groupings used by the diversity guard (G1). Profiles in
60
+ # the same family are likely to share training data, system prompt biases,
61
+ # and hallucination patterns — so pairing the coder with a reviewer in the
62
+ # same family defeats the cross-check the reviewer is meant to provide.
63
+ #
64
+ # Anything not listed here is treated as its own family.
65
+ PROVIDER_FAMILIES: dict[str, str] = {
66
+ "anthropic": "anthropic",
67
+ "openai": "openai",
68
+ "openai-compat": "openai",
69
+ "azure-openai": "openai",
70
+ "gemini": "google",
71
+ "google": "google",
72
+ "google-genai": "google",
73
+ "mistral": "mistral",
74
+ "ollama": "ollama",
75
+ "llamacpp": "ollama",
76
+ "vllm": "ollama",
77
+ "lmstudio": "ollama",
78
+ }
79
+
80
+
81
+ def provider_family(provider: str) -> str:
82
+ """Return the family name for ``provider`` (or the provider verbatim)."""
83
+ key = (provider or "").strip().lower()
84
+ return PROVIDER_FAMILIES.get(key, key or "unknown")
85
+
59
86
 
60
87
  # Default presets shipped with the CLI so a fresh install Just Works.
61
88
  # The exact model strings can be customised per-deployment via
@@ -493,7 +520,64 @@ class ProfileStore:
493
520
  def list_all(self) -> list[Profile]:
494
521
  return list(self.profiles)
495
522
 
496
- # ── Routing ───────────────────────────────────────────────────────
523
+ def filter_by_capability(self, capability: str) -> list[Profile]:
524
+ """Return profiles whose ``capabilities`` list contains ``capability``.
525
+
526
+ Matching is case-insensitive and trims whitespace. An empty
527
+ ``capability`` argument returns ``[]`` rather than “everything” so
528
+ callers can distinguish “no filter” (don’t call this method) from
529
+ “filter for an empty value” (which is never meaningful).
530
+ """
531
+ needle = (capability or "").strip().lower()
532
+ if not needle:
533
+ return []
534
+ return [
535
+ p
536
+ for p in self.profiles
537
+ if any(needle == str(c).strip().lower() for c in p.capabilities)
538
+ ]
539
+
540
+ def diversity_warnings(self, *, candidate: Profile | None = None) -> list[str]:
541
+ """Return a list of plain-English diversity warnings for the store.
542
+
543
+ The reviewer profile exists to cross-check the coder; if both call
544
+ the same provider family the cross-check is degenerate. Same logic
545
+ applies to architect vs. reviewer (both should be skeptical of the
546
+ coder). When ``candidate`` is supplied the candidate is added to
547
+ the population *and* takes precedence over any same-id profile
548
+ already in the store, so a `specsmith agents add` invocation can
549
+ preview the warnings *before* writing the store.
550
+ """
551
+ population: dict[str, Profile] = {p.id: p for p in self.profiles}
552
+ if candidate is not None:
553
+ population[candidate.id] = candidate
554
+ by_role: dict[str, list[Profile]] = {}
555
+ for p in population.values():
556
+ by_role.setdefault(p.role, []).append(p)
557
+
558
+ warnings: list[str] = []
559
+ for left_role, right_role in (
560
+ ("coder", "reviewer"),
561
+ ("architect", "reviewer"),
562
+ ):
563
+ left = by_role.get(left_role) or []
564
+ right = by_role.get(right_role) or []
565
+ if not left or not right:
566
+ continue
567
+ for lp in left:
568
+ lf = provider_family(lp.provider)
569
+ for rp in right:
570
+ if provider_family(rp.provider) == lf:
571
+ warnings.append(
572
+ f"{rp.id} ({rp.role}, {rp.provider}/{rp.model}) "
573
+ f"shares the {lf!r} family with "
574
+ f"{lp.id} ({lp.role}, {lp.provider}/{lp.model}); "
575
+ "diversity is recommended so the reviewer can catch "
576
+ "the coder's blind spots."
577
+ )
578
+ return warnings
579
+
580
+ # ── Routing ─────────────────────────────────────────────────
497
581
 
498
582
  def set_route(self, activity: str, profile_id: str) -> None:
499
583
  activity = activity.strip()
@@ -558,6 +642,7 @@ def apply_preset(name: str, *, path: Path | None = None) -> ProfileStore:
558
642
 
559
643
  __all__ = [
560
644
  "DEFAULT_PRESETS",
645
+ "PROVIDER_FAMILIES",
561
646
  "Profile",
562
647
  "ProfileError",
563
648
  "ProfileStore",
@@ -566,4 +651,5 @@ __all__ = [
566
651
  "apply_preset",
567
652
  "default_store_path",
568
653
  "project_store_path",
654
+ "provider_family",
569
655
  ]
@@ -277,6 +277,13 @@ class AgentRunner:
277
277
  self.profile_id = new_profile or None
278
278
  self._state.profile_id = new_profile
279
279
  self._emit_event(type="system", message=f"profile = {new_profile or '(default)'}")
280
+ # G4: pin the profile choice into the project trace vault so the
281
+ # decision “I explicitly asked for profile X here” is
282
+ # cryptographically chained into the audit trail. Best-effort:
283
+ # missing TraceVault dependency / read-only filesystem must not
284
+ # break the chat loop.
285
+ if new_profile:
286
+ self._seal_profile_pin(new_profile)
280
287
  return None
281
288
  if text.startswith("/endpoint "):
282
289
  new_endpoint = text.split(maxsplit=1)[1].strip()
@@ -321,14 +328,21 @@ class AgentRunner:
321
328
  )
322
329
  return None
323
330
 
324
- # Aggregate metrics into the session state. ``run_chat`` does not
325
- # currently surface token counts, so we credit zero the field is
326
- # still updated so the TokenMeter chip shows turn counts.
331
+ # Aggregate metrics into the session state (C1).
332
+ # ``run_chat`` now reports tokens_in / tokens_out / cost_usd off the
333
+ # provider response (Ollama prompt_eval_count + eval_count, OpenAI
334
+ # streaming usage, Anthropic final_message.usage, Gemini
335
+ # usage_metadata) with a 4-chars-per-token fallback when the SDK
336
+ # omits them. The TokenMeter chip therefore shows real numbers
337
+ # instead of staying pinned at zero.
338
+ tokens_in = int(getattr(result, "tokens_in", 0) or 0) if result is not None else 0
339
+ tokens_out = int(getattr(result, "tokens_out", 0) or 0) if result is not None else 0
340
+ cost_usd = float(getattr(result, "cost_usd", 0.0) or 0.0) if result is not None else 0.0
327
341
  self._state.credit(
328
342
  profile_id=(profile.id if profile is not None else self.profile_id or ""),
329
- tokens_in=0,
330
- tokens_out=0,
331
- cost_usd=0.0,
343
+ tokens_in=tokens_in,
344
+ tokens_out=tokens_out,
345
+ cost_usd=cost_usd,
332
346
  tool_calls=0,
333
347
  )
334
348
  self._state.elapsed_minutes = round((time.time() - self._started_at) / 60.0, 2)
@@ -397,3 +411,24 @@ class AgentRunner:
397
411
  return _v("specsmith")
398
412
  except Exception: # noqa: BLE001
399
413
  return "0.0.0"
414
+
415
+ def _seal_profile_pin(self, profile_id: str) -> None:
416
+ """Append a TraceVault decision seal recording the ``/agent`` pin (G4).
417
+
418
+ Wrapped in best-effort try/except so an unwriteable
419
+ ``.specsmith/trace.jsonl`` (read-only fs, missing project root, etc.)
420
+ never breaks the chat loop. The seal type is ``decision`` because
421
+ a profile pin is an explicit governance choice the user made.
422
+ """
423
+ try:
424
+ from specsmith.trace import SealType, TraceVault
425
+
426
+ vault = TraceVault(Path(self.project_dir))
427
+ vault.seal(
428
+ seal_type=SealType.DECISION,
429
+ description=f"agent profile pinned via /agent: {profile_id}",
430
+ author="runner",
431
+ artifact_ids=[f"profile:{profile_id}"],
432
+ )
433
+ except Exception: # noqa: BLE001 — trace sealing is best-effort
434
+ return