specsmith 0.6.0.dev230__tar.gz → 0.6.0.dev232__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 (180) hide show
  1. {specsmith-0.6.0.dev230/src/specsmith.egg-info → specsmith-0.6.0.dev232}/PKG-INFO +1 -1
  2. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/pyproject.toml +1 -1
  3. specsmith-0.6.0.dev232/src/specsmith/agent/mcp.py +387 -0
  4. specsmith-0.6.0.dev232/src/specsmith/agent/suggester.py +264 -0
  5. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/cli.py +42 -5
  6. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232/src/specsmith.egg-info}/PKG-INFO +1 -1
  7. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/SOURCES.txt +3 -0
  8. specsmith-0.6.0.dev232/tests/test_mcp_client.py +157 -0
  9. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_phase34_completion.py +29 -18
  10. specsmith-0.6.0.dev232/tests/test_suggester.py +88 -0
  11. specsmith-0.6.0.dev230/src/specsmith/agent/mcp.py +0 -117
  12. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/LICENSE +0 -0
  13. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/README.md +0 -0
  14. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/setup.cfg +0 -0
  15. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/__init__.py +0 -0
  16. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/belief.py +0 -0
  17. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/certainty.py +0 -0
  18. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/failure_graph.py +0 -0
  19. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/py.typed +0 -0
  20. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/recovery.py +0 -0
  21. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/session.py +0 -0
  22. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/stress_tester.py +0 -0
  23. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/trace.py +0 -0
  24. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/__init__.py +0 -0
  25. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/__main__.py +0 -0
  26. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/__init__.py +0 -0
  27. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/broker.py +0 -0
  28. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/chat_runner.py +0 -0
  29. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/cleanup.py +0 -0
  30. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/events.py +0 -0
  31. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/indexer.py +0 -0
  32. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/memory.py +0 -0
  33. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/orchestrator.py +0 -0
  34. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/repl.py +0 -0
  35. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/router.py +0 -0
  36. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/rules.py +0 -0
  37. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/safety.py +0 -0
  38. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/tools.py +0 -0
  39. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/verifier.py +0 -0
  40. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/architect.py +0 -0
  41. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/auditor.py +0 -0
  42. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/auth.py +0 -0
  43. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/commands/__init__.py +0 -0
  44. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/compressor.py +0 -0
  45. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/config.py +0 -0
  46. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/console_utils.py +0 -0
  47. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/credit_analyzer.py +0 -0
  48. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/credits.py +0 -0
  49. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/differ.py +0 -0
  50. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/doctor.py +0 -0
  51. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/__init__.py +0 -0
  52. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/belief.py +0 -0
  53. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/certainty.py +0 -0
  54. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/failure_graph.py +0 -0
  55. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/recovery.py +0 -0
  56. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/stress_tester.py +0 -0
  57. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/executor.py +0 -0
  58. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/exporter.py +0 -0
  59. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/__init__.py +0 -0
  60. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/app.py +0 -0
  61. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/main_window.py +0 -0
  62. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/session_tab.py +0 -0
  63. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/theme.py +0 -0
  64. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/__init__.py +0 -0
  65. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/chat_view.py +0 -0
  66. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/input_bar.py +0 -0
  67. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/provider_bar.py +0 -0
  68. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/token_meter.py +0 -0
  69. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/tool_panel.py +0 -0
  70. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/update_checker.py +0 -0
  71. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/worker.py +0 -0
  72. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/importer.py +0 -0
  73. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/__init__.py +0 -0
  74. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/agent_skill.py +0 -0
  75. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/aider.py +0 -0
  76. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/base.py +0 -0
  77. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/claude_code.py +0 -0
  78. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/copilot.py +0 -0
  79. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/cursor.py +0 -0
  80. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/gemini.py +0 -0
  81. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/windsurf.py +0 -0
  82. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/languages.py +0 -0
  83. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/ledger.py +0 -0
  84. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/patent.py +0 -0
  85. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/phase.py +0 -0
  86. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/plugins.py +0 -0
  87. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/profiles.py +0 -0
  88. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/rate_limits.py +0 -0
  89. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/releaser.py +0 -0
  90. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/requirements.py +0 -0
  91. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/requirements_parser.py +0 -0
  92. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/retrieval.py +0 -0
  93. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/scaffolder.py +0 -0
  94. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/serve.py +0 -0
  95. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/session.py +0 -0
  96. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/skills.py +0 -0
  97. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/agents.md.j2 +0 -0
  98. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
  99. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
  100. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/contributing.md.j2 +0 -0
  101. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
  102. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
  103. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/license-MIT.j2 +0 -0
  104. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
  105. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/security.md.j2 +0 -0
  106. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
  107. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
  108. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
  109. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
  110. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
  111. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/editorconfig.j2 +0 -0
  112. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/gitattributes.j2 +0 -0
  113. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/gitignore.j2 +0 -0
  114. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/go/go.mod.j2 +0 -0
  115. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/go/main.go.j2 +0 -0
  116. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
  117. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
  118. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
  119. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
  120. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
  121. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
  122. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/roles.md.j2 +0 -0
  123. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/rules.md.j2 +0 -0
  124. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
  125. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
  126. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/verification.md.j2 +0 -0
  127. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/js/package.json.j2 +0 -0
  128. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/ledger.md.j2 +0 -0
  129. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/python/cli.py.j2 +0 -0
  130. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/python/init.py.j2 +0 -0
  131. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
  132. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/readme.md.j2 +0 -0
  133. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
  134. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/rust/main.rs.j2 +0 -0
  135. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
  136. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
  137. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
  138. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
  139. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
  140. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
  141. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
  142. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/tool_installer.py +0 -0
  143. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/toolrules.py +0 -0
  144. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/tools.py +0 -0
  145. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/trace.py +0 -0
  146. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/updater.py +0 -0
  147. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/upgrader.py +0 -0
  148. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/validator.py +0 -0
  149. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/__init__.py +0 -0
  150. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/base.py +0 -0
  151. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/bitbucket.py +0 -0
  152. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/github.py +0 -0
  153. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/gitlab.py +0 -0
  154. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs_commands.py +0 -0
  155. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/wireframes.py +0 -0
  156. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/workspace.py +0 -0
  157. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/dependency_links.txt +0 -0
  158. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/entry_points.txt +0 -0
  159. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/requires.txt +0 -0
  160. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/top_level.txt +0 -0
  161. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_CMD_001.py +0 -0
  162. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_auditor.py +0 -0
  163. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_chat_diff_decision.py +0 -0
  164. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_chat_stdin_protocol.py +0 -0
  165. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_cli.py +0 -0
  166. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_cli_workflows_history_drive.py +0 -0
  167. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_compressor.py +0 -0
  168. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_e2e_nexus.py +0 -0
  169. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_epistemic.py +0 -0
  170. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_importer.py +0 -0
  171. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_integrations.py +0 -0
  172. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_nexus.py +0 -0
  173. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_phase1_4_new.py +0 -0
  174. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_rate_limits.py +0 -0
  175. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_scaffolder.py +0 -0
  176. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_skill_marketplace.py +0 -0
  177. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_smoke.py +0 -0
  178. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_tools.py +0 -0
  179. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_validator.py +0 -0
  180. {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_vcs.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: specsmith
3
- Version: 0.6.0.dev230
3
+ Version: 0.6.0.dev232
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.6.0.dev230"
7
+ version = "0.6.0.dev232"
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,387 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Real MCP (Model Context Protocol) client for Nexus (REQ-121, REQ-130).
4
+
5
+ Replaces the prior loader-only stub with a working JSON-RPC 2.0 client
6
+ that drives the official MCP handshake over stdio:
7
+
8
+ * ``initialize`` request -> response (capability negotiation).
9
+ * ``notifications/initialized`` notification.
10
+ * ``tools/list`` request -> response (tool catalog discovery).
11
+ * ``tools/call`` requests -> responses (per-tool invocation).
12
+
13
+ The Specsmith safety middleware still wraps every call: see
14
+ ``MCPTool.invoke_with_safety``. Servers configured via ``.specsmith/mcp.yml``
15
+ are listed at the top of every ``specsmith chat`` session and exposed to
16
+ the orchestrator as additional Nexus tools.
17
+
18
+ Protocol pin: 2024-11-05 (current stable). Servers that advertise a newer
19
+ version still work because MCP guarantees backwards compatibility within
20
+ the same major track.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import contextlib
26
+ import json
27
+ import subprocess
28
+ import threading
29
+ import time
30
+ from collections.abc import Callable
31
+ from dataclasses import dataclass, field
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ MCP_PROTOCOL_VERSION = "2024-11-05"
36
+ DEFAULT_REQUEST_TIMEOUT = 30.0
37
+
38
+
39
+ @dataclass
40
+ class MCPServerSpec:
41
+ """Static configuration for an MCP server (mirrors `.specsmith/mcp.yml`)."""
42
+
43
+ name: str
44
+ command: str
45
+ args: list[str] = field(default_factory=list)
46
+ env: dict[str, str] = field(default_factory=dict)
47
+
48
+
49
+ @dataclass
50
+ class MCPToolDescriptor:
51
+ """One tool advertised by an MCP server's ``tools/list`` response."""
52
+
53
+ name: str
54
+ description: str
55
+ input_schema: dict[str, Any]
56
+ server_name: str
57
+
58
+
59
+ class MCPError(RuntimeError):
60
+ """Raised on transport or JSON-RPC errors from an MCP server."""
61
+
62
+ def __init__(self, *, code: int, message: str, data: Any = None) -> None:
63
+ super().__init__(f"MCP error {code}: {message}")
64
+ self.code = code
65
+ self.detail = message
66
+ self.data = data
67
+
68
+
69
+ class MCPSession:
70
+ """One stdio-attached MCP server with full JSON-RPC framing.
71
+
72
+ The session owns the subprocess lifecycle. ``open()`` performs the
73
+ initialize handshake and discovery; ``call_tool()`` drives ``tools/call``;
74
+ ``close()`` flushes pending requests and terminates the child.
75
+ Concurrent calls into a single session are not supported (one in-flight
76
+ request at a time, matching the stdio MCP transport model).
77
+ """
78
+
79
+ def __init__(self, spec: MCPServerSpec) -> None:
80
+ self.spec = spec
81
+ self._proc: subprocess.Popen[bytes] | None = None
82
+ self._next_id = 1
83
+ self._lock = threading.Lock()
84
+ self._tools: list[MCPToolDescriptor] = []
85
+ self._closed = False
86
+
87
+ # ── Lifecycle ─────────────────────────────────────────────────────────
88
+
89
+ def open(self, *, timeout: float = DEFAULT_REQUEST_TIMEOUT) -> list[MCPToolDescriptor]:
90
+ """Spawn the server, run the initialize handshake, return discovered tools."""
91
+ env = {**self.spec.env}
92
+ self._proc = subprocess.Popen( # noqa: S603 - argv is user-configured
93
+ [self.spec.command, *self.spec.args],
94
+ stdin=subprocess.PIPE,
95
+ stdout=subprocess.PIPE,
96
+ stderr=subprocess.PIPE,
97
+ env=env or None,
98
+ bufsize=0,
99
+ )
100
+ self._request(
101
+ "initialize",
102
+ {
103
+ "protocolVersion": MCP_PROTOCOL_VERSION,
104
+ "capabilities": {"tools": {}},
105
+ "clientInfo": {"name": "specsmith", "version": "0"},
106
+ },
107
+ timeout=timeout,
108
+ )
109
+ # Per spec: send notifications/initialized after a successful initialize.
110
+ self._notify("notifications/initialized", {})
111
+ result = self._request("tools/list", {}, timeout=timeout)
112
+ raw_tools = result.get("tools", []) if isinstance(result, dict) else []
113
+ self._tools = []
114
+ for entry in raw_tools:
115
+ if not isinstance(entry, dict):
116
+ continue
117
+ name = entry.get("name")
118
+ if not name:
119
+ continue
120
+ schema = entry.get("inputSchema", {})
121
+ self._tools.append(
122
+ MCPToolDescriptor(
123
+ name=str(name),
124
+ description=str(entry.get("description", "")),
125
+ input_schema=schema if isinstance(schema, dict) else {},
126
+ server_name=self.spec.name,
127
+ )
128
+ )
129
+ return list(self._tools)
130
+
131
+ def close(self) -> None:
132
+ """Terminate the server. Idempotent."""
133
+ if self._closed:
134
+ return
135
+ self._closed = True
136
+ if self._proc is None:
137
+ return
138
+ try:
139
+ if self._proc.stdin and not self._proc.stdin.closed:
140
+ self._proc.stdin.close()
141
+ except OSError:
142
+ pass
143
+ try:
144
+ self._proc.terminate()
145
+ self._proc.wait(timeout=2.0)
146
+ except (OSError, subprocess.TimeoutExpired):
147
+ with contextlib.suppress(OSError):
148
+ self._proc.kill()
149
+
150
+ # ── Public API ────────────────────────────────────────────────────────
151
+
152
+ @property
153
+ def tools(self) -> list[MCPToolDescriptor]:
154
+ """Return the catalog discovered during ``open()``."""
155
+ return list(self._tools)
156
+
157
+ def call_tool(
158
+ self,
159
+ name: str,
160
+ arguments: dict[str, Any] | None = None,
161
+ *,
162
+ timeout: float = DEFAULT_REQUEST_TIMEOUT,
163
+ ) -> str:
164
+ """Invoke ``tools/call`` and return a flat string result.
165
+
166
+ MCP returns content blocks; we concatenate text blocks and report
167
+ non-text blocks descriptively so downstream consumers can render a
168
+ single string.
169
+ """
170
+ params: dict[str, Any] = {"name": name}
171
+ if arguments:
172
+ params["arguments"] = arguments
173
+ result = self._request("tools/call", params, timeout=timeout)
174
+ if not isinstance(result, dict):
175
+ return str(result)
176
+ if result.get("isError"):
177
+ return f"mcp error: {_format_content(result.get('content', []))}"
178
+ return _format_content(result.get("content", []))
179
+
180
+ # ── JSON-RPC framing ──────────────────────────────────────────────────
181
+
182
+ def _request(
183
+ self,
184
+ method: str,
185
+ params: dict[str, Any],
186
+ *,
187
+ timeout: float,
188
+ ) -> Any:
189
+ with self._lock:
190
+ req_id = self._next_id
191
+ self._next_id += 1
192
+ self._send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
193
+ response = self._read_response_for(req_id, timeout)
194
+ if "error" in response:
195
+ err = response["error"]
196
+ raise MCPError(
197
+ code=int(err.get("code", -1)),
198
+ message=str(err.get("message", "(no message)")),
199
+ data=err.get("data"),
200
+ )
201
+ return response.get("result", {})
202
+
203
+ def _notify(self, method: str, params: dict[str, Any]) -> None:
204
+ with self._lock:
205
+ self._send({"jsonrpc": "2.0", "method": method, "params": params})
206
+
207
+ def _send(self, payload: dict[str, Any]) -> None:
208
+ if self._proc is None or self._proc.stdin is None:
209
+ raise MCPError(code=-32000, message="server not open")
210
+ line = (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8")
211
+ try:
212
+ self._proc.stdin.write(line)
213
+ self._proc.stdin.flush()
214
+ except (OSError, BrokenPipeError) as exc:
215
+ raise MCPError(code=-32001, message=f"send failed: {exc}") from exc
216
+
217
+ def _read_response_for(self, req_id: int, timeout: float) -> dict[str, Any]:
218
+ if self._proc is None or self._proc.stdout is None:
219
+ raise MCPError(code=-32000, message="server not open")
220
+ deadline = time.monotonic() + timeout
221
+ while time.monotonic() < deadline:
222
+ line = self._proc.stdout.readline()
223
+ if not line:
224
+ stderr_tail = b""
225
+ if self._proc.stderr is not None:
226
+ try:
227
+ stderr_tail = self._proc.stderr.read() or b""
228
+ except OSError:
229
+ stderr_tail = b""
230
+ raise MCPError(
231
+ code=-32002,
232
+ message=f"mcp server closed: {stderr_tail.decode('utf-8', 'replace').strip()}",
233
+ )
234
+ try:
235
+ msg = json.loads(line.decode("utf-8", "replace"))
236
+ except ValueError:
237
+ continue
238
+ if not isinstance(msg, dict):
239
+ continue
240
+ if msg.get("id") == req_id:
241
+ return msg
242
+ raise MCPError(code=-32003, message=f"timeout waiting for response to id={req_id}")
243
+
244
+
245
+ def _format_content(blocks: Any) -> str:
246
+ """Concatenate MCP content blocks into a single human-readable string."""
247
+ if not isinstance(blocks, list):
248
+ return str(blocks)
249
+ parts: list[str] = []
250
+ for block in blocks:
251
+ if not isinstance(block, dict):
252
+ continue
253
+ kind = block.get("type", "")
254
+ if kind == "text":
255
+ parts.append(str(block.get("text", "")))
256
+ elif kind == "image":
257
+ parts.append(f"[image: {block.get('mimeType', 'unknown')}]")
258
+ elif kind == "resource":
259
+ uri = (block.get("resource") or {}).get("uri", "?")
260
+ parts.append(f"[resource: {uri}]")
261
+ else:
262
+ parts.append(f"[unknown block: {kind}]")
263
+ return "\n".join(parts) if parts else "(empty mcp response)"
264
+
265
+
266
+ @dataclass
267
+ class MCPTool:
268
+ """A Nexus-side handle that wraps one descriptor + an open session."""
269
+
270
+ descriptor: MCPToolDescriptor
271
+ session: MCPSession
272
+
273
+ @property
274
+ def name(self) -> str:
275
+ return self.descriptor.name
276
+
277
+ @property
278
+ def server(self) -> str:
279
+ return self.descriptor.server_name
280
+
281
+ @property
282
+ def description(self) -> str:
283
+ return self.descriptor.description
284
+
285
+ @property
286
+ def spec(self) -> MCPServerSpec:
287
+ """Back-compat shim — older callers expect a `.spec` attribute."""
288
+ return self.session.spec
289
+
290
+ def invoke(self, arguments: dict[str, Any] | None = None) -> str:
291
+ """Direct invocation (no safety middleware)."""
292
+ return self.session.call_tool(self.descriptor.name, arguments)
293
+
294
+ def invoke_with_safety(
295
+ self,
296
+ arguments: dict[str, Any] | None,
297
+ safety_check: Callable[[str, dict[str, Any]], tuple[bool, str]] | None,
298
+ ) -> str:
299
+ """Invoke after running the supplied safety check.
300
+
301
+ The check returns ``(allowed, reason)``. When disallowed, the call
302
+ is not made and a redacted error string is returned.
303
+ """
304
+ if safety_check is not None:
305
+ allowed, reason = safety_check(self.descriptor.name, arguments or {})
306
+ if not allowed:
307
+ return f"mcp blocked by safety: {reason}"
308
+ return self.invoke(arguments or None)
309
+
310
+
311
+ # ── Loader-style helpers (back-compat with prior callers) ────────────────
312
+
313
+
314
+ def _read_specs(project_dir: Path) -> list[MCPServerSpec]:
315
+ cfg_path = Path(project_dir) / ".specsmith" / "mcp.yml"
316
+ if not cfg_path.is_file():
317
+ return []
318
+ try:
319
+ import yaml
320
+
321
+ raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or []
322
+ except Exception: # noqa: BLE001
323
+ return []
324
+ if not isinstance(raw, list):
325
+ return []
326
+ out: list[MCPServerSpec] = []
327
+ for entry in raw:
328
+ if not isinstance(entry, dict):
329
+ continue
330
+ name = str(entry.get("name", "")).strip()
331
+ command = str(entry.get("command", "")).strip()
332
+ if not name or not command:
333
+ continue
334
+ args_raw = entry.get("args", []) or []
335
+ env_raw = entry.get("env", {}) or {}
336
+ out.append(
337
+ MCPServerSpec(
338
+ name=name,
339
+ command=command,
340
+ args=[str(a) for a in args_raw if isinstance(a, (str, int, float))],
341
+ env={str(k): str(v) for k, v in env_raw.items()},
342
+ )
343
+ )
344
+ return out
345
+
346
+
347
+ def load_mcp_tools(project_dir: Path) -> list[MCPTool]:
348
+ """Open every configured MCP server and return its tools (back-compat).
349
+
350
+ Servers that fail to open are silently skipped. Returns an empty list
351
+ when no servers are configured. The underlying sessions remain open
352
+ until the process exits — convenient for one-shot scripts and tests.
353
+ Long-running consumers should prefer :func:`open_mcp_sessions` and
354
+ explicitly ``close()`` each session.
355
+ """
356
+ sessions = open_mcp_sessions(project_dir)
357
+ out: list[MCPTool] = []
358
+ for session in sessions:
359
+ for descriptor in session.tools:
360
+ out.append(MCPTool(descriptor=descriptor, session=session))
361
+ return out
362
+
363
+
364
+ def open_mcp_sessions(project_dir: Path) -> list[MCPSession]:
365
+ """Open all configured MCP sessions and return them. Caller owns close."""
366
+ out: list[MCPSession] = []
367
+ for spec in _read_specs(project_dir):
368
+ session = MCPSession(spec)
369
+ try:
370
+ session.open()
371
+ except (OSError, MCPError):
372
+ session.close()
373
+ continue
374
+ out.append(session)
375
+ return out
376
+
377
+
378
+ __all__ = [
379
+ "MCP_PROTOCOL_VERSION",
380
+ "MCPError",
381
+ "MCPServerSpec",
382
+ "MCPSession",
383
+ "MCPTool",
384
+ "MCPToolDescriptor",
385
+ "load_mcp_tools",
386
+ "open_mcp_sessions",
387
+ ]
@@ -0,0 +1,264 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
3
+ """Lightweight NL-to-command suggester for `specsmith suggest-command` (REQ-131).
4
+
5
+ Given a partial natural-language fragment, return a structured suggestion
6
+ that the VS Code extension renders as inline ghost-text in the chat input.
7
+ Three classification buckets:
8
+
9
+ * ``command`` -- the input is shell-y (starts with an imperative verb that
10
+ maps to a known CLI). Suggest a concrete shell command.
11
+ * ``utterance`` -- the input is plain English meant for the agent. Suggest
12
+ a refined utterance that names a likely component (best-effort).
13
+ * ``passthrough`` -- input is too short or ambiguous; echo it back so the
14
+ ghost-text matches what the user typed (no-op suggestion).
15
+
16
+ The suggester is **deterministic and LLM-free**. The IDE may layer an LLM
17
+ predictor on top, but the CLI baseline must always succeed quickly. If the
18
+ extension wants a richer suggestion, it can call `specsmith preflight
19
+ --predict-only` separately for utterances.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import re
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ # Common imperative verbs that map to shell-y intents.
30
+ _SHELL_VERBS = {
31
+ "run",
32
+ "exec",
33
+ "execute",
34
+ "kill",
35
+ "stop",
36
+ "start",
37
+ "restart",
38
+ "build",
39
+ "test",
40
+ "lint",
41
+ "format",
42
+ "git",
43
+ "cd",
44
+ "ls",
45
+ "cat",
46
+ "rm",
47
+ "mv",
48
+ "cp",
49
+ "find",
50
+ "grep",
51
+ "ps",
52
+ "top",
53
+ "open",
54
+ "edit",
55
+ "tail",
56
+ "head",
57
+ "make",
58
+ "npm",
59
+ "pnpm",
60
+ "yarn",
61
+ "pip",
62
+ "pipx",
63
+ "uv",
64
+ "pytest",
65
+ "ruff",
66
+ "mypy",
67
+ "cargo",
68
+ "go",
69
+ "docker",
70
+ "kubectl",
71
+ "terraform",
72
+ }
73
+
74
+ # Map verb -> default refined command.
75
+ _VERB_TEMPLATES: dict[str, str] = {
76
+ "run tests": "pytest -q",
77
+ "run lint": "ruff check .",
78
+ "run mypy": "mypy src/",
79
+ "format": "ruff format .",
80
+ "lint": "ruff check .",
81
+ "test": "pytest -q",
82
+ "build": "python -m build",
83
+ "git status": "git --no-pager status",
84
+ "git log": "git --no-pager log --oneline -20",
85
+ "git diff": "git --no-pager diff",
86
+ }
87
+
88
+
89
+ @dataclass
90
+ class CommandSuggestion:
91
+ """Output payload of :func:`suggest_command`."""
92
+
93
+ kind: str # "command" | "utterance" | "passthrough"
94
+ suggestion: str
95
+ confidence: float = 0.5
96
+ reasoning: str = ""
97
+ candidates: list[str] = field(default_factory=list)
98
+
99
+ def to_dict(self) -> dict[str, Any]:
100
+ return {
101
+ "kind": self.kind,
102
+ "suggestion": self.suggestion,
103
+ "confidence": round(self.confidence, 3),
104
+ "reasoning": self.reasoning,
105
+ "candidates": list(self.candidates),
106
+ }
107
+
108
+
109
+ def classify(text: str) -> str:
110
+ """Return ``command``, ``utterance``, or ``passthrough``."""
111
+ stripped = text.strip()
112
+ if len(stripped) < 2:
113
+ return "passthrough"
114
+ first = stripped.split()[0].lower()
115
+ if first in _SHELL_VERBS:
116
+ return "command"
117
+ return "utterance"
118
+
119
+
120
+ def suggest_command(text: str, *, project_dir: Path | None = None) -> CommandSuggestion:
121
+ """Return a structured suggestion for ``text``.
122
+
123
+ The suggester is deterministic. It looks for verb prefixes and a short
124
+ catalogue of common templates; if nothing matches, it returns the input
125
+ unchanged with kind=``passthrough``.
126
+ """
127
+ stripped = text.strip()
128
+ kind = classify(stripped)
129
+ if kind == "passthrough":
130
+ return CommandSuggestion(
131
+ kind="passthrough",
132
+ suggestion=text,
133
+ confidence=0.0,
134
+ reasoning="input too short to suggest",
135
+ )
136
+ if kind == "utterance":
137
+ return _suggest_utterance(stripped, project_dir=project_dir)
138
+ return _suggest_shell(stripped)
139
+
140
+
141
+ def _suggest_shell(text: str) -> CommandSuggestion:
142
+ lower = text.lower()
143
+ # Direct multi-word template match (e.g. "run tests").
144
+ for phrase, command in _VERB_TEMPLATES.items():
145
+ if lower.startswith(phrase):
146
+ return CommandSuggestion(
147
+ kind="command",
148
+ suggestion=command,
149
+ confidence=0.85,
150
+ reasoning=f"matched template '{phrase}'",
151
+ )
152
+ # Single-verb fallback: if the user typed "git" alone, propose
153
+ # `git status`. If "test", propose pytest -q.
154
+ first = lower.split()[0]
155
+ fallback = {
156
+ "git": "git --no-pager status",
157
+ "ls": "ls -la",
158
+ "test": "pytest -q",
159
+ "lint": "ruff check .",
160
+ "format": "ruff format .",
161
+ "build": "python -m build",
162
+ "find": "find . -name '*.py'",
163
+ }.get(first)
164
+ if fallback and lower.strip() == first:
165
+ return CommandSuggestion(
166
+ kind="command",
167
+ suggestion=fallback,
168
+ confidence=0.7,
169
+ reasoning=f"single verb '{first}' resolved to default command",
170
+ )
171
+ # Pass through what the user typed; mark as command anyway so the IDE
172
+ # knows it's shell-y rather than NL.
173
+ return CommandSuggestion(
174
+ kind="command",
175
+ suggestion=text,
176
+ confidence=0.3,
177
+ reasoning="recognised as shell command but no template applied",
178
+ )
179
+
180
+
181
+ _REQ_REGEX = re.compile(r"REQ-[A-Z0-9-]+", re.IGNORECASE)
182
+ _KNOWN_VERBS = ("add", "fix", "refactor", "remove", "rename", "document", "test")
183
+
184
+
185
+ def _suggest_utterance(text: str, *, project_dir: Path | None) -> CommandSuggestion:
186
+ lower = text.lower()
187
+ candidates: list[str] = []
188
+
189
+ # If the text already names a REQ, surface it verbatim with a higher
190
+ # confidence — the user is already specific.
191
+ matched = _REQ_REGEX.findall(text)
192
+ if matched:
193
+ return CommandSuggestion(
194
+ kind="utterance",
195
+ suggestion=text,
196
+ confidence=0.9,
197
+ reasoning=f"references {matched[0]} explicitly",
198
+ candidates=matched,
199
+ )
200
+
201
+ # If the text starts with a change verb but doesn't name a component,
202
+ # suggest a refined version that asks the user to add a target.
203
+ first = lower.split()[0] if lower.split() else ""
204
+ if first in _KNOWN_VERBS and len(lower.split()) <= 3:
205
+ return CommandSuggestion(
206
+ kind="utterance",
207
+ suggestion=f"{text.rstrip()} (please name the component or file)",
208
+ confidence=0.6,
209
+ reasoning=f"verb '{first}' lacks an explicit target",
210
+ )
211
+
212
+ # Project-aware refinement: scan REQUIREMENTS.md for keywords that match
213
+ # the input and propose the first hit. Best-effort; never blocks.
214
+ if project_dir is not None:
215
+ candidates = _scan_requirements(text, project_dir)
216
+ if candidates:
217
+ return CommandSuggestion(
218
+ kind="utterance",
219
+ suggestion=f"{text.rstrip()} ({candidates[0]})",
220
+ confidence=0.65,
221
+ reasoning=f"matched {candidates[0]} from REQUIREMENTS.md",
222
+ candidates=candidates,
223
+ )
224
+
225
+ # Default: echo back unchanged.
226
+ return CommandSuggestion(
227
+ kind="utterance",
228
+ suggestion=text,
229
+ confidence=0.4,
230
+ reasoning="no project-specific refinement available",
231
+ )
232
+
233
+
234
+ def _scan_requirements(text: str, project_dir: Path) -> list[str]:
235
+ """Return up to 5 REQ ids whose description shares words with ``text``."""
236
+ candidates: list[tuple[int, str]] = []
237
+ for path in (
238
+ project_dir / "REQUIREMENTS.md",
239
+ project_dir / "docs" / "REQUIREMENTS.md",
240
+ ):
241
+ if not path.is_file():
242
+ continue
243
+ try:
244
+ content = path.read_text(encoding="utf-8")
245
+ except OSError:
246
+ continue
247
+ words = {w.lower() for w in re.findall(r"[A-Za-z]{4,}", text)}
248
+ if not words:
249
+ return []
250
+ for match in re.finditer(
251
+ r"^###?\s+(REQ-[A-Z0-9-]+)\s*(.*?)(?=^###?\s+REQ|^##\s|\Z)",
252
+ content,
253
+ re.MULTILINE | re.DOTALL,
254
+ ):
255
+ req_id, body = match.group(1), match.group(2)
256
+ body_words = {w.lower() for w in re.findall(r"[A-Za-z]{4,}", body)}
257
+ score = len(words & body_words)
258
+ if score > 0:
259
+ candidates.append((score, req_id))
260
+ candidates.sort(key=lambda x: (-x[0], x[1]))
261
+ return [req for _, req in candidates[:5]]
262
+
263
+
264
+ __all__ = ["CommandSuggestion", "classify", "suggest_command"]