Skip to content

Commit bcb17a9

Browse files
committed
feat: wire Skills/Eval pages into Kairos settings; add skills/eval/teams/esdb CLI commands; fix ruff lint in cli.py; update API surface snapshot
1 parent 9524a70 commit bcb17a9

3 files changed

Lines changed: 295 additions & 2 deletions

File tree

src/specsmith/cli.py

Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7983,5 +7983,294 @@ def rules_list_cmd(project_dir: str, as_json: bool) -> None:
79837983
pass # graceful degradation if commands module has issues
79847984

79857985

7986+
# ---------------------------------------------------------------------------
7987+
# specsmith skills — AI Skills Builder (Phase A)
7988+
# ---------------------------------------------------------------------------
7989+
7990+
7991+
@main.group(name="skills")
7992+
def skills_group() -> None:
7993+
"""Build, list, test, and activate AI agent skills."""
7994+
7995+
7996+
@skills_group.command(name="build")
7997+
@click.argument("description")
7998+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
7999+
@click.option("--tag", "tags", multiple=True, help="Tags for the skill.")
8000+
def skills_build_cmd(description: str, project_dir: str, tags: tuple[str, ...]) -> None:
8001+
"""Generate a new skill from a natural-language description."""
8002+
from specsmith.skills_builder import build_skill
8003+
8004+
spec = build_skill(description, project_dir=project_dir, tags=list(tags))
8005+
console.print(f"[green]\u2713[/green] Skill created: [bold]{spec.name}[/bold] ({spec.id})")
8006+
console.print(f" [dim]{spec.purpose}[/dim]")
8007+
8008+
8009+
@skills_group.command(name="list")
8010+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8011+
@click.option("--json", "as_json", is_flag=True, default=False)
8012+
def skills_list_cmd(project_dir: str, as_json: bool) -> None:
8013+
"""List available skills."""
8014+
import json as _json
8015+
8016+
from specsmith.skills_builder import list_skills
8017+
8018+
skills = list_skills(project_dir)
8019+
if as_json:
8020+
click.echo(_json.dumps({"skills": [s.to_dict() for s in skills]}, indent=2))
8021+
return
8022+
if not skills:
8023+
console.print("[dim]No skills found. Use `specsmith skills build` to create one.[/dim]")
8024+
return
8025+
console.print(f"[bold]Skills[/bold] ({len(skills)})\n")
8026+
for s in skills:
8027+
badge = "[green]\u2714[/green]" if s.active else "[dim]\u25cb[/dim]"
8028+
console.print(f" {badge} [bold]{s.id}[/bold] {s.name}")
8029+
8030+
8031+
@skills_group.command(name="test")
8032+
@click.argument("skill_id")
8033+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8034+
def skills_test_cmd(skill_id: str, project_dir: str) -> None:
8035+
"""Dry-run a skill to verify its spec."""
8036+
from specsmith.skills_builder import list_skills
8037+
8038+
skills = {s.id: s for s in list_skills(project_dir)}
8039+
if skill_id not in skills:
8040+
console.print(f"[red]Skill not found:[/red] {skill_id}")
8041+
raise SystemExit(1)
8042+
spec = skills[skill_id]
8043+
console.print(f"[bold]Testing:[/bold] {spec.name}")
8044+
console.print(f" Purpose: {spec.purpose}")
8045+
console.print(f" Tools: {', '.join(spec.tools_used) or 'none'}")
8046+
console.print(f" Stop conditions: {len(spec.stop_conditions)}")
8047+
console.print("[green]\u2713[/green] Skill spec is valid (dry-run).")
8048+
8049+
8050+
@skills_group.command(name="activate")
8051+
@click.argument("skill_id")
8052+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8053+
def skills_activate_cmd(skill_id: str, project_dir: str) -> None:
8054+
"""Activate a skill for agent use."""
8055+
from specsmith.skills_builder import activate_skill
8056+
8057+
if activate_skill(skill_id, project_dir):
8058+
console.print(f"[green]\u2713[/green] Skill [bold]{skill_id}[/bold] activated.")
8059+
else:
8060+
console.print(f"[red]Skill not found:[/red] {skill_id}")
8061+
raise SystemExit(1)
8062+
8063+
8064+
main.add_command(skills_group)
8065+
8066+
8067+
# ---------------------------------------------------------------------------
8068+
# specsmith eval — Eval-Driven Development framework (Phase P3)
8069+
# ---------------------------------------------------------------------------
8070+
8071+
8072+
@main.group(name="eval")
8073+
def eval_group() -> None:
8074+
"""Run eval suites to benchmark AI model capabilities."""
8075+
8076+
8077+
@eval_group.command(name="list")
8078+
@click.option("--json", "as_json", is_flag=True, default=False)
8079+
def eval_list_cmd(as_json: bool) -> None:
8080+
"""List available eval suites."""
8081+
import json as _json
8082+
8083+
from specsmith.eval.builtins import list_suites
8084+
8085+
suites = list_suites()
8086+
if as_json:
8087+
click.echo(_json.dumps({"suites": [s.to_dict() for s in suites]}, indent=2))
8088+
return
8089+
if not suites:
8090+
console.print("[dim]No eval suites available.[/dim]")
8091+
return
8092+
console.print(f"[bold]Eval Suites[/bold] ({len(suites)})\n")
8093+
for s in suites:
8094+
console.print(f" [bold]{s.id}[/bold] {s.name} ({len(s.cases)} cases)")
8095+
console.print(f" [dim]{s.description}[/dim]")
8096+
8097+
8098+
@eval_group.command(name="run")
8099+
@click.argument("suite_id", default="core")
8100+
@click.option("--json", "as_json", is_flag=True, default=False)
8101+
def eval_run_cmd(suite_id: str, as_json: bool) -> None:
8102+
"""Run an eval suite (stub mode — no real LLM calls)."""
8103+
import json as _json
8104+
8105+
from specsmith.eval.builtins import get_suite
8106+
from specsmith.eval.runner import run_suite
8107+
8108+
suite = get_suite(suite_id)
8109+
if suite is None:
8110+
console.print(f"[red]Suite not found:[/red] {suite_id}")
8111+
raise SystemExit(1)
8112+
report = run_suite(suite, stub=True)
8113+
if as_json:
8114+
click.echo(_json.dumps(report.to_dict(), indent=2))
8115+
return
8116+
icon = "[green]\u2714[/green]" if report.failed == 0 else "[red]\u2717[/red]"
8117+
console.print(
8118+
f"{icon} [bold]{suite_id}[/bold] "
8119+
f"{report.passed}/{report.total} passed "
8120+
f"avg score {report.avg_score:.0%} "
8121+
f"avg latency {report.avg_latency_ms:.0f}ms"
8122+
)
8123+
for r in report.results:
8124+
ri = "[green]\u2713[/green]" if r.passed else "[red]\u2717[/red]"
8125+
console.print(f" {ri} {r.case_id} score={r.score:.0%} {r.latency_ms:.0f}ms")
8126+
8127+
8128+
@eval_group.command(name="report")
8129+
@click.argument("suite_id", default="core")
8130+
@click.option("--output", type=click.Path(), default=None, help="Write markdown report to file.")
8131+
def eval_report_cmd(suite_id: str, output: str | None) -> None:
8132+
"""Generate a markdown eval report."""
8133+
from specsmith.eval.builtins import get_suite
8134+
from specsmith.eval.runner import generate_markdown_report, run_suite
8135+
8136+
suite = get_suite(suite_id)
8137+
if suite is None:
8138+
console.print(f"[red]Suite not found:[/red] {suite_id}")
8139+
raise SystemExit(1)
8140+
report = run_suite(suite, stub=True)
8141+
md = generate_markdown_report(report)
8142+
if output:
8143+
Path(output).write_text(md, encoding="utf-8")
8144+
console.print(f"[green]\u2713[/green] Report written to {output}")
8145+
else:
8146+
click.echo(md)
8147+
8148+
8149+
main.add_command(eval_group)
8150+
8151+
8152+
# ---------------------------------------------------------------------------
8153+
# specsmith teams — Multi-agent team coordination (Phase P4)
8154+
# ---------------------------------------------------------------------------
8155+
8156+
8157+
@main.group(name="teams")
8158+
def teams_group() -> None:
8159+
"""List and run multi-agent teams."""
8160+
8161+
8162+
@teams_group.command(name="list")
8163+
@click.option("--json", "as_json", is_flag=True, default=False)
8164+
def teams_list_cmd(as_json: bool) -> None:
8165+
"""List predefined agent teams."""
8166+
import json as _json
8167+
8168+
from specsmith.agent.teams import list_teams
8169+
8170+
teams = list_teams()
8171+
if as_json:
8172+
click.echo(_json.dumps({"teams": [t.to_dict() for t in teams]}, indent=2))
8173+
return
8174+
console.print(f"[bold]Agent Teams[/bold] ({len(teams)})\n")
8175+
for t in teams:
8176+
roles = ", ".join(m.role for m in t.members)
8177+
console.print(f" [bold]{t.id}[/bold] {t.name} [{roles}]")
8178+
console.print(f" [dim]{t.description}[/dim]")
8179+
8180+
8181+
@teams_group.command(name="run")
8182+
@click.argument("team_id")
8183+
@click.argument("task")
8184+
def teams_run_cmd(team_id: str, task: str) -> None:
8185+
"""Spawn a team to execute a task (stub — prints team plan)."""
8186+
from specsmith.agent.teams import get_team
8187+
8188+
team = get_team(team_id)
8189+
if team is None:
8190+
console.print(f"[red]Team not found:[/red] {team_id}")
8191+
raise SystemExit(1)
8192+
console.print(f"[bold]Spawning team:[/bold] {team.name}")
8193+
for m in team.members:
8194+
console.print(f" \u2192 {m.role} ({'required' if m.required else 'optional'})")
8195+
console.print(f"[dim]Task: {task}[/dim]")
8196+
console.print(
8197+
"[yellow]\u26a0[/yellow] Team execution is in stub mode (no real agents spawned)."
8198+
)
8199+
8200+
8201+
main.add_command(teams_group)
8202+
8203+
8204+
# ---------------------------------------------------------------------------
8205+
# specsmith esdb — ChronoMemory ESDB management (Phase ESDB)
8206+
# ---------------------------------------------------------------------------
8207+
8208+
8209+
@main.group(name="esdb")
8210+
def esdb_group() -> None:
8211+
"""Manage the ChronoMemory Epistemic State Database."""
8212+
8213+
8214+
@esdb_group.command(name="status")
8215+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8216+
@click.option("--json", "as_json", is_flag=True, default=False)
8217+
def esdb_status_cmd(project_dir: str, as_json: bool) -> None:
8218+
"""Show ESDB status and record counts."""
8219+
import json as _json
8220+
8221+
from specsmith.esdb.bridge import EsdbBridge
8222+
8223+
bridge = EsdbBridge(project_dir)
8224+
st = bridge.status()
8225+
counts = bridge.record_counts()
8226+
if as_json:
8227+
click.echo(_json.dumps({"status": st.to_dict(), "counts": counts}, indent=2))
8228+
return
8229+
icon = "[green]\u25cf[/green]" if st.available else "[red]\u25cf[/red]"
8230+
console.print(f"{icon} ESDB — {st.backend}")
8231+
console.print(f" Records: {st.record_count}")
8232+
for kind, count in counts.items():
8233+
console.print(f" {kind}: {count}")
8234+
if st.chain_valid:
8235+
console.print(" [green]\u2714[/green] WAL chain integrity OK")
8236+
8237+
8238+
@esdb_group.command(name="migrate")
8239+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8240+
def esdb_migrate_cmd(project_dir: str) -> None:
8241+
"""Migrate .specsmith/ flat JSON to ESDB (stub — validates data)."""
8242+
from specsmith.esdb.bridge import EsdbBridge
8243+
8244+
bridge = EsdbBridge(project_dir)
8245+
reqs = bridge.requirements()
8246+
tests = bridge.testcases()
8247+
console.print("[bold]Migration scan:[/bold]")
8248+
console.print(f" Requirements: {len(reqs)}")
8249+
console.print(f" Test cases: {len(tests)}")
8250+
console.print("[green]\u2713[/green] Data validated. Full Rust ESDB migration not yet active.")
8251+
console.print(
8252+
"[dim]When ChronoMemory native engine is linked, run this again to migrate.[/dim]"
8253+
)
8254+
8255+
8256+
@esdb_group.command(name="replay")
8257+
@click.option("--project-dir", type=click.Path(exists=True), default=".")
8258+
def esdb_replay_cmd(project_dir: str) -> None:
8259+
"""Replay ESDB WAL to verify state integrity (stub)."""
8260+
from specsmith.esdb.bridge import EsdbBridge
8261+
8262+
bridge = EsdbBridge(project_dir)
8263+
st = bridge.status()
8264+
console.print(f"[bold]Replay check:[/bold] {st.backend}")
8265+
console.print(f" Records: {st.record_count}")
8266+
if st.chain_valid:
8267+
console.print("[green]\u2714[/green] WAL chain valid — state consistent.")
8268+
else:
8269+
console.print("[red]\u2717[/red] WAL chain integrity failure detected.")
8270+
8271+
8272+
main.add_command(esdb_group)
8273+
8274+
79868275
if __name__ == "__main__":
79878276
main()

src/specsmith/esdb/bridge.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def status(self) -> EsdbStatus:
6969
tests = self._load_testcases()
7070
return EsdbStatus(
7171
available=True,
72-
backend="json-fallback",
72+
backend=".specsmith/ JSON (ChronoMemory native pending)",
7373
record_count=len(reqs) + len(tests),
7474
)
7575

tests/fixtures/api_surface.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
"drive",
2525
"endpoints",
2626
"epistemic-audit",
27+
"esdb",
28+
"eval",
2729
"exec",
2830
"exec-profiles",
2931
"export",
@@ -66,10 +68,12 @@
6668
"session",
6769
"session-end",
6870
"skill",
71+
"skills",
6972
"status",
7073
"stress-test",
7174
"suggest-command",
7275
"sync",
76+
"teams",
7377
"tools",
7478
"trace",
7579
"update",
@@ -102,4 +106,4 @@
102106
"verify_retry": 2,
103107
"verify_stop": 3
104108
}
105-
}
109+
}

0 commit comments

Comments
 (0)