Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/forge_loop/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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"),
Expand Down
42 changes: 42 additions & 0 deletions src/forge_loop/cli_product_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
56 changes: 56 additions & 0 deletions src/forge_loop/skill_stats.py
Original file line number Diff line number Diff line change
@@ -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))
110 changes: 110 additions & 0 deletions tests/test_skill_stats.py
Original file line number Diff line number Diff line change
@@ -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
Loading