super-skill-cli 0.9.2__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.
- super_skill/__init__.py +9 -0
- super_skill/candidate.py +213 -0
- super_skill/capture.py +76 -0
- super_skill/cli.py +381 -0
- super_skill/config.py +25 -0
- super_skill/doctor.py +119 -0
- super_skill/evallite.py +93 -0
- super_skill/gate.py +102 -0
- super_skill/hooks.py +33 -0
- super_skill/mine.py +101 -0
- super_skill/minestate.py +57 -0
- super_skill/redact.py +95 -0
- super_skill/registry.py +221 -0
- super_skill/schemas.py +207 -0
- super_skill/seed.py +75 -0
- super_skill/skillmd.py +61 -0
- super_skill_cli-0.9.2.dist-info/METADATA +213 -0
- super_skill_cli-0.9.2.dist-info/RECORD +21 -0
- super_skill_cli-0.9.2.dist-info/WHEEL +4 -0
- super_skill_cli-0.9.2.dist-info/entry_points.txt +2 -0
- super_skill_cli-0.9.2.dist-info/licenses/LICENSE +21 -0
super_skill/registry.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Git-backed registry (WS backend).
|
|
2
|
+
|
|
3
|
+
Each skill is a directory under ``registry/skills/<skill_id>/`` holding immutable
|
|
4
|
+
``versions/<version>/SKILL.md`` snapshots plus a ``meta.json`` aggregate (Skill
|
|
5
|
+
pointer + SkillVersion DAG + audit trail). Git provides integrity, audit history
|
|
6
|
+
and rollback; M1 replaces this with the SQLite Registry + signed Publisher
|
|
7
|
+
without changing the entity shapes (docs/03 §3 M1, docs/02 §7.7).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
16
|
+
|
|
17
|
+
from . import config
|
|
18
|
+
from .schemas import (
|
|
19
|
+
AuditEvent,
|
|
20
|
+
CandidateType,
|
|
21
|
+
OperationType,
|
|
22
|
+
Provenance,
|
|
23
|
+
Scope,
|
|
24
|
+
Skill,
|
|
25
|
+
SkillStatus,
|
|
26
|
+
SkillVersion,
|
|
27
|
+
)
|
|
28
|
+
from .skillmd import content_hash, parse
|
|
29
|
+
|
|
30
|
+
_GIT_ID = ["-c", "user.name=super-skill", "-c", "user.email=super-skill@localhost"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SkillRecord(BaseModel):
|
|
34
|
+
"""Per-skill aggregate persisted as meta.json."""
|
|
35
|
+
|
|
36
|
+
model_config = ConfigDict(extra="forbid")
|
|
37
|
+
|
|
38
|
+
skill: Skill
|
|
39
|
+
versions: dict[str, SkillVersion] = Field(default_factory=dict)
|
|
40
|
+
audit: list[AuditEvent] = Field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def active(self) -> SkillVersion | None:
|
|
44
|
+
v = self.skill.active_version
|
|
45
|
+
return self.versions.get(v) if v else None
|
|
46
|
+
|
|
47
|
+
def next_version(self) -> str:
|
|
48
|
+
nums = [int(k.lstrip("v")) for k in self.versions if k.lstrip("v").isdigit()]
|
|
49
|
+
return f"v{1 + max(nums, default=0)}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class RegistryError(RuntimeError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Registry:
|
|
57
|
+
"""WS registry rooted at a state dir (injectable for tests)."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
60
|
+
self.root = root or config.state_root()
|
|
61
|
+
self.skills_root = self.root / "registry" / "skills"
|
|
62
|
+
|
|
63
|
+
# ---- lifecycle ---------------------------------------------------------
|
|
64
|
+
def init(self) -> None:
|
|
65
|
+
self.skills_root.mkdir(parents=True, exist_ok=True)
|
|
66
|
+
# candidates/ holds pre-promotion draft scratch — kept out of tracked
|
|
67
|
+
# history so only approve (the Publisher path) writes registry state.
|
|
68
|
+
gitignore = "locks/\ncandidates/\n"
|
|
69
|
+
gi = self.root / ".gitignore"
|
|
70
|
+
if not (self.root / ".git").exists():
|
|
71
|
+
self._git("init", "-q")
|
|
72
|
+
gi.write_text(gitignore, encoding="utf-8")
|
|
73
|
+
self._commit("chore: initialize super-skill registry")
|
|
74
|
+
elif gi.exists() and "candidates/" not in gi.read_text(encoding="utf-8"):
|
|
75
|
+
gi.write_text(gitignore, encoding="utf-8")
|
|
76
|
+
self._commit("chore: ignore candidates/ scratch dir")
|
|
77
|
+
|
|
78
|
+
def _git(self, *args: str) -> str:
|
|
79
|
+
proc = subprocess.run(
|
|
80
|
+
["git", "-C", str(self.root), *args],
|
|
81
|
+
capture_output=True,
|
|
82
|
+
text=True,
|
|
83
|
+
)
|
|
84
|
+
if proc.returncode != 0:
|
|
85
|
+
raise RegistryError(f"git {' '.join(args)} failed: {proc.stderr.strip()}")
|
|
86
|
+
return proc.stdout.strip()
|
|
87
|
+
|
|
88
|
+
def _commit(self, message: str) -> str:
|
|
89
|
+
self._git("add", "-A")
|
|
90
|
+
# nothing staged is not an error (idempotent re-runs)
|
|
91
|
+
status = self._git("status", "--porcelain")
|
|
92
|
+
if not status:
|
|
93
|
+
return self.head()
|
|
94
|
+
subprocess.run(
|
|
95
|
+
["git", "-C", str(self.root), *_GIT_ID, "commit", "-q", "-m", message],
|
|
96
|
+
capture_output=True,
|
|
97
|
+
text=True,
|
|
98
|
+
check=True,
|
|
99
|
+
)
|
|
100
|
+
return self.head()
|
|
101
|
+
|
|
102
|
+
def head(self) -> str:
|
|
103
|
+
try:
|
|
104
|
+
return self._git("rev-parse", "--short", "HEAD")
|
|
105
|
+
except RegistryError:
|
|
106
|
+
return "(no commits)"
|
|
107
|
+
|
|
108
|
+
# ---- reads -------------------------------------------------------------
|
|
109
|
+
def _meta_path(self, skill_id: str) -> Path:
|
|
110
|
+
return self.skills_root / skill_id / "meta.json"
|
|
111
|
+
|
|
112
|
+
def get(self, skill_id: str) -> SkillRecord | None:
|
|
113
|
+
p = self._meta_path(skill_id)
|
|
114
|
+
if not p.exists():
|
|
115
|
+
return None
|
|
116
|
+
return SkillRecord.model_validate_json(p.read_text(encoding="utf-8"))
|
|
117
|
+
|
|
118
|
+
def list_skills(self) -> list[SkillRecord]:
|
|
119
|
+
if not self.skills_root.exists():
|
|
120
|
+
return []
|
|
121
|
+
out = [self.get(d.name) for d in sorted(self.skills_root.iterdir()) if d.is_dir()]
|
|
122
|
+
return [r for r in out if r is not None]
|
|
123
|
+
|
|
124
|
+
def version_text(self, skill_id: str, version: str) -> str:
|
|
125
|
+
p = self.skills_root / skill_id / "versions" / version / "SKILL.md"
|
|
126
|
+
if not p.exists():
|
|
127
|
+
raise RegistryError(f"{skill_id}@{version} has no SKILL.md")
|
|
128
|
+
return p.read_text(encoding="utf-8")
|
|
129
|
+
|
|
130
|
+
# ---- writes ------------------------------------------------------------
|
|
131
|
+
def add_version(
|
|
132
|
+
self,
|
|
133
|
+
skill_id: str,
|
|
134
|
+
raw: str,
|
|
135
|
+
candidate_type: CandidateType,
|
|
136
|
+
provenance: list[Provenance],
|
|
137
|
+
*,
|
|
138
|
+
status: SkillStatus = SkillStatus.ACTIVE,
|
|
139
|
+
scope: Scope = Scope.GLOBAL,
|
|
140
|
+
make_active: bool = True,
|
|
141
|
+
actor: str = "user",
|
|
142
|
+
reason: str | None = None,
|
|
143
|
+
commit: bool = True,
|
|
144
|
+
) -> SkillVersion:
|
|
145
|
+
"""Register a new immutable version; optionally make it the active pointer."""
|
|
146
|
+
parsed = parse(raw)
|
|
147
|
+
rec = self.get(skill_id) or SkillRecord(skill=Skill(skill_id=skill_id, scope=scope))
|
|
148
|
+
version = rec.next_version()
|
|
149
|
+
parents = [rec.skill.active_version] if rec.skill.active_version else []
|
|
150
|
+
sv = SkillVersion(
|
|
151
|
+
skill_id=skill_id,
|
|
152
|
+
version=version,
|
|
153
|
+
parent_versions=[p for p in parents if p],
|
|
154
|
+
candidate_type=candidate_type,
|
|
155
|
+
status=status,
|
|
156
|
+
artifact_hash=content_hash(raw),
|
|
157
|
+
frontmatter=parsed.frontmatter,
|
|
158
|
+
provenance=provenance,
|
|
159
|
+
)
|
|
160
|
+
vdir = self.skills_root / skill_id / "versions" / version
|
|
161
|
+
vdir.mkdir(parents=True, exist_ok=True)
|
|
162
|
+
(vdir / "SKILL.md").write_text(parsed.raw, encoding="utf-8")
|
|
163
|
+
|
|
164
|
+
rec.versions[version] = sv
|
|
165
|
+
prev = rec.skill.active_version
|
|
166
|
+
if make_active:
|
|
167
|
+
rec.skill.active_version = version
|
|
168
|
+
rec.audit.append(
|
|
169
|
+
AuditEvent(
|
|
170
|
+
skill_id=skill_id,
|
|
171
|
+
op=OperationType.PROMOTE,
|
|
172
|
+
from_version=prev,
|
|
173
|
+
to_version=version if make_active else prev,
|
|
174
|
+
actor=actor,
|
|
175
|
+
reason=reason,
|
|
176
|
+
artifact_hash=sv.artifact_hash,
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
self._write(rec)
|
|
180
|
+
if commit:
|
|
181
|
+
self._commit(f"promote: {skill_id}@{version} ({candidate_type})")
|
|
182
|
+
return sv
|
|
183
|
+
|
|
184
|
+
def set_active(
|
|
185
|
+
self, skill_id: str, version: str, *, op: OperationType, actor: str = "user",
|
|
186
|
+
reason: str | None = None, commit: bool = True,
|
|
187
|
+
) -> SkillRecord:
|
|
188
|
+
"""Switch the active-version pointer (rollback = point at an older version)."""
|
|
189
|
+
rec = self.get(skill_id)
|
|
190
|
+
if rec is None:
|
|
191
|
+
raise RegistryError(f"unknown skill {skill_id!r}")
|
|
192
|
+
if version not in rec.versions:
|
|
193
|
+
raise RegistryError(f"{skill_id} has no version {version!r}")
|
|
194
|
+
prev = rec.skill.active_version
|
|
195
|
+
rec.skill.active_version = version
|
|
196
|
+
rec.audit.append(
|
|
197
|
+
AuditEvent(skill_id=skill_id, op=op, from_version=prev, to_version=version,
|
|
198
|
+
actor=actor, reason=reason)
|
|
199
|
+
)
|
|
200
|
+
self._write(rec)
|
|
201
|
+
if commit:
|
|
202
|
+
self._commit(f"{op.lower()}: {skill_id} -> {version}")
|
|
203
|
+
return rec
|
|
204
|
+
|
|
205
|
+
def _write(self, rec: SkillRecord) -> None:
|
|
206
|
+
p = self._meta_path(rec.skill.skill_id)
|
|
207
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
208
|
+
p.write_text(rec.model_dump_json(indent=2), encoding="utf-8")
|
|
209
|
+
|
|
210
|
+
# ---- distribution ------------------------------------------------------
|
|
211
|
+
def materialize(self, skill_id: str, host_dir: Path) -> Path:
|
|
212
|
+
"""Write the active version's SKILL.md into the host skills dir so the
|
|
213
|
+
Agent picks it up. Only writes that one file — never deletes siblings."""
|
|
214
|
+
rec = self.get(skill_id)
|
|
215
|
+
if rec is None or rec.skill.active_version is None:
|
|
216
|
+
raise RegistryError(f"{skill_id!r} has no active version to materialize")
|
|
217
|
+
raw = self.version_text(skill_id, rec.skill.active_version)
|
|
218
|
+
dest = host_dir / skill_id / "SKILL.md"
|
|
219
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
220
|
+
dest.write_text(raw, encoding="utf-8")
|
|
221
|
+
return dest
|
super_skill/schemas.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Core data model — single source of truth (M0 deliverable).
|
|
2
|
+
|
|
3
|
+
Scoped to the v1 package manager. Fields the self-learning loop (M2-M5) needs
|
|
4
|
+
(ExperienceCard, CapabilityLedger, EvalRun, ...) are intentionally absent; they
|
|
5
|
+
land when/if the GATE opens the research track. Entity shapes here mirror
|
|
6
|
+
docs/02-architecture.md §5 so the M1 Registry/Publisher can adopt them unchanged.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
|
+
|
|
18
|
+
# agentskills.io spec: name 1-64 chars, lowercase + hyphen, must match dir name.
|
|
19
|
+
NAME_RE = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def utcnow() -> datetime:
|
|
23
|
+
"""Timezone-aware now(). Centralized so tests can monkeypatch one symbol."""
|
|
24
|
+
return datetime.now(UTC)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Scope(StrEnum):
|
|
28
|
+
GLOBAL = "global"
|
|
29
|
+
PROJECT = "project"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CandidateType(StrEnum):
|
|
33
|
+
"""FR-GEN-3 six types. v1 seed/capture uses CAPTURED/FIX/DISTILLED."""
|
|
34
|
+
|
|
35
|
+
FIX = "FIX"
|
|
36
|
+
DERIVED = "DERIVED"
|
|
37
|
+
CAPTURED = "CAPTURED"
|
|
38
|
+
DISTILLED = "DISTILLED"
|
|
39
|
+
CONSOLIDATED = "CONSOLIDATED"
|
|
40
|
+
RETIRED = "RETIRED"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SkillStatus(StrEnum):
|
|
44
|
+
"""Lifecycle states (docs/02 §6). v1/WS uses OBSERVED..ACTIVE minus the
|
|
45
|
+
security-gate states (QUARANTINED/EVALUATED) that need M4 machinery."""
|
|
46
|
+
|
|
47
|
+
OBSERVED = "Observed"
|
|
48
|
+
CANDIDATE = "Candidate"
|
|
49
|
+
QUARANTINED = "Quarantined"
|
|
50
|
+
EVALUATED = "Evaluated"
|
|
51
|
+
STAGED = "Staged"
|
|
52
|
+
CANARY = "Canary"
|
|
53
|
+
ACTIVE = "Active"
|
|
54
|
+
REJECTED = "Rejected"
|
|
55
|
+
RETIRED = "Retired"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class OperationType(StrEnum):
|
|
59
|
+
"""Writes that mutate the active skill set. In WS these are git commits;
|
|
60
|
+
M1 wraps them in a signed OperationRecord verified by the Publisher."""
|
|
61
|
+
|
|
62
|
+
PROMOTE = "PROMOTE"
|
|
63
|
+
ROLLBACK = "ROLLBACK"
|
|
64
|
+
REVOKE_CANARY = "REVOKE_CANARY"
|
|
65
|
+
QUARANTINE = "QUARANTINE"
|
|
66
|
+
RETIRE = "RETIRE"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProvenanceKind(StrEnum):
|
|
70
|
+
SEED_EXISTING_SKILL = "seed_existing_skill"
|
|
71
|
+
CAPTURED_SESSION = "captured_session"
|
|
72
|
+
DISTILLED_EXTERNAL = "distilled_external"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class EventType(StrEnum):
|
|
76
|
+
"""The six host hook events captured in WS (docs/01 FR-CAP-1)."""
|
|
77
|
+
|
|
78
|
+
SESSION_START = "SessionStart"
|
|
79
|
+
USER_PROMPT_SUBMIT = "UserPromptSubmit"
|
|
80
|
+
PRE_TOOL_USE = "PreToolUse"
|
|
81
|
+
POST_TOOL_USE = "PostToolUse"
|
|
82
|
+
STOP = "Stop"
|
|
83
|
+
SESSION_END = "SessionEnd"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class RedactionMark(BaseModel):
|
|
87
|
+
"""What was redacted and where — never the value (docs/01 FR-CAP-2)."""
|
|
88
|
+
|
|
89
|
+
model_config = ConfigDict(extra="forbid")
|
|
90
|
+
|
|
91
|
+
kind: str
|
|
92
|
+
location: str = Field(description="dotted path of the field the secret was found in")
|
|
93
|
+
count: int = 1
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CaptureEvent(BaseModel):
|
|
97
|
+
"""One redacted host event appended to the JSONL WAL. WS keeps only observable
|
|
98
|
+
actions + short outcomes — never hidden chain-of-thought (FR-CAP-7)."""
|
|
99
|
+
|
|
100
|
+
model_config = ConfigDict(extra="forbid")
|
|
101
|
+
|
|
102
|
+
event_id: str
|
|
103
|
+
session_id: str
|
|
104
|
+
event_type: EventType
|
|
105
|
+
project_id: str | None = None
|
|
106
|
+
timestamp: datetime = Field(default_factory=utcnow)
|
|
107
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
108
|
+
redactions: list[RedactionMark] = Field(default_factory=list)
|
|
109
|
+
consent_scope: str = "default"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Provenance(BaseModel):
|
|
113
|
+
"""Where a version's content came from and under what license."""
|
|
114
|
+
|
|
115
|
+
model_config = ConfigDict(extra="forbid")
|
|
116
|
+
|
|
117
|
+
kind: ProvenanceKind
|
|
118
|
+
origin: str = Field(description="path, session id, or source URL")
|
|
119
|
+
imported_at: datetime = Field(default_factory=utcnow)
|
|
120
|
+
license: str | None = None
|
|
121
|
+
notes: str | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class SkillFrontmatter(BaseModel):
|
|
125
|
+
"""agentskills.io core frontmatter. Host-specific extensions are NOT held
|
|
126
|
+
here — they are injected per-host at distribution time (docs/02 §4.2)."""
|
|
127
|
+
|
|
128
|
+
model_config = ConfigDict(extra="allow")
|
|
129
|
+
|
|
130
|
+
name: str = Field(min_length=1, max_length=64)
|
|
131
|
+
description: str = Field(min_length=1, max_length=1024)
|
|
132
|
+
license: str | None = None
|
|
133
|
+
compatibility: str | None = None
|
|
134
|
+
metadata: dict[str, Any] | None = None
|
|
135
|
+
|
|
136
|
+
@field_validator("name")
|
|
137
|
+
@classmethod
|
|
138
|
+
def _name_shape(cls, v: str) -> str:
|
|
139
|
+
if not NAME_RE.match(v):
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"name must be lowercase alphanumerics + single hyphens (got {v!r})"
|
|
142
|
+
)
|
|
143
|
+
return v
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class SkillVersion(BaseModel):
|
|
147
|
+
"""One immutable node in a skill's version DAG."""
|
|
148
|
+
|
|
149
|
+
model_config = ConfigDict(extra="forbid")
|
|
150
|
+
|
|
151
|
+
skill_id: str
|
|
152
|
+
version: str
|
|
153
|
+
parent_versions: list[str] = Field(default_factory=list)
|
|
154
|
+
candidate_type: CandidateType
|
|
155
|
+
status: SkillStatus
|
|
156
|
+
artifact_hash: str = Field(description="sha256 of the normalized SKILL.md payload")
|
|
157
|
+
frontmatter: SkillFrontmatter
|
|
158
|
+
provenance: list[Provenance] = Field(default_factory=list)
|
|
159
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Skill(BaseModel):
|
|
163
|
+
"""Skill-level state: the active-version pointer that rollback switches,
|
|
164
|
+
distinct from the SkillVersion DAG nodes (docs/02 §5.1, review H4)."""
|
|
165
|
+
|
|
166
|
+
model_config = ConfigDict(extra="forbid")
|
|
167
|
+
|
|
168
|
+
skill_id: str
|
|
169
|
+
scope: Scope = Scope.GLOBAL
|
|
170
|
+
active_version: str | None = None
|
|
171
|
+
user_disabled: bool = False
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class OperationRecord(BaseModel):
|
|
175
|
+
"""Record of an active-set mutation. WS fills actor/reason and relies on git
|
|
176
|
+
for integrity; nonce/registry_state_hash/signature are added at M1 when the
|
|
177
|
+
real Publisher exists (fields present-but-optional so the shape is stable)."""
|
|
178
|
+
|
|
179
|
+
model_config = ConfigDict(extra="forbid")
|
|
180
|
+
|
|
181
|
+
op: OperationType
|
|
182
|
+
skill_id: str
|
|
183
|
+
version: str | None = None
|
|
184
|
+
rollback_version: str | None = None
|
|
185
|
+
actor: str = "user"
|
|
186
|
+
reason: str | None = None
|
|
187
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
188
|
+
# --- M1 (signed Publisher) ---
|
|
189
|
+
nonce: str | None = None
|
|
190
|
+
registry_state_hash: str | None = None
|
|
191
|
+
signature: str | None = None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AuditEvent(BaseModel):
|
|
195
|
+
"""Immutable audit trail entry. In WS these are reconstructable from git
|
|
196
|
+
history; kept structured so `explain` renders a uniform provenance chain."""
|
|
197
|
+
|
|
198
|
+
model_config = ConfigDict(extra="forbid")
|
|
199
|
+
|
|
200
|
+
skill_id: str
|
|
201
|
+
op: OperationType
|
|
202
|
+
from_version: str | None = None
|
|
203
|
+
to_version: str | None = None
|
|
204
|
+
actor: str = "user"
|
|
205
|
+
reason: str | None = None
|
|
206
|
+
artifact_hash: str | None = None
|
|
207
|
+
created_at: datetime = Field(default_factory=utcnow)
|
super_skill/seed.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Seed mode (docs/03 M0, D-3): ingest existing host skills into the registry so
|
|
2
|
+
explain/rollback/list have day-1 value with zero authoring.
|
|
3
|
+
|
|
4
|
+
Read-only with respect to the host skills directory — it reads each SKILL.md and
|
|
5
|
+
writes only into the registry. Idempotent: unchanged skills are skipped, changed
|
|
6
|
+
ones get a new version.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .registry import Registry
|
|
15
|
+
from .schemas import CandidateType, Provenance, ProvenanceKind, SkillStatus
|
|
16
|
+
from .skillmd import SkillMdError, content_hash, parse
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class SeedReport:
|
|
21
|
+
imported: list[str] = field(default_factory=list)
|
|
22
|
+
updated: list[str] = field(default_factory=list)
|
|
23
|
+
unchanged: list[str] = field(default_factory=list)
|
|
24
|
+
skipped: list[tuple[str, str]] = field(default_factory=list) # (name, reason)
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def total_seen(self) -> int:
|
|
28
|
+
return len(self.imported) + len(self.updated) + len(self.unchanged) + len(self.skipped)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def seed_from_host(reg: Registry, host_dir: Path) -> SeedReport:
|
|
32
|
+
report = SeedReport()
|
|
33
|
+
reg.init()
|
|
34
|
+
if not host_dir.exists():
|
|
35
|
+
return report
|
|
36
|
+
|
|
37
|
+
for d in sorted(p for p in host_dir.iterdir() if p.is_dir()):
|
|
38
|
+
md = d / "SKILL.md"
|
|
39
|
+
if not md.exists():
|
|
40
|
+
report.skipped.append((d.name, "no SKILL.md"))
|
|
41
|
+
continue
|
|
42
|
+
raw = md.read_text(encoding="utf-8")
|
|
43
|
+
try:
|
|
44
|
+
parsed = parse(raw)
|
|
45
|
+
except SkillMdError as e:
|
|
46
|
+
report.skipped.append((d.name, str(e)))
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
skill_id = parsed.frontmatter.name
|
|
50
|
+
existing = reg.get(skill_id)
|
|
51
|
+
if (
|
|
52
|
+
existing is not None
|
|
53
|
+
and existing.active is not None
|
|
54
|
+
and existing.active.artifact_hash == content_hash(raw)
|
|
55
|
+
):
|
|
56
|
+
report.unchanged.append(skill_id)
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
prov = [Provenance(kind=ProvenanceKind.SEED_EXISTING_SKILL, origin=str(md))]
|
|
60
|
+
reg.add_version(
|
|
61
|
+
skill_id,
|
|
62
|
+
raw,
|
|
63
|
+
CandidateType.DISTILLED,
|
|
64
|
+
prov,
|
|
65
|
+
status=SkillStatus.ACTIVE,
|
|
66
|
+
actor="seed",
|
|
67
|
+
reason=f"seed import from {md}",
|
|
68
|
+
commit=False,
|
|
69
|
+
)
|
|
70
|
+
(report.updated if existing else report.imported).append(skill_id)
|
|
71
|
+
|
|
72
|
+
reg._commit(
|
|
73
|
+
f"seed: import {len(report.imported)} new / {len(report.updated)} updated from host"
|
|
74
|
+
)
|
|
75
|
+
return report
|
super_skill/skillmd.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Parse and hash SKILL.md files (YAML frontmatter + markdown body)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from .schemas import SkillFrontmatter
|
|
11
|
+
|
|
12
|
+
_FENCE = "---"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SkillMdError(ValueError):
|
|
16
|
+
"""Malformed SKILL.md (missing/broken frontmatter)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class ParsedSkillMd:
|
|
21
|
+
frontmatter: SkillFrontmatter
|
|
22
|
+
body: str
|
|
23
|
+
raw: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def normalize(text: str) -> str:
|
|
27
|
+
"""Strip BOM and CRLF so hashes ignore meaningless byte differences
|
|
28
|
+
(docs/02 §4.2 normalized hash)."""
|
|
29
|
+
return text.lstrip("").replace("\r\n", "\n").replace("\r", "\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def content_hash(text: str) -> str:
|
|
33
|
+
return "sha256:" + hashlib.sha256(normalize(text).encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse(text: str) -> ParsedSkillMd:
|
|
37
|
+
"""Split a SKILL.md into validated frontmatter + body.
|
|
38
|
+
|
|
39
|
+
Frontmatter is the first ``---``-fenced YAML block. Raises SkillMdError if
|
|
40
|
+
absent or not a mapping — the seed importer skips such directories rather
|
|
41
|
+
than registering an invalid skill.
|
|
42
|
+
"""
|
|
43
|
+
norm = normalize(text)
|
|
44
|
+
if not norm.startswith(_FENCE):
|
|
45
|
+
raise SkillMdError("missing YAML frontmatter fence")
|
|
46
|
+
end = norm.find(f"\n{_FENCE}", len(_FENCE))
|
|
47
|
+
if end == -1:
|
|
48
|
+
raise SkillMdError("unterminated frontmatter fence")
|
|
49
|
+
fm_text = norm[len(_FENCE) : end]
|
|
50
|
+
body = norm[end + len(_FENCE) + 1 :].lstrip("\n")
|
|
51
|
+
try:
|
|
52
|
+
data = yaml.safe_load(fm_text)
|
|
53
|
+
except yaml.YAMLError as e:
|
|
54
|
+
raise SkillMdError(f"invalid frontmatter YAML: {e}") from e
|
|
55
|
+
if not isinstance(data, dict):
|
|
56
|
+
raise SkillMdError("frontmatter is not a mapping")
|
|
57
|
+
try:
|
|
58
|
+
fm = SkillFrontmatter.model_validate(data)
|
|
59
|
+
except ValueError as e:
|
|
60
|
+
raise SkillMdError(f"frontmatter failed validation: {e}") from e
|
|
61
|
+
return ParsedSkillMd(frontmatter=fm, body=body, raw=norm)
|