skillstate-kit 0.1.1__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.
skillstate/hosts.py ADDED
@@ -0,0 +1,322 @@
1
+ """Non-destructive, project-scoped host installation and capability diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+ import shutil
7
+ import sqlite3
8
+ import sys
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+ import tomlkit
13
+
14
+ from .errors import ConflictError, ValidationError
15
+ from .jsonio import atomic_write, digest, dumps, loads, read_json, slug, within
16
+
17
+ HOSTS = ("codex", "claude-code", "antigravity")
18
+ BOOTSTRAP = """---
19
+ name: generate-skill-state
20
+ description: Generate skill state from an existing SKILL.md or project, validate its schema, and install portable state-backed skills. Use when asked to generate skill state, convert a skill to state, or set up skillstate-kit.
21
+ metadata:
22
+ version: "1"
23
+ ---
24
+ <!-- skillstate_generated: true -->
25
+
26
+ Use the installed `skillstate` CLI (or `python -m skillstate`) from the project root.
27
+ The user's instructions and existing authorizations take precedence over this skill.
28
+
29
+ For a state-tracking wrapper that preserves an existing procedure, run:
30
+ `skillstate generate path/to/SKILL.md --install`
31
+ This deterministic mode does not invent domain-specific business rules.
32
+
33
+ For a domain-specific state schema, run:
34
+ `skillstate generate path/to/source --prepare`
35
+ Read the returned source inventory and proposal schema. Draft a JSON proposal with
36
+ name, instructions, state_schema, initial_state, and steps. Each step must cite
37
+ existing source paths. Keep future-relevant facts, bounds, and completion checks;
38
+ do not invent tools, completed actions, or credentials. Use inline JSON Schema.
39
+ Write the proposal to a local JSON file, then run:
40
+ `skillstate generate path/to/source --proposal proposal.json --source-hash HASH --install`
41
+ Use the source_hash returned by prepare so a stale analysis cannot be applied.
42
+
43
+ Run `skillstate validate NAME` and `skillstate doctor` after installation. Resolve
44
+ reported errors; distinguish structural validation from actual execution tests.
45
+ Do not rewrite the original skill or change the user's unrelated agent settings.
46
+ The generated wrapper uses durable state; it does not remove the host's own chat history.
47
+ """
48
+
49
+
50
+ def wrapper(name: str) -> str:
51
+ slug(name)
52
+ return f"""---
53
+ name: {name}
54
+ description: Execute the {name} procedure with durable, validated skill state and resumable checkpoints.
55
+ metadata:
56
+ version: "1"
57
+ ---
58
+ <!-- skillstate_generated: true -->
59
+
60
+ Use the user's current task and authorization. Run commands from the project root.
61
+ Use `skillstate` or `python -m skillstate`; do not edit state files by hand.
62
+
63
+ 1. Run `skillstate validate {name}`. Resolve source drift before starting a new run.
64
+ 2. List runs with `skillstate status`. Reuse only the intended run. Otherwise open
65
+ one with `skillstate run open {name} --owner HOST --observation task.json`.
66
+ Replace HOST with codex, claude-code, or antigravity. Output includes run_id.
67
+ 3. Get `skillstate run context RUN_ID`. Follow the returned original instructions;
68
+ resolve resource references relative to the source directory described there.
69
+ 4. Before a tool with external effects, reserve its intent:
70
+ `skillstate run reserve RUN_ID --owner HOST --revision N --decision decision.json`.
71
+ The decision file contains exactly action (name, arguments) and patch (a list).
72
+ Do not infer success before the tool completes. Execute only authorized tools.
73
+ 5. Record its actual result with
74
+ `skillstate run result OPERATION_ID --owner HOST --result result.json`.
75
+ The file contains success (boolean) and observation. If the outcome is uncertain,
76
+ run `skillstate run unknown OPERATION_ID --owner HOST` and reconcile before retrying.
77
+ 6. For facts without an external action, use
78
+ `skillstate run update RUN_ID --owner HOST --revision N --patch patch.json`.
79
+ Patch uses set/delete with JSON Pointer paths. Null assignment is different from deletion.
80
+ 7. Refresh context after each update; use its revision. Large outputs can be stored
81
+ with `skillstate artifact put output.txt`. Keep future-relevant facts in state.
82
+ 8. When the source procedure's completion checks pass, update with `--done`.
83
+ To switch hosts, use `skillstate run handoff RUN_ID --owner HOST --revision N --to TARGET`.
84
+
85
+ Keep facts and hypotheses distinct. A native agent's reports are agent-reported
86
+ evidence, not independently verified tool results. Do not claim the host transcript
87
+ is replaced or that every native tool is automatically intercepted.
88
+ """
89
+
90
+
91
+ def _manifest_path(project: Path) -> Path:
92
+ return within(project, ".skillstate/local/install-record.json")
93
+
94
+
95
+ def _manifest(project: Path) -> dict:
96
+ path = _manifest_path(project)
97
+ data = read_json(path) if path.exists() else {"version": 1, "files": {}, "configs": {}}
98
+ if (
99
+ data.get("version") != 1
100
+ or not isinstance(data.get("files"), dict)
101
+ or not isinstance(data.get("configs"), dict)
102
+ ):
103
+ raise ValidationError("Unsupported installation manifest")
104
+ return data
105
+
106
+
107
+ @contextmanager
108
+ def _lock(project: Path):
109
+ path = within(project, ".skillstate/local/install-lock.sqlite3")
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ db = sqlite3.connect(path, timeout=10, isolation_level=None)
112
+ try:
113
+ db.execute("BEGIN IMMEDIATE")
114
+ yield
115
+ db.execute("COMMIT")
116
+ finally:
117
+ db.close()
118
+
119
+
120
+ def _config_entries(project: Path, hosts: list[str]) -> dict:
121
+ # Local installation: absolute interpreter/project paths avoid PATH ambiguity
122
+ # and support Windows spaces. These configurations must not be shared as-is.
123
+ entry = {
124
+ "command": sys.executable,
125
+ "args": ["-m", "skillstate", "--project", str(project), "serve"],
126
+ }
127
+ result = {}
128
+ if "codex" in hosts:
129
+ result[".codex/config.toml"] = {"format": "toml", "section": "mcp_servers", "value": entry}
130
+ if "claude-code" in hosts:
131
+ result[".mcp.json"] = {"format": "json", "section": "mcpServers", "value": entry}
132
+ if "antigravity" in hosts:
133
+ result[".agents/mcp_config.json"] = {
134
+ "format": "json",
135
+ "section": "mcpServers",
136
+ "value": entry,
137
+ }
138
+ return result
139
+
140
+
141
+ def _config_document(path: Path, format: str):
142
+ text = path.read_text(encoding="utf-8-sig") if path.exists() else ""
143
+ if len(text.encode("utf-8")) > 512_000:
144
+ raise ValidationError("Host configuration is too large")
145
+ try:
146
+ doc = tomlkit.parse(text) if format == "toml" else (loads(text) if text else {})
147
+ if not isinstance(doc, dict):
148
+ raise ValidationError("Host config must be an object")
149
+ return doc
150
+ except (ValueError, tomlkit.exceptions.ParseError) as exc:
151
+ raise ValidationError(f"Cannot parse host config: {path.name}") from exc
152
+
153
+
154
+ def _commit(project: Path, changes: dict[str, str | None], manifest: dict) -> None:
155
+ # Preflight happens before this call. Roll back write errors. Individual files
156
+ # use atomic replacement; re-running init also converges after process death.
157
+ originals = {}
158
+ try:
159
+ for relative, content in changes.items():
160
+ path = within(project, relative)
161
+ originals[relative] = path.read_bytes() if path.exists() else None
162
+ if content is None:
163
+ path.unlink(missing_ok=True)
164
+ else:
165
+ atomic_write(path, content)
166
+ atomic_write(_manifest_path(project), dumps(manifest) + "\n")
167
+ except BaseException:
168
+ for relative, original in reversed(list(originals.items())):
169
+ path = within(project, relative)
170
+ if original is None:
171
+ path.unlink(missing_ok=True)
172
+ else:
173
+ atomic_write(path, original)
174
+ raise
175
+
176
+
177
+ def install(
178
+ project: Path, hosts: list[str] | None = None, *, name: str | None = None, mcp: bool = False
179
+ ) -> dict:
180
+ project = project.resolve()
181
+ hosts = list(dict.fromkeys(hosts or HOSTS))
182
+ if any(host not in HOSTS for host in hosts):
183
+ raise ValidationError("Unknown host")
184
+ if mcp and importlib.util.find_spec("mcp") is None:
185
+ raise ValidationError("Install skillstate-kit[mcp] before enabling MCP")
186
+ skill_name, content = (name, wrapper(name)) if name else ("generate-skill-state", BOOTSTRAP)
187
+ files = {}
188
+ if "codex" in hosts or "antigravity" in hosts:
189
+ files[f".agents/skills/{skill_name}/SKILL.md"] = content
190
+ if "claude-code" in hosts:
191
+ files[f".claude/skills/{skill_name}/SKILL.md"] = content
192
+ with _lock(project):
193
+ manifest = _manifest(project)
194
+ changes = {}
195
+ for relative, text in files.items():
196
+ path = within(project, relative)
197
+ existing_hash = digest(path.read_bytes()) if path.exists() else None
198
+ if existing_hash not in (None, digest(text), manifest["files"].get(relative)):
199
+ raise ConflictError(f"Refusing to overwrite user-modified skill: {relative}")
200
+ if existing_hash != digest(text):
201
+ changes[relative] = text
202
+ manifest["files"][relative] = digest(text)
203
+ configs = _config_entries(project, hosts) if mcp else {}
204
+ for relative, entry in configs.items():
205
+ path = within(project, relative)
206
+ doc = _config_document(path, entry["format"])
207
+ section = doc.setdefault(entry["section"], {})
208
+ if not isinstance(section, dict):
209
+ raise ValidationError("MCP config section must be a table/object")
210
+ current = section.get("skillstate-kit")
211
+ previous = manifest["configs"].get(relative, {}).get("value")
212
+ if current is not None and current != entry["value"] and current != previous:
213
+ raise ConflictError(
214
+ f"Existing skillstate-kit MCP entry belongs to the user: {relative}"
215
+ )
216
+ if current != entry["value"]:
217
+ section["skillstate-kit"] = entry["value"]
218
+ changes[relative] = (
219
+ tomlkit.dumps(doc) if entry["format"] == "toml" else dumps(doc) + "\n"
220
+ )
221
+ manifest["configs"][relative] = entry
222
+ _commit(project, changes, manifest)
223
+ return {
224
+ "installed": skill_name,
225
+ "hosts": hosts,
226
+ "changed_files": list(changes),
227
+ "mcp": mcp,
228
+ "configuration_scope": "project-local",
229
+ "next": "Run skillstate doctor; reload host skill/MCP discovery if needed.",
230
+ }
231
+
232
+
233
+ def uninstall(project: Path) -> dict:
234
+ project = project.resolve()
235
+ with _lock(project):
236
+ manifest = _manifest(project)
237
+ changes, retained = {}, []
238
+ for relative, fingerprint in list(manifest["files"].items()):
239
+ path = within(project, relative)
240
+ if path.exists() and digest(path.read_bytes()) != fingerprint:
241
+ retained.append(relative)
242
+ continue
243
+ if path.exists():
244
+ changes[relative] = None
245
+ del manifest["files"][relative]
246
+ for relative, entry in list(manifest["configs"].items()):
247
+ path = within(project, relative)
248
+ if path.exists():
249
+ doc = _config_document(path, entry["format"])
250
+ section = doc.get(entry["section"], {})
251
+ if not isinstance(section, dict) or section.get("skillstate-kit") not in (
252
+ None,
253
+ entry["value"],
254
+ ):
255
+ retained.append(relative)
256
+ continue
257
+ section.pop("skillstate-kit", None)
258
+ changes[relative] = (
259
+ tomlkit.dumps(doc) if entry["format"] == "toml" else dumps(doc) + "\n"
260
+ )
261
+ del manifest["configs"][relative]
262
+ _commit(project, changes, manifest)
263
+ return {
264
+ "removed_or_updated": list(changes),
265
+ "retained_modified_files": retained,
266
+ "state_retained": True,
267
+ }
268
+
269
+
270
+ def doctor(project: Path) -> dict:
271
+ project = project.resolve()
272
+ manifest = _manifest(project)
273
+ checks = []
274
+ for relative, fingerprint in manifest["files"].items():
275
+ path = within(project, relative)
276
+ ok = path.is_file() and digest(path.read_bytes()) == fingerprint
277
+ checks.append({"check": relative, "ok": ok, "kind": "managed_skill"})
278
+ for relative, entry in manifest["configs"].items():
279
+ path = within(project, relative)
280
+ try:
281
+ doc = _config_document(path, entry["format"])
282
+ section = doc.get(entry["section"], {})
283
+ ok = isinstance(section, dict) and section.get("skillstate-kit") == entry["value"]
284
+ except ValidationError:
285
+ ok = False
286
+ checks.append(
287
+ {
288
+ "check": relative,
289
+ "kind": "mcp_configuration",
290
+ "ok": ok,
291
+ }
292
+ )
293
+ command = entry["value"]["command"]
294
+ checks.append(
295
+ {
296
+ "check": f"interpreter:{relative}",
297
+ "kind": "executable",
298
+ "ok": Path(command).is_file(),
299
+ }
300
+ )
301
+ if manifest["configs"]:
302
+ checks.append(
303
+ {
304
+ "check": "mcp dependency",
305
+ "kind": "dependency",
306
+ "ok": importlib.util.find_spec("mcp") is not None,
307
+ }
308
+ )
309
+ return {
310
+ "ok": bool(checks) and all(check["ok"] for check in checks),
311
+ "checks": checks,
312
+ "host_executables": {
313
+ host: shutil.which(binary)
314
+ for host, binary in (
315
+ ("codex", "codex"),
316
+ ("claude-code", "claude"),
317
+ ("antigravity", "agy"),
318
+ )
319
+ },
320
+ "live_host_verified": False,
321
+ "note": "File/config checks do not prove a host loaded the skill. Use doctor --mcp for a real protocol handshake, then verify a host session.",
322
+ }
skillstate/jsonio.py ADDED
@@ -0,0 +1,143 @@
1
+ """Strict JSON and bounded, local filesystem operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import math
8
+ import os
9
+ import re
10
+ import stat
11
+ import tempfile
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .errors import BudgetExceeded, ValidationError
16
+
17
+ MAX_JSON_BYTES = 2_000_000
18
+
19
+
20
+ def normalize(value: Any, depth: int = 0, active: set[int] | None = None) -> Any:
21
+ if depth > 48:
22
+ raise ValidationError("JSON nesting exceeds 48 levels")
23
+ if value is None or type(value) in (bool, int):
24
+ return value
25
+ if type(value) is str:
26
+ try:
27
+ value.encode("utf-8")
28
+ except UnicodeError as exc:
29
+ raise ValidationError("JSON strings must be valid UTF-8") from exc
30
+ return value
31
+ if type(value) is float:
32
+ if not math.isfinite(value):
33
+ raise ValidationError("Non-finite JSON numbers are not supported")
34
+ return value
35
+ if type(value) not in (dict, list):
36
+ raise ValidationError("Expected plain JSON values")
37
+ active = set() if active is None else active
38
+ if id(value) in active:
39
+ raise ValidationError("Circular JSON value")
40
+ active.add(id(value))
41
+ try:
42
+ if type(value) is list:
43
+ return [normalize(v, depth + 1, active) for v in value]
44
+ result = {}
45
+ for key, item in value.items():
46
+ if type(key) is not str:
47
+ raise ValidationError("JSON keys must be plain strings")
48
+ result[normalize(key)] = normalize(item, depth + 1, active)
49
+ return result
50
+ finally:
51
+ active.remove(id(value))
52
+
53
+
54
+ def dumps(value: Any, limit: int = MAX_JSON_BYTES) -> str:
55
+ try:
56
+ result = json.dumps(
57
+ normalize(value),
58
+ ensure_ascii=False,
59
+ allow_nan=False,
60
+ sort_keys=True,
61
+ separators=(",", ":"),
62
+ )
63
+ except (ValueError, RecursionError) as exc:
64
+ raise ValidationError("Invalid JSON value") from exc
65
+ if len(result.encode("utf-8")) > limit:
66
+ raise BudgetExceeded(f"JSON exceeds {limit} UTF-8 bytes")
67
+ return result
68
+
69
+
70
+ def _pairs(items: list[tuple[str, Any]]) -> dict:
71
+ result = {}
72
+ for key, value in items:
73
+ if key in result:
74
+ raise ValidationError(f"Duplicate JSON key: {key}")
75
+ result[key] = value
76
+ return result
77
+
78
+
79
+ def _constant(value: str) -> None:
80
+ raise ValidationError(f"Invalid JSON constant: {value}")
81
+
82
+
83
+ def loads(text: str, limit: int = MAX_JSON_BYTES) -> Any:
84
+ if type(text) is not str:
85
+ raise ValidationError("JSON input must be text")
86
+ try:
87
+ size = len(text.encode("utf-8"))
88
+ except UnicodeError as exc:
89
+ raise ValidationError("JSON input must be valid UTF-8") from exc
90
+ if size > limit:
91
+ raise BudgetExceeded(f"JSON exceeds {limit} UTF-8 bytes")
92
+ try:
93
+ return normalize(json.loads(text, object_pairs_hook=_pairs, parse_constant=_constant))
94
+ except (ValueError, RecursionError) as exc:
95
+ raise ValidationError("Malformed JSON") from exc
96
+
97
+
98
+ def read_json(path: Path, limit: int = MAX_JSON_BYTES) -> Any:
99
+ if path.stat().st_size > limit:
100
+ raise BudgetExceeded(f"File exceeds {limit} bytes: {path.name}")
101
+ return loads(path.read_text(encoding="utf-8-sig"), limit)
102
+
103
+
104
+ def digest(data: str | bytes) -> str:
105
+ return hashlib.sha256(data.encode("utf-8") if isinstance(data, str) else data).hexdigest()
106
+
107
+
108
+ def slug(value: str) -> str:
109
+ if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value) or len(value) > 64:
110
+ raise ValidationError("Name must be 1–64 lowercase letters/digits separated by hyphens")
111
+ return value
112
+
113
+
114
+ def within(root: Path, value: str | Path) -> Path:
115
+ root = root.resolve()
116
+ path = Path(value)
117
+ path = path if path.is_absolute() else root / path
118
+ resolved = path.resolve()
119
+ if not resolved.is_relative_to(root):
120
+ raise ValidationError("Path escapes project root")
121
+ # Reject symlinks/junctions even when their target currently stays in the project.
122
+ cursor = path.absolute()
123
+ while cursor != root and cursor != cursor.parent:
124
+ attrs = cursor.lstat().st_file_attributes if os.name == "nt" and cursor.exists() else 0
125
+ if cursor.is_symlink() or attrs & getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0x400):
126
+ raise ValidationError("Symlink/junction paths are not supported")
127
+ cursor = cursor.parent
128
+ return resolved
129
+
130
+
131
+ def atomic_write(path: Path, data: str | bytes) -> None:
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ raw = data.encode("utf-8") if isinstance(data, str) else data
134
+ descriptor, name = tempfile.mkstemp(prefix=".skillstate-", dir=path.parent)
135
+ try:
136
+ with os.fdopen(descriptor, "wb") as stream:
137
+ stream.write(raw)
138
+ stream.flush()
139
+ os.fsync(stream.fileno())
140
+ os.replace(name, path)
141
+ finally:
142
+ if os.path.exists(name):
143
+ os.unlink(name)
@@ -0,0 +1,142 @@
1
+ """Project-scoped STDIO MCP server; no model invocation or arbitrary code tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .artifacts import ArtifactStore
10
+ from .compiler import generate, prepare
11
+ from .hosts import install
12
+ from .service import ProjectService
13
+
14
+
15
+ def create_server(project: Path):
16
+ from mcp.server.fastmcp import FastMCP
17
+
18
+ project = project.resolve()
19
+ service = ProjectService(project)
20
+ server = FastMCP(
21
+ "skillstate-kit",
22
+ instructions=(
23
+ "Manage bounded, structured skill execution state in this project. "
24
+ "Use prepare/apply for generation. Read context and its revision before writes. "
25
+ "Reserve effects before executing; record actual outcomes. Unknown operations "
26
+ "require reconciliation, never blind replay. Native reports are agent-reported. "
27
+ "Do not claim host conversation history is replaced."
28
+ ),
29
+ )
30
+
31
+ @server.tool()
32
+ def generation_prepare(source: str = ".", name: str | None = None) -> dict:
33
+ """Scan local sources and return the schema for a host-generated SkillIR proposal."""
34
+ return prepare(project, source, name)
35
+
36
+ @server.tool()
37
+ def generation_apply(
38
+ source: str, proposal: dict, source_hash: str, install_skills: bool = True
39
+ ) -> dict:
40
+ """Validate a source-bound proposal, persist its bundle and optionally install wrappers."""
41
+ result = generate(project, source, proposal=proposal, expected_source_hash=source_hash)
42
+ if install_skills:
43
+ result["installation"] = install(project, name=result["name"])
44
+ return result
45
+
46
+ @server.tool()
47
+ def run_open(name: str, owner: str, observation: dict, run_id: str | None = None) -> dict:
48
+ """Open a new run; duplicate IDs are rejected instead of resetting existing work."""
49
+ return service.open_run(name, owner, run_id, observation)
50
+
51
+ @server.tool()
52
+ def run_context(run_id: str) -> dict:
53
+ """Get current state, instructions, observation, revision and source drift status."""
54
+ return service.run_context(run_id)
55
+
56
+ @server.tool()
57
+ def run_update(
58
+ run_id: str,
59
+ owner: str,
60
+ revision: int,
61
+ patch: list[dict],
62
+ observation: dict | None = None,
63
+ done: bool = False,
64
+ ) -> dict:
65
+ """Apply validated agent-reported facts without executing external actions."""
66
+ with service.store() as store:
67
+ return store.update(run_id, owner, revision, patch, observation, done=done)
68
+
69
+ @server.tool()
70
+ def run_reserve(
71
+ run_id: str, owner: str, revision: int, action: dict, patch: list[dict]
72
+ ) -> dict:
73
+ """Persist an operation intent before the host executes its authorized tool."""
74
+ with service.store() as store:
75
+ return store.reserve(run_id, owner, revision, action, patch)
76
+
77
+ @server.tool()
78
+ def run_record_result(
79
+ operation_id: str, owner: str, success: bool, observation: dict, reconcile: bool = False
80
+ ) -> dict:
81
+ """Record a native tool outcome; reconcile=true explicitly resolves uncertainty."""
82
+ with service.store() as store:
83
+ return store.record_result(
84
+ operation_id, owner, success, observation, reconcile=reconcile
85
+ )
86
+
87
+ @server.tool()
88
+ def run_unknown(operation_id: str, owner: str, reason: str) -> dict:
89
+ """Mark an ambiguous outcome; blocks further actions until reconciled."""
90
+ with service.store() as store:
91
+ return store.mark_unknown(operation_id, owner, reason)
92
+
93
+ @server.tool()
94
+ def run_handoff(run_id: str, owner: str, revision: int, target: str) -> dict:
95
+ """Transfer an idle run to another owner, refusing pending actions and source drift."""
96
+ return service.handoff(run_id, owner, revision, target)
97
+
98
+ @server.tool()
99
+ def run_status() -> list[dict]:
100
+ """List recent runs in this project."""
101
+ with service.store() as store:
102
+ return store.list_runs()
103
+
104
+ @server.tool()
105
+ def artifact_put(content: str) -> dict:
106
+ """Persist a bounded text artifact outside execution context."""
107
+ return {"id": ArtifactStore(project).put(content)}
108
+
109
+ @server.tool()
110
+ def artifact_read(artifact_id: str, offset: int = 0, length: int = 4000) -> dict:
111
+ """Read at most 8000 characters from a content-addressed artifact."""
112
+ return ArtifactStore(project).read(artifact_id, offset, length)
113
+
114
+ return server
115
+
116
+
117
+ def serve(project: Path) -> None:
118
+ create_server(project).run(transport="stdio")
119
+
120
+
121
+ async def smoke_test(project: Path) -> dict:
122
+ """Negotiate MCP with a real child server and call the read-only status tool."""
123
+ from mcp import ClientSession, StdioServerParameters
124
+ from mcp.client.stdio import stdio_client
125
+
126
+ async def exercise():
127
+ params = StdioServerParameters(
128
+ command=sys.executable,
129
+ args=["-m", "skillstate", "--project", str(project.resolve()), "serve"],
130
+ )
131
+ async with stdio_client(params) as (read, write), ClientSession(read, write) as session:
132
+ result = await session.initialize()
133
+ tools = await session.list_tools()
134
+ status = await session.call_tool("run_status", {})
135
+ return {
136
+ "ok": not status.isError,
137
+ "protocol_version": result.protocolVersion,
138
+ "tool_count": len(tools.tools),
139
+ "transport": "stdio",
140
+ }
141
+
142
+ return await asyncio.wait_for(exercise(), timeout=20)
skillstate/models.py ADDED
@@ -0,0 +1,88 @@
1
+ """Provider-independent skill and tool contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any
8
+
9
+ from .errors import ValidationError
10
+ from .jsonio import digest, dumps, loads, slug
11
+ from .schema import validate
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Limits:
16
+ state_bytes: int = 64_000
17
+ observation_bytes: int = 32_000
18
+ context_bytes: int = 160_000
19
+ output_bytes: int = 64_000
20
+
21
+ def __post_init__(self):
22
+ if any(type(v) is not int or not 256 <= v <= 2_000_000 for v in asdict(self).values()):
23
+ raise ValidationError("Byte budgets must be integers between 256 and 2000000")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Skill:
28
+ name: str
29
+ instructions: str
30
+ state_schema: dict
31
+ initial_state: dict
32
+ version: str = "1"
33
+ limits: Limits = field(default_factory=Limits)
34
+
35
+ def __post_init__(self):
36
+ slug(self.name)
37
+ if not isinstance(self.instructions, str) or not self.instructions.strip():
38
+ raise ValidationError("Skill instructions are required")
39
+ if not isinstance(self.version, str) or not self.version:
40
+ raise ValidationError("Skill version is required")
41
+ dumps(self.instructions, 48_000)
42
+ validate(self.state_schema, self.initial_state)
43
+ dumps(self.initial_state, self.limits.state_bytes)
44
+
45
+ def to_dict(self) -> dict:
46
+ return loads(dumps(asdict(self)))
47
+
48
+ @property
49
+ def fingerprint(self) -> str:
50
+ return digest(dumps(self.to_dict()))
51
+
52
+ @classmethod
53
+ def from_dict(cls, data: dict) -> Skill:
54
+ try:
55
+ data = loads(dumps(data))
56
+ limits = Limits(**data.pop("limits", {}))
57
+ return cls(**data, limits=limits)
58
+ except (TypeError, KeyError) as exc:
59
+ raise ValidationError("Invalid skill definition") from exc
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ToolResult:
64
+ success: bool
65
+ observation: Any
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Tool:
70
+ name: str
71
+ description: str
72
+ input_schema: dict
73
+ handler: Callable[[dict, str], Any]
74
+ timeout: float = 60.0
75
+ # A handler receives (arguments, operation_id). The operation_id can be forwarded
76
+ # to a service's idempotency facility; it is not an exactly-once promise.
77
+ output_schema: dict | None = None
78
+
79
+ def specification(self) -> dict:
80
+ return loads(
81
+ dumps(
82
+ {
83
+ "name": self.name,
84
+ "description": self.description,
85
+ "input_schema": self.input_schema,
86
+ }
87
+ )
88
+ )