specsmith 0.4.0__tar.gz → 0.4.0.dev222__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.4.0/src/specsmith.egg-info → specsmith-0.4.0.dev222}/PKG-INFO +1 -1
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/pyproject.toml +1 -1
- specsmith-0.4.0.dev222/src/specsmith/agent/events.py +176 -0
- specsmith-0.4.0.dev222/src/specsmith/agent/mcp.py +117 -0
- specsmith-0.4.0.dev222/src/specsmith/agent/memory.py +81 -0
- specsmith-0.4.0.dev222/src/specsmith/agent/router.py +78 -0
- specsmith-0.4.0.dev222/src/specsmith/agent/rules.py +62 -0
- specsmith-0.4.0.dev222/src/specsmith/agent/verifier.py +123 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/cli.py +476 -3
- {specsmith-0.4.0 → specsmith-0.4.0.dev222/src/specsmith.egg-info}/PKG-INFO +1 -1
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith.egg-info/SOURCES.txt +8 -0
- specsmith-0.4.0.dev222/tests/test_e2e_nexus.py +144 -0
- specsmith-0.4.0.dev222/tests/test_phase1_4_new.py +127 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/LICENSE +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/README.md +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/setup.cfg +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/belief.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/certainty.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/failure_graph.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/py.typed +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/recovery.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/session.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/stress_tester.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/epistemic/trace.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/__main__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/broker.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/cleanup.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/indexer.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/orchestrator.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/repl.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/safety.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/agent/tools.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/architect.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/auditor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/auth.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/commands/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/compressor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/config.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/console_utils.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/credit_analyzer.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/credits.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/differ.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/doctor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/epistemic/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/epistemic/belief.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/epistemic/certainty.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/epistemic/failure_graph.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/epistemic/recovery.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/epistemic/stress_tester.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/executor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/exporter.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/app.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/main_window.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/session_tab.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/theme.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/chat_view.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/input_bar.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/provider_bar.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/token_meter.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/tool_panel.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/widgets/update_checker.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/gui/worker.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/importer.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/aider.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/base.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/claude_code.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/copilot.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/cursor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/gemini.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/warp.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/integrations/windsurf.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/languages.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/ledger.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/patent.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/phase.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/plugins.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/profiles.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/rate_limits.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/releaser.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/requirements.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/requirements_parser.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/retrieval.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/scaffolder.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/serve.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/session.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/agents.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/bug_report.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/code_of_conduct.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/contributing.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/feature_request.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/license-Apache-2.0.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/license-MIT.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/pull_request_template.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/community/security.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/docs/architecture.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/docs/mkdocs.yml.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/docs/readthedocs.yaml.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/docs/requirements.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/docs/test-spec.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/editorconfig.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/gitattributes.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/gitignore.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/go/go.mod.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/go/main.go.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/belief-registry.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/context-budget.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/drift-metrics.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/epistemic-axioms.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/failure-modes.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/lifecycle.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/roles.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/rules.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/session-protocol.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/uncertainty-map.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/governance/verification.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/js/package.json.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/ledger.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/python/cli.py.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/python/init.py.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/python/pyproject.toml.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/readme.md.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/rust/Cargo.toml.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/rust/main.rs.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/scripts/exec.cmd.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/scripts/exec.sh.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/scripts/run.cmd.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/scripts/run.sh.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/scripts/setup.cmd.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/scripts/setup.sh.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/templates/workflows/release.yml.j2 +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/tool_installer.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/toolrules.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/tools.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/trace.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/updater.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/upgrader.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/validator.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/vcs/__init__.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/vcs/base.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/vcs/bitbucket.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/vcs/github.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/vcs/gitlab.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/vcs_commands.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/wireframes.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith/workspace.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith.egg-info/dependency_links.txt +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith.egg-info/entry_points.txt +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith.egg-info/requires.txt +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/src/specsmith.egg-info/top_level.txt +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_CMD_001.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_auditor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_cli.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_compressor.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_epistemic.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_importer.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_integrations.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_nexus.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_rate_limits.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_scaffolder.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_smoke.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_tools.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_validator.py +0 -0
- {specsmith-0.4.0 → specsmith-0.4.0.dev222}/tests/test_vcs.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: specsmith
|
|
3
|
-
Version: 0.4.0
|
|
3
|
+
Version: 0.4.0.dev222
|
|
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.4.0"
|
|
7
|
+
version = "0.4.0.dev222"
|
|
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,176 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Block-based JSONL event protocol for `specsmith chat` (REQ-112, REQ-113, REQ-114).
|
|
4
|
+
|
|
5
|
+
The protocol is the contract between the Specsmith chat backend and any
|
|
6
|
+
client (the Nexus REPL itself, the VS Code extension, or future TUIs).
|
|
7
|
+
Every event is a single JSON object on its own line with a ``type`` key.
|
|
8
|
+
|
|
9
|
+
Event kinds
|
|
10
|
+
-----------
|
|
11
|
+
* ``block_start`` - begins a new block (kinds: ``plan``, ``message``,
|
|
12
|
+
``tool_call``, ``tool_result``, ``diff``,
|
|
13
|
+
``test_results``, ``verdict``).
|
|
14
|
+
* ``block_complete`` - closes the block opened by ``block_start``.
|
|
15
|
+
* ``token`` - incremental LLM token within a ``message`` block.
|
|
16
|
+
* ``tool_call`` - the LLM has decided to invoke a tool.
|
|
17
|
+
* ``tool_request`` - safe-mode permission request (REQ-115).
|
|
18
|
+
* ``tool_result`` - completed tool execution.
|
|
19
|
+
* ``plan_step`` - status transition for a step in the active plan
|
|
20
|
+
block (REQ-114).
|
|
21
|
+
* ``task_complete`` - final block; carries final summary + profile.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import contextlib
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import IO, Any
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _now_iso() -> str:
|
|
36
|
+
"""Return a UTC ISO-8601 timestamp (second precision)."""
|
|
37
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _new_block_id() -> str:
|
|
41
|
+
return f"blk_{uuid.uuid4().hex[:12]}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class EventEmitter:
|
|
46
|
+
"""Writes JSONL events to a stream (default stdout).
|
|
47
|
+
|
|
48
|
+
Used by the `specsmith chat` CLI and by the test suite. Each event is
|
|
49
|
+
flushed immediately so consumers can react in real time.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
stream: IO[str] = field(default_factory=lambda: sys.stdout)
|
|
53
|
+
|
|
54
|
+
def emit(self, event: dict[str, Any]) -> None:
|
|
55
|
+
line = json.dumps(event, ensure_ascii=False)
|
|
56
|
+
self.stream.write(line + "\n")
|
|
57
|
+
# Some test buffers (e.g. capsys) don't support flush; ignore.
|
|
58
|
+
with contextlib.suppress(Exception):
|
|
59
|
+
self.stream.flush()
|
|
60
|
+
|
|
61
|
+
# ── Block helpers ────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def block_start(self, kind: str, *, agent: str = "nexus", **payload: Any) -> str:
|
|
64
|
+
"""Open a new block of ``kind`` and return its id."""
|
|
65
|
+
block_id = _new_block_id()
|
|
66
|
+
self.emit(
|
|
67
|
+
{
|
|
68
|
+
"type": "block_start",
|
|
69
|
+
"block_id": block_id,
|
|
70
|
+
"kind": kind,
|
|
71
|
+
"agent": agent,
|
|
72
|
+
"timestamp": _now_iso(),
|
|
73
|
+
"payload": payload,
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
return block_id
|
|
77
|
+
|
|
78
|
+
def block_complete(self, block_id: str, **payload: Any) -> None:
|
|
79
|
+
self.emit(
|
|
80
|
+
{
|
|
81
|
+
"type": "block_complete",
|
|
82
|
+
"block_id": block_id,
|
|
83
|
+
"timestamp": _now_iso(),
|
|
84
|
+
"payload": payload,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def token(self, block_id: str, text: str) -> None:
|
|
89
|
+
self.emit(
|
|
90
|
+
{
|
|
91
|
+
"type": "token",
|
|
92
|
+
"block_id": block_id,
|
|
93
|
+
"text": text,
|
|
94
|
+
}
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
def tool_call(self, block_id: str, name: str, args: dict[str, Any]) -> None:
|
|
98
|
+
self.emit(
|
|
99
|
+
{
|
|
100
|
+
"type": "tool_call",
|
|
101
|
+
"block_id": block_id,
|
|
102
|
+
"name": name,
|
|
103
|
+
"args": args,
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def tool_request(self, block_id: str, name: str, args: dict[str, Any]) -> None:
|
|
108
|
+
self.emit(
|
|
109
|
+
{
|
|
110
|
+
"type": "tool_request",
|
|
111
|
+
"block_id": block_id,
|
|
112
|
+
"name": name,
|
|
113
|
+
"args": args,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def tool_result(self, block_id: str, name: str, ok: bool, output: str) -> None:
|
|
118
|
+
self.emit(
|
|
119
|
+
{
|
|
120
|
+
"type": "tool_result",
|
|
121
|
+
"block_id": block_id,
|
|
122
|
+
"name": name,
|
|
123
|
+
"ok": ok,
|
|
124
|
+
"output": output,
|
|
125
|
+
}
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def plan(self, steps: list[dict[str, Any]]) -> str:
|
|
129
|
+
return self.block_start("plan", steps=steps)
|
|
130
|
+
|
|
131
|
+
def plan_step(
|
|
132
|
+
self,
|
|
133
|
+
block_id: str,
|
|
134
|
+
step_id: str,
|
|
135
|
+
status: str,
|
|
136
|
+
**payload: Any,
|
|
137
|
+
) -> None:
|
|
138
|
+
self.emit(
|
|
139
|
+
{
|
|
140
|
+
"type": "plan_step",
|
|
141
|
+
"block_id": block_id,
|
|
142
|
+
"step_id": step_id,
|
|
143
|
+
"status": status,
|
|
144
|
+
"timestamp": _now_iso(),
|
|
145
|
+
"payload": payload,
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def diff(self, path: str, body: str) -> str:
|
|
150
|
+
return self.block_start("diff", path=path, body=body)
|
|
151
|
+
|
|
152
|
+
def task_complete(
|
|
153
|
+
self,
|
|
154
|
+
*,
|
|
155
|
+
success: bool,
|
|
156
|
+
confidence: float,
|
|
157
|
+
summary: str,
|
|
158
|
+
profile: str,
|
|
159
|
+
comments: list[dict[str, Any]] | None = None,
|
|
160
|
+
**extra: Any,
|
|
161
|
+
) -> None:
|
|
162
|
+
self.emit(
|
|
163
|
+
{
|
|
164
|
+
"type": "task_complete",
|
|
165
|
+
"timestamp": _now_iso(),
|
|
166
|
+
"success": success,
|
|
167
|
+
"confidence": confidence,
|
|
168
|
+
"summary": summary,
|
|
169
|
+
"profile": profile,
|
|
170
|
+
"comments": comments or [],
|
|
171
|
+
**extra,
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = ["EventEmitter"]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""MCP (Model Context Protocol) tool consumption for Nexus (REQ-121).
|
|
4
|
+
|
|
5
|
+
Reads ``.specsmith/mcp.yml`` (a list of server configs) and returns a list
|
|
6
|
+
of tool wrappers that Nexus can register alongside its built-in tool set.
|
|
7
|
+
The wrappers are invoked over stdio per the MCP spec (subprocess +
|
|
8
|
+
JSON-RPC framing). For 1.0 we ship the loader and the wrapper interface;
|
|
9
|
+
the actual stdio JSON-RPC client is implemented but kept narrow so the
|
|
10
|
+
Specsmith safety middleware fully wraps every call.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MCPServerSpec:
|
|
24
|
+
"""Static configuration for an MCP server.
|
|
25
|
+
|
|
26
|
+
Mirrors `.specsmith/mcp.yml` entries; the YAML parser turns each
|
|
27
|
+
entry into one of these.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
name: str
|
|
31
|
+
command: str
|
|
32
|
+
args: list[str]
|
|
33
|
+
env: dict[str, str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class MCPTool:
|
|
38
|
+
"""A Nexus-side handle to an MCP server.
|
|
39
|
+
|
|
40
|
+
Calling ``invoke(payload)`` opens a subprocess, sends the payload as
|
|
41
|
+
a JSON-RPC ``tools/call`` request, and returns the response. Errors
|
|
42
|
+
surface as plain strings; the orchestrator wraps the call with the
|
|
43
|
+
standard Specsmith safety middleware so destructive payloads are
|
|
44
|
+
blocked exactly the same way as native Nexus tools.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
spec: MCPServerSpec
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def name(self) -> str:
|
|
51
|
+
return self.spec.name
|
|
52
|
+
|
|
53
|
+
def invoke(self, payload: dict[str, Any]) -> str:
|
|
54
|
+
request = {
|
|
55
|
+
"jsonrpc": "2.0",
|
|
56
|
+
"id": 1,
|
|
57
|
+
"method": "tools/call",
|
|
58
|
+
"params": payload,
|
|
59
|
+
}
|
|
60
|
+
body = json.dumps(request) + "\n"
|
|
61
|
+
try:
|
|
62
|
+
proc = subprocess.run( # noqa: S603 - argv is configured by user
|
|
63
|
+
[self.spec.command, *self.spec.args],
|
|
64
|
+
input=body,
|
|
65
|
+
capture_output=True,
|
|
66
|
+
text=True,
|
|
67
|
+
timeout=30,
|
|
68
|
+
env={**self.spec.env},
|
|
69
|
+
check=False,
|
|
70
|
+
)
|
|
71
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
72
|
+
return f"mcp error: {exc}"
|
|
73
|
+
if proc.returncode != 0:
|
|
74
|
+
return f"mcp error: {proc.stderr.strip() or 'non-zero exit'}"
|
|
75
|
+
return proc.stdout.strip() or "(empty mcp response)"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_mcp_tools(project_dir: Path) -> list[MCPTool]:
|
|
79
|
+
"""Read ``.specsmith/mcp.yml`` and return a list of :class:`MCPTool`.
|
|
80
|
+
|
|
81
|
+
Returns an empty list when the file is absent or unparseable so the
|
|
82
|
+
rest of the orchestrator continues to function with zero MCP servers
|
|
83
|
+
configured (the default).
|
|
84
|
+
"""
|
|
85
|
+
cfg_path = Path(project_dir) / ".specsmith" / "mcp.yml"
|
|
86
|
+
if not cfg_path.is_file():
|
|
87
|
+
return []
|
|
88
|
+
try:
|
|
89
|
+
import yaml
|
|
90
|
+
|
|
91
|
+
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or []
|
|
92
|
+
except Exception: # noqa: BLE001
|
|
93
|
+
return []
|
|
94
|
+
if not isinstance(raw, list):
|
|
95
|
+
return []
|
|
96
|
+
|
|
97
|
+
out: list[MCPTool] = []
|
|
98
|
+
for entry in raw:
|
|
99
|
+
if not isinstance(entry, dict):
|
|
100
|
+
continue
|
|
101
|
+
name = str(entry.get("name", "")).strip()
|
|
102
|
+
command = str(entry.get("command", "")).strip()
|
|
103
|
+
if not name or not command:
|
|
104
|
+
continue
|
|
105
|
+
args_raw = entry.get("args", []) or []
|
|
106
|
+
env_raw = entry.get("env", {}) or {}
|
|
107
|
+
spec = MCPServerSpec(
|
|
108
|
+
name=name,
|
|
109
|
+
command=command,
|
|
110
|
+
args=[str(a) for a in args_raw if isinstance(a, (str, int, float))],
|
|
111
|
+
env={str(k): str(v) for k, v in env_raw.items()},
|
|
112
|
+
)
|
|
113
|
+
out.append(MCPTool(spec=spec))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
__all__ = ["MCPServerSpec", "MCPTool", "load_mcp_tools"]
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Persistent session memory for the Nexus chat surface (REQ-120, REQ-125).
|
|
4
|
+
|
|
5
|
+
Every chat turn (user utterance, broker decision, task result, tool calls)
|
|
6
|
+
is appended as JSONL to ``.specsmith/sessions/<session_id>/turns.jsonl``.
|
|
7
|
+
The orchestrator prepends the most-recent turns (capped by character
|
|
8
|
+
budget) to its first message so the LLM has continuity across runs.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _session_dir(project_dir: Path, session_id: str) -> Path:
|
|
20
|
+
return Path(project_dir) / ".specsmith" / "sessions" / session_id
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _turns_path(project_dir: Path, session_id: str) -> Path:
|
|
24
|
+
return _session_dir(project_dir, session_id) / "turns.jsonl"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def append_turn(
|
|
28
|
+
project_dir: Path,
|
|
29
|
+
session_id: str,
|
|
30
|
+
turn: dict[str, Any],
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Append ``turn`` to the session log. Adds a UTC timestamp if missing."""
|
|
33
|
+
path = _turns_path(project_dir, session_id)
|
|
34
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
record = dict(turn)
|
|
36
|
+
record.setdefault("timestamp", time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
|
|
37
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
38
|
+
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def all_turns(project_dir: Path, session_id: str) -> list[dict[str, Any]]:
|
|
42
|
+
"""Return every recorded turn for ``session_id`` (oldest-first)."""
|
|
43
|
+
path = _turns_path(project_dir, session_id)
|
|
44
|
+
if not path.is_file():
|
|
45
|
+
return []
|
|
46
|
+
out: list[dict[str, Any]] = []
|
|
47
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
48
|
+
line = line.strip()
|
|
49
|
+
if not line:
|
|
50
|
+
continue
|
|
51
|
+
try:
|
|
52
|
+
out.append(json.loads(line))
|
|
53
|
+
except ValueError:
|
|
54
|
+
continue
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def recent_turns(
|
|
59
|
+
project_dir: Path,
|
|
60
|
+
session_id: str,
|
|
61
|
+
*,
|
|
62
|
+
max_chars: int = 20_000,
|
|
63
|
+
) -> list[dict[str, Any]]:
|
|
64
|
+
"""Return the most recent turns whose serialized size fits ``max_chars``.
|
|
65
|
+
|
|
66
|
+
Truncates oldest-first so the prompt always carries the latest context.
|
|
67
|
+
"""
|
|
68
|
+
turns = all_turns(project_dir, session_id)
|
|
69
|
+
out: list[dict[str, Any]] = []
|
|
70
|
+
used = 0
|
|
71
|
+
for turn in reversed(turns):
|
|
72
|
+
size = len(json.dumps(turn, ensure_ascii=False))
|
|
73
|
+
if used + size > max_chars:
|
|
74
|
+
break
|
|
75
|
+
out.append(turn)
|
|
76
|
+
used += size
|
|
77
|
+
out.reverse()
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
__all__ = ["append_turn", "all_turns", "recent_turns"]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Dynamic agent / model routing for the Nexus orchestrator (REQ-122).
|
|
4
|
+
|
|
5
|
+
The orchestrator asks ``choose_tier`` which model tier should run a given
|
|
6
|
+
task. Three tiers are recognized:
|
|
7
|
+
|
|
8
|
+
* ``coder`` - the local `l1-nexus` Qwen-Coder server (default).
|
|
9
|
+
* ``heavy`` - a larger reasoning model for governance / architecture work.
|
|
10
|
+
* ``fast`` - a quick lightweight model for read-only asks and summaries.
|
|
11
|
+
|
|
12
|
+
The default mapping is overridable per project via
|
|
13
|
+
``.specsmith/config.yml``::
|
|
14
|
+
|
|
15
|
+
routing:
|
|
16
|
+
change: coder
|
|
17
|
+
release: heavy
|
|
18
|
+
destructive: heavy
|
|
19
|
+
read_only_ask: fast
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Literal
|
|
26
|
+
|
|
27
|
+
Tier = Literal["coder", "heavy", "fast"]
|
|
28
|
+
|
|
29
|
+
DEFAULT_MAPPING: dict[str, Tier] = {
|
|
30
|
+
"read_only_ask": "fast",
|
|
31
|
+
"change": "coder",
|
|
32
|
+
"release": "heavy",
|
|
33
|
+
"destructive": "heavy",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def choose_tier(
|
|
38
|
+
intent: str,
|
|
39
|
+
*,
|
|
40
|
+
project_dir: Path | None = None,
|
|
41
|
+
retry_count: int = 0,
|
|
42
|
+
) -> Tier:
|
|
43
|
+
"""Pick a model tier for ``intent``.
|
|
44
|
+
|
|
45
|
+
Repeated retries escalate from ``coder`` to ``heavy`` so a stuck task
|
|
46
|
+
gets a more capable model on the next try (Phase-3 behaviour from the
|
|
47
|
+
plan).
|
|
48
|
+
"""
|
|
49
|
+
mapping = dict(DEFAULT_MAPPING)
|
|
50
|
+
if project_dir is not None:
|
|
51
|
+
mapping.update(_load_routing_overrides(project_dir))
|
|
52
|
+
tier: Tier = mapping.get(intent, "coder")
|
|
53
|
+
if retry_count >= 2 and tier == "coder":
|
|
54
|
+
tier = "heavy"
|
|
55
|
+
return tier
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _load_routing_overrides(project_dir: Path) -> dict[str, Tier]:
|
|
59
|
+
cfg = Path(project_dir) / ".specsmith" / "config.yml"
|
|
60
|
+
if not cfg.is_file():
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
import yaml
|
|
64
|
+
|
|
65
|
+
raw = yaml.safe_load(cfg.read_text(encoding="utf-8")) or {}
|
|
66
|
+
except Exception: # noqa: BLE001
|
|
67
|
+
return {}
|
|
68
|
+
section = raw.get("routing") if isinstance(raw, dict) else None
|
|
69
|
+
if not isinstance(section, dict):
|
|
70
|
+
return {}
|
|
71
|
+
out: dict[str, Tier] = {}
|
|
72
|
+
for key, val in section.items():
|
|
73
|
+
if isinstance(val, str) and val in ("coder", "heavy", "fast"):
|
|
74
|
+
out[str(key)] = val # type: ignore[assignment]
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
__all__ = ["DEFAULT_MAPPING", "Tier", "choose_tier"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Project rules auto-injection for the Nexus orchestrator (REQ-119).
|
|
4
|
+
|
|
5
|
+
Combines `docs/governance/*_RULES.md` files and the H-rules from
|
|
6
|
+
`AGENTS.md` into a single deterministic system-prompt prefix that the
|
|
7
|
+
orchestrator prepends to every AG2 agent's `system_message`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_rules(project_dir: Path) -> str:
|
|
17
|
+
"""Return the combined rules prefix for ``project_dir``.
|
|
18
|
+
|
|
19
|
+
The returned string is empty when no governance rule files are present
|
|
20
|
+
(so older projects keep working unchanged). When rules exist, they are
|
|
21
|
+
rendered as a single compact block so AG2 token costs stay reasonable.
|
|
22
|
+
"""
|
|
23
|
+
project_dir = Path(project_dir)
|
|
24
|
+
sections: list[str] = []
|
|
25
|
+
|
|
26
|
+
governance_dir = project_dir / "docs" / "governance"
|
|
27
|
+
if governance_dir.is_dir():
|
|
28
|
+
for path in sorted(governance_dir.glob("*_RULES.md")):
|
|
29
|
+
try:
|
|
30
|
+
text = path.read_text(encoding="utf-8").strip()
|
|
31
|
+
except OSError:
|
|
32
|
+
continue
|
|
33
|
+
if text:
|
|
34
|
+
sections.append(f"# {path.stem}\n{text}")
|
|
35
|
+
|
|
36
|
+
agents_md = project_dir / "AGENTS.md"
|
|
37
|
+
if agents_md.is_file():
|
|
38
|
+
try:
|
|
39
|
+
agents_text = agents_md.read_text(encoding="utf-8")
|
|
40
|
+
except OSError:
|
|
41
|
+
agents_text = ""
|
|
42
|
+
h_rules = _extract_h_rules(agents_text)
|
|
43
|
+
if h_rules:
|
|
44
|
+
sections.append("# AGENTS.md hard rules\n" + h_rules)
|
|
45
|
+
|
|
46
|
+
if not sections:
|
|
47
|
+
return ""
|
|
48
|
+
|
|
49
|
+
return "## Project Governance Rules (auto-loaded)\n" + "\n\n".join(sections) + "\n"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _extract_h_rules(text: str) -> str:
|
|
53
|
+
"""Extract numbered hard-rules (`H1`, `H2`, ...) from AGENTS.md."""
|
|
54
|
+
lines: list[str] = []
|
|
55
|
+
for line in text.splitlines():
|
|
56
|
+
stripped = line.strip()
|
|
57
|
+
if re.match(r"^[*\-]?\s*\*?\*?H\d+\b", stripped):
|
|
58
|
+
lines.append(stripped.lstrip("*-").lstrip())
|
|
59
|
+
return "\n".join(lines)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = ["load_rules"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# SPDX-License-Identifier: MIT
|
|
2
|
+
# Copyright (c) 2026 BitConcepts, LLC. All rights reserved.
|
|
3
|
+
"""Real verifier signal for the Nexus orchestrator (REQ-108).
|
|
4
|
+
|
|
5
|
+
Replaces the hardcoded ``0.85 / 0.4 / 0.0`` confidence in
|
|
6
|
+
``Orchestrator._build_task_result`` with a real signal derived from:
|
|
7
|
+
|
|
8
|
+
* test_results (failures > 0 -> confidence <= 0.5)
|
|
9
|
+
* ruff_errors (>= 1 -> confidence x 0.7)
|
|
10
|
+
* mypy_errors (>= 1 -> confidence x 0.8)
|
|
11
|
+
|
|
12
|
+
Equilibrium is reached only when all three gates are clean **and** the
|
|
13
|
+
measured confidence meets or exceeds the preflight ``confidence_target``.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class VerifierReport:
|
|
23
|
+
"""Inputs to the verifier; produced by parsing the orchestrator output."""
|
|
24
|
+
|
|
25
|
+
test_passed: int = 0
|
|
26
|
+
test_failed: int = 0
|
|
27
|
+
ruff_errors: int = 0
|
|
28
|
+
mypy_errors: int = 0
|
|
29
|
+
has_changes: bool = False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class VerifierVerdict:
|
|
34
|
+
"""Outputs of the verifier; consumed by the harness."""
|
|
35
|
+
|
|
36
|
+
confidence: float
|
|
37
|
+
equilibrium: bool
|
|
38
|
+
summary: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def score(
|
|
42
|
+
report: VerifierReport,
|
|
43
|
+
*,
|
|
44
|
+
confidence_target: float = 0.7,
|
|
45
|
+
) -> VerifierVerdict:
|
|
46
|
+
"""Score a :class:`VerifierReport` into a :class:`VerifierVerdict`.
|
|
47
|
+
|
|
48
|
+
Deterministic, pure function so the harness behaviour is reproducible.
|
|
49
|
+
"""
|
|
50
|
+
base = 1.0 if report.has_changes else 0.0
|
|
51
|
+
if report.test_failed > 0:
|
|
52
|
+
base = min(base, 0.5)
|
|
53
|
+
if report.ruff_errors > 0:
|
|
54
|
+
base *= 0.7
|
|
55
|
+
if report.mypy_errors > 0:
|
|
56
|
+
base *= 0.8
|
|
57
|
+
base = round(max(0.0, min(1.0, base)), 3)
|
|
58
|
+
|
|
59
|
+
clean = report.test_failed == 0 and report.ruff_errors == 0 and report.mypy_errors == 0
|
|
60
|
+
equilibrium = clean and report.has_changes and base >= confidence_target
|
|
61
|
+
|
|
62
|
+
parts: list[str] = []
|
|
63
|
+
if report.has_changes:
|
|
64
|
+
parts.append(f"{report.test_passed} passed / {report.test_failed} failed")
|
|
65
|
+
else:
|
|
66
|
+
parts.append("no changes detected")
|
|
67
|
+
if report.ruff_errors:
|
|
68
|
+
parts.append(f"{report.ruff_errors} ruff error(s)")
|
|
69
|
+
if report.mypy_errors:
|
|
70
|
+
parts.append(f"{report.mypy_errors} mypy error(s)")
|
|
71
|
+
summary = "; ".join(parts) + (" — equilibrium" if equilibrium else " — retry recommended")
|
|
72
|
+
|
|
73
|
+
return VerifierVerdict(confidence=base, equilibrium=equilibrium, summary=summary)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def report_from_chat_sections(
|
|
77
|
+
sections: dict[str, str],
|
|
78
|
+
*,
|
|
79
|
+
files_changed: list[str] | None = None,
|
|
80
|
+
) -> VerifierReport:
|
|
81
|
+
"""Build a :class:`VerifierReport` from parsed Nexus output-contract sections.
|
|
82
|
+
|
|
83
|
+
The orchestrator's ``_parse_output_contract`` produces a dict keyed by
|
|
84
|
+
``plan``, ``commands_to_run``, ``files_changed``, ``diff``,
|
|
85
|
+
``test_results``, and ``next_action``. We extract structured signals
|
|
86
|
+
from the free-form ``test_results`` text. This is deliberately
|
|
87
|
+
forgiving: passes/failures are counted by simple regex.
|
|
88
|
+
"""
|
|
89
|
+
import re
|
|
90
|
+
|
|
91
|
+
raw = sections.get("test_results", "") or ""
|
|
92
|
+
test_passed = 0
|
|
93
|
+
test_failed = 0
|
|
94
|
+
m_pass = re.search(r"(\d+)\s+passed", raw, re.IGNORECASE)
|
|
95
|
+
if m_pass:
|
|
96
|
+
test_passed = int(m_pass.group(1))
|
|
97
|
+
m_fail = re.search(r"(\d+)\s+failed", raw, re.IGNORECASE)
|
|
98
|
+
if m_fail:
|
|
99
|
+
test_failed = int(m_fail.group(1))
|
|
100
|
+
|
|
101
|
+
diff_text = sections.get("diff", "") or ""
|
|
102
|
+
has_changes = bool(diff_text.strip()) or bool(files_changed)
|
|
103
|
+
|
|
104
|
+
# ruff/mypy signals are not in the standard contract; scan the raw test
|
|
105
|
+
# output for the canonical error markers.
|
|
106
|
+
ruff_errors = len(re.findall(r"^\s*[A-Z]\d{3,4}\s", raw, re.MULTILINE))
|
|
107
|
+
mypy_errors = len(re.findall(r"\berror:", raw))
|
|
108
|
+
|
|
109
|
+
return VerifierReport(
|
|
110
|
+
test_passed=test_passed,
|
|
111
|
+
test_failed=test_failed,
|
|
112
|
+
ruff_errors=ruff_errors,
|
|
113
|
+
mypy_errors=mypy_errors,
|
|
114
|
+
has_changes=has_changes,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
__all__ = [
|
|
119
|
+
"VerifierReport",
|
|
120
|
+
"VerifierVerdict",
|
|
121
|
+
"report_from_chat_sections",
|
|
122
|
+
"score",
|
|
123
|
+
]
|