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.
Files changed (90) hide show
  1. skillpool/__init__.py +74 -0
  2. skillpool/__main__.py +6 -0
  3. skillpool/adapters/__init__.py +8 -0
  4. skillpool/adapters/base.py +41 -0
  5. skillpool/adapters/claude_adapter.py +36 -0
  6. skillpool/adapters/codex_adapter.py +92 -0
  7. skillpool/adapters/hermes_adapter.py +38 -0
  8. skillpool/audit/__init__.py +651 -0
  9. skillpool/bridge/__init__.py +16 -0
  10. skillpool/bridge/freeze_detector.py +134 -0
  11. skillpool/bridge/maintenance.py +119 -0
  12. skillpool/bridge/wal_manager.py +136 -0
  13. skillpool/clawmem_client.py +176 -0
  14. skillpool/cli.py +700 -0
  15. skillpool/combiner/__init__.py +31 -0
  16. skillpool/combiner/lifecycle.py +453 -0
  17. skillpool/combiner/models.py +99 -0
  18. skillpool/config.py +34 -0
  19. skillpool/cost/__init__.py +111 -0
  20. skillpool/cost/audit_hash.py +51 -0
  21. skillpool/cost/budget_tracker.py +66 -0
  22. skillpool/cost/dashboard.py +189 -0
  23. skillpool/cost/models.py +129 -0
  24. skillpool/cost/token_governor.py +264 -0
  25. skillpool/cost/trace_ceiling.py +38 -0
  26. skillpool/csdf.py +126 -0
  27. skillpool/evolver/__init__.py +978 -0
  28. skillpool/gain/__init__.py +285 -0
  29. skillpool/gate.py +282 -0
  30. skillpool/gate_policy/__init__.py +31 -0
  31. skillpool/gate_policy/incremental.py +157 -0
  32. skillpool/gate_policy/parser.py +258 -0
  33. skillpool/gate_policy/state_machine.py +432 -0
  34. skillpool/graph/__init__.py +14 -0
  35. skillpool/graph/ppr.py +279 -0
  36. skillpool/health/__init__.py +73 -0
  37. skillpool/health/check.py +85 -0
  38. skillpool/health/degradation.py +90 -0
  39. skillpool/health/models.py +43 -0
  40. skillpool/hooks/__init__.py +4 -0
  41. skillpool/hooks/security_scanner.py +288 -0
  42. skillpool/lifecycle.py +150 -0
  43. skillpool/materializer/__init__.py +124 -0
  44. skillpool/materializer/budget_cropper.py +178 -0
  45. skillpool/materializer/csdf_loader.py +114 -0
  46. skillpool/materializer/lazy_loader.py +265 -0
  47. skillpool/materializer/lifecycle_filter.py +93 -0
  48. skillpool/materializer/mapper.py +178 -0
  49. skillpool/materializer/models.py +66 -0
  50. skillpool/mcp_server.py +2005 -0
  51. skillpool/monitor/__init__.py +576 -0
  52. skillpool/monitor/bug_collector.py +392 -0
  53. skillpool/monitor/defect_classifier.py +218 -0
  54. skillpool/monitor/self_healing.py +530 -0
  55. skillpool/monitor/telemetry_bridge.py +197 -0
  56. skillpool/paradigm/__init__.py +312 -0
  57. skillpool/paradigm/override.py +285 -0
  58. skillpool/profile.py +94 -0
  59. skillpool/quality.py +254 -0
  60. skillpool/registry/__init__.py +509 -0
  61. skillpool/registry/models.py +98 -0
  62. skillpool/resolver/__init__.py +320 -0
  63. skillpool/resolver/cache.py +103 -0
  64. skillpool/resolver/circuit_breaker.py +103 -0
  65. skillpool/resolver/conflict_detector.py +111 -0
  66. skillpool/resolver/health_filter.py +38 -0
  67. skillpool/resolver/models.py +154 -0
  68. skillpool/resolver/rate_limiter.py +48 -0
  69. skillpool/resolver/skill_graph.py +183 -0
  70. skillpool/review/__init__.py +242 -0
  71. skillpool/review/async_queue.py +96 -0
  72. skillpool/review/checkpoint_runner.py +345 -0
  73. skillpool/review/models.py +164 -0
  74. skillpool/review/suspect_marker.py +39 -0
  75. skillpool/review/veto_evaluator.py +94 -0
  76. skillpool/router/__init__.py +481 -0
  77. skillpool/schemas.py +119 -0
  78. skillpool/synergy/__init__.py +240 -0
  79. skillpool/synergy/detector.py +5 -0
  80. skillpool/telemetry.py +126 -0
  81. skillpool/utils/__init__.py +21 -0
  82. skillpool/utils/changelog.py +218 -0
  83. skillpool/utils/logger.py +273 -0
  84. skillpool/utils/runtime_audit.py +163 -0
  85. skillpool/utils/time_utils.py +13 -0
  86. skillpool-4.3.0.dist-info/METADATA +21 -0
  87. skillpool-4.3.0.dist-info/RECORD +90 -0
  88. skillpool-4.3.0.dist-info/WHEEL +5 -0
  89. skillpool-4.3.0.dist-info/entry_points.txt +3 -0
  90. 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")