From 7c839d2e1273e8ecbcbc8e6fc638bfce458451ef Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 17 May 2026 19:47:13 -0700 Subject: [PATCH] feat(cli): `core chat --list-identity-packs` + companion-file filter Adds the discovery flag callers have been asking for since ADR-0027. Short-circuits before the REPL launches; supports both a human-readable table and `--json` machine output. Drives the loader's existing `available_packs()` helper. Bug fix on the way: `available_packs()` was globbing every `*.json` in the search path, so the Phase-5 companion `.mastery_report.json` files were leaking into the list as fake packs with empty fields. The helper now skips any file ending in `.mastery_report.json` and rejects JSON that lacks the required `schema_version` / `value_axes` fields. CLI output: pack_id version ratified description ------------------- ------- -------- ----------- default_general_v1 1.0.0 yes Balanced general identity... generosity_first_v1 1.0.0 yes Generosity-first specialization... precision_first_v1 1.0.0 yes Precision-first specialization... Tests: +3 (CLI table, CLI JSON, companion-file filter regression). test_identity_packs.py: 23 -> 26. cognition / smoke green. Docs: docs/identity_packs.md CLI usage block updated; memory 'identity-packs.md' closes that follow-up. --- core/cli.py | 38 +++++++++++++++++++++++++ docs/identity_packs.md | 17 ++++++++---- packs/identity/loader.py | 29 ++++++++++++------- tests/test_identity_packs.py | 54 ++++++++++++++++++++++++++++++++++++ 4 files changed, 122 insertions(+), 16 deletions(-) diff --git a/core/cli.py b/core/cli.py index 99f6cb19..f258749f 100644 --- a/core/cli.py +++ b/core/cli.py @@ -175,8 +175,36 @@ def _runtime_config_from_args(args: argparse.Namespace): ) +def _print_identity_packs(use_json: bool) -> int: + """Print discoverable identity packs. Returns process exit code.""" + from packs.identity.loader import available_packs + + packs = available_packs() + if use_json: + import json as _json + print(_json.dumps(packs, indent=2, sort_keys=True)) + return 0 + if not packs: + print("(no identity packs found on default search path)") + return 0 + pack_w = max(len("pack_id"), max(len(str(p["pack_id"])) for p in packs)) + ver_w = max(len("version"), max(len(str(p["version"])) for p in packs)) + print(f"{'pack_id':<{pack_w}} {'version':<{ver_w}} ratified description") + print(f"{'-' * pack_w} {'-' * ver_w} -------- -----------") + for p in packs: + flag = "yes" if p["ratified"] else "no " + print( + f"{str(p['pack_id']):<{pack_w}} " + f"{str(p['version']):<{ver_w}} " + f"{flag:<8} {p['description']}" + ) + return 0 + + def cmd_chat(args: argparse.Namespace) -> int: """Launch a readline REPL backed by ChatRuntime.""" + if getattr(args, "list_identity_packs", False): + return _print_identity_packs(use_json=getattr(args, "json", False)) try: from chat.runtime import ChatRuntime except Exception as exc: # pragma: no cover - exercised by CLI in broken envs @@ -1107,6 +1135,16 @@ def build_parser() -> argparse.ArgumentParser: chat = subparsers.add_parser("chat", help="start the interactive chat REPL") _add_runtime_policy_args(chat) + chat.add_argument( + "--list-identity-packs", + action="store_true", + help="list discoverable identity packs and exit (no REPL launched)", + ) + chat.add_argument( + "--json", + action="store_true", + help="emit machine-readable JSON (with --list-identity-packs)", + ) chat.set_defaults(func=cmd_chat) test = subparsers.add_parser("test", help="run pytest with curated suite aliases or direct passthrough") diff --git a/docs/identity_packs.md b/docs/identity_packs.md index 4ae65202..36e56736 100644 --- a/docs/identity_packs.md +++ b/docs/identity_packs.md @@ -124,17 +124,22 @@ The loader is path-aware: deployments may supply `search_paths=("/srv/myapp/pack ## CLI usage ```bash -core pulse "What is truth?" -# Loads default identity (currently default_general_v1). +core chat +# Loads the default identity pack (currently default_general_v1). -core pulse --identity precision_first_v1 "What is truth?" -# Loads a specific pack. Pack must exist on the loader's search paths. +core chat --identity precision_first_v1 +# Loads a specific pack. Pack must exist on the loader's search paths. -core pulse --list-identity-packs +core chat --list-identity-packs # Lists discoverable packs with description + ratification status. +# Short-circuits before the REPL launches. + +core chat --list-identity-packs --json +# Same listing as machine-readable JSON (pack_id, version, description, +# ratified, path). core chat --identity generosity_first_v1 -# Same flag, applies to the chat surface. +# A different specialization on the chat surface. CORE_DEFAULT_IDENTITY_PACK=precision_first_v1 core pulse "..." # Environment override of the default. Takes precedence over the diff --git a/packs/identity/loader.py b/packs/identity/loader.py index dde8ffa7..a0087abb 100644 --- a/packs/identity/loader.py +++ b/packs/identity/loader.py @@ -103,20 +103,29 @@ def available_packs( if not d.is_dir(): continue for entry in sorted(d.glob("*.json")): + # Skip companion artifacts (mastery reports, etc.) — only + # real identity packs declare ``schema_version`` and + # ``value_axes``. + if entry.name.endswith(".mastery_report.json"): + continue try: raw = _read_json(entry) - pack_id = str(raw.get("pack_id", entry.stem)) - if pack_id in seen: - continue - seen[pack_id] = { - "pack_id": pack_id, - "version": str(raw.get("version", "")), - "description": str(raw.get("description", "")), - "ratified": bool(raw.get("mastery_report_sha256")), - "path": str(entry), - } except IdentityPackError: continue + if not isinstance(raw, dict): + continue + if "schema_version" not in raw or "value_axes" not in raw: + continue + pack_id = str(raw.get("pack_id", entry.stem)) + if pack_id in seen: + continue + seen[pack_id] = { + "pack_id": pack_id, + "version": str(raw.get("version", "")), + "description": str(raw.get("description", "")), + "ratified": bool(raw.get("mastery_report_sha256")), + "path": str(entry), + } return sorted(seen.values(), key=lambda d: str(d["pack_id"])) diff --git a/tests/test_identity_packs.py b/tests/test_identity_packs.py index 5068a910..2b98c040 100644 --- a/tests/test_identity_packs.py +++ b/tests/test_identity_packs.py @@ -70,6 +70,15 @@ class TestLoaderHappyPath: ratified_ids = {p["pack_id"] for p in packs if p["ratified"]} assert {"default_general_v1", "precision_first_v1", "generosity_first_v1"} <= ratified_ids + def test_available_packs_excludes_mastery_reports(self) -> None: + """Companion ``.mastery_report.json`` files must not surface as packs.""" + packs = available_packs() + ids = {p["pack_id"] for p in packs} + for pid in ids: + assert not pid.endswith(".mastery_report"), ( + f"mastery report companion leaked into available_packs(): {pid!r}" + ) + def test_v1_packs_load_in_production_mode(self) -> None: # require_ratified default (None) -> production unless env override. for pid in ( @@ -291,6 +300,51 @@ class TestPackSwap: assert "no_hot_path_repair" in m.boundary_ids +# ---------- CLI: --list-identity-packs ---------- + + +class TestListIdentityPacksCLI: + def test_table_output_lists_all_three_packs(self) -> None: + import subprocess + import sys + + repo_root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [sys.executable, "-m", "core.cli", "chat", "--list-identity-packs"], + cwd=str(repo_root), + env={"PYTHONPATH": str(repo_root), "PATH": "/usr/bin:/bin"}, + capture_output=True, text=True, check=True, + ) + # Header + three pack rows; no mastery_report companion rows. + assert "default_general_v1" in result.stdout + assert "precision_first_v1" in result.stdout + assert "generosity_first_v1" in result.stdout + assert "mastery_report" not in result.stdout + # All three ratified -> three "yes" flags. + assert result.stdout.count(" yes ") >= 3 + + def test_json_output_is_valid_json(self) -> None: + import subprocess + import sys + + repo_root = Path(__file__).resolve().parents[1] + result = subprocess.run( + [ + sys.executable, "-m", "core.cli", "chat", + "--list-identity-packs", "--json", + ], + cwd=str(repo_root), + env={"PYTHONPATH": str(repo_root), "PATH": "/usr/bin:/bin"}, + capture_output=True, text=True, check=True, + ) + payload = json.loads(result.stdout) + assert isinstance(payload, list) + pack_ids = {p["pack_id"] for p in payload} + assert {"default_general_v1", "precision_first_v1", "generosity_first_v1"} <= pack_ids + for p in payload: + assert p["ratified"] is True + + # ---------- ratification script idempotency ----------