mokata 0.0.9__py3-none-any.whl
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.
- mokata/__init__.py +72 -0
- mokata/__main__.py +6 -0
- mokata/adapters/__init__.py +39 -0
- mokata/adapters/contract.py +97 -0
- mokata/adapters/mcp.py +85 -0
- mokata/adapters/precedence.py +36 -0
- mokata/agent_skills.py +251 -0
- mokata/baseline.py +89 -0
- mokata/bootstrap.py +253 -0
- mokata/brainstorm.py +717 -0
- mokata/ci_check.py +220 -0
- mokata/cli.py +214 -0
- mokata/cli_commands/__init__.py +1 -0
- mokata/cli_commands/_common.py +226 -0
- mokata/cli_commands/collab.py +355 -0
- mokata/cli_commands/core.py +218 -0
- mokata/cli_commands/diagnostics.py +103 -0
- mokata/cli_commands/distribution.py +281 -0
- mokata/cli_commands/index.py +94 -0
- mokata/cli_commands/knowledge.py +241 -0
- mokata/cli_commands/mcp.py +110 -0
- mokata/cli_commands/memory.py +220 -0
- mokata/cli_commands/pipeline.py +224 -0
- mokata/cli_commands/plan.py +109 -0
- mokata/cli_commands/reset.py +47 -0
- mokata/cli_commands/rules.py +230 -0
- mokata/cli_commands/runviews.py +311 -0
- mokata/cli_commands/setup.py +221 -0
- mokata/cli_commands/skills.py +240 -0
- mokata/compose.py +72 -0
- mokata/config.py +161 -0
- mokata/config_cmd.py +172 -0
- mokata/crossplat.py +46 -0
- mokata/dashboard.py +497 -0
- mokata/detect.py +107 -0
- mokata/engine/__init__.py +117 -0
- mokata/engine/acmapper.py +95 -0
- mokata/engine/completeness.py +107 -0
- mokata/engine/compliance.py +67 -0
- mokata/engine/phases.py +204 -0
- mokata/engine/premortem.py +78 -0
- mokata/engine/preview.py +77 -0
- mokata/engine/ship.py +131 -0
- mokata/engine/spec.py +59 -0
- mokata/engine/spec_awareness.py +256 -0
- mokata/engine/spec_gate.py +83 -0
- mokata/execmode/__init__.py +62 -0
- mokata/execmode/decompose.py +394 -0
- mokata/execmode/estimate.py +41 -0
- mokata/execmode/orchestrator.py +205 -0
- mokata/execmode/review.py +51 -0
- mokata/execmode/routing.py +99 -0
- mokata/execmode/selector.py +134 -0
- mokata/execmode/tasks.py +40 -0
- mokata/govern/__init__.py +137 -0
- mokata/govern/authoring.py +65 -0
- mokata/govern/budget.py +89 -0
- mokata/govern/cache.py +53 -0
- mokata/govern/compaction.py +39 -0
- mokata/govern/compress.py +71 -0
- mokata/govern/deviation.py +117 -0
- mokata/govern/doctor.py +109 -0
- mokata/govern/gate.py +92 -0
- mokata/govern/hooks.py +58 -0
- mokata/govern/karpathy.py +129 -0
- mokata/govern/learning.py +112 -0
- mokata/govern/ledger.py +123 -0
- mokata/govern/lifecycle.py +85 -0
- mokata/govern/outbound.py +47 -0
- mokata/govern/resume.py +57 -0
- mokata/govern/retrieval.py +74 -0
- mokata/govern/revert.py +86 -0
- mokata/govern/rules.py +165 -0
- mokata/govern/secrets.py +175 -0
- mokata/govern/tdd.py +47 -0
- mokata/govern/tokens.py +56 -0
- mokata/govern/trifecta.py +81 -0
- mokata/govern/trust.py +49 -0
- mokata/harness.py +184 -0
- mokata/harness_paths.py +38 -0
- mokata/harness_setup.py +876 -0
- mokata/hook_cli.py +400 -0
- mokata/hooks/hooks.json +25 -0
- mokata/hooks/launch.sh +68 -0
- mokata/hooks/secret_guard.py +26 -0
- mokata/hooks/session_start.py +40 -0
- mokata/init.py +247 -0
- mokata/knowledge/__init__.py +91 -0
- mokata/knowledge/anchors.py +115 -0
- mokata/knowledge/graph_backend.py +84 -0
- mokata/knowledge/grep_backend.py +177 -0
- mokata/knowledge/index.py +126 -0
- mokata/knowledge/layer.py +260 -0
- mokata/knowledge/neo4j_backend.py +114 -0
- mokata/knowledge/query.py +78 -0
- mokata/languages.py +351 -0
- mokata/legibility.py +147 -0
- mokata/manifest.py +138 -0
- mokata/mcp/__init__.py +51 -0
- mokata/mcp/registry.py +60 -0
- mokata/mcp/server.py +88 -0
- mokata/mcp/tools_read.py +565 -0
- mokata/mcp/tools_write.py +628 -0
- mokata/mcp_admin.py +252 -0
- mokata/mcp_server.py +33 -0
- mokata/memory/__init__.py +199 -0
- mokata/memory/_pg.py +30 -0
- mokata/memory/backends.py +387 -0
- mokata/memory/brain.py +116 -0
- mokata/memory/consolidation.py +92 -0
- mokata/memory/embed.py +60 -0
- mokata/memory/episodic.py +76 -0
- mokata/memory/healing.py +76 -0
- mokata/memory/intelligence.py +193 -0
- mokata/memory/item.py +148 -0
- mokata/memory/migrate.py +197 -0
- mokata/memory/share.py +160 -0
- mokata/memory/store.py +496 -0
- mokata/memory/tiered.py +104 -0
- mokata/memory/vector.py +166 -0
- mokata/modes/__init__.py +33 -0
- mokata/modes/bug.py +76 -0
- mokata/modes/debug.py +76 -0
- mokata/modes/optimize.py +77 -0
- mokata/netguard.py +80 -0
- mokata/onboard.py +76 -0
- mokata/onboarding.py +663 -0
- mokata/packaging.py +175 -0
- mokata/parity.py +319 -0
- mokata/perf.py +186 -0
- mokata/pipeline.py +126 -0
- mokata/plans.py +135 -0
- mokata/playbook.py +204 -0
- mokata/plugin_cache.py +53 -0
- mokata/profiles.py +236 -0
- mokata/progress.py +671 -0
- mokata/progress_events.py +265 -0
- mokata/project.py +144 -0
- mokata/prompt.py +103 -0
- mokata/refine.py +282 -0
- mokata/router.py +110 -0
- mokata/schema.py +265 -0
- mokata/session_bundle.py +602 -0
- mokata/session_transport.py +253 -0
- mokata/share.py +83 -0
- mokata/skills/brainstorm/SKILL.md +125 -0
- mokata/skills/bug/SKILL.md +23 -0
- mokata/skills/debug/SKILL.md +23 -0
- mokata/skills/develop/SKILL.md +35 -0
- mokata/skills/govern/SKILL.md +40 -0
- mokata/skills/mcp/SKILL.md +86 -0
- mokata/skills/onboard/SKILL.md +51 -0
- mokata/skills/optimize/SKILL.md +23 -0
- mokata/skills/playbook/SKILL.md +32 -0
- mokata/skills/refine/SKILL.md +88 -0
- mokata/skills/review/SKILL.md +35 -0
- mokata/skills/session/SKILL.md +107 -0
- mokata/skills/ship/SKILL.md +34 -0
- mokata/skills/spec/SKILL.md +26 -0
- mokata/skills/test/SKILL.md +29 -0
- mokata/skills.py +586 -0
- mokata/stacks/go-service.json +97 -0
- mokata/stacks/index.json +79 -0
- mokata/stacks/node-ts.json +98 -0
- mokata/stacks/python-web.json +99 -0
- mokata/stacks.py +367 -0
- mokata/state.py +64 -0
- mokata/team.py +583 -0
- mokata/team_audit.py +372 -0
- mokata/templates/README.md +11 -0
- mokata/templates/commands/brainstorm.md +119 -0
- mokata/templates/commands/bug.md +17 -0
- mokata/templates/commands/chain.md +27 -0
- mokata/templates/commands/debug.md +17 -0
- mokata/templates/commands/decompose.md +47 -0
- mokata/templates/commands/develop.md +29 -0
- mokata/templates/commands/enter.md +30 -0
- mokata/templates/commands/exec.md +30 -0
- mokata/templates/commands/govern.md +36 -0
- mokata/templates/commands/init.md +79 -0
- mokata/templates/commands/mcp.md +81 -0
- mokata/templates/commands/onboard.md +46 -0
- mokata/templates/commands/optimize.md +17 -0
- mokata/templates/commands/playbook.md +28 -0
- mokata/templates/commands/progress.md +41 -0
- mokata/templates/commands/reconfigure.md +69 -0
- mokata/templates/commands/refine.md +83 -0
- mokata/templates/commands/resume.md +32 -0
- mokata/templates/commands/review.md +29 -0
- mokata/templates/commands/session.md +103 -0
- mokata/templates/commands/setup.md +79 -0
- mokata/templates/commands/ship.md +28 -0
- mokata/templates/commands/skill.md +44 -0
- mokata/templates/commands/spec.md +20 -0
- mokata/templates/commands/stacks.md +65 -0
- mokata/templates/commands/team.md +80 -0
- mokata/templates/commands/test.md +23 -0
- mokata/templates/commands/tour.md +47 -0
- mokata/templates/commands/upgrade.md +35 -0
- mokata/templates/commands/vault.md +82 -0
- mokata/templates/commands/version.md +14 -0
- mokata/templates/commands/watch.md +35 -0
- mokata/templates/manifest.schema.json +128 -0
- mokata/vault.py +306 -0
- mokata/version.py +165 -0
- mokata/visibility.py +200 -0
- mokata/worktree.py +157 -0
- mokata-0.0.9.dist-info/METADATA +203 -0
- mokata-0.0.9.dist-info/RECORD +214 -0
- mokata-0.0.9.dist-info/WHEEL +5 -0
- mokata-0.0.9.dist-info/entry_points.txt +4 -0
- mokata-0.0.9.dist-info/licenses/LICENSE +201 -0
- mokata-0.0.9.dist-info/licenses/NOTICE +8 -0
- mokata-0.0.9.dist-info/top_level.txt +1 -0
mokata/__init__.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# mokata — framework spine.
|
|
2
|
+
#
|
|
3
|
+
# Copyright 2026 MoStack. Licensed under the Apache License, Version 2.0.
|
|
4
|
+
#
|
|
5
|
+
# The spine is the conductor every other layer plugs into:
|
|
6
|
+
# - A1 stack manifest (schema + file) -> manifest.py, schema.py
|
|
7
|
+
# - A2 capability router (need -> tool + fallback) -> router.py
|
|
8
|
+
# - A3 tool-presence detection + degradation -> detect.py
|
|
9
|
+
# - A4 SessionStart bootstrap (<= 2k tokens) -> bootstrap.py
|
|
10
|
+
# - A5 unified config + constitution surface -> config.py
|
|
11
|
+
# - A7 `mokata init` onboarding -> init.py, profiles.py, cli.py
|
|
12
|
+
|
|
13
|
+
__version__ = "0.0.9"
|
|
14
|
+
|
|
15
|
+
# The directory, relative to a repo root, that holds mokata's committed config.
|
|
16
|
+
MOKATA_DIR = ".mokata"
|
|
17
|
+
MANIFEST_FILENAME = "manifest.json"
|
|
18
|
+
CONSTITUTION_FILENAME = "constitution.md"
|
|
19
|
+
|
|
20
|
+
# Everything mokata creates as its own data lives under MOKATA_DIR. Inside it there is a
|
|
21
|
+
# committed/transient split (Stage 24D): committed config (manifest, constitution, an
|
|
22
|
+
# exported stack if the team commits it) sits at the .mokata/ root; everything
|
|
23
|
+
# transient/runtime (pipeline state, resume checkpoints, the freshness index, caches, the
|
|
24
|
+
# SQLite memory store + vault, and — by default — the audit ledger) lives under
|
|
25
|
+
# .mokata/temp_local/, which a committed .mokata/.gitignore keeps out of version control.
|
|
26
|
+
TEMP_LOCAL_DIRNAME = "temp_local"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def package_data_root():
|
|
30
|
+
"""The directory that holds mokata's packaged data — ``templates/``, ``hooks/``,
|
|
31
|
+
``skills/`` (and ``stacks/``).
|
|
32
|
+
|
|
33
|
+
Stage 3 relocated that data INSIDE the installed package (``src/mokata/…``), so it
|
|
34
|
+
resolves the SAME way for a pip-installed wheel and for an editable/clone install:
|
|
35
|
+
``importlib.resources.files("mokata")`` is the package directory in both cases (site-
|
|
36
|
+
packages for a wheel, ``<clone>/src/mokata`` for ``-e .``). This removes the old
|
|
37
|
+
``parents[2]`` clone assumption that made ``pip install <wheel>`` unable to run
|
|
38
|
+
``mokata setup``. Falls back to this module's own directory if importlib.resources
|
|
39
|
+
can't hand back a concrete filesystem path (never raises)."""
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
try:
|
|
42
|
+
from importlib.resources import files
|
|
43
|
+
root = Path(str(files("mokata")))
|
|
44
|
+
if root.is_dir():
|
|
45
|
+
return root
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
return Path(__file__).resolve().parent
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _force_utf8_io() -> None:
|
|
52
|
+
"""On Windows, make stdout/stderr speak UTF-8 so mokata's console output — arrows (→),
|
|
53
|
+
checkmarks (✓), box drawing, em-dashes — never dies with `UnicodeEncodeError` on the legacy
|
|
54
|
+
cp1252 console (or a cp1252 pipe when output is captured). POSIX terminals are already UTF-8,
|
|
55
|
+
so this is a no-op there, keeping behavior byte-identical across platforms. Fully guarded:
|
|
56
|
+
it never raises and never blocks import (an embedder with an exotic stream is left alone)."""
|
|
57
|
+
import os
|
|
58
|
+
import sys
|
|
59
|
+
|
|
60
|
+
if os.name != "nt":
|
|
61
|
+
return
|
|
62
|
+
for stream in (sys.stdout, sys.stderr):
|
|
63
|
+
try:
|
|
64
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
65
|
+
current = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
|
|
66
|
+
if reconfigure is not None and current != "utf8":
|
|
67
|
+
reconfigure(encoding="utf-8")
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
_force_utf8_io()
|
mokata/__main__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""mokata adapter & negotiation layer (Part A6 / H4–H6).
|
|
2
|
+
|
|
3
|
+
A typed adapter ecosystem mokata can reason about, built on the Stage-1 capability model:
|
|
4
|
+
- A6: AdapterContract + negotiate -> coverage and unmet gaps.
|
|
5
|
+
- H5: validate_adapter -> validate a third-party adapter against the contract.
|
|
6
|
+
- H4: MCPRegistry / discover_mcp_servers -> enumerate MCP servers, map to roles
|
|
7
|
+
(degrades cleanly when none present).
|
|
8
|
+
- H6: declared_precedence / overlapping_capabilities / resolve_conflict -> two tools
|
|
9
|
+
claiming one role are resolved by manifest precedence; the router honors it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .contract import (
|
|
13
|
+
AdapterContract,
|
|
14
|
+
CoverageReport,
|
|
15
|
+
negotiate,
|
|
16
|
+
validate_adapter,
|
|
17
|
+
)
|
|
18
|
+
from .mcp import MCPRegistry, MCPServer, discover_mcp_servers
|
|
19
|
+
from .precedence import (
|
|
20
|
+
declared_precedence,
|
|
21
|
+
overlapping_capabilities,
|
|
22
|
+
resolve_conflict,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
# A6 / H5
|
|
27
|
+
"AdapterContract",
|
|
28
|
+
"CoverageReport",
|
|
29
|
+
"negotiate",
|
|
30
|
+
"validate_adapter",
|
|
31
|
+
# H4
|
|
32
|
+
"MCPServer",
|
|
33
|
+
"MCPRegistry",
|
|
34
|
+
"discover_mcp_servers",
|
|
35
|
+
# H6
|
|
36
|
+
"declared_precedence",
|
|
37
|
+
"overlapping_capabilities",
|
|
38
|
+
"resolve_conflict",
|
|
39
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""A6 + H5 — typed adapter contract.
|
|
2
|
+
|
|
3
|
+
A6: an `AdapterContract` declares which capabilities a tool provides, so `negotiate`
|
|
4
|
+
can report coverage and the unmet gaps across a set of needs. H5: `validate_adapter`
|
|
5
|
+
checks a third party's adapter dict against the contract before it is wired in. Reuses
|
|
6
|
+
the spine's capability vocabulary (schema kinds/detect types) — one capability model.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from ..schema import KNOWN_DETECT_TYPES, KNOWN_TOOL_KINDS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class AdapterContract:
|
|
19
|
+
name: str
|
|
20
|
+
provides: List[str]
|
|
21
|
+
kind: str = "external"
|
|
22
|
+
version: Optional[str] = None
|
|
23
|
+
detect: Optional[Dict[str, Any]] = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
26
|
+
d: Dict[str, Any] = {"name": self.name, "provides": list(self.provides),
|
|
27
|
+
"kind": self.kind, "version": self.version}
|
|
28
|
+
if self.detect is not None:
|
|
29
|
+
d["detect"] = dict(self.detect)
|
|
30
|
+
return d
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_dict(cls, data: Dict[str, Any]) -> "AdapterContract":
|
|
34
|
+
return cls(name=data["name"], provides=list(data.get("provides", [])),
|
|
35
|
+
kind=data.get("kind", "external"), version=data.get("version"),
|
|
36
|
+
detect=data.get("detect"))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate_adapter(data: Any) -> List[str]:
|
|
40
|
+
"""Return contract-violation errors for a candidate adapter (empty == valid)."""
|
|
41
|
+
errors: List[str] = []
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
return ["adapter must be an object"]
|
|
44
|
+
if not isinstance(data.get("name"), str) or not data.get("name"):
|
|
45
|
+
errors.append("adapter.name must be a non-empty string")
|
|
46
|
+
provides = data.get("provides")
|
|
47
|
+
if not isinstance(provides, list) or not provides:
|
|
48
|
+
errors.append("adapter.provides must be a non-empty array")
|
|
49
|
+
else:
|
|
50
|
+
for p in provides:
|
|
51
|
+
if not isinstance(p, str) or not p:
|
|
52
|
+
errors.append("adapter.provides entries must be non-empty strings")
|
|
53
|
+
break
|
|
54
|
+
kind = data.get("kind", "external")
|
|
55
|
+
if kind not in KNOWN_TOOL_KINDS:
|
|
56
|
+
errors.append(f"adapter.kind '{kind}' invalid (one of {KNOWN_TOOL_KINDS})")
|
|
57
|
+
detect = data.get("detect")
|
|
58
|
+
if detect is not None:
|
|
59
|
+
if not isinstance(detect, dict):
|
|
60
|
+
errors.append("adapter.detect must be an object")
|
|
61
|
+
else:
|
|
62
|
+
dt = detect.get("type")
|
|
63
|
+
if dt not in KNOWN_DETECT_TYPES:
|
|
64
|
+
errors.append(f"adapter.detect.type '{dt}' invalid")
|
|
65
|
+
elif dt in ("command", "python_module", "path") and not detect.get("name"):
|
|
66
|
+
errors.append(f"adapter.detect.name is required for type '{dt}'")
|
|
67
|
+
return errors
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class CoverageReport:
|
|
72
|
+
needs: List[str]
|
|
73
|
+
covered: Dict[str, List[str]] = field(default_factory=dict)
|
|
74
|
+
gaps: List[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def fully_covered(self) -> bool:
|
|
78
|
+
return not self.gaps
|
|
79
|
+
|
|
80
|
+
def render(self) -> str:
|
|
81
|
+
lines = ["capability coverage:"]
|
|
82
|
+
for need in self.needs:
|
|
83
|
+
who = self.covered.get(need) or []
|
|
84
|
+
mark = ", ".join(who) if who else "— UNMET"
|
|
85
|
+
lines.append(f" {need}: {mark}")
|
|
86
|
+
if self.gaps:
|
|
87
|
+
lines.append(f"gaps: {', '.join(self.gaps)}")
|
|
88
|
+
else:
|
|
89
|
+
lines.append("gaps: none — full coverage")
|
|
90
|
+
return "\n".join(lines)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def negotiate(needs: List[str], adapters: List[AdapterContract]) -> CoverageReport:
|
|
94
|
+
"""Report which needs each adapter set covers, and which remain unmet (A6)."""
|
|
95
|
+
covered = {need: [a.name for a in adapters if need in a.provides] for need in needs}
|
|
96
|
+
gaps = [need for need in needs if not covered[need]]
|
|
97
|
+
return CoverageReport(needs=list(needs), covered=covered, gaps=gaps)
|
mokata/adapters/mcp.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""H4 — MCP registry + discovery.
|
|
2
|
+
|
|
3
|
+
Enumerate available MCP servers (from an injected config or a JSON file) and map them to
|
|
4
|
+
stack roles (capabilities) via the capabilities they declare. Discovery is pluggable and
|
|
5
|
+
degrades cleanly: with no config/file present, the registry is empty and never errors.
|
|
6
|
+
Each MCP server is also an `AdapterContract`, so it flows into A6 negotiation.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from .contract import AdapterContract
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class MCPServer:
|
|
21
|
+
name: str
|
|
22
|
+
provides: List[str] = field(default_factory=list)
|
|
23
|
+
command: Optional[str] = None
|
|
24
|
+
url: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
def to_adapter(self) -> AdapterContract:
|
|
27
|
+
return AdapterContract(name=self.name, provides=list(self.provides), kind="mcp")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _normalize(entries: Any) -> List[MCPServer]:
|
|
31
|
+
servers: List[MCPServer] = []
|
|
32
|
+
if isinstance(entries, dict):
|
|
33
|
+
# { name: {provides, command, url} } form
|
|
34
|
+
items = [{"name": k, **(v or {})} for k, v in entries.items()]
|
|
35
|
+
elif isinstance(entries, list):
|
|
36
|
+
items = entries
|
|
37
|
+
else:
|
|
38
|
+
return []
|
|
39
|
+
for e in items:
|
|
40
|
+
if not isinstance(e, dict) or not e.get("name"):
|
|
41
|
+
continue
|
|
42
|
+
servers.append(MCPServer(name=e["name"], provides=list(e.get("provides", [])),
|
|
43
|
+
command=e.get("command"), url=e.get("url")))
|
|
44
|
+
return servers
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover_mcp_servers(config: Any = None,
|
|
48
|
+
path: Optional[str] = None) -> List[MCPServer]:
|
|
49
|
+
"""Discover MCP servers from an injected config, a JSON file, or — failing both —
|
|
50
|
+
return [] (degrade cleanly; no MCP present)."""
|
|
51
|
+
if config is not None:
|
|
52
|
+
return _normalize(config)
|
|
53
|
+
if path and os.path.exists(path):
|
|
54
|
+
try:
|
|
55
|
+
with open(path, encoding="utf-8") as fh:
|
|
56
|
+
return _normalize(json.load(fh))
|
|
57
|
+
except (OSError, ValueError):
|
|
58
|
+
return []
|
|
59
|
+
return []
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MCPRegistry:
|
|
63
|
+
def __init__(self, servers: List[MCPServer]) -> None:
|
|
64
|
+
self.servers = servers
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def discover(cls, config: Any = None,
|
|
68
|
+
path: Optional[str] = None) -> "MCPRegistry":
|
|
69
|
+
return cls(discover_mcp_servers(config=config, path=path))
|
|
70
|
+
|
|
71
|
+
def names(self) -> List[str]:
|
|
72
|
+
return [s.name for s in self.servers]
|
|
73
|
+
|
|
74
|
+
def map_to_roles(self) -> Dict[str, List[str]]:
|
|
75
|
+
roles: Dict[str, List[str]] = {}
|
|
76
|
+
for s in self.servers:
|
|
77
|
+
for cap in s.provides:
|
|
78
|
+
roles.setdefault(cap, []).append(s.name)
|
|
79
|
+
return roles
|
|
80
|
+
|
|
81
|
+
def servers_for(self, capability: str) -> List[MCPServer]:
|
|
82
|
+
return [s for s in self.servers if capability in s.provides]
|
|
83
|
+
|
|
84
|
+
def adapters(self) -> List[AdapterContract]:
|
|
85
|
+
return [s.to_adapter() for s in self.servers]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""H6 — conflict / overlap resolution.
|
|
2
|
+
|
|
3
|
+
When two tools claim the same role, the manifest's declared precedence (the capability's
|
|
4
|
+
fallback order, A2) resolves it. This surfaces overlaps and the precedence; the existing
|
|
5
|
+
router already honors it deterministically (it walks the fallback order and picks the
|
|
6
|
+
first present provider) — there is no parallel resolution path.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def declared_precedence(manifest: Any, need: str) -> List[str]:
|
|
15
|
+
"""The precedence order for a capability — i.e. its declared fallback order."""
|
|
16
|
+
return manifest.fallback_order(need) # raises ManifestError if unknown
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def overlapping_capabilities(manifest: Any) -> Dict[str, List[str]]:
|
|
20
|
+
"""Capabilities claimed by more than one provider (an overlap to be resolved by
|
|
21
|
+
precedence)."""
|
|
22
|
+
out: Dict[str, List[str]] = {}
|
|
23
|
+
for need in manifest.capabilities:
|
|
24
|
+
order = manifest.fallback_order(need)
|
|
25
|
+
if len(order) > 1:
|
|
26
|
+
out[need] = order
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_conflict(manifest: Any, need: str,
|
|
31
|
+
present: Set[str]) -> Optional[str]:
|
|
32
|
+
"""The winning provider for a need: the highest-precedence one that is present."""
|
|
33
|
+
for tool in declared_precedence(manifest, need):
|
|
34
|
+
if tool in present:
|
|
35
|
+
return tool
|
|
36
|
+
return None
|
mokata/agent_skills.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Agent Skills surface — the model-invocable twin of mokata's slash commands.
|
|
2
|
+
|
|
3
|
+
Claude Code exposes TWO distinct surfaces: slash *commands* (`/mokata:<name>`) and *Agent
|
|
4
|
+
Skills* (`SKILL.md` files Claude auto-engages from their `description`). mokata already ships
|
|
5
|
+
commands; this module renders the matching Agent Skills so mokata's capabilities also appear
|
|
6
|
+
in — and auto-trigger from — the Agent Skills list.
|
|
7
|
+
|
|
8
|
+
Single source, no drift: a skill is rendered from the SAME `templates/commands/<name>.md`
|
|
9
|
+
template the command ships from. The skill's trigger text is the template's own
|
|
10
|
+
`description` (+ `when_to_use` when present); the skill's body is the template's protocol
|
|
11
|
+
body VERBATIM, behind a fixed banner. Nothing is hand-copied, so the two surfaces can't
|
|
12
|
+
diverge — a drift-guard test re-renders and compares, exactly like the command templates.
|
|
13
|
+
|
|
14
|
+
Curated: only capabilities a user would want Claude to engage on its own are surfaced (the
|
|
15
|
+
pipeline gates + knowledge/session capabilities) — not the pure utilities (version, tour,
|
|
16
|
+
setup, …). The allow-list is an explicit constant below; add/remove a name to tune it.
|
|
17
|
+
|
|
18
|
+
Precedence note (Claude Code): when a skill and a command share a name, the SKILL takes
|
|
19
|
+
precedence. That's why the body carries the full protocol inline and never tells Claude to
|
|
20
|
+
"go run the /<name> command" (which would loop) — it follows the protocol right here.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# The curated allow-list: command names that are genuinely MODEL-INVOCABLE — Claude should be
|
|
31
|
+
# able to engage them on its own when the moment fits. Pipeline gates + knowledge/session
|
|
32
|
+
# capabilities. Deliberately EXCLUDES pure utilities/orchestration mechanics (version, tour,
|
|
33
|
+
# setup, reconfigure, upgrade, init, enter, exec, resume, watch, progress, chain, decompose,
|
|
34
|
+
# skill, stacks, team, vault). Keep alphabetical-by-intent groups for legibility.
|
|
35
|
+
CURATED_SKILLS: tuple = (
|
|
36
|
+
# exploration → spec → TDD build → land
|
|
37
|
+
"brainstorm",
|
|
38
|
+
"spec",
|
|
39
|
+
"test",
|
|
40
|
+
"develop",
|
|
41
|
+
"review",
|
|
42
|
+
"refine",
|
|
43
|
+
"debug",
|
|
44
|
+
"bug",
|
|
45
|
+
"optimize",
|
|
46
|
+
"ship",
|
|
47
|
+
# knowledge / governance / portability
|
|
48
|
+
"onboard",
|
|
49
|
+
"govern",
|
|
50
|
+
"session",
|
|
51
|
+
"playbook",
|
|
52
|
+
# harness repair (Stage 3b.4) — auto-engages when the MCP server/tools aren't connecting
|
|
53
|
+
"mcp",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# A stable marker every rendered SKILL.md carries (in the banner). unsetup uses it to identify
|
|
57
|
+
# mokata-authored skills for clean removal WITHOUT ever touching a user's own SKILL.md — the
|
|
58
|
+
# same ownership discipline the hook/statusline wiring uses.
|
|
59
|
+
SKILL_MARKER = "mokata Agent Skill."
|
|
60
|
+
|
|
61
|
+
# The banner every rendered SKILL.md carries above the protocol body. Fixed text (only the
|
|
62
|
+
# capability name is interpolated) so it stays driftless. It frames the skill as the
|
|
63
|
+
# auto-engaged twin of the command and reinforces mokata's human-gate — WITHOUT telling
|
|
64
|
+
# Claude to re-invoke the command (skills shadow commands, so that would loop).
|
|
65
|
+
_SKILL_BANNER = (
|
|
66
|
+
"> **mokata Agent Skill.** This is mokata's `{name}` capability, surfaced so Claude can "
|
|
67
|
+
"engage it\n"
|
|
68
|
+
"> automatically when the moment fits. It runs the SAME protocol as the `/mokata:{name}` "
|
|
69
|
+
"command,\n"
|
|
70
|
+
"> from one shared source — follow that protocol directly here; do not hand off to a "
|
|
71
|
+
"parallel\n"
|
|
72
|
+
"> flow. mokata's non-negotiables still hold: durable writes are **human-gated** (preview, "
|
|
73
|
+
"then\n"
|
|
74
|
+
"> explicit approval), and this capability's own gate is never silently skipped."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SkillSourceError(RuntimeError):
|
|
79
|
+
"""A curated skill's source template is missing or malformed."""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class SkillSource:
|
|
84
|
+
name: str
|
|
85
|
+
description: str
|
|
86
|
+
when_to_use: Optional[str]
|
|
87
|
+
body: str # the template's protocol body, verbatim (no frontmatter)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_frontmatter(md: str) -> Dict[str, str]:
|
|
91
|
+
"""Parse a template's leading `---` frontmatter into a flat dict of single-line values.
|
|
92
|
+
|
|
93
|
+
mokata's command templates use simple `key: value` frontmatter (one line per key); this
|
|
94
|
+
is a deliberately small parser for exactly that shape, not a general YAML loader.
|
|
95
|
+
"""
|
|
96
|
+
if not md.startswith("---"):
|
|
97
|
+
return {}
|
|
98
|
+
end = md.find("\n---", 3)
|
|
99
|
+
if end == -1:
|
|
100
|
+
return {}
|
|
101
|
+
block = md[3:end]
|
|
102
|
+
fm: Dict[str, str] = {}
|
|
103
|
+
for line in block.splitlines():
|
|
104
|
+
if not line.strip() or ":" not in line:
|
|
105
|
+
continue
|
|
106
|
+
key, value = line.split(":", 1)
|
|
107
|
+
fm[key.strip()] = value.strip()
|
|
108
|
+
return fm
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _split_frontmatter(md: str) -> str:
|
|
112
|
+
"""Return the body of a template (everything after its `---` frontmatter), leading blank
|
|
113
|
+
lines stripped. Falls back to the whole text when there is no frontmatter."""
|
|
114
|
+
if md.startswith("---"):
|
|
115
|
+
end = md.find("\n---", 3)
|
|
116
|
+
if end != -1:
|
|
117
|
+
after = md[end + 4:] # skip the closing "\n---"
|
|
118
|
+
nl = after.find("\n")
|
|
119
|
+
after = after[nl + 1:] if nl != -1 else ""
|
|
120
|
+
return after.lstrip("\n")
|
|
121
|
+
return md
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_skill_source(name: str, templates_dir: Path) -> SkillSource:
|
|
125
|
+
"""Read one curated command template and extract the skill source (frontmatter + body)."""
|
|
126
|
+
path = templates_dir / f"{name}.md"
|
|
127
|
+
if not path.is_file():
|
|
128
|
+
raise SkillSourceError(f"no command template for curated skill '{name}' at {path}")
|
|
129
|
+
md = path.read_text(encoding="utf-8")
|
|
130
|
+
fm = parse_frontmatter(md)
|
|
131
|
+
description = fm.get("description", "").strip()
|
|
132
|
+
if not description:
|
|
133
|
+
raise SkillSourceError(f"template '{name}.md' has no frontmatter description")
|
|
134
|
+
when = fm.get("when_to_use") or None
|
|
135
|
+
return SkillSource(name=name, description=description, when_to_use=when,
|
|
136
|
+
body=_split_frontmatter(md))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def render_skill_md(src: SkillSource) -> str:
|
|
140
|
+
"""Render a SKILL.md from a skill source. Frontmatter carries the model-invocation trigger
|
|
141
|
+
(`description` [+ `when_to_use`]); the body is the fixed banner + the command's protocol
|
|
142
|
+
body verbatim. Deterministic — the drift guard depends on it."""
|
|
143
|
+
when_line = f"when_to_use: {src.when_to_use}\n" if src.when_to_use else ""
|
|
144
|
+
return (
|
|
145
|
+
f"---\n"
|
|
146
|
+
f"name: {src.name}\n"
|
|
147
|
+
f"description: {src.description}\n"
|
|
148
|
+
f"{when_line}"
|
|
149
|
+
f"---\n\n"
|
|
150
|
+
f"{_SKILL_BANNER.format(name=src.name)}\n\n"
|
|
151
|
+
f"{src.body}"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def skill_markdown(name: str, templates_dir: Path) -> str:
|
|
156
|
+
"""One-shot: render the SKILL.md content for a curated skill name from its template."""
|
|
157
|
+
return render_skill_md(load_skill_source(name, templates_dir))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def generate_skill_files(templates_dir: Path,
|
|
161
|
+
names: Optional[tuple] = None) -> Dict[str, str]:
|
|
162
|
+
"""Return {name: SKILL.md content} for the curated set (or a supplied subset). This is the
|
|
163
|
+
single generator both the plugin-root `skills/` tree and the `setup` path render from."""
|
|
164
|
+
chosen = names if names is not None else CURATED_SKILLS
|
|
165
|
+
return {name: skill_markdown(name, templates_dir) for name in chosen}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def skill_relpaths(names: Optional[tuple] = None) -> List[str]:
|
|
169
|
+
"""The on-disk layout Claude Code expects: `<name>/SKILL.md`, one dir per skill."""
|
|
170
|
+
chosen = names if names is not None else CURATED_SKILLS
|
|
171
|
+
return [f"{name}/SKILL.md" for name in chosen]
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_skill_files(skills_dir: Path, files: Dict[str, str]) -> List[Path]:
|
|
175
|
+
"""Materialize {name: content} into `<skills_dir>/<name>/SKILL.md`. Returns written paths."""
|
|
176
|
+
written: List[Path] = []
|
|
177
|
+
for name, content in files.items():
|
|
178
|
+
dst = skills_dir / name / "SKILL.md"
|
|
179
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
dst.write_text(content, encoding="utf-8")
|
|
181
|
+
written.append(dst)
|
|
182
|
+
return written
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def find_orphan_skills(skills_dir: Optional[Path], keep) -> List[Path]:
|
|
186
|
+
"""The mokata-AUTHORED (``SKILL_MARKER``-bearing) ``<skills_dir>/<name>/SKILL.md`` paths whose
|
|
187
|
+
dir name is NOT in ``keep``. READ-ONLY — this is what the setup PLAN previews and what
|
|
188
|
+
:func:`prune_orphan_skills` deletes, sharing ONE marker check so plan and apply can't diverge.
|
|
189
|
+
A skill WITHOUT the marker (a user's own) or an unreadable file is skipped — never flagged."""
|
|
190
|
+
keep = set(keep)
|
|
191
|
+
found: List[Path] = []
|
|
192
|
+
if skills_dir is None:
|
|
193
|
+
return found
|
|
194
|
+
d = Path(skills_dir)
|
|
195
|
+
if not d.is_dir():
|
|
196
|
+
return found
|
|
197
|
+
for sk in sorted(d.glob("*/SKILL.md")):
|
|
198
|
+
if sk.parent.name in keep:
|
|
199
|
+
continue
|
|
200
|
+
try:
|
|
201
|
+
if SKILL_MARKER not in sk.read_text(encoding="utf-8"):
|
|
202
|
+
continue # a user's own skill — never touch it
|
|
203
|
+
except OSError:
|
|
204
|
+
continue # unreadable → leave it alone, don't crash
|
|
205
|
+
found.append(sk)
|
|
206
|
+
return found
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def prune_orphan_skills(skills_dir: Optional[Path], keep) -> List[Path]:
|
|
210
|
+
"""SYNC the on-disk skills tree to the current curated set: remove every mokata-authored
|
|
211
|
+
(marker-bearing) skill dir whose name is NOT in ``keep`` — the counterpart to
|
|
212
|
+
:func:`write_skill_files`, which only writes the current set and never removes a dropped one.
|
|
213
|
+
Cleans the now-empty ``<name>/`` dir, and the ``skills/`` dir itself if it ends up empty (so
|
|
214
|
+
``unsetup``, which passes ``keep=()``, leaves no residue). NEVER removes a non-marker (user)
|
|
215
|
+
skill; an unreadable/undeletable file is skipped (degrade-clean). Returns the removed paths."""
|
|
216
|
+
removed: List[Path] = []
|
|
217
|
+
for sk in find_orphan_skills(skills_dir, keep):
|
|
218
|
+
try:
|
|
219
|
+
sk.unlink()
|
|
220
|
+
removed.append(sk)
|
|
221
|
+
if not any(sk.parent.iterdir()):
|
|
222
|
+
sk.parent.rmdir()
|
|
223
|
+
except OSError:
|
|
224
|
+
continue # can't delete it → leave it, don't crash
|
|
225
|
+
try:
|
|
226
|
+
d = Path(skills_dir) if skills_dir is not None else None
|
|
227
|
+
if d is not None and d.is_dir() and not any(d.iterdir()):
|
|
228
|
+
d.rmdir()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
return removed
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _regenerate_plugin_skills() -> List[Path]:
|
|
235
|
+
"""Regenerate the shipped plugin-root `skills/` tree from the command templates. Run this
|
|
236
|
+
(``python -m mokata.agent_skills``) whenever a curated command's frontmatter changes; the
|
|
237
|
+
drift-guard test then goes GREEN. This module is the single source — never hand-edit a
|
|
238
|
+
SKILL.md. It's a SYNC: writes the current curated set, then prunes any marker-bearing skill
|
|
239
|
+
dir no longer curated, so a removed skill can't ship stale in the wheel/plugin."""
|
|
240
|
+
from . import package_data_root
|
|
241
|
+
root = package_data_root() # the mokata package dir (holds the data)
|
|
242
|
+
templates_dir = root / "templates" / "commands"
|
|
243
|
+
skills_dir = root / "skills"
|
|
244
|
+
written = write_skill_files(skills_dir, generate_skill_files(templates_dir))
|
|
245
|
+
prune_orphan_skills(skills_dir, CURATED_SKILLS) # drop any skill dropped from the curated set
|
|
246
|
+
return written
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__": # pragma: no cover
|
|
250
|
+
for _p in _regenerate_plugin_skills():
|
|
251
|
+
print(_p)
|
mokata/baseline.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Stage 34 Part B — clean-test-baseline check.
|
|
2
|
+
|
|
3
|
+
Before an implementation run starts, it's worth knowing the test suite is GREEN at baseline,
|
|
4
|
+
so any new failure is attributable to the change (TDD hygiene). This runs the project's
|
|
5
|
+
configured test command and reports green/red. It **degrades cleanly**: when no test command
|
|
6
|
+
is known, it says so and does NOT hard-block (mokata never guesses a test framework — that
|
|
7
|
+
would be an assumption; the user states the command).
|
|
8
|
+
|
|
9
|
+
The command comes from `settings.baseline.test_command` in the manifest, or an explicit
|
|
10
|
+
override. Running it is a read-only diagnostic the user invokes; it makes no durable write and
|
|
11
|
+
no network call.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
# settings.baseline.test_command — the project's test command (mokata never guesses one).
|
|
21
|
+
BASELINE_SETTINGS_KEY = "baseline"
|
|
22
|
+
# How long the baseline test command may run before we report (not crash) a timeout.
|
|
23
|
+
BASELINE_TIMEOUT_SECONDS = 600
|
|
24
|
+
|
|
25
|
+
GREEN = "green"
|
|
26
|
+
RED = "red"
|
|
27
|
+
UNKNOWN = "unknown"
|
|
28
|
+
|
|
29
|
+
NO_COMMAND_MESSAGE = (
|
|
30
|
+
"baseline: no test command known — set `settings.baseline.test_command` (or pass one) "
|
|
31
|
+
"so mokata can confirm a green baseline. Skipping (not a hard failure)."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class BaselineResult:
|
|
37
|
+
state: str # green | red | unknown
|
|
38
|
+
command: Optional[str] = None
|
|
39
|
+
detail: str = ""
|
|
40
|
+
returncode: Optional[int] = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def ok(self) -> bool:
|
|
44
|
+
# green is good; unknown degrades clean (not a hard failure); only red is "not ok".
|
|
45
|
+
return self.state != RED
|
|
46
|
+
|
|
47
|
+
def render(self) -> str:
|
|
48
|
+
if self.state == GREEN:
|
|
49
|
+
return f"baseline: GREEN — `{self.command}` passed. New failures are yours."
|
|
50
|
+
if self.state == RED:
|
|
51
|
+
return (f"baseline: RED — `{self.command}` is already failing (rc "
|
|
52
|
+
f"{self.returncode}). Fix or acknowledge before starting, so new "
|
|
53
|
+
"failures are attributable to your change.")
|
|
54
|
+
return NO_COMMAND_MESSAGE
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def baseline_command(manifest: Any = None, override: Optional[str] = None) -> Optional[str]:
|
|
58
|
+
"""Resolve the test command: an explicit override, else settings.baseline.test_command."""
|
|
59
|
+
if override:
|
|
60
|
+
return override
|
|
61
|
+
if manifest is None:
|
|
62
|
+
return None
|
|
63
|
+
try:
|
|
64
|
+
s = manifest.setting(BASELINE_SETTINGS_KEY, {}) or {}
|
|
65
|
+
except AttributeError:
|
|
66
|
+
return None
|
|
67
|
+
cmd = s.get("test_command") if isinstance(s, dict) else None
|
|
68
|
+
return cmd or None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def baseline_status(command: Optional[str], cwd: Optional[str] = None,
|
|
72
|
+
timeout: int = BASELINE_TIMEOUT_SECONDS) -> BaselineResult:
|
|
73
|
+
"""Run the test command and report green/red; UNKNOWN (degrade-clean) when none given.
|
|
74
|
+
Never raises — a command that can't run reports red with the reason, not an exception."""
|
|
75
|
+
if not command:
|
|
76
|
+
return BaselineResult(state=UNKNOWN, detail=NO_COMMAND_MESSAGE)
|
|
77
|
+
try:
|
|
78
|
+
# Justification for the B602 suppression: `command` is the USER's OWN test command (their
|
|
79
|
+
# `settings.baseline` config / CLI arg), run in their own shell exactly as they'd run it;
|
|
80
|
+
# not attacker-controlled input. A shell is required so a normal test one-liner
|
|
81
|
+
# (`pytest -q && ruff .`, pipes, globs) works. Bounded by `timeout`; degrade-clean.
|
|
82
|
+
proc = subprocess.run(command, shell=True, cwd=cwd, capture_output=True, # nosec B602
|
|
83
|
+
text=True, timeout=timeout)
|
|
84
|
+
except Exception as exc: # missing binary, timeout, etc. — report, don't crash
|
|
85
|
+
return BaselineResult(state=RED, command=command,
|
|
86
|
+
detail=f"could not run test command: {exc}")
|
|
87
|
+
state = GREEN if proc.returncode == 0 else RED
|
|
88
|
+
return BaselineResult(state=state, command=command, returncode=proc.returncode,
|
|
89
|
+
detail=(proc.stderr or proc.stdout or "").strip()[-500:])
|