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.
Files changed (214) hide show
  1. mokata/__init__.py +72 -0
  2. mokata/__main__.py +6 -0
  3. mokata/adapters/__init__.py +39 -0
  4. mokata/adapters/contract.py +97 -0
  5. mokata/adapters/mcp.py +85 -0
  6. mokata/adapters/precedence.py +36 -0
  7. mokata/agent_skills.py +251 -0
  8. mokata/baseline.py +89 -0
  9. mokata/bootstrap.py +253 -0
  10. mokata/brainstorm.py +717 -0
  11. mokata/ci_check.py +220 -0
  12. mokata/cli.py +214 -0
  13. mokata/cli_commands/__init__.py +1 -0
  14. mokata/cli_commands/_common.py +226 -0
  15. mokata/cli_commands/collab.py +355 -0
  16. mokata/cli_commands/core.py +218 -0
  17. mokata/cli_commands/diagnostics.py +103 -0
  18. mokata/cli_commands/distribution.py +281 -0
  19. mokata/cli_commands/index.py +94 -0
  20. mokata/cli_commands/knowledge.py +241 -0
  21. mokata/cli_commands/mcp.py +110 -0
  22. mokata/cli_commands/memory.py +220 -0
  23. mokata/cli_commands/pipeline.py +224 -0
  24. mokata/cli_commands/plan.py +109 -0
  25. mokata/cli_commands/reset.py +47 -0
  26. mokata/cli_commands/rules.py +230 -0
  27. mokata/cli_commands/runviews.py +311 -0
  28. mokata/cli_commands/setup.py +221 -0
  29. mokata/cli_commands/skills.py +240 -0
  30. mokata/compose.py +72 -0
  31. mokata/config.py +161 -0
  32. mokata/config_cmd.py +172 -0
  33. mokata/crossplat.py +46 -0
  34. mokata/dashboard.py +497 -0
  35. mokata/detect.py +107 -0
  36. mokata/engine/__init__.py +117 -0
  37. mokata/engine/acmapper.py +95 -0
  38. mokata/engine/completeness.py +107 -0
  39. mokata/engine/compliance.py +67 -0
  40. mokata/engine/phases.py +204 -0
  41. mokata/engine/premortem.py +78 -0
  42. mokata/engine/preview.py +77 -0
  43. mokata/engine/ship.py +131 -0
  44. mokata/engine/spec.py +59 -0
  45. mokata/engine/spec_awareness.py +256 -0
  46. mokata/engine/spec_gate.py +83 -0
  47. mokata/execmode/__init__.py +62 -0
  48. mokata/execmode/decompose.py +394 -0
  49. mokata/execmode/estimate.py +41 -0
  50. mokata/execmode/orchestrator.py +205 -0
  51. mokata/execmode/review.py +51 -0
  52. mokata/execmode/routing.py +99 -0
  53. mokata/execmode/selector.py +134 -0
  54. mokata/execmode/tasks.py +40 -0
  55. mokata/govern/__init__.py +137 -0
  56. mokata/govern/authoring.py +65 -0
  57. mokata/govern/budget.py +89 -0
  58. mokata/govern/cache.py +53 -0
  59. mokata/govern/compaction.py +39 -0
  60. mokata/govern/compress.py +71 -0
  61. mokata/govern/deviation.py +117 -0
  62. mokata/govern/doctor.py +109 -0
  63. mokata/govern/gate.py +92 -0
  64. mokata/govern/hooks.py +58 -0
  65. mokata/govern/karpathy.py +129 -0
  66. mokata/govern/learning.py +112 -0
  67. mokata/govern/ledger.py +123 -0
  68. mokata/govern/lifecycle.py +85 -0
  69. mokata/govern/outbound.py +47 -0
  70. mokata/govern/resume.py +57 -0
  71. mokata/govern/retrieval.py +74 -0
  72. mokata/govern/revert.py +86 -0
  73. mokata/govern/rules.py +165 -0
  74. mokata/govern/secrets.py +175 -0
  75. mokata/govern/tdd.py +47 -0
  76. mokata/govern/tokens.py +56 -0
  77. mokata/govern/trifecta.py +81 -0
  78. mokata/govern/trust.py +49 -0
  79. mokata/harness.py +184 -0
  80. mokata/harness_paths.py +38 -0
  81. mokata/harness_setup.py +876 -0
  82. mokata/hook_cli.py +400 -0
  83. mokata/hooks/hooks.json +25 -0
  84. mokata/hooks/launch.sh +68 -0
  85. mokata/hooks/secret_guard.py +26 -0
  86. mokata/hooks/session_start.py +40 -0
  87. mokata/init.py +247 -0
  88. mokata/knowledge/__init__.py +91 -0
  89. mokata/knowledge/anchors.py +115 -0
  90. mokata/knowledge/graph_backend.py +84 -0
  91. mokata/knowledge/grep_backend.py +177 -0
  92. mokata/knowledge/index.py +126 -0
  93. mokata/knowledge/layer.py +260 -0
  94. mokata/knowledge/neo4j_backend.py +114 -0
  95. mokata/knowledge/query.py +78 -0
  96. mokata/languages.py +351 -0
  97. mokata/legibility.py +147 -0
  98. mokata/manifest.py +138 -0
  99. mokata/mcp/__init__.py +51 -0
  100. mokata/mcp/registry.py +60 -0
  101. mokata/mcp/server.py +88 -0
  102. mokata/mcp/tools_read.py +565 -0
  103. mokata/mcp/tools_write.py +628 -0
  104. mokata/mcp_admin.py +252 -0
  105. mokata/mcp_server.py +33 -0
  106. mokata/memory/__init__.py +199 -0
  107. mokata/memory/_pg.py +30 -0
  108. mokata/memory/backends.py +387 -0
  109. mokata/memory/brain.py +116 -0
  110. mokata/memory/consolidation.py +92 -0
  111. mokata/memory/embed.py +60 -0
  112. mokata/memory/episodic.py +76 -0
  113. mokata/memory/healing.py +76 -0
  114. mokata/memory/intelligence.py +193 -0
  115. mokata/memory/item.py +148 -0
  116. mokata/memory/migrate.py +197 -0
  117. mokata/memory/share.py +160 -0
  118. mokata/memory/store.py +496 -0
  119. mokata/memory/tiered.py +104 -0
  120. mokata/memory/vector.py +166 -0
  121. mokata/modes/__init__.py +33 -0
  122. mokata/modes/bug.py +76 -0
  123. mokata/modes/debug.py +76 -0
  124. mokata/modes/optimize.py +77 -0
  125. mokata/netguard.py +80 -0
  126. mokata/onboard.py +76 -0
  127. mokata/onboarding.py +663 -0
  128. mokata/packaging.py +175 -0
  129. mokata/parity.py +319 -0
  130. mokata/perf.py +186 -0
  131. mokata/pipeline.py +126 -0
  132. mokata/plans.py +135 -0
  133. mokata/playbook.py +204 -0
  134. mokata/plugin_cache.py +53 -0
  135. mokata/profiles.py +236 -0
  136. mokata/progress.py +671 -0
  137. mokata/progress_events.py +265 -0
  138. mokata/project.py +144 -0
  139. mokata/prompt.py +103 -0
  140. mokata/refine.py +282 -0
  141. mokata/router.py +110 -0
  142. mokata/schema.py +265 -0
  143. mokata/session_bundle.py +602 -0
  144. mokata/session_transport.py +253 -0
  145. mokata/share.py +83 -0
  146. mokata/skills/brainstorm/SKILL.md +125 -0
  147. mokata/skills/bug/SKILL.md +23 -0
  148. mokata/skills/debug/SKILL.md +23 -0
  149. mokata/skills/develop/SKILL.md +35 -0
  150. mokata/skills/govern/SKILL.md +40 -0
  151. mokata/skills/mcp/SKILL.md +86 -0
  152. mokata/skills/onboard/SKILL.md +51 -0
  153. mokata/skills/optimize/SKILL.md +23 -0
  154. mokata/skills/playbook/SKILL.md +32 -0
  155. mokata/skills/refine/SKILL.md +88 -0
  156. mokata/skills/review/SKILL.md +35 -0
  157. mokata/skills/session/SKILL.md +107 -0
  158. mokata/skills/ship/SKILL.md +34 -0
  159. mokata/skills/spec/SKILL.md +26 -0
  160. mokata/skills/test/SKILL.md +29 -0
  161. mokata/skills.py +586 -0
  162. mokata/stacks/go-service.json +97 -0
  163. mokata/stacks/index.json +79 -0
  164. mokata/stacks/node-ts.json +98 -0
  165. mokata/stacks/python-web.json +99 -0
  166. mokata/stacks.py +367 -0
  167. mokata/state.py +64 -0
  168. mokata/team.py +583 -0
  169. mokata/team_audit.py +372 -0
  170. mokata/templates/README.md +11 -0
  171. mokata/templates/commands/brainstorm.md +119 -0
  172. mokata/templates/commands/bug.md +17 -0
  173. mokata/templates/commands/chain.md +27 -0
  174. mokata/templates/commands/debug.md +17 -0
  175. mokata/templates/commands/decompose.md +47 -0
  176. mokata/templates/commands/develop.md +29 -0
  177. mokata/templates/commands/enter.md +30 -0
  178. mokata/templates/commands/exec.md +30 -0
  179. mokata/templates/commands/govern.md +36 -0
  180. mokata/templates/commands/init.md +79 -0
  181. mokata/templates/commands/mcp.md +81 -0
  182. mokata/templates/commands/onboard.md +46 -0
  183. mokata/templates/commands/optimize.md +17 -0
  184. mokata/templates/commands/playbook.md +28 -0
  185. mokata/templates/commands/progress.md +41 -0
  186. mokata/templates/commands/reconfigure.md +69 -0
  187. mokata/templates/commands/refine.md +83 -0
  188. mokata/templates/commands/resume.md +32 -0
  189. mokata/templates/commands/review.md +29 -0
  190. mokata/templates/commands/session.md +103 -0
  191. mokata/templates/commands/setup.md +79 -0
  192. mokata/templates/commands/ship.md +28 -0
  193. mokata/templates/commands/skill.md +44 -0
  194. mokata/templates/commands/spec.md +20 -0
  195. mokata/templates/commands/stacks.md +65 -0
  196. mokata/templates/commands/team.md +80 -0
  197. mokata/templates/commands/test.md +23 -0
  198. mokata/templates/commands/tour.md +47 -0
  199. mokata/templates/commands/upgrade.md +35 -0
  200. mokata/templates/commands/vault.md +82 -0
  201. mokata/templates/commands/version.md +14 -0
  202. mokata/templates/commands/watch.md +35 -0
  203. mokata/templates/manifest.schema.json +128 -0
  204. mokata/vault.py +306 -0
  205. mokata/version.py +165 -0
  206. mokata/visibility.py +200 -0
  207. mokata/worktree.py +157 -0
  208. mokata-0.0.9.dist-info/METADATA +203 -0
  209. mokata-0.0.9.dist-info/RECORD +214 -0
  210. mokata-0.0.9.dist-info/WHEEL +5 -0
  211. mokata-0.0.9.dist-info/entry_points.txt +4 -0
  212. mokata-0.0.9.dist-info/licenses/LICENSE +201 -0
  213. mokata-0.0.9.dist-info/licenses/NOTICE +8 -0
  214. 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,6 @@
1
+ """Enables `python -m mokata ...` (no install required)."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -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:])