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/__init__.py +35 -0
- skillstate/__main__.py +3 -0
- skillstate/artifacts.py +55 -0
- skillstate/cli.py +256 -0
- skillstate/compiler.py +367 -0
- skillstate/demo.py +74 -0
- skillstate/errors.py +33 -0
- skillstate/hosts.py +322 -0
- skillstate/jsonio.py +143 -0
- skillstate/mcp_server.py +142 -0
- skillstate/models.py +88 -0
- skillstate/providers.py +89 -0
- skillstate/py.typed +0 -0
- skillstate/runtime.py +179 -0
- skillstate/schema.py +91 -0
- skillstate/service.py +59 -0
- skillstate/store.py +332 -0
- skillstate_kit-0.1.1.dist-info/METADATA +157 -0
- skillstate_kit-0.1.1.dist-info/RECORD +22 -0
- skillstate_kit-0.1.1.dist-info/WHEEL +4 -0
- skillstate_kit-0.1.1.dist-info/entry_points.txt +2 -0
- skillstate_kit-0.1.1.dist-info/licenses/LICENSE +21 -0
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)
|
skillstate/mcp_server.py
ADDED
|
@@ -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
|
+
)
|