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/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""super-skill: personal Agent-Skill package manager (M0+WS scope).
|
|
2
|
+
|
|
3
|
+
v1 is the package-manager form the GATE-1 measurement supports: a git-backed,
|
|
4
|
+
versioned skill registry with seed import, explain, and rollback. The
|
|
5
|
+
self-learning loop (M2-M5) is deferred to a research track until candidate
|
|
6
|
+
opportunity flow is proven real. See docs/03-development-plan.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
super_skill/candidate.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Candidate approval loop (WS second half, docs/03: mine -> draft -> approve).
|
|
2
|
+
|
|
3
|
+
A *candidate* is a proposed skill drafted from a mined OpportunityFamily. It is
|
|
4
|
+
pre-promotion scratch: it lives under ``candidates/`` (git-ignored by the
|
|
5
|
+
registry) and is human-editable. Nothing reaches the production skill set until
|
|
6
|
+
``approve`` — the single write path that runs ``registry.add_version`` +
|
|
7
|
+
``materialize`` (git commit + copy to host). This preserves the One Writer Rule:
|
|
8
|
+
candidate -> gate (human approve) -> promote, never bypassed.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
from . import config
|
|
20
|
+
from .evallite import EvalError, eval_lite
|
|
21
|
+
from .gate import InstructionGateError, scan_skill_md
|
|
22
|
+
from .mine import OpportunityFamily
|
|
23
|
+
from .registry import Registry
|
|
24
|
+
from .schemas import (
|
|
25
|
+
CandidateType,
|
|
26
|
+
Provenance,
|
|
27
|
+
ProvenanceKind,
|
|
28
|
+
SkillStatus,
|
|
29
|
+
SkillVersion,
|
|
30
|
+
utcnow,
|
|
31
|
+
)
|
|
32
|
+
from .skillmd import parse
|
|
33
|
+
|
|
34
|
+
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def slugify(label: str) -> str:
|
|
38
|
+
"""Reduce a family label to an agentskills.io-legal skill name (may be empty;
|
|
39
|
+
callers skip empties). Matches NAME_RE: lowercase alnum + single hyphens."""
|
|
40
|
+
return _SLUG_RE.sub("-", label.lower()).strip("-")[:64].strip("-")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CandidateError(RuntimeError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Candidate(BaseModel):
|
|
48
|
+
"""Persisted metadata for one drafted candidate (SKILL.md stored alongside)."""
|
|
49
|
+
|
|
50
|
+
model_config = ConfigDict(extra="forbid")
|
|
51
|
+
|
|
52
|
+
candidate_id: str
|
|
53
|
+
family_label: str
|
|
54
|
+
session_count: int
|
|
55
|
+
event_count: int
|
|
56
|
+
projects: list[str] = Field(default_factory=list)
|
|
57
|
+
status: str = "pending" # pending | approved | rejected
|
|
58
|
+
skill_id: str | None = None
|
|
59
|
+
version: str | None = None
|
|
60
|
+
created_at: datetime = Field(default_factory=utcnow)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _draft_md(cand_id: str, fam: OpportunityFamily) -> str:
|
|
64
|
+
"""A stub SKILL.md the human is expected to edit before approving. WS coarse
|
|
65
|
+
mining can name a recurring family, not write its procedure — so we scaffold
|
|
66
|
+
honestly and leave TODOs rather than fabricate steps."""
|
|
67
|
+
return (
|
|
68
|
+
f"---\n"
|
|
69
|
+
f"name: {cand_id}\n"
|
|
70
|
+
f'description: Recurring workflow around "{fam.label}" '
|
|
71
|
+
f"(seen in {fam.session_count} sessions). EDIT before approving.\n"
|
|
72
|
+
f"---\n"
|
|
73
|
+
f"# {fam.label}\n\n"
|
|
74
|
+
f"<!-- WS draft mined from {fam.session_count} sessions"
|
|
75
|
+
f"{f', {len(fam.projects)} projects' if fam.projects else ''}. "
|
|
76
|
+
f"Replace the TODOs with the real reusable procedure, then `candidate approve`. -->\n\n"
|
|
77
|
+
f"## When to use\n\n"
|
|
78
|
+
f"TODO: the trigger you hit repeatedly.\n\n"
|
|
79
|
+
f"## Steps\n\n"
|
|
80
|
+
f"TODO: the procedure you re-derived each time.\n"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class CandidateStore:
|
|
85
|
+
"""Filesystem store for draft candidates under ``<root>/candidates/<id>/``."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
88
|
+
self.root = root or config.state_root()
|
|
89
|
+
self.dir = self.root / "candidates"
|
|
90
|
+
|
|
91
|
+
def _cdir(self, cand_id: str) -> Path:
|
|
92
|
+
return self.dir / cand_id
|
|
93
|
+
|
|
94
|
+
def get(self, cand_id: str) -> Candidate | None:
|
|
95
|
+
meta = self._cdir(cand_id) / "candidate.json"
|
|
96
|
+
if not meta.exists():
|
|
97
|
+
return None
|
|
98
|
+
return Candidate.model_validate_json(meta.read_text(encoding="utf-8"))
|
|
99
|
+
|
|
100
|
+
def list(self) -> list[Candidate]:
|
|
101
|
+
if not self.dir.exists():
|
|
102
|
+
return []
|
|
103
|
+
out = [self.get(d.name) for d in sorted(self.dir.iterdir()) if d.is_dir()]
|
|
104
|
+
return [c for c in out if c is not None]
|
|
105
|
+
|
|
106
|
+
def skill_md(self, cand_id: str) -> str:
|
|
107
|
+
md = self._cdir(cand_id) / "SKILL.md"
|
|
108
|
+
if not md.exists():
|
|
109
|
+
raise CandidateError(f"candidate {cand_id!r} has no SKILL.md")
|
|
110
|
+
return md.read_text(encoding="utf-8")
|
|
111
|
+
|
|
112
|
+
def write_skill_md(self, cand_id: str, raw: str) -> None:
|
|
113
|
+
self._cdir(cand_id).mkdir(parents=True, exist_ok=True)
|
|
114
|
+
(self._cdir(cand_id) / "SKILL.md").write_text(raw, encoding="utf-8")
|
|
115
|
+
|
|
116
|
+
def save(self, cand: Candidate) -> None:
|
|
117
|
+
cdir = self._cdir(cand.candidate_id)
|
|
118
|
+
cdir.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
(cdir / "candidate.json").write_text(
|
|
120
|
+
cand.model_dump_json(indent=2), encoding="utf-8"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def draft_from_families(
|
|
125
|
+
store: CandidateStore, families: list[OpportunityFamily]
|
|
126
|
+
) -> list[Candidate]:
|
|
127
|
+
"""Create one pending candidate per new family (idempotent by slug). Families
|
|
128
|
+
whose label yields no legal slug are skipped."""
|
|
129
|
+
created: list[Candidate] = []
|
|
130
|
+
for fam in families:
|
|
131
|
+
cand_id = slugify(fam.label)
|
|
132
|
+
if not cand_id or store.get(cand_id) is not None:
|
|
133
|
+
continue
|
|
134
|
+
cand = Candidate(
|
|
135
|
+
candidate_id=cand_id,
|
|
136
|
+
family_label=fam.label,
|
|
137
|
+
session_count=fam.session_count,
|
|
138
|
+
event_count=fam.event_count,
|
|
139
|
+
projects=sorted(fam.projects),
|
|
140
|
+
)
|
|
141
|
+
store.write_skill_md(cand_id, _draft_md(cand_id, fam))
|
|
142
|
+
store.save(cand)
|
|
143
|
+
created.append(cand)
|
|
144
|
+
return created
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def approve(
|
|
148
|
+
store: CandidateStore,
|
|
149
|
+
reg: Registry,
|
|
150
|
+
cand_id: str,
|
|
151
|
+
host_dir: Path,
|
|
152
|
+
*,
|
|
153
|
+
actor: str = "user",
|
|
154
|
+
reason: str | None = None,
|
|
155
|
+
) -> SkillVersion:
|
|
156
|
+
"""Promote a candidate into the registry and materialize it to the host.
|
|
157
|
+
|
|
158
|
+
The single write path (One Writer Rule): parses the current — possibly
|
|
159
|
+
human-edited — SKILL.md, registers an immutable ACTIVE version, copies it to
|
|
160
|
+
the host skills dir, and marks the candidate approved. Raises before any
|
|
161
|
+
write if the candidate is unknown or already approved."""
|
|
162
|
+
cand = store.get(cand_id)
|
|
163
|
+
if cand is None:
|
|
164
|
+
raise CandidateError(f"unknown candidate: {cand_id}")
|
|
165
|
+
if cand.status == "approved":
|
|
166
|
+
raise CandidateError(f"candidate {cand_id!r} already approved")
|
|
167
|
+
|
|
168
|
+
raw = store.skill_md(cand_id)
|
|
169
|
+
# Instruction-layer adversarial gate (docs/04 §2.4bis): v1's only mandatory
|
|
170
|
+
# security gate. Runs BEFORE any write — a blocked candidate never reaches
|
|
171
|
+
# the registry or the host. Captured content is untrusted; auto-approval
|
|
172
|
+
# never bypasses this (there is no auto-approval, but the invariant holds).
|
|
173
|
+
findings = scan_skill_md(raw)
|
|
174
|
+
if findings:
|
|
175
|
+
raise InstructionGateError(findings)
|
|
176
|
+
# Deterministic eval-lite hard gate (docs/04 §1.6): schema, zero secret leak,
|
|
177
|
+
# token budget. The No Skill/Skill two-arm is Insufficient Evidence at WS.
|
|
178
|
+
report = eval_lite(raw)
|
|
179
|
+
if not report.passed:
|
|
180
|
+
raise EvalError(report)
|
|
181
|
+
skill_id = parse(raw).frontmatter.name # frontmatter name is authoritative
|
|
182
|
+
reg.init()
|
|
183
|
+
prov = [
|
|
184
|
+
Provenance(
|
|
185
|
+
kind=ProvenanceKind.CAPTURED_SESSION,
|
|
186
|
+
origin=f"mined:{cand.family_label} ({cand.session_count} sessions)",
|
|
187
|
+
)
|
|
188
|
+
]
|
|
189
|
+
sv = reg.add_version(
|
|
190
|
+
skill_id,
|
|
191
|
+
raw,
|
|
192
|
+
CandidateType.CAPTURED,
|
|
193
|
+
prov,
|
|
194
|
+
status=SkillStatus.ACTIVE,
|
|
195
|
+
actor=actor,
|
|
196
|
+
reason=reason or f"approve candidate {cand_id}",
|
|
197
|
+
)
|
|
198
|
+
reg.materialize(skill_id, host_dir)
|
|
199
|
+
|
|
200
|
+
cand.status = "approved"
|
|
201
|
+
cand.skill_id = skill_id
|
|
202
|
+
cand.version = sv.version
|
|
203
|
+
store.save(cand)
|
|
204
|
+
return sv
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def reject(store: CandidateStore, cand_id: str) -> Candidate:
|
|
208
|
+
cand = store.get(cand_id)
|
|
209
|
+
if cand is None:
|
|
210
|
+
raise CandidateError(f"unknown candidate: {cand_id}")
|
|
211
|
+
cand.status = "rejected"
|
|
212
|
+
store.save(cand)
|
|
213
|
+
return cand
|
super_skill/capture.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Append-only JSONL event WAL (docs/01 FR-CAP-1, WS form).
|
|
2
|
+
|
|
3
|
+
Every event is redacted before it is written (capture.append never persists a raw
|
|
4
|
+
payload), then appended as one JSON line under events/<date>/events.jsonl. Raw
|
|
5
|
+
events are TTL-bounded (FR-CAP-6); structured products live elsewhere.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import uuid
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from . import config
|
|
15
|
+
from .redact import redact_payload, redact_text
|
|
16
|
+
from .schemas import CaptureEvent, EventType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventLog:
|
|
20
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
21
|
+
self.root = root or config.state_root()
|
|
22
|
+
self.events_dir = self.root / "events"
|
|
23
|
+
|
|
24
|
+
def _new_id(self) -> str:
|
|
25
|
+
return uuid.uuid4().hex
|
|
26
|
+
|
|
27
|
+
def append(
|
|
28
|
+
self,
|
|
29
|
+
event_type: EventType,
|
|
30
|
+
session_id: str,
|
|
31
|
+
payload: dict[str, object],
|
|
32
|
+
*,
|
|
33
|
+
project_id: str | None = None,
|
|
34
|
+
event_id: str | None = None,
|
|
35
|
+
consent_scope: str = "default",
|
|
36
|
+
) -> CaptureEvent:
|
|
37
|
+
"""Redact then append. Returns the persisted (redacted) event.
|
|
38
|
+
|
|
39
|
+
Every field that can carry private content — the payload AND the
|
|
40
|
+
project_id (derived from cwd, often a home path) — is redacted here so
|
|
41
|
+
nothing raw reaches disk (FR-CAP-2, §8 SAFETY)."""
|
|
42
|
+
red_payload, marks = redact_payload(payload)
|
|
43
|
+
red_project = redact_text(project_id)[0] if project_id else None
|
|
44
|
+
event = CaptureEvent(
|
|
45
|
+
event_id=event_id or self._new_id(),
|
|
46
|
+
session_id=session_id,
|
|
47
|
+
event_type=event_type,
|
|
48
|
+
project_id=red_project,
|
|
49
|
+
payload=red_payload,
|
|
50
|
+
redactions=marks,
|
|
51
|
+
consent_scope=consent_scope,
|
|
52
|
+
)
|
|
53
|
+
day = event.timestamp.strftime("%Y-%m-%d")
|
|
54
|
+
out = self.events_dir / day / "events.jsonl"
|
|
55
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
with out.open("a", encoding="utf-8") as f:
|
|
57
|
+
f.write(event.model_dump_json() + "\n")
|
|
58
|
+
return event
|
|
59
|
+
|
|
60
|
+
def iter_events(self) -> Iterator[CaptureEvent]:
|
|
61
|
+
if not self.events_dir.exists():
|
|
62
|
+
return
|
|
63
|
+
for day in sorted(self.events_dir.iterdir()):
|
|
64
|
+
wal = day / "events.jsonl"
|
|
65
|
+
if not wal.exists():
|
|
66
|
+
continue
|
|
67
|
+
for line in wal.read_text(encoding="utf-8").splitlines():
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if line:
|
|
70
|
+
yield CaptureEvent.model_validate_json(line)
|
|
71
|
+
|
|
72
|
+
def count(self) -> int:
|
|
73
|
+
return sum(1 for _ in self.iter_events())
|
|
74
|
+
|
|
75
|
+
def distinct_sessions(self) -> int:
|
|
76
|
+
return len({ev.session_id for ev in self.iter_events()})
|
super_skill/cli.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
"""super-skill CLI (WS P0 subset, docs/01 FR-IF-1): seed / status / list / show /
|
|
2
|
+
explain / rollback. The self-learning commands (sleep, distill, candidate-eval)
|
|
3
|
+
arrive only if the GATE opens the M2-M5 research track."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from . import config, minestate
|
|
13
|
+
from .candidate import CandidateError, CandidateStore, approve, draft_from_families, reject
|
|
14
|
+
from .capture import EventLog
|
|
15
|
+
from .doctor import check_registry, repair
|
|
16
|
+
from .evallite import EvalError, eval_lite
|
|
17
|
+
from .gate import InstructionGateError, scan_skill_md
|
|
18
|
+
from .hooks import hooks_settings
|
|
19
|
+
from .mine import mine_families
|
|
20
|
+
from .registry import Registry, RegistryError
|
|
21
|
+
from .schemas import EventType, OperationType
|
|
22
|
+
from .seed import seed_from_host
|
|
23
|
+
from .skillmd import SkillMdError
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(add_completion=False, help="Personal Agent-Skill package manager.")
|
|
26
|
+
candidate_app = typer.Typer(help="Draft / review / approve skill candidates (mine -> approve).")
|
|
27
|
+
app.add_typer(candidate_app, name="candidate")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _registry() -> Registry:
|
|
31
|
+
return Registry(root=config.state_root())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _short(text: str, n: int = 60) -> str:
|
|
35
|
+
text = " ".join(text.split())
|
|
36
|
+
return text if len(text) <= n else text[: n - 1] + "…"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def seed() -> None:
|
|
41
|
+
"""Import existing host skills into the registry (idempotent, read-only on host)."""
|
|
42
|
+
reg = _registry()
|
|
43
|
+
report = seed_from_host(reg, config.host_skills_dir())
|
|
44
|
+
typer.echo(
|
|
45
|
+
f"seed: {len(report.imported)} imported, {len(report.updated)} updated, "
|
|
46
|
+
f"{len(report.unchanged)} unchanged, {len(report.skipped)} skipped "
|
|
47
|
+
f"(host={config.host_skills_dir()})"
|
|
48
|
+
)
|
|
49
|
+
for name, reason in report.skipped:
|
|
50
|
+
typer.echo(f" skipped {name}: {reason}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@app.command()
|
|
54
|
+
def capture(
|
|
55
|
+
event_type: str = typer.Option("", "--event-type", help="override hook_event_name"),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Append a host hook event (JSON on stdin) to the WAL, redacted.
|
|
58
|
+
|
|
59
|
+
Designed to be called from a Claude Code hook. Never fails the session
|
|
60
|
+
(NFR-3): malformed input or an unknown event type exits 0 without writing.
|
|
61
|
+
"""
|
|
62
|
+
try:
|
|
63
|
+
raw = sys.stdin.read()
|
|
64
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
65
|
+
except (json.JSONDecodeError, OSError):
|
|
66
|
+
raise typer.Exit(0) from None
|
|
67
|
+
name = event_type or str(data.get("hook_event_name", ""))
|
|
68
|
+
try:
|
|
69
|
+
etype = EventType(name)
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise typer.Exit(0) from None
|
|
72
|
+
session_id = str(data.get("session_id", "unknown"))
|
|
73
|
+
project_id = data.get("cwd")
|
|
74
|
+
EventLog().append(
|
|
75
|
+
etype, session_id, data, project_id=str(project_id) if project_id else None
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.command()
|
|
80
|
+
def mine(min_sessions: int = typer.Option(3, "--min-sessions")) -> None:
|
|
81
|
+
"""Surface recurring task families from captured events (FR-GEN-1 signal)."""
|
|
82
|
+
log = EventLog()
|
|
83
|
+
families = mine_families(log.iter_events(), min_sessions=min_sessions)
|
|
84
|
+
distinct = log.distinct_sessions()
|
|
85
|
+
if not families:
|
|
86
|
+
typer.echo(
|
|
87
|
+
f"no families recurring across >={min_sessions} sessions yet "
|
|
88
|
+
f"({distinct} distinct sessions captured)"
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
typer.echo(f"{'sessions':>8} {'events':>6} family")
|
|
92
|
+
for fam in families:
|
|
93
|
+
typer.echo(f"{fam.session_count:>8} {fam.event_count:>6} {fam.label}")
|
|
94
|
+
# Mining acknowledges the accumulated sessions: reset the reminder watermark.
|
|
95
|
+
minestate.record_mined(log.root, distinct)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def status() -> None:
|
|
100
|
+
"""Registry summary: location, git head, skill/version/candidate counts."""
|
|
101
|
+
reg = _registry()
|
|
102
|
+
records = reg.list_skills()
|
|
103
|
+
active = sum(1 for r in records if r.skill.active_version)
|
|
104
|
+
versions = sum(len(r.versions) for r in records)
|
|
105
|
+
cands = CandidateStore(reg.root).list()
|
|
106
|
+
by_status: dict[str, int] = {}
|
|
107
|
+
for c in cands:
|
|
108
|
+
by_status[c.status] = by_status.get(c.status, 0) + 1
|
|
109
|
+
breakdown = ", ".join(f"{n} {s}" for s, n in sorted(by_status.items())) or "none"
|
|
110
|
+
typer.echo(f"state root : {reg.root}")
|
|
111
|
+
typer.echo(f"git head : {reg.head()}")
|
|
112
|
+
typer.echo(f"skills : {len(records)} ({active} active)")
|
|
113
|
+
typer.echo(f"versions : {versions}")
|
|
114
|
+
log = EventLog(reg.root)
|
|
115
|
+
typer.echo(f"events : {log.count()}")
|
|
116
|
+
typer.echo(f"candidates : {len(cands)} ({breakdown})")
|
|
117
|
+
unmined = minestate.unmined(reg.root, log.distinct_sessions())
|
|
118
|
+
if unmined >= minestate.reminder_threshold():
|
|
119
|
+
typer.echo(f"reminder : {unmined} distinct sessions unmined "
|
|
120
|
+
f"— run `super-skill mine`")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command("list")
|
|
124
|
+
def list_() -> None:
|
|
125
|
+
"""List registered skills plus any pending candidates awaiting approval."""
|
|
126
|
+
reg = _registry()
|
|
127
|
+
records = reg.list_skills()
|
|
128
|
+
if not records:
|
|
129
|
+
typer.echo("no skills registered — run `super-skill seed`")
|
|
130
|
+
for r in records:
|
|
131
|
+
av = r.active
|
|
132
|
+
ver = r.skill.active_version or "-"
|
|
133
|
+
desc = _short(av.frontmatter.description) if av else ""
|
|
134
|
+
typer.echo(f"{r.skill.skill_id:<32} {ver:<5} {desc}")
|
|
135
|
+
|
|
136
|
+
pending = [c for c in CandidateStore(reg.root).list() if c.status == "pending"]
|
|
137
|
+
if pending:
|
|
138
|
+
typer.echo(f"\npending candidates ({len(pending)}) — review with `candidate show <id>`:")
|
|
139
|
+
for c in pending:
|
|
140
|
+
typer.echo(f" {c.candidate_id:<30} {c.session_count} sessions {c.family_label}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def show(skill_id: str) -> None:
|
|
145
|
+
"""Show a skill's frontmatter, version history and provenance."""
|
|
146
|
+
reg = _registry()
|
|
147
|
+
rec = reg.get(skill_id)
|
|
148
|
+
if rec is None:
|
|
149
|
+
typer.echo(f"unknown skill: {skill_id}", err=True)
|
|
150
|
+
raise typer.Exit(1)
|
|
151
|
+
av = rec.active
|
|
152
|
+
typer.echo(f"skill : {rec.skill.skill_id} (scope={rec.skill.scope})")
|
|
153
|
+
typer.echo(f"active : {rec.skill.active_version}")
|
|
154
|
+
if av:
|
|
155
|
+
typer.echo(f"description: {av.frontmatter.description}")
|
|
156
|
+
typer.echo(f"hash : {av.artifact_hash}")
|
|
157
|
+
typer.echo("versions :")
|
|
158
|
+
for ver, sv in rec.versions.items():
|
|
159
|
+
mark = "*" if ver == rec.skill.active_version else " "
|
|
160
|
+
typer.echo(f" {mark} {ver:<5} {sv.candidate_type:<11} {sv.status}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def explain(skill_id: str) -> None:
|
|
165
|
+
"""FR-IF-5: why this skill exists, where it came from, and how to roll it back."""
|
|
166
|
+
reg = _registry()
|
|
167
|
+
rec = reg.get(skill_id)
|
|
168
|
+
if rec is None:
|
|
169
|
+
typer.echo(f"unknown skill: {skill_id}", err=True)
|
|
170
|
+
raise typer.Exit(1)
|
|
171
|
+
typer.echo(f"# {rec.skill.skill_id}")
|
|
172
|
+
for sv in rec.versions.values():
|
|
173
|
+
origins = ", ".join(f"{p.kind}:{p.origin}" for p in sv.provenance) or "(none)"
|
|
174
|
+
typer.echo(f"{sv.version} [{sv.candidate_type}] from {origins}")
|
|
175
|
+
typer.echo("\naudit:")
|
|
176
|
+
for ev in rec.audit:
|
|
177
|
+
detail = f" ({ev.reason})" if ev.reason else ""
|
|
178
|
+
typer.echo(f" {ev.created_at:%Y-%m-%d %H:%M} {ev.op} {ev.from_version}->{ev.to_version}"
|
|
179
|
+
f" by {ev.actor}{detail}")
|
|
180
|
+
versions = list(rec.versions)
|
|
181
|
+
active = rec.skill.active_version
|
|
182
|
+
if active and versions.index(active) > 0:
|
|
183
|
+
prev = versions[versions.index(active) - 1]
|
|
184
|
+
typer.echo(f"\nrollback : super-skill rollback {skill_id} --to {prev}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def rollback(
|
|
189
|
+
skill_id: str,
|
|
190
|
+
to: str = typer.Option("", "--to", help="target version; default = previous"),
|
|
191
|
+
reason: str = typer.Option("", "--reason"),
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Switch the active pointer to an older version and re-materialize to the host."""
|
|
194
|
+
reg = _registry()
|
|
195
|
+
rec = reg.get(skill_id)
|
|
196
|
+
if rec is None:
|
|
197
|
+
typer.echo(f"unknown skill: {skill_id}", err=True)
|
|
198
|
+
raise typer.Exit(1)
|
|
199
|
+
versions = list(rec.versions)
|
|
200
|
+
if not to:
|
|
201
|
+
idx = versions.index(rec.skill.active_version) if rec.skill.active_version else 0
|
|
202
|
+
if idx <= 0:
|
|
203
|
+
typer.echo("no previous version to roll back to", err=True)
|
|
204
|
+
raise typer.Exit(1)
|
|
205
|
+
to = versions[idx - 1]
|
|
206
|
+
try:
|
|
207
|
+
reg.set_active(skill_id, to, op=OperationType.ROLLBACK, reason=reason or None)
|
|
208
|
+
dest = reg.materialize(skill_id, config.host_skills_dir())
|
|
209
|
+
except RegistryError as e:
|
|
210
|
+
typer.echo(str(e), err=True)
|
|
211
|
+
raise typer.Exit(1) from e
|
|
212
|
+
typer.echo(f"rolled back {skill_id} -> {to}; materialized {dest}")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def doctor(
|
|
217
|
+
fix: bool = typer.Option(False, "--fix", help="restore tampered/missing versions from git "
|
|
218
|
+
"and re-materialize host drift, then re-verify"),
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Check registry integrity: hashes, pointers, host sync.
|
|
221
|
+
|
|
222
|
+
Read-only by default; exits 1 if any integrity error is found. With --fix it
|
|
223
|
+
restores git-recoverable versions and re-materializes host drift, then
|
|
224
|
+
re-verifies — exit status reflects what remains, not what was attempted.
|
|
225
|
+
Dangling pointers / name mismatches need judgment and are left to you."""
|
|
226
|
+
reg = _registry()
|
|
227
|
+
host = config.host_skills_dir()
|
|
228
|
+
|
|
229
|
+
if fix:
|
|
230
|
+
actions, remaining = repair(reg, host)
|
|
231
|
+
for a in actions:
|
|
232
|
+
mark = "✓" if a.ok else "✗"
|
|
233
|
+
typer.echo(f" {mark} {a.issue.skill_id}: {a.action}")
|
|
234
|
+
if not actions:
|
|
235
|
+
typer.echo("doctor --fix: nothing mechanically fixable")
|
|
236
|
+
errors = [i for i in remaining if i.severity == "error"]
|
|
237
|
+
for i in remaining:
|
|
238
|
+
m = "✗" if i.severity == "error" else "!"
|
|
239
|
+
typer.echo(f" {m} [{i.severity}] {i.skill_id}: {i.message} (needs manual fix)")
|
|
240
|
+
typer.echo(
|
|
241
|
+
f"doctor --fix: {len(actions)} action(s); "
|
|
242
|
+
f"{len(errors)} error(s) remain, {len(remaining) - len(errors)} warning(s)"
|
|
243
|
+
)
|
|
244
|
+
if errors:
|
|
245
|
+
raise typer.Exit(1)
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
issues = check_registry(reg, host)
|
|
249
|
+
if not issues:
|
|
250
|
+
typer.echo("doctor: OK — registry consistent, host in sync")
|
|
251
|
+
return
|
|
252
|
+
errors = [i for i in issues if i.severity == "error"]
|
|
253
|
+
for i in issues:
|
|
254
|
+
mark = "✗" if i.severity == "error" else "!"
|
|
255
|
+
typer.echo(f" {mark} [{i.severity}] {i.skill_id}: {i.message}")
|
|
256
|
+
typer.echo(f"doctor: {len(errors)} error(s), {len(issues) - len(errors)} warning(s)")
|
|
257
|
+
if errors:
|
|
258
|
+
raise typer.Exit(1)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@app.command("hooks-config")
|
|
262
|
+
def hooks_config(
|
|
263
|
+
command: str = typer.Option("super-skill capture", "--command", help="capture invocation"),
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Print the settings.json hooks block that feeds real sessions to capture.
|
|
266
|
+
|
|
267
|
+
Prints only — merge it into ~/.claude/settings.json yourself (editing
|
|
268
|
+
user-global config is your call, not the tool's)."""
|
|
269
|
+
typer.echo(json.dumps(hooks_settings(command), indent=2))
|
|
270
|
+
typer.echo(
|
|
271
|
+
"\n# merge the above into ~/.claude/settings.json (or a project .claude/settings.json)",
|
|
272
|
+
err=True,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@candidate_app.command("draft")
|
|
277
|
+
def candidate_draft(min_sessions: int = typer.Option(3, "--min-sessions")) -> None:
|
|
278
|
+
"""Draft skill candidates from mined families (idempotent, pre-promotion)."""
|
|
279
|
+
log = EventLog()
|
|
280
|
+
families = mine_families(log.iter_events(), min_sessions=min_sessions)
|
|
281
|
+
store = CandidateStore(config.state_root())
|
|
282
|
+
created = draft_from_families(store, families)
|
|
283
|
+
# Drafting acts on the mining, so it too clears the status reminder.
|
|
284
|
+
minestate.record_mined(log.root, log.distinct_sessions())
|
|
285
|
+
if not created:
|
|
286
|
+
typer.echo("no new candidates (nothing mined, or all already drafted)")
|
|
287
|
+
return
|
|
288
|
+
typer.echo(f"drafted {len(created)} candidate(s):")
|
|
289
|
+
for c in created:
|
|
290
|
+
typer.echo(f" {c.candidate_id:<32} {c.session_count} sessions — edit then approve")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@candidate_app.command("list")
|
|
294
|
+
def candidate_list() -> None:
|
|
295
|
+
"""List drafted candidates and their status."""
|
|
296
|
+
cands = CandidateStore(config.state_root()).list()
|
|
297
|
+
if not cands:
|
|
298
|
+
typer.echo("no candidates — run `super-skill candidate draft`")
|
|
299
|
+
return
|
|
300
|
+
for c in cands:
|
|
301
|
+
typer.echo(
|
|
302
|
+
f"{c.candidate_id:<32} {c.status:<9} "
|
|
303
|
+
f"{c.session_count} sessions {c.family_label}"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@candidate_app.command("show")
|
|
308
|
+
def candidate_show(candidate_id: str) -> None:
|
|
309
|
+
"""Show a candidate's metadata and drafted SKILL.md."""
|
|
310
|
+
store = CandidateStore(config.state_root())
|
|
311
|
+
cand = store.get(candidate_id)
|
|
312
|
+
if cand is None:
|
|
313
|
+
typer.echo(f"unknown candidate: {candidate_id}", err=True)
|
|
314
|
+
raise typer.Exit(1)
|
|
315
|
+
typer.echo(f"candidate : {cand.candidate_id} ({cand.status})")
|
|
316
|
+
typer.echo(f"family : {cand.family_label}")
|
|
317
|
+
typer.echo(f"recurrence : {cand.session_count} sessions, {cand.event_count} events")
|
|
318
|
+
if cand.version:
|
|
319
|
+
typer.echo(f"promoted : {cand.skill_id}@{cand.version}")
|
|
320
|
+
raw = store.skill_md(candidate_id)
|
|
321
|
+
findings = scan_skill_md(raw)
|
|
322
|
+
if findings:
|
|
323
|
+
typer.echo(f"gate : {len(findings)} finding(s) — approve will be BLOCKED:")
|
|
324
|
+
for f in findings:
|
|
325
|
+
typer.echo(f" ! {f.category} in {f.location}: {f.snippet}")
|
|
326
|
+
else:
|
|
327
|
+
typer.echo("gate : clean (no injection patterns)")
|
|
328
|
+
report = eval_lite(raw)
|
|
329
|
+
ev = "pass" if report.passed else "FAIL"
|
|
330
|
+
typer.echo(f"eval-lite : {ev} ({'; '.join(f'{c.name}={c.detail}' for c in report.checks)})")
|
|
331
|
+
if report.insufficient_evidence:
|
|
332
|
+
typer.echo(" : two-arm (No Skill/Skill) = Insufficient Evidence — human accept")
|
|
333
|
+
typer.echo("--- SKILL.md ---")
|
|
334
|
+
typer.echo(raw)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@candidate_app.command("approve")
|
|
338
|
+
def candidate_approve(
|
|
339
|
+
candidate_id: str,
|
|
340
|
+
reason: str = typer.Option("", "--reason"),
|
|
341
|
+
) -> None:
|
|
342
|
+
"""Approve a candidate: promote to the registry and materialize to the host."""
|
|
343
|
+
store = CandidateStore(config.state_root())
|
|
344
|
+
reg = _registry()
|
|
345
|
+
try:
|
|
346
|
+
sv = approve(store, reg, candidate_id, config.host_skills_dir(), reason=reason or None)
|
|
347
|
+
except InstructionGateError as e:
|
|
348
|
+
typer.echo(str(e), err=True)
|
|
349
|
+
for f in e.findings:
|
|
350
|
+
typer.echo(f" ! {f.category} in {f.location}: {f.snippet}", err=True)
|
|
351
|
+
typer.echo("edit the candidate's SKILL.md to remove the flagged text, then re-approve.",
|
|
352
|
+
err=True)
|
|
353
|
+
raise typer.Exit(1) from e
|
|
354
|
+
except EvalError as e:
|
|
355
|
+
typer.echo(str(e), err=True)
|
|
356
|
+
for c in e.report.failures():
|
|
357
|
+
typer.echo(f" ! {c.name}: {c.detail}", err=True)
|
|
358
|
+
raise typer.Exit(1) from e
|
|
359
|
+
except (CandidateError, RegistryError, SkillMdError) as e:
|
|
360
|
+
typer.echo(str(e), err=True)
|
|
361
|
+
raise typer.Exit(1) from e
|
|
362
|
+
typer.echo(
|
|
363
|
+
f"approved {candidate_id} -> {sv.skill_id}@{sv.version}; "
|
|
364
|
+
f"materialized to {config.host_skills_dir() / sv.skill_id}"
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@candidate_app.command("reject")
|
|
369
|
+
def candidate_reject(candidate_id: str) -> None:
|
|
370
|
+
"""Mark a candidate rejected (leaves it on disk for the record)."""
|
|
371
|
+
store = CandidateStore(config.state_root())
|
|
372
|
+
try:
|
|
373
|
+
reject(store, candidate_id)
|
|
374
|
+
except CandidateError as e:
|
|
375
|
+
typer.echo(str(e), err=True)
|
|
376
|
+
raise typer.Exit(1) from e
|
|
377
|
+
typer.echo(f"rejected {candidate_id}")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
if __name__ == "__main__":
|
|
381
|
+
app()
|