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.
@@ -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"
@@ -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()