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
8 changes: 4 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2565,11 +2565,11 @@ All remaining open issues are enhancements (no bugs as of 2026-07-18). Prioritiz
### #291 — SpecKit-style preset composition (layered template resolution) — CLOSED
**COMPLETE (PRs #370/#371/#372, 2026-07-18)**: Full `mapify preset` sub-command group. Commands: `list`, `add --from <path>`, `remove`, `enable`, `disable`, `resolve <template>`, `render <template>`, `set-priority <id> <n>`. `.map/presets/<id>/` directory structure with `manifest.json` (id/title/version/strategies) and `.state.json` sidecar for enabled/priority state. Four composition strategies in `mapify preset render`: replace/prepend/append/wrap (wrap uses `{CORE_TEMPLATE}` placeholder). 3-tier resolution: project overrides → enabled presets (priority-ordered) → core templates. 57 tests in `tests/test_preset_commands.py`. Extension hooks and remote catalog remain as optional future work.

### #363 — Architecture deepening report (`/map-architecture` skill)
New opt-in skill that ranks codebase areas by recent git hotspot + design friction, generates a candidate report (HTML or Markdown+Mermaid under `.map/<branch>/architecture-report/`), and holds off implementation until the user picks a candidate. First slice: create `src/mapify_cli/templates_src/skills/map-architecture/SKILL.md.jinja` + register in skill-rules.json. No Python code in slice 1 — just the workflow instructions.
### #363 — Architecture deepening report (`/map-architecture` skill) — CLOSED
**COMPLETE (PR #373, 2026-07-18)**: `/map-architecture` skill shipped. Three-phase workflow (scope → report → select): accepts optional `<module-path-or-pain-point>` argument or falls back to `git log --since="90 days ago"` hotspot analysis; scores candidates on 6 design-friction signals (change frequency, shallow interface, low locality, seam leakage, hard-to-test, ADR conflict; 0–2 each); writes `.map/<branch>/architecture-report/report.md` (Markdown+Mermaid with before/after diagrams) plus `report.json` machine-readable companion; presents top-3 and waits for user to pick ONE before any code changes; hands off to `/map-plan` (score ≥ 6) or `/map-fast` (score < 6). Registered in skill-rules.json; 289+43 tests pass. Optional future work: Python tooling for automated report generation scripting.

### #353 — Eval-gated prompt profile canary and rollback
Local-first prompt lifecycle: versioned profiles under `.map/prompt-profiles/`, `active.json` pointer, `mapify prompt-profile list/diff/activate/rollback` commands. Integrates with skill-eval (trigger changes) and trajectory eval (#351, for body changes). First slice: manifest format + `mapify prompt-profile list`. Key constraint: do not edit generated trees as the durable authoring source.
### #353 — Eval-gated prompt profile canary and rollback — CLOSED (slice 1)
**COMPLETE (PR #374, 2026-07-18)**: `mapify prompt-profile list` command shipped. Manifest format: `.map/prompt-profiles/<id>/manifest.json` (required: `id`, `title`, `version`; optional: `description`, `owner`, `targets`, `eval_requirements`, `rollback_notes`). Active pointer: `.map/prompt-profiles/active.json` (`{"active": "<id>"|null}`). `list` command shows table with ID/title/version/status/description and active marker; `--json` for machine-readable output; stale-pointer warning when `active.json` names a non-existent profile. 20 tests in `tests/test_prompt_profile_commands.py`. Future slices: `diff`, `activate`, `rollback`, `report` commands + integration with #291 preset composition as rendering substrate.

### #339 — GRACE semantic code-contract anchor eval
Eval harness comparing inline code contracts vs prompt-injected vs no-anchor variants for bug-fix workflows. Variants: baseline, inline (LEX/MIN), prompt-injected (INJ), stale (LIE). First slice: fixture structure + report schema (JSON). No external model calls required for slice 1.
Expand Down
114 changes: 114 additions & 0 deletions src/mapify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ def create_ssl_context():

app.add_typer(preset_app, name="preset")

prompt_profile_app = typer.Typer(
name="prompt-profile",
help="Manage MAP prompt profiles — versioned, eval-gated prompt lifecycle.",
)

app.add_typer(prompt_profile_app, name="prompt-profile")


def version_callback(value: bool):
"""Callback to show version and exit."""
Expand Down Expand Up @@ -2214,6 +2221,113 @@ def preset_set_priority(
console.print(f"[green]Preset '{preset_id}'[/green] priority set to {priority}.")


# Prompt profile commands

_PROFILE_MANIFEST_KEYS = ("id", "title", "version")


def _prompt_profiles_dir(project_dir: Path) -> Path:
return project_dir / ".map" / "prompt-profiles"


def _read_profile_manifest(profile_path: Path) -> dict[str, Any] | None:
manifest_path = profile_path / "manifest.json"
if not manifest_path.is_file():
return None
try:
return json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None


def _read_active_profile(profiles_root: Path) -> str | None:
"""Return the active profile id from active.json, or None."""
active_path = profiles_root / "active.json"
if not active_path.is_file():
return None
try:
data = json.loads(active_path.read_text(encoding="utf-8"))
return data.get("active") or None
except (OSError, json.JSONDecodeError):
return None


@prompt_profile_app.command("list")
def prompt_profile_list(
project_path: Path = typer.Option(
Path("."),
"--project-path",
"-p",
help="Root of the target project (where .map/ lives).",
resolve_path=True,
),
output_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table."),
) -> None:
"""List installed MAP prompt profiles in a project's .map/prompt-profiles/ directory."""
from rich.table import Table

profiles_root = _prompt_profiles_dir(project_path)
active_id = _read_active_profile(profiles_root)

profiles: list[dict[str, Any]] = []

if profiles_root.is_dir():
for entry in sorted(profiles_root.iterdir()):
if not entry.is_dir():
continue
manifest = _read_profile_manifest(entry)
if manifest is None:
continue
missing = [k for k in _PROFILE_MANIFEST_KEYS if k not in manifest]
if missing:
continue
profiles.append({
"id": manifest["id"],
"title": manifest["title"],
"version": manifest["version"],
"description": manifest.get("description", ""),
"targets": manifest.get("targets", []),
"active": manifest["id"] == active_id,
})

if output_json:
console.print_json(json.dumps({"profiles": profiles, "active": active_id}))
return

if not profiles:
console.print("No prompt profiles found in .map/prompt-profiles/.")
console.print(
"Create a profile at .map/prompt-profiles/<id>/manifest.json "
"with required keys: id, title, version."
)
return

table = Table(title="Prompt Profiles", box=None, show_header=True, header_style="bold")
table.add_column("ID", style="cyan")
table.add_column("Title")
table.add_column("Version")
table.add_column("Status")
table.add_column("Description")

for profile in profiles:
status = "active" if profile["active"] else "installed"
table.add_row(
profile["id"],
profile["title"],
profile["version"],
status,
profile["description"] or "",
)

console.print(table)

if active_id and not any(p["id"] == active_id for p in profiles):
console.print(
f"[yellow]Warning:[/yellow] active profile '{active_id}' not found in "
f".map/prompt-profiles/. The active.json pointer may be stale."
)


# Research localization eval commands


Expand Down
246 changes: 246 additions & 0 deletions tests/test_prompt_profile_commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""Tests for mapify prompt-profile sub-commands (#353).

Covers:
PP1 — empty project list behavior
PP2 — installed profiles appear in output
PP3 — missing/malformed manifest graceful degradation
PP4 — --json flag on list
PP5 — active profile marker from active.json
PP6 — stale active pointer warning
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from mapify_cli import app

runner = CliRunner()


def _profiles_dir(tmp_path: Path) -> Path:
d = tmp_path / ".map" / "prompt-profiles"
d.mkdir(parents=True, exist_ok=True)
return d


def _make_profile(profiles_root: Path, profile_id: str, manifest: dict) -> Path:
profile_dir = profiles_root / profile_id
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
return profile_dir


def _set_active(profiles_root: Path, profile_id: str | None) -> None:
(profiles_root / "active.json").write_text(
json.dumps({"active": profile_id}), encoding="utf-8"
)


# ---------------------------------------------------------------------------
# PP1 — empty project list behavior
# ---------------------------------------------------------------------------


class TestPp1EmptyList:
def test_no_profiles_dir_shows_message(self, tmp_path: Path):
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "No prompt profiles found" in result.output

def test_empty_profiles_dir_shows_message(self, tmp_path: Path):
_profiles_dir(tmp_path)
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "No prompt profiles found" in result.output

def test_empty_message_includes_creation_hint(self, tmp_path: Path):
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "manifest.json" in result.output


# ---------------------------------------------------------------------------
# PP2 — installed profiles appear in output
# ---------------------------------------------------------------------------


class TestPp2InstalledProfiles:
def test_single_profile_shows_id(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "efficiency-v2", {"id": "efficiency-v2", "title": "Efficiency v2", "version": "1.0.0"})
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "efficiency-v2" in result.output

def test_single_profile_shows_title(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "my-profile", {"id": "my-profile", "title": "My Profile Title", "version": "0.1.0"})
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "My Profile Title" in result.output

def test_multiple_profiles_shown(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "alpha", {"id": "alpha", "title": "Alpha", "version": "1.0.0"})
_make_profile(root, "beta", {"id": "beta", "title": "Beta", "version": "2.0.0"})
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "alpha" in result.output
assert "beta" in result.output

def test_optional_description_shown(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "p1", {
"id": "p1", "title": "Profile 1", "version": "1.0.0",
"description": "Improves actor token efficiency"
})
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "Improves actor token efficiency" in result.output


# ---------------------------------------------------------------------------
# PP3 — missing/malformed manifest graceful degradation
# ---------------------------------------------------------------------------


class TestPp3MalformedManifest:
def test_dir_without_manifest_json_skipped(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
(root / "orphan-dir").mkdir()
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "orphan-dir" not in result.output

def test_invalid_json_manifest_skipped(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
bad_dir = root / "broken"
bad_dir.mkdir()
(bad_dir / "manifest.json").write_text("{not valid json", encoding="utf-8")
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "broken" not in result.output

def test_manifest_missing_required_keys_skipped(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "incomplete", {"id": "incomplete", "title": "No version"})
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "incomplete" not in result.output

def test_valid_profile_still_shown_when_invalid_sibling(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "good", {"id": "good", "title": "Good", "version": "1.0.0"})
(root / "bad").mkdir()
(root / "bad" / "manifest.json").write_text("{bad}", encoding="utf-8")
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "good" in result.output


# ---------------------------------------------------------------------------
# PP4 — --json flag on list
# ---------------------------------------------------------------------------


class TestPp4JsonOutput:
def test_json_output_empty(self, tmp_path: Path):
result = runner.invoke(app, ["prompt-profile", "list", "--json", "--project-path", str(tmp_path)])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["profiles"] == []
assert data["active"] is None

def test_json_output_contains_profiles(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "p1", {"id": "p1", "title": "P1", "version": "1.0.0"})
result = runner.invoke(app, ["prompt-profile", "list", "--json", "--project-path", str(tmp_path)])
assert result.exit_code == 0
data = json.loads(result.output)
assert len(data["profiles"]) == 1
assert data["profiles"][0]["id"] == "p1"

def test_json_required_fields_present(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "x", {"id": "x", "title": "X", "version": "2.0.0", "description": "test"})
result = runner.invoke(app, ["prompt-profile", "list", "--json", "--project-path", str(tmp_path)])
assert result.exit_code == 0
data = json.loads(result.output)
profile = data["profiles"][0]
for key in ("id", "title", "version", "description", "targets", "active"):
assert key in profile, f"missing key: {key}"

def test_json_active_field_null_when_no_active(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "x", {"id": "x", "title": "X", "version": "1.0.0"})
result = runner.invoke(app, ["prompt-profile", "list", "--json", "--project-path", str(tmp_path)])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["active"] is None
assert data["profiles"][0]["active"] is False


# ---------------------------------------------------------------------------
# PP5 — active profile marker from active.json
# ---------------------------------------------------------------------------


class TestPp5ActiveProfile:
def test_active_profile_marked_in_table(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "p1", {"id": "p1", "title": "P1", "version": "1.0.0"})
_make_profile(root, "p2", {"id": "p2", "title": "P2", "version": "1.0.0"})
_set_active(root, "p1")
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "active" in result.output

def test_active_profile_json_flag(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "p1", {"id": "p1", "title": "P1", "version": "1.0.0"})
_set_active(root, "p1")
result = runner.invoke(app, ["prompt-profile", "list", "--json", "--project-path", str(tmp_path)])
assert result.exit_code == 0
data = json.loads(result.output)
assert data["active"] == "p1"
assert data["profiles"][0]["active"] is True

def test_inactive_profile_not_marked_active(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "p1", {"id": "p1", "title": "P1", "version": "1.0.0"})
_make_profile(root, "p2", {"id": "p2", "title": "P2", "version": "1.0.0"})
_set_active(root, "p1")
result = runner.invoke(app, ["prompt-profile", "list", "--json", "--project-path", str(tmp_path)])
assert result.exit_code == 0
data = json.loads(result.output)
profiles_by_id = {p["id"]: p for p in data["profiles"]}
assert profiles_by_id["p1"]["active"] is True
assert profiles_by_id["p2"]["active"] is False


# ---------------------------------------------------------------------------
# PP6 — stale active pointer warning
# ---------------------------------------------------------------------------


class TestPp6StaleActivePointer:
def test_stale_pointer_shows_warning(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "real", {"id": "real", "title": "Real", "version": "1.0.0"})
_set_active(root, "deleted-profile")
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "stale" in result.output.lower() or "Warning" in result.output

def test_stale_pointer_still_shows_real_profiles(self, tmp_path: Path):
root = _profiles_dir(tmp_path)
_make_profile(root, "real", {"id": "real", "title": "Real", "version": "1.0.0"})
_set_active(root, "ghost")
result = runner.invoke(app, ["prompt-profile", "list", "--project-path", str(tmp_path)])
assert result.exit_code == 0
assert "real" in result.output