skillpool 4.3.0__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.
- skillpool/__init__.py +74 -0
- skillpool/__main__.py +6 -0
- skillpool/adapters/__init__.py +8 -0
- skillpool/adapters/base.py +41 -0
- skillpool/adapters/claude_adapter.py +36 -0
- skillpool/adapters/codex_adapter.py +92 -0
- skillpool/adapters/hermes_adapter.py +38 -0
- skillpool/audit/__init__.py +651 -0
- skillpool/bridge/__init__.py +16 -0
- skillpool/bridge/freeze_detector.py +134 -0
- skillpool/bridge/maintenance.py +119 -0
- skillpool/bridge/wal_manager.py +136 -0
- skillpool/clawmem_client.py +176 -0
- skillpool/cli.py +700 -0
- skillpool/combiner/__init__.py +31 -0
- skillpool/combiner/lifecycle.py +453 -0
- skillpool/combiner/models.py +99 -0
- skillpool/config.py +34 -0
- skillpool/cost/__init__.py +111 -0
- skillpool/cost/audit_hash.py +51 -0
- skillpool/cost/budget_tracker.py +66 -0
- skillpool/cost/dashboard.py +189 -0
- skillpool/cost/models.py +129 -0
- skillpool/cost/token_governor.py +264 -0
- skillpool/cost/trace_ceiling.py +38 -0
- skillpool/csdf.py +126 -0
- skillpool/evolver/__init__.py +978 -0
- skillpool/gain/__init__.py +285 -0
- skillpool/gate.py +282 -0
- skillpool/gate_policy/__init__.py +31 -0
- skillpool/gate_policy/incremental.py +157 -0
- skillpool/gate_policy/parser.py +258 -0
- skillpool/gate_policy/state_machine.py +432 -0
- skillpool/graph/__init__.py +14 -0
- skillpool/graph/ppr.py +279 -0
- skillpool/health/__init__.py +73 -0
- skillpool/health/check.py +85 -0
- skillpool/health/degradation.py +90 -0
- skillpool/health/models.py +43 -0
- skillpool/hooks/__init__.py +4 -0
- skillpool/hooks/security_scanner.py +288 -0
- skillpool/lifecycle.py +150 -0
- skillpool/materializer/__init__.py +124 -0
- skillpool/materializer/budget_cropper.py +178 -0
- skillpool/materializer/csdf_loader.py +114 -0
- skillpool/materializer/lazy_loader.py +265 -0
- skillpool/materializer/lifecycle_filter.py +93 -0
- skillpool/materializer/mapper.py +178 -0
- skillpool/materializer/models.py +66 -0
- skillpool/mcp_server.py +2005 -0
- skillpool/monitor/__init__.py +576 -0
- skillpool/monitor/bug_collector.py +392 -0
- skillpool/monitor/defect_classifier.py +218 -0
- skillpool/monitor/self_healing.py +530 -0
- skillpool/monitor/telemetry_bridge.py +197 -0
- skillpool/paradigm/__init__.py +312 -0
- skillpool/paradigm/override.py +285 -0
- skillpool/profile.py +94 -0
- skillpool/quality.py +254 -0
- skillpool/registry/__init__.py +509 -0
- skillpool/registry/models.py +98 -0
- skillpool/resolver/__init__.py +320 -0
- skillpool/resolver/cache.py +103 -0
- skillpool/resolver/circuit_breaker.py +103 -0
- skillpool/resolver/conflict_detector.py +111 -0
- skillpool/resolver/health_filter.py +38 -0
- skillpool/resolver/models.py +154 -0
- skillpool/resolver/rate_limiter.py +48 -0
- skillpool/resolver/skill_graph.py +183 -0
- skillpool/review/__init__.py +242 -0
- skillpool/review/async_queue.py +96 -0
- skillpool/review/checkpoint_runner.py +345 -0
- skillpool/review/models.py +164 -0
- skillpool/review/suspect_marker.py +39 -0
- skillpool/review/veto_evaluator.py +94 -0
- skillpool/router/__init__.py +481 -0
- skillpool/schemas.py +119 -0
- skillpool/synergy/__init__.py +240 -0
- skillpool/synergy/detector.py +5 -0
- skillpool/telemetry.py +126 -0
- skillpool/utils/__init__.py +21 -0
- skillpool/utils/changelog.py +218 -0
- skillpool/utils/logger.py +273 -0
- skillpool/utils/runtime_audit.py +163 -0
- skillpool/utils/time_utils.py +13 -0
- skillpool-4.3.0.dist-info/METADATA +21 -0
- skillpool-4.3.0.dist-info/RECORD +90 -0
- skillpool-4.3.0.dist-info/WHEEL +5 -0
- skillpool-4.3.0.dist-info/entry_points.txt +3 -0
- skillpool-4.3.0.dist-info/top_level.txt +1 -0
skillpool/cli.py
ADDED
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
"""SkillPool CLI — command-line interface for skill governance and materialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from skillpool.config import get_data_dir
|
|
12
|
+
|
|
13
|
+
DEFAULT_SKILLPOOL_DIR = get_data_dir()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _find_skillpool_dir() -> Path:
|
|
17
|
+
"""Locate .skillpool directory (cwd first, then env/home)."""
|
|
18
|
+
cwd_dir = Path.cwd() / ".skillpool"
|
|
19
|
+
if cwd_dir.exists():
|
|
20
|
+
return cwd_dir
|
|
21
|
+
env_dir = get_data_dir()
|
|
22
|
+
if env_dir.exists():
|
|
23
|
+
return env_dir
|
|
24
|
+
return cwd_dir
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group()
|
|
28
|
+
@click.version_option(version="4.3.0")
|
|
29
|
+
def main():
|
|
30
|
+
"""SkillPool V4.3 — AI Agent Skill Governance & Delivery Platform."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ── Init ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
def init():
|
|
38
|
+
"""Initialize SkillPool data directory."""
|
|
39
|
+
DEFAULT_SKILLPOOL_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
(DEFAULT_SKILLPOOL_DIR / "registry.jsonl").touch()
|
|
41
|
+
(DEFAULT_SKILLPOOL_DIR / "logs").mkdir(exist_ok=True)
|
|
42
|
+
(DEFAULT_SKILLPOOL_DIR / "materialization_state").mkdir(exist_ok=True)
|
|
43
|
+
(DEFAULT_SKILLPOOL_DIR / "emergency_overrides.json").write_text('{"overrides": {}}\n')
|
|
44
|
+
click.echo(f"[skillpool] Initialized at {DEFAULT_SKILLPOOL_DIR}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# ── Materialize ───────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.option("--agent", "agent_type", default="claude-code", help="Target agent type (claude-code, codex, hermes)")
|
|
52
|
+
@click.option("--target", "target_dir", type=click.Path(), default=None, help="Target directory for materialized files")
|
|
53
|
+
@click.option(
|
|
54
|
+
"--csdf",
|
|
55
|
+
"csdf_path",
|
|
56
|
+
type=click.Path(exists=True),
|
|
57
|
+
default=None,
|
|
58
|
+
help="Path to a single CSDF YAML file to materialize",
|
|
59
|
+
)
|
|
60
|
+
def materialize(agent_type: str, target_dir: str | None, csdf_path: str | None):
|
|
61
|
+
"""Materialize skills into agent-specific runtime format.
|
|
62
|
+
|
|
63
|
+
This is the primary delivery mechanism (V4.1 materialization channel).
|
|
64
|
+
Transforms CSDF governance data into SKILL.md / AGENTS.md / hermes_skill.
|
|
65
|
+
"""
|
|
66
|
+
from skillpool.materializer import Materializer
|
|
67
|
+
from skillpool.profile import (
|
|
68
|
+
CLAUDE_CODE_PROFILE,
|
|
69
|
+
CODEX_PROFILE,
|
|
70
|
+
HERMES_PROFILE,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
profiles = {
|
|
74
|
+
"claude-code": CLAUDE_CODE_PROFILE,
|
|
75
|
+
"codex": CODEX_PROFILE,
|
|
76
|
+
"hermes": HERMES_PROFILE,
|
|
77
|
+
}
|
|
78
|
+
profile = profiles.get(agent_type, CLAUDE_CODE_PROFILE)
|
|
79
|
+
|
|
80
|
+
# Default target directories per agent type
|
|
81
|
+
if target_dir is None:
|
|
82
|
+
defaults = {
|
|
83
|
+
"claude-code": str(Path.home() / ".claude" / "skills"),
|
|
84
|
+
"codex": str(Path.home() / ".codex"),
|
|
85
|
+
"hermes": str(Path.home() / ".hermes" / "skills"),
|
|
86
|
+
}
|
|
87
|
+
target_dir = defaults.get(agent_type, str(DEFAULT_SKILLPOOL_DIR / "output"))
|
|
88
|
+
|
|
89
|
+
mat = Materializer(profile=profile)
|
|
90
|
+
|
|
91
|
+
if csdf_path:
|
|
92
|
+
result = mat.materialize(csdf_path=Path(csdf_path))
|
|
93
|
+
if result.status == "success" and result.skill:
|
|
94
|
+
out_path = Path(target_dir)
|
|
95
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
skill_file = out_path / f"{result.skill.id}.md"
|
|
97
|
+
skill_file.write_text(result.skill.markdown)
|
|
98
|
+
click.echo(f"Materialized: {result.skill.id} -> {skill_file}")
|
|
99
|
+
click.echo(f" Tokens: {result.skill.token_count}")
|
|
100
|
+
else:
|
|
101
|
+
click.echo(f"Materialization failed: {result.errors}")
|
|
102
|
+
else:
|
|
103
|
+
# Materialize all skills from registry
|
|
104
|
+
sp_dir = _find_skillpool_dir()
|
|
105
|
+
skills_dir = sp_dir / "skills"
|
|
106
|
+
if skills_dir.exists():
|
|
107
|
+
count = 0
|
|
108
|
+
for yaml_file in skills_dir.glob("*.yaml"):
|
|
109
|
+
result = mat.materialize(csdf_path=yaml_file)
|
|
110
|
+
if result.status == "success" and result.skill:
|
|
111
|
+
out_path = Path(target_dir)
|
|
112
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
skill_file = out_path / f"{result.skill.id}.md"
|
|
114
|
+
skill_file.write_text(result.skill.markdown)
|
|
115
|
+
count += 1
|
|
116
|
+
click.echo(f"Materialized {count} skill(s) -> {target_dir}")
|
|
117
|
+
else:
|
|
118
|
+
click.echo(f"No skills directory found at {skills_dir}")
|
|
119
|
+
click.echo("Run 'skillpool register --path <yaml>' to add skills.")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ── Sync ──────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@main.command()
|
|
126
|
+
@click.option("--agent", "agent_type", default="claude-code", help="Target agent type")
|
|
127
|
+
@click.option("--target", "target_dir", type=click.Path(), default=None, help="Target directory")
|
|
128
|
+
@click.option("--force", is_flag=True, help="Force re-materialize all skills")
|
|
129
|
+
def sync(agent_type: str, target_dir: str | None, force: bool):
|
|
130
|
+
"""Incremental sync — only re-materialize changed skills.
|
|
131
|
+
|
|
132
|
+
Compares content hashes; skips unchanged files.
|
|
133
|
+
"""
|
|
134
|
+
import hashlib
|
|
135
|
+
import yaml
|
|
136
|
+
|
|
137
|
+
from skillpool.materializer import Materializer
|
|
138
|
+
from skillpool.profile import (
|
|
139
|
+
CLAUDE_CODE_PROFILE,
|
|
140
|
+
CODEX_PROFILE,
|
|
141
|
+
HERMES_PROFILE,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
profiles = {
|
|
145
|
+
"claude-code": CLAUDE_CODE_PROFILE,
|
|
146
|
+
"codex": CODEX_PROFILE,
|
|
147
|
+
"hermes": HERMES_PROFILE,
|
|
148
|
+
}
|
|
149
|
+
profile = profiles.get(agent_type, CLAUDE_CODE_PROFILE)
|
|
150
|
+
|
|
151
|
+
# Default target directories per agent type
|
|
152
|
+
if target_dir is None:
|
|
153
|
+
defaults = {
|
|
154
|
+
"claude-code": str(Path.home() / ".claude" / "skills"),
|
|
155
|
+
"codex": str(Path.home() / ".codex"),
|
|
156
|
+
"hermes": str(Path.home() / ".hermes" / "skills"),
|
|
157
|
+
}
|
|
158
|
+
target_dir = defaults.get(agent_type, str(DEFAULT_SKILLPOOL_DIR / "output"))
|
|
159
|
+
|
|
160
|
+
sp_dir = _find_skillpool_dir()
|
|
161
|
+
skills_dir = sp_dir / "skills"
|
|
162
|
+
out_path = Path(target_dir)
|
|
163
|
+
out_path.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
|
|
165
|
+
# Hash state file for incremental sync
|
|
166
|
+
hash_file = out_path / ".sync_hashes.yaml"
|
|
167
|
+
old_hashes: dict[str, str] = {}
|
|
168
|
+
if hash_file.exists() and not force:
|
|
169
|
+
try:
|
|
170
|
+
old_hashes = yaml.safe_load(hash_file.read_text()) or {}
|
|
171
|
+
except yaml.YAMLError:
|
|
172
|
+
old_hashes = {}
|
|
173
|
+
|
|
174
|
+
mat = Materializer(profile=profile)
|
|
175
|
+
new_hashes: dict[str, str] = {}
|
|
176
|
+
synced = 0
|
|
177
|
+
skipped = 0
|
|
178
|
+
|
|
179
|
+
if not skills_dir.exists():
|
|
180
|
+
click.echo(f"No skills directory found at {skills_dir}")
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
for yaml_file in skills_dir.glob("*.yaml"):
|
|
184
|
+
content = yaml_file.read_bytes()
|
|
185
|
+
content_hash = hashlib.sha256(content).hexdigest()[:16]
|
|
186
|
+
skill_id = yaml_file.stem
|
|
187
|
+
new_hashes[skill_id] = content_hash
|
|
188
|
+
|
|
189
|
+
if not force and old_hashes.get(skill_id) == content_hash:
|
|
190
|
+
skipped += 1
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
result = mat.materialize(csdf_path=yaml_file)
|
|
194
|
+
if result.status == "success" and result.skill:
|
|
195
|
+
skill_file = out_path / f"{result.skill.id}.md"
|
|
196
|
+
skill_file.write_text(result.skill.markdown)
|
|
197
|
+
synced += 1
|
|
198
|
+
|
|
199
|
+
# Also process directory-based skills
|
|
200
|
+
for skill_dir in skills_dir.iterdir():
|
|
201
|
+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists():
|
|
202
|
+
skill_md = skill_dir / "SKILL.md"
|
|
203
|
+
content = skill_md.read_bytes()
|
|
204
|
+
content_hash = hashlib.sha256(content).hexdigest()[:16]
|
|
205
|
+
skill_id = skill_dir.name
|
|
206
|
+
new_hashes[skill_id] = content_hash
|
|
207
|
+
|
|
208
|
+
if not force and old_hashes.get(skill_id) == content_hash:
|
|
209
|
+
skipped += 1
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
# Copy directory skill as-is
|
|
213
|
+
import shutil
|
|
214
|
+
|
|
215
|
+
dest_dir = out_path / skill_id
|
|
216
|
+
if dest_dir.exists():
|
|
217
|
+
shutil.rmtree(dest_dir)
|
|
218
|
+
shutil.copytree(skill_dir, dest_dir)
|
|
219
|
+
synced += 1
|
|
220
|
+
|
|
221
|
+
# Save new hashes
|
|
222
|
+
hash_file.write_text(yaml.dump(new_hashes, default_flow_style=False))
|
|
223
|
+
|
|
224
|
+
click.echo(f"[sync] Synced {synced} skill(s), skipped {skipped} unchanged -> {target_dir}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── Register ──────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@main.command()
|
|
231
|
+
@click.option("--name", default="", help="Skill name")
|
|
232
|
+
@click.option("--path", "skill_path", type=click.Path(exists=True), default=None, help="Path to CSDF YAML file")
|
|
233
|
+
def register(name: str, skill_path: str | None):
|
|
234
|
+
"""Register a skill into the Registry.
|
|
235
|
+
|
|
236
|
+
Requires supply chain evidence (SBOM, provenance, source pin, signature).
|
|
237
|
+
Skill enters 'testing' state (not production-routable).
|
|
238
|
+
"""
|
|
239
|
+
from skillpool.registry import Registry
|
|
240
|
+
from skillpool.registry.models import RegisterSkillRequest, SkillMetadata
|
|
241
|
+
from skillpool.audit import AuditLayer
|
|
242
|
+
|
|
243
|
+
audit = AuditLayer()
|
|
244
|
+
reg = Registry(audit_layer=audit)
|
|
245
|
+
|
|
246
|
+
if skill_path:
|
|
247
|
+
import yaml
|
|
248
|
+
|
|
249
|
+
content = Path(skill_path).read_text()
|
|
250
|
+
csdf = yaml.safe_load(content) or {}
|
|
251
|
+
skill_id = csdf.get("id", Path(skill_path).stem)
|
|
252
|
+
skill_name = name or csdf.get("name", skill_id)
|
|
253
|
+
version = csdf.get("version", "0.1.0")
|
|
254
|
+
security = csdf.get("security", {})
|
|
255
|
+
|
|
256
|
+
meta = SkillMetadata(
|
|
257
|
+
skill_id=skill_id,
|
|
258
|
+
name=skill_name,
|
|
259
|
+
version=version,
|
|
260
|
+
security=security,
|
|
261
|
+
)
|
|
262
|
+
req = RegisterSkillRequest(skill_metadata=meta)
|
|
263
|
+
try:
|
|
264
|
+
resp = reg.register_candidate(req)
|
|
265
|
+
click.echo(f"Registered: {resp.skill_id} -> status={resp.status}")
|
|
266
|
+
except Exception as e:
|
|
267
|
+
click.echo(f"Registration failed: {type(e).__name__}: {e}")
|
|
268
|
+
else:
|
|
269
|
+
click.echo("Use --path to specify a CSDF YAML file.")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ── Inspect ───────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@main.command()
|
|
276
|
+
@click.argument("skill_id")
|
|
277
|
+
def inspect(skill_id: str):
|
|
278
|
+
"""Inspect a registered skill.
|
|
279
|
+
|
|
280
|
+
Lookup order: Registry (by id or name) → CSDF YAML file → Directory-based skill.
|
|
281
|
+
"""
|
|
282
|
+
from skillpool.registry import Registry
|
|
283
|
+
from skillpool.audit import AuditLayer
|
|
284
|
+
|
|
285
|
+
audit = AuditLayer()
|
|
286
|
+
reg = Registry(audit_layer=audit)
|
|
287
|
+
|
|
288
|
+
# 1. Try Registry lookup (by skill_id)
|
|
289
|
+
record = reg.get_skill(skill_id)
|
|
290
|
+
|
|
291
|
+
# 2. Try Registry lookup by name (for name-based lookups)
|
|
292
|
+
if record is None:
|
|
293
|
+
for rec in reg._skills.values():
|
|
294
|
+
if rec.metadata.name == skill_id:
|
|
295
|
+
record = rec
|
|
296
|
+
break
|
|
297
|
+
|
|
298
|
+
# 3. Try CSDF YAML file (direct filesystem lookup)
|
|
299
|
+
if record is None:
|
|
300
|
+
import yaml as _yaml
|
|
301
|
+
|
|
302
|
+
sp_dir = _find_skillpool_dir()
|
|
303
|
+
skills_dir = sp_dir / "skills"
|
|
304
|
+
|
|
305
|
+
# Exact match
|
|
306
|
+
yaml_path = skills_dir / f"{skill_id}.yaml"
|
|
307
|
+
csdf = None
|
|
308
|
+
if yaml_path.exists():
|
|
309
|
+
csdf = _yaml.safe_load(yaml_path.read_text()) or {}
|
|
310
|
+
else:
|
|
311
|
+
# Prefix match (e.g., "S09" matches "S09-resilience-degradation.yaml")
|
|
312
|
+
for p in skills_dir.glob(f"{skill_id}-*.yaml"):
|
|
313
|
+
csdf = _yaml.safe_load(p.read_text()) or {}
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
# Directory-based skill (e.g., "scaffold-docs")
|
|
317
|
+
if csdf is None:
|
|
318
|
+
skill_md = skills_dir / skill_id / "SKILL.md"
|
|
319
|
+
if skill_md.exists():
|
|
320
|
+
content = skill_md.read_text(encoding="utf-8")
|
|
321
|
+
if content.startswith("---"):
|
|
322
|
+
end = content.find("---", 3)
|
|
323
|
+
if end > 0:
|
|
324
|
+
csdf = _yaml.safe_load(content[3:end]) or {}
|
|
325
|
+
csdf["id"] = csdf.get("name", skill_id)
|
|
326
|
+
csdf["_is_directory_skill"] = True
|
|
327
|
+
|
|
328
|
+
if csdf is not None:
|
|
329
|
+
click.echo(f"Skill: {csdf.get('name', skill_id)} [from CSDF file]")
|
|
330
|
+
click.echo(f" ID: {csdf.get('id', skill_id)}")
|
|
331
|
+
click.echo(f" Version: {csdf.get('version', 'N/A')}")
|
|
332
|
+
click.echo(f" Dimension: {csdf.get('dimension', 'N/A')}")
|
|
333
|
+
click.echo(f" Weight: {csdf.get('weight', 0)}")
|
|
334
|
+
click.echo(f" Veto: {csdf.get('veto_rule', 'none')}")
|
|
335
|
+
if csdf.get("_is_directory_skill"):
|
|
336
|
+
click.echo(" Type: directory")
|
|
337
|
+
click.echo(f" Tags: {', '.join(csdf.get('tags', []))}")
|
|
338
|
+
click.echo(f" Category: {csdf.get('category', 'N/A')}")
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
if record is None:
|
|
342
|
+
click.echo(f"Skill '{skill_id}' not found")
|
|
343
|
+
click.echo(" Hint: Check available skills with 'skillpool status'")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
click.echo(f"Skill: {record.metadata.name}")
|
|
347
|
+
click.echo(f" ID: {record.metadata.skill_id}")
|
|
348
|
+
click.echo(f" Version: {record.metadata.version}")
|
|
349
|
+
click.echo(f" Status: {record.metadata.status.value}")
|
|
350
|
+
click.echo(f" Enabled: {reg.is_enabled(skill_id)}")
|
|
351
|
+
if record.evidence:
|
|
352
|
+
click.echo(f" Evidence: {', '.join(sorted(record.evidence))}")
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ── Status ────────────────────────────────────────────────────────
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@main.command()
|
|
359
|
+
def status():
|
|
360
|
+
"""Show SkillPool status."""
|
|
361
|
+
sp_dir = _find_skillpool_dir()
|
|
362
|
+
if sp_dir.exists():
|
|
363
|
+
click.echo(f"SkillPool directory: {sp_dir}")
|
|
364
|
+
skills_dir = sp_dir / "skills"
|
|
365
|
+
if skills_dir.exists():
|
|
366
|
+
count = len(list(skills_dir.glob("*.yaml")))
|
|
367
|
+
click.echo(f" CSDF skills: {count}")
|
|
368
|
+
click.echo(f" Registry: {sp_dir / 'registry.jsonl'}")
|
|
369
|
+
else:
|
|
370
|
+
click.echo("SkillPool not initialized. Run 'skillpool init'.")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@main.command()
|
|
374
|
+
@click.argument("skill_id")
|
|
375
|
+
@click.option(
|
|
376
|
+
"--upgrade-type", default="PATCH", type=click.Choice(["PATCH", "MINOR", "MAJOR"]), help="Evolution upgrade type"
|
|
377
|
+
)
|
|
378
|
+
@click.option("--updates", default=None, help="JSON string of field updates to apply")
|
|
379
|
+
def evolve(skill_id: str, upgrade_type: str, updates: str | None):
|
|
380
|
+
"""Execute an evolution for a skill: write changes to CSDF YAML + re-materialize.
|
|
381
|
+
|
|
382
|
+
This is the CLI counterpart of the evolution_proposal + execute_evolution
|
|
383
|
+
MCP tools, providing a direct command-line path for skill evolution.
|
|
384
|
+
"""
|
|
385
|
+
from skillpool.evolver import EvolverLayer
|
|
386
|
+
from skillpool.audit import AuditLayer
|
|
387
|
+
|
|
388
|
+
audit = AuditLayer()
|
|
389
|
+
evolver = EvolverLayer(audit_layer=audit)
|
|
390
|
+
|
|
391
|
+
# Parse updates if provided
|
|
392
|
+
update_dict = {}
|
|
393
|
+
if updates:
|
|
394
|
+
try:
|
|
395
|
+
update_dict = json.loads(updates)
|
|
396
|
+
except json.JSONDecodeError:
|
|
397
|
+
click.echo(f"Invalid JSON in --updates: {updates}")
|
|
398
|
+
return
|
|
399
|
+
|
|
400
|
+
# Create proposal
|
|
401
|
+
proposal = evolver.create_proposal(
|
|
402
|
+
context={"skill_id": skill_id},
|
|
403
|
+
upgrade_type=upgrade_type,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
# Execute evolution
|
|
407
|
+
result = evolver.execute_evolution(proposal.proposal_id, updates=update_dict)
|
|
408
|
+
|
|
409
|
+
if result["status"] == "success":
|
|
410
|
+
click.echo(f"Evolved: {skill_id} v{result['version']}")
|
|
411
|
+
click.echo(f" Proposal: {proposal.proposal_id}")
|
|
412
|
+
click.echo(f" YAML updated: {result['yaml_updated']}")
|
|
413
|
+
if result.get("materialized"):
|
|
414
|
+
click.echo(" Re-materialized: yes")
|
|
415
|
+
else:
|
|
416
|
+
click.echo(f"Evolution failed: {result.get('error', result['status'])}")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@main.command()
|
|
420
|
+
@click.argument("proposal_id")
|
|
421
|
+
def heal(proposal_id: str):
|
|
422
|
+
"""Execute a healing proposal: apply fix and verify via BDD.
|
|
423
|
+
|
|
424
|
+
This is the CLI counterpart of the healing_execute MCP tool.
|
|
425
|
+
Use 'skillpool review --checkpoint L3' to scan for bugs first.
|
|
426
|
+
"""
|
|
427
|
+
from skillpool.evolver import EvolverLayer
|
|
428
|
+
from skillpool.monitor.bug_collector import BugCollector
|
|
429
|
+
from skillpool.monitor.self_healing import SelfHealingLoop
|
|
430
|
+
from skillpool.audit import AuditLayer
|
|
431
|
+
|
|
432
|
+
audit = AuditLayer()
|
|
433
|
+
evolver = EvolverLayer(audit_layer=audit)
|
|
434
|
+
collector = BugCollector(audit_layer=audit)
|
|
435
|
+
loop = SelfHealingLoop(bug_collector=collector, evolver=evolver, audit_layer=audit)
|
|
436
|
+
|
|
437
|
+
result = loop.execute_healing(proposal_id)
|
|
438
|
+
|
|
439
|
+
if result["status"] == "not_found":
|
|
440
|
+
click.echo(f"Healing proposal '{proposal_id}' not found.")
|
|
441
|
+
click.echo("Run a scan first to generate proposals.")
|
|
442
|
+
elif result["status"] == "needs_human":
|
|
443
|
+
click.echo("MAJOR upgrade requires human approval.")
|
|
444
|
+
elif result["status"] == "verified":
|
|
445
|
+
click.echo(f"Healed: {result['proposal_id']}")
|
|
446
|
+
click.echo(f" BDD passed: {result['verification']['bdd_passed']}")
|
|
447
|
+
if result["verification"].get("yaml_updated") or result["verification"].get("yaml_restored"):
|
|
448
|
+
click.echo(" YAML changes persisted: yes")
|
|
449
|
+
elif result["status"] == "rolled_back":
|
|
450
|
+
click.echo(f"Healing rolled back: {result['proposal_id']}")
|
|
451
|
+
click.echo(f" Reason: {result['verification']['reason']}")
|
|
452
|
+
else:
|
|
453
|
+
click.echo(f"Healing result: {result}")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# ── Review ────────────────────────────────────────────────────────
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
@main.command()
|
|
460
|
+
@click.option("--checkpoint", type=click.Choice(["L1", "L2", "L3", "L4"]), default="L2", help="Review checkpoint level")
|
|
461
|
+
def review(checkpoint: str):
|
|
462
|
+
"""Run a review checkpoint (L1-L4).
|
|
463
|
+
|
|
464
|
+
L1: DocsDD — 7-dim shadow review (non-blocking)
|
|
465
|
+
L2: SDD — 12-dim full review + VETO V1-V6
|
|
466
|
+
L3: BDD — baseline 5-dim + all VETO
|
|
467
|
+
L4: TDD — baseline regression, new blind spots only
|
|
468
|
+
"""
|
|
469
|
+
from skillpool.review import ReviewManager
|
|
470
|
+
from skillpool.audit import AuditLayer
|
|
471
|
+
|
|
472
|
+
audit = AuditLayer()
|
|
473
|
+
rm = ReviewManager(audit_layer=audit)
|
|
474
|
+
result = rm.run_checkpoint(checkpoint)
|
|
475
|
+
click.echo(f"Checkpoint {checkpoint}: {result.status}")
|
|
476
|
+
if result.veto_details:
|
|
477
|
+
for v in result.veto_details:
|
|
478
|
+
click.echo(f" VETO {v.rule}: {v.decision} ({v.reason})")
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ── Gate ────────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@main.group()
|
|
485
|
+
def gate():
|
|
486
|
+
"""4D paradigm gate management — assess, transition, status."""
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@gate.command()
|
|
490
|
+
@click.argument("task_description")
|
|
491
|
+
@click.option(
|
|
492
|
+
"--policy", "policy_path", type=click.Path(exists=True), default=None, help="Path to gate.policy YAML file"
|
|
493
|
+
)
|
|
494
|
+
@click.option("--files", "changed_files", default=None, help="Comma-separated list of changed files")
|
|
495
|
+
def assess(task_description: str, policy_path: str | None, changed_files: str | None):
|
|
496
|
+
"""Assess task complexity and set gate level.
|
|
497
|
+
|
|
498
|
+
Example: skillpool gate assess "new feature for core module" --policy gate.policy
|
|
499
|
+
"""
|
|
500
|
+
from skillpool.gate_policy.state_machine import GateStateMachine
|
|
501
|
+
from skillpool.gate_policy.parser import load_gate_policy
|
|
502
|
+
|
|
503
|
+
policy = None
|
|
504
|
+
if policy_path:
|
|
505
|
+
policy = load_gate_policy(Path(policy_path))
|
|
506
|
+
|
|
507
|
+
files_list = changed_files.split(",") if changed_files else []
|
|
508
|
+
gate_path = Path(tempfile.gettempdir()) / "skillpool_gate.json"
|
|
509
|
+
sm = GateStateMachine(gate_path)
|
|
510
|
+
|
|
511
|
+
level = sm.assess(task_description, files_list, policy)
|
|
512
|
+
click.echo(f"Assessed level: {level}")
|
|
513
|
+
click.echo(f"Current phase: {sm.state.current_phase}")
|
|
514
|
+
if sm.state.assessed_at:
|
|
515
|
+
click.echo(f"Assessed at: {sm.state.assessed_at}")
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
@gate.command()
|
|
519
|
+
@click.argument("target_phase")
|
|
520
|
+
@click.option("--state-path", type=click.Path(), default=None, help="Path to gate.json file")
|
|
521
|
+
def transition(target_phase: str, state_path: str | None):
|
|
522
|
+
"""Transition gate to target phase.
|
|
523
|
+
|
|
524
|
+
Valid phases: IDLE, ASSESSING, DOCSDD, SDD, BDD, TDD, REVIEW, COMPLETE
|
|
525
|
+
|
|
526
|
+
Example: skillpool gate transition DOCSDD
|
|
527
|
+
"""
|
|
528
|
+
from skillpool.gate_policy.state_machine import GateStateMachine
|
|
529
|
+
from skillpool.gate_policy.parser import GatePolicyError
|
|
530
|
+
|
|
531
|
+
gate_path = Path(state_path) if state_path else Path(tempfile.gettempdir()) / "skillpool_gate.json"
|
|
532
|
+
sm = GateStateMachine(gate_path)
|
|
533
|
+
|
|
534
|
+
try:
|
|
535
|
+
result = sm.transition(target_phase)
|
|
536
|
+
click.echo(f"Transitioned to: {result.current_phase}")
|
|
537
|
+
click.echo(f"Phase history: {len(result.phase_history)} transitions")
|
|
538
|
+
except GatePolicyError as e:
|
|
539
|
+
click.echo(f"Error [{e.error_code}]: {e.detail}", err=True)
|
|
540
|
+
raise SystemExit(1)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
@gate.command("status")
|
|
544
|
+
@click.option("--state-path", type=click.Path(), default=None, help="Path to gate.json file")
|
|
545
|
+
def gate_status(state_path: str | None):
|
|
546
|
+
"""Show current gate state.
|
|
547
|
+
|
|
548
|
+
Example: skillpool gate status
|
|
549
|
+
"""
|
|
550
|
+
from skillpool.gate_policy.state_machine import GateStateMachine
|
|
551
|
+
|
|
552
|
+
gate_path = Path(state_path) if state_path else Path(tempfile.gettempdir()) / "skillpool_gate.json"
|
|
553
|
+
sm = GateStateMachine(gate_path)
|
|
554
|
+
s = sm.state
|
|
555
|
+
|
|
556
|
+
click.echo(f"Current phase: {s.current_phase}")
|
|
557
|
+
click.echo(f"Assessed level: {s.assessed_level or 'N/A'}")
|
|
558
|
+
click.echo(f"Incremental mode: {s.incremental_mode}")
|
|
559
|
+
click.echo(f"Phase history: {len(s.phase_history)} transitions")
|
|
560
|
+
if s.changed_files:
|
|
561
|
+
click.echo(f"Changed files: {', '.join(s.changed_files)}")
|
|
562
|
+
if s.review_checkpoint.triggered:
|
|
563
|
+
click.echo(f"Review checkpoint: triggered (level={s.review_checkpoint.checkpoint_level})")
|
|
564
|
+
click.echo(f"Artifacts: {len([v for v in s.artifacts.values() if v])} complete / {len(s.artifacts)} total")
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@gate.command("reset")
|
|
568
|
+
@click.option("--state-path", type=click.Path(), default=None, help="Path to gate.json file")
|
|
569
|
+
def gate_reset(state_path: str | None):
|
|
570
|
+
"""Reset gate state to IDLE (preserves created_at).
|
|
571
|
+
|
|
572
|
+
Example: skillpool gate reset
|
|
573
|
+
"""
|
|
574
|
+
from skillpool.gate_policy.state_machine import GateStateMachine
|
|
575
|
+
|
|
576
|
+
gate_path = Path(state_path) if state_path else Path(tempfile.gettempdir()) / "skillpool_gate.json"
|
|
577
|
+
sm = GateStateMachine(gate_path)
|
|
578
|
+
result = sm.reset()
|
|
579
|
+
click.echo(f"Gate reset to: {result.current_phase}")
|
|
580
|
+
click.echo(f"Preserved created_at: {result.metadata.created_at}")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
# ── MCP ───────────────────────────────────────────────────────────
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@main.command()
|
|
587
|
+
@click.option("--agent-type", default="claude-code", help="Agent type for MCP server context")
|
|
588
|
+
def mcp(agent_type: str):
|
|
589
|
+
"""Start the SkillPool MCP server (stdio transport).
|
|
590
|
+
|
|
591
|
+
This is how Agents connect to SkillPool at runtime.
|
|
592
|
+
"""
|
|
593
|
+
from skillpool.mcp_server import mcp as mcp_server
|
|
594
|
+
|
|
595
|
+
mcp_server.run(transport="stdio")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
if __name__ == "__main__":
|
|
599
|
+
main()
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ── Audit Runtime ──────────────────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
@main.command("audit-runtime")
|
|
606
|
+
@click.option("--duration", default=5, type=int, help="Seconds to monitor before reporting (default: 5)")
|
|
607
|
+
@click.option("--log-file", type=click.Path(), default=None, help="Custom path for runtime audit JSONL log")
|
|
608
|
+
def audit_runtime(duration: int, log_file: str | None):
|
|
609
|
+
"""Install runtime audit hook and report security-sensitive events.
|
|
610
|
+
|
|
611
|
+
Uses sys.addaudithook (PEP 578) to monitor: exec, compile, open,
|
|
612
|
+
subprocess.Popen, socket.connect. The hook cannot be removed once
|
|
613
|
+
registered (by design).
|
|
614
|
+
"""
|
|
615
|
+
import time as _time
|
|
616
|
+
from pathlib import Path as _Path
|
|
617
|
+
|
|
618
|
+
from skillpool.utils.runtime_audit import RuntimeAuditHook
|
|
619
|
+
|
|
620
|
+
log_path = _Path(log_file) if log_file else None
|
|
621
|
+
hook = RuntimeAuditHook(log_file=log_path)
|
|
622
|
+
hook.install()
|
|
623
|
+
|
|
624
|
+
click.echo(f"[audit-runtime] Hook installed. Monitoring for {duration}s...")
|
|
625
|
+
click.echo(f"[audit-runtime] Tracked events: {', '.join(sorted(RuntimeAuditHook.MONITORED_EVENTS))}")
|
|
626
|
+
|
|
627
|
+
_time.sleep(duration)
|
|
628
|
+
|
|
629
|
+
events = hook.get_events()
|
|
630
|
+
if events:
|
|
631
|
+
click.echo(f"\n[audit-runtime] {len(events)} event(s) captured:")
|
|
632
|
+
for evt in events:
|
|
633
|
+
click.echo(f" {evt['timestamp']} {evt['event']} {evt['args']}")
|
|
634
|
+
else:
|
|
635
|
+
click.echo(f"\n[audit-runtime] No monitored events captured in {duration}s.")
|
|
636
|
+
|
|
637
|
+
if log_path is None:
|
|
638
|
+
default_log = get_data_dir() / "logs" / "runtime_audit.jsonl"
|
|
639
|
+
click.echo(f"[audit-runtime] Full log: {default_log}")
|
|
640
|
+
else:
|
|
641
|
+
click.echo(f"[audit-runtime] Full log: {log_path}")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ---------------------------------------------------------------------------
|
|
645
|
+
# cost command group
|
|
646
|
+
# ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@main.group()
|
|
650
|
+
def cost() -> None:
|
|
651
|
+
"""Cost estimation and budget management."""
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@cost.command()
|
|
655
|
+
@click.argument("skill_id")
|
|
656
|
+
@click.option("--skill-length", type=int, default=0, help="Character count of skill definition")
|
|
657
|
+
@click.option(
|
|
658
|
+
"--review-level", type=click.Choice(["L0", "L1", "L2", "L3+L2+"]), default="L1", help="Complexity review level"
|
|
659
|
+
)
|
|
660
|
+
@click.option(
|
|
661
|
+
"--include-review-checkpoint/--no-review-checkpoint", default=False, help="Include review checkpoint overhead"
|
|
662
|
+
)
|
|
663
|
+
@click.option("--emergency-bypass-path", type=str, default=None, help="Path to emergency_overrides.json")
|
|
664
|
+
def estimate(
|
|
665
|
+
skill_id: str,
|
|
666
|
+
skill_length: int,
|
|
667
|
+
review_level: str,
|
|
668
|
+
include_review_checkpoint: bool,
|
|
669
|
+
emergency_bypass_path: str | None,
|
|
670
|
+
) -> None:
|
|
671
|
+
"""Estimate session cost for a skill execution (P50 pricing).
|
|
672
|
+
|
|
673
|
+
Uses conservative $0.003/1K tokens pricing model.
|
|
674
|
+
"""
|
|
675
|
+
from skillpool.cost.token_governor import TokenGovernor, PRESET_AGENT_CONFIGS
|
|
676
|
+
|
|
677
|
+
governor = TokenGovernor(PRESET_AGENT_CONFIGS)
|
|
678
|
+
result = governor.estimate_session_cost(
|
|
679
|
+
skill_id=skill_id,
|
|
680
|
+
skill_length=skill_length,
|
|
681
|
+
review_level=review_level,
|
|
682
|
+
include_review_checkpoint=include_review_checkpoint,
|
|
683
|
+
emergency_bypass_path=emergency_bypass_path,
|
|
684
|
+
)
|
|
685
|
+
click.echo(f"Skill: {result.skill_id}")
|
|
686
|
+
click.echo(f"Skill Length: {result.skill_length} chars")
|
|
687
|
+
click.echo(f"Token Count: {result.token_count}")
|
|
688
|
+
click.echo(f"Base Cost: ${result.base_cost_usd:.6f}")
|
|
689
|
+
if result.l2_review_overhead_usd > 0:
|
|
690
|
+
click.echo(f"L2 Review Overhead: ${result.l2_review_overhead_usd:.6f}")
|
|
691
|
+
if result.l3_review_overhead_usd > 0:
|
|
692
|
+
click.echo(f"L3 Review Overhead: ${result.l3_review_overhead_usd:.6f}")
|
|
693
|
+
if result.review_checkpoint_overhead_usd > 0:
|
|
694
|
+
click.echo(f"Review Checkpoint Overhead: ${result.review_checkpoint_overhead_usd:.6f}")
|
|
695
|
+
click.echo(f"Total Cost: ${result.total_cost_usd:.6f}")
|
|
696
|
+
click.echo(f"Price: ${result.price_per_1k_tokens}/1K tokens (P50)")
|
|
697
|
+
if not result.gate_passed:
|
|
698
|
+
click.echo(f"Gate: BLOCKED — {result.gate_block_reason}")
|
|
699
|
+
if result.emergency_bypass_active:
|
|
700
|
+
click.echo("Emergency Bypass: ACTIVE")
|