diff --git a/src/forge_loop/cli.py b/src/forge_loop/cli.py index f0cb8ab..f6cdf5d 100644 --- a/src/forge_loop/cli.py +++ b/src/forge_loop/cli.py @@ -290,6 +290,7 @@ def _cmd(args: SimpleNamespace) -> int: _cmd_audit, _cmd_research_add, _cmd_memory_list, + _cmd_memory_skills, _cmd_manifesto_suggest, _cmd_record_session, _cmd_retry, @@ -321,6 +322,7 @@ def _cmd(args: SimpleNamespace) -> int: _make_cmd("audit"), _make_cmd("research_add"), _make_cmd("memory_list"), + _make_cmd("memory_skills"), _make_cmd("manifesto_suggest"), _make_cmd("record_session"), _make_cmd("retry"), @@ -581,6 +583,17 @@ def cmd_memory_list( _exit(_cmd_memory_list(SimpleNamespace(kind=kind, tag=tag))) +@memory_app.command( + "skills", + help=( + "Read-only: print the learned skill-tree inventory — leaf recipes, " + "internal nodes, expired cards, and the per-area distribution (#458)." + ), +) +def cmd_memory_skills() -> None: + _exit(_cmd_memory_skills(SimpleNamespace())) + + @app.command("record-session", help="Record a real SDK session to a JSONL fixture.") def cmd_record_session( issue: int | None = typer.Option(None, "--issue"), diff --git a/src/forge_loop/cli_product_commands.py b/src/forge_loop/cli_product_commands.py index 75f4f93..e77ab7f 100644 --- a/src/forge_loop/cli_product_commands.py +++ b/src/forge_loop/cli_product_commands.py @@ -790,6 +790,48 @@ def _echo_memory_item(self, item: Any) -> None: f"source={_format_memory_source(prov)}" ) + def _cmd_memory_skills(self, args: SimpleNamespace) -> int: + """`forge-loop memory skills` — read-only skill-tree inventory (#458). + + Surfaces the learned procedural skill cards — leaf recipes and the + internal nodes distilled over them — plus the per-area distribution, so + an operator can see at a glance whether the skill tree is auto- + maintaining. Strictly read-only (calls only ``compute_skill_inventory`` + over ``list_active``). Store absent/unreadable → fail soft (exit 1), + mirroring ``memory list``. + """ + from forge_loop.settings import ConfigError + from forge_loop.skill_stats import compute_skill_inventory + + repo_path = Path.cwd() + try: + cfg = self.load() + repo_path = Path(cfg.repo).resolve() if getattr(cfg, "repo", None) else repo_path + except ConfigError as exc: + _log.warning( + "memory_skills: config load failed; using cwd", + repo_path=str(repo_path), + error=str(exc), + ) + + try: + store = self.memory_store_factory(repo_path) + inv = compute_skill_inventory(store) + except Exception as exc: # noqa: BLE001 — clear operator-facing failure, no traceback + typer.echo(f"memory skills: memory store unavailable: {exc}", err=True) + return 1 + + typer.echo( + f"skill tree: {inv.leaves} leaves + {inv.nodes} internal nodes ({inv.expired} expired)" + ) + if inv.areas: + typer.echo("by area:") + for area, count in sorted(inv.areas.items(), key=lambda kv: (-kv[1], kv[0])): + typer.echo(f" {count:3d} {area}") + else: + typer.echo(" (no skills harvested yet)") + return 0 + def _cmd_audit(self, args: SimpleNamespace) -> int: """`forge-loop audit` — codebase-state audit (issue #156). diff --git a/src/forge_loop/skill_stats.py b/src/forge_loop/skill_stats.py new file mode 100644 index 0000000..3a2e266 --- /dev/null +++ b/src/forge_loop/skill_stats.py @@ -0,0 +1,56 @@ +"""Skill-tree inventory stats — the data behind ``forge-loop skill-stats``. + +Pure read over the procedural-memory store: how many leaf skills and internal +nodes are live, how many are expired, and the per-area distribution. Kept free +of I/O so it is unit-tested against a real +:class:`~forge_loop.memory.store.SqliteMemoryStore`; the CLI layer renders it. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass + +from forge_loop.memory.models import ( + AREA_NODE_TAG, + EXPIRED_TAG, + MemoryKind, + area_from_tags, +) +from forge_loop.memory.store import MemoryStore + +__all__ = ["SkillInventory", "compute_skill_inventory"] + + +@dataclass(frozen=True) +class SkillInventory: + """A snapshot of the live skill tree.""" + + leaves: int + nodes: int + expired: int + areas: dict[str, int] + + +def compute_skill_inventory(store: MemoryStore) -> SkillInventory: + """Summarise the active procedural skill cards in ``store``. + + Leaves and internal nodes are counted separately; expired cards (tagged + :data:`~forge_loop.memory.models.EXPIRED_TAG`) are counted on their own and + excluded from the leaf/node/area tallies — they no longer participate in the + tree. ``areas`` maps each area path to its live (non-expired) card count. + """ + leaves = nodes = expired = 0 + areas: Counter[str] = Counter() + for item in store.list_active(kind=MemoryKind.PROCEDURAL): + if EXPIRED_TAG in item.tags: + expired += 1 + continue + area = area_from_tags(item.tags) + if area: + areas[area] += 1 + if AREA_NODE_TAG in item.tags: + nodes += 1 + else: + leaves += 1 + return SkillInventory(leaves=leaves, nodes=nodes, expired=expired, areas=dict(areas)) diff --git a/tests/test_skill_stats.py b/tests/test_skill_stats.py new file mode 100644 index 0000000..58088bd --- /dev/null +++ b/tests/test_skill_stats.py @@ -0,0 +1,110 @@ +"""Skill-tree inventory stats for the `forge-loop skill-stats` command.""" + +from __future__ import annotations + +from pathlib import Path + +from forge_loop.memory.models import AREA_NODE_TAG, EXPIRED_TAG, area_tag +from forge_loop.memory.store import MemoryStore, SqliteMemoryStore +from forge_loop.runner.learning import record_procedural_skill +from forge_loop.skill_stats import compute_skill_inventory + + +def _store(tmp_path: Path) -> SqliteMemoryStore: + return SqliteMemoryStore(tmp_path / "memory.db") + + +def _leaf(store: MemoryStore, *, area: str, sig: str, extra: tuple[str, ...] = ()) -> str: + return record_procedural_skill( + store, + failing_signal=sig, + target=f"{area}.rs", + title=f"{area}: {sig}", + body="recipe", + source_key=f"k:{area}:{sig}", + source_task_ref="issue:#1", + extra_tags=(area_tag(area), *extra), + ) + + +def test_inventory_counts_leaves_nodes_areas(tmp_path: Path) -> None: + store = _store(tmp_path) + _leaf(store, area="a/b", sig="s1") + _leaf(store, area="a/c", sig="s2") + _leaf(store, area="a", sig="node", extra=(AREA_NODE_TAG,)) + + inv = compute_skill_inventory(store) + + assert inv.leaves == 2 + assert inv.nodes == 1 + assert inv.expired == 0 + assert inv.areas == {"a/b": 1, "a/c": 1, "a": 1} + + +def test_inventory_excludes_expired_from_leaves_and_areas(tmp_path: Path) -> None: + store = _store(tmp_path) + _leaf(store, area="a/b", sig="live") + _leaf(store, area="a/d", sig="dead", extra=(EXPIRED_TAG,)) + + inv = compute_skill_inventory(store) + + assert inv.leaves == 1 # the expired one is not a live leaf + assert inv.expired == 1 + assert "a/d" not in inv.areas + + +def test_inventory_empty_store(tmp_path: Path) -> None: + inv = compute_skill_inventory(_store(tmp_path)) + assert inv.leaves == 0 + assert inv.nodes == 0 + assert inv.expired == 0 + assert inv.areas == {} + + +# --- CLI: forge-loop memory skills ------------------------------------------- + +from types import SimpleNamespace # noqa: E402 + +import pytest # noqa: E402 +from typer.testing import CliRunner # noqa: E402 + +from forge_loop import cli # noqa: E402 +from forge_loop._testing.memory_store import FakeMemoryStore # noqa: E402 + + +@pytest.fixture +def runner() -> CliRunner: + try: + return CliRunner(mix_stderr=False) # type: ignore[call-arg] + except TypeError: + return CliRunner() + + +def test_cli_memory_skills_reports_inventory( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli, "load", lambda: SimpleNamespace(repo=tmp_path, github_repo="acme/x")) + store = FakeMemoryStore() + monkeypatch.setattr(cli, "_memory_store_factory", lambda _repo: store) + _leaf(store, area="pulsar-node/ledger", sig="s1") + _leaf(store, area="pulsar-node/http", sig="s2") + + result = runner.invoke(cli.app, ["memory", "skills"]) + + assert result.exit_code == 0 + assert "2 leaves" in result.stdout + assert "pulsar-node/ledger" in result.stdout + + +def test_cli_memory_skills_empty( + runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(cli, "load", lambda: SimpleNamespace(repo=tmp_path, github_repo="x/y")) + monkeypatch.setattr(cli, "_memory_store_factory", lambda _repo: FakeMemoryStore()) + + result = runner.invoke(cli.app, ["memory", "skills"]) + + assert result.exit_code == 0 + assert "no skills harvested yet" in result.stdout