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.
- {specsmith-0.6.0.dev230/src/specsmith.egg-info → specsmith-0.6.0.dev232}/PKG-INFO +1 -1
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/pyproject.toml +1 -1
- specsmith-0.6.0.dev232/src/specsmith/agent/mcp.py +387 -0
- specsmith-0.6.0.dev232/src/specsmith/agent/suggester.py +264 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/cli.py +42 -5
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/SOURCES.txt +3 -0
- specsmith-0.6.0.dev232/tests/test_mcp_client.py +157 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_phase34_completion.py +29 -18
- specsmith-0.6.0.dev232/tests/test_suggester.py +88 -0
- specsmith-0.6.0.dev230/src/specsmith/agent/mcp.py +0 -117
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/LICENSE +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/README.md +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/setup.cfg +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/belief.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/certainty.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/failure_graph.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/py.typed +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/recovery.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/session.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/stress_tester.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/epistemic/trace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/__main__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/broker.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/chat_runner.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/cleanup.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/events.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/indexer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/memory.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/orchestrator.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/repl.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/router.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/rules.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/safety.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/tools.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/agent/verifier.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/architect.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/auditor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/auth.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/compressor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/config.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/console_utils.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/credit_analyzer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/credits.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/differ.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/doctor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/belief.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/certainty.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/failure_graph.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/recovery.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/epistemic/stress_tester.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/executor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/exporter.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/app.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/main_window.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/session_tab.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/theme.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/chat_view.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/input_bar.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/provider_bar.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/token_meter.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/tool_panel.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/widgets/update_checker.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/gui/worker.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/importer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/agent_skill.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/languages.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/ledger.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/patent.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/phase.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/plugins.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/profiles.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/rate_limits.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/releaser.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/requirements.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/requirements_parser.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/retrieval.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/scaffolder.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/serve.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/session.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/skills.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/contributing.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/license-MIT.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/community/security.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/go/go.mod.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/go/main.go.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/js/package.json.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/rust/main.rs.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/tool_installer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/toolrules.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/tools.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/trace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/updater.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/validator.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/wireframes.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith/workspace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_CMD_001.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_auditor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_chat_diff_decision.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_chat_stdin_protocol.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_cli.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_cli_workflows_history_drive.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_compressor.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_e2e_nexus.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_epistemic.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_importer.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_integrations.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_nexus.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_phase1_4_new.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_rate_limits.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_scaffolder.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_skill_marketplace.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_smoke.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_tools.py +0 -0
- {specsmith-0.6.0.dev230 → specsmith-0.6.0.dev232}/tests/test_validator.py +0 -0
- {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.
|
|
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.
|
|
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"]
|