mapify-cli 3.22.0__py3-none-any.whl → 3.23.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. mapify_cli/__init__.py +783 -1
  2. mapify_cli/config/project_config.py +47 -0
  3. mapify_cli/grace_eval/__init__.py +42 -0
  4. mapify_cli/grace_eval/schema.py +437 -0
  5. mapify_cli/grace_eval/sweep.py +136 -0
  6. mapify_cli/memory/recall.py +66 -2
  7. mapify_cli/schemas.py +346 -0
  8. mapify_cli/scope_classifier.py +111 -0
  9. mapify_cli/skills_eval/trajectory/__init__.py +42 -0
  10. mapify_cli/skills_eval/trajectory/bundle.py +220 -0
  11. mapify_cli/skills_eval/trajectory/dispatcher.py +160 -0
  12. mapify_cli/skills_eval/trajectory/eval_schema.py +394 -0
  13. mapify_cli/skills_eval/trajectory/gates.py +255 -0
  14. mapify_cli/skills_eval/trajectory/judge.py +360 -0
  15. mapify_cli/skills_eval/trajectory/repeated.py +163 -0
  16. mapify_cli/skills_eval/trajectory/report.py +314 -0
  17. mapify_cli/skills_eval/trajectory/runner.py +462 -0
  18. mapify_cli/skills_eval/trajectory/seeding.py +213 -0
  19. mapify_cli/templates/codex/skills/map-plan/SKILL.md +81 -4
  20. mapify_cli/templates/map/scripts/classify_scope.py +144 -0
  21. mapify_cli/templates/map/scripts/map_orchestrator.py +55 -0
  22. mapify_cli/templates/map/scripts/map_step_runner.py +421 -0
  23. mapify_cli/templates/map/scripts/wayfind_runner.py +1574 -0
  24. mapify_cli/templates/skills/map-architecture/SKILL.md +217 -0
  25. mapify_cli/templates/skills/map-plan/SKILL.md +93 -6
  26. mapify_cli/templates/skills/map-wayfind/SKILL.md +131 -0
  27. mapify_cli/templates/skills/map-wayfind/wayfind-reference.md +132 -0
  28. mapify_cli/templates/skills/skill-rules.json +45 -0
  29. mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja +81 -4
  30. mapify_cli/templates_src/map/scripts/classify_scope.py.jinja +144 -0
  31. mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja +55 -0
  32. mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +421 -0
  33. mapify_cli/templates_src/map/scripts/wayfind_runner.py.jinja +1574 -0
  34. mapify_cli/templates_src/skills/map-architecture/SKILL.md.jinja +217 -0
  35. mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja +93 -6
  36. mapify_cli/templates_src/skills/map-wayfind/SKILL.md.jinja +131 -0
  37. mapify_cli/templates_src/skills/map-wayfind/wayfind-reference.md.jinja +132 -0
  38. mapify_cli/templates_src/skills/skill-rules.json.jinja +45 -0
  39. {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/METADATA +3 -1
  40. {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/RECORD +43 -19
  41. {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/WHEEL +0 -0
  42. {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/entry_points.txt +0 -0
  43. {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/licenses/LICENSE +0 -0
mapify_cli/__init__.py CHANGED
@@ -23,8 +23,9 @@ Or install globally:
23
23
  mapify check
24
24
  """
25
25
 
26
- __version__ = "3.22.0"
26
+ __version__ = "3.23.0"
27
27
 
28
+ import json
28
29
  import os
29
30
  import subprocess
30
31
  import sys
@@ -173,6 +174,20 @@ governance_app = typer.Typer(
173
174
 
174
175
  app.add_typer(governance_app, name="governance")
175
176
 
177
+ preset_app = typer.Typer(
178
+ name="preset",
179
+ help="Manage MAP workflow customization presets.",
180
+ )
181
+
182
+ app.add_typer(preset_app, name="preset")
183
+
184
+ prompt_profile_app = typer.Typer(
185
+ name="prompt-profile",
186
+ help="Manage MAP prompt profiles — versioned, eval-gated prompt lifecycle.",
187
+ )
188
+
189
+ app.add_typer(prompt_profile_app, name="prompt-profile")
190
+
176
191
 
177
192
  def version_callback(value: bool):
178
193
  """Callback to show version and exit."""
@@ -1762,6 +1777,557 @@ def uninstall(
1762
1777
  console.print("[dim]Nothing to do.[/dim]")
1763
1778
 
1764
1779
 
1780
+ # ---------------------------------------------------------------------------
1781
+ # Preset management commands (#291)
1782
+ # ---------------------------------------------------------------------------
1783
+
1784
+ _PRESET_MANIFEST_KEYS = ("id", "title", "version")
1785
+
1786
+
1787
+ def _presets_dir(project_dir: Path) -> Path:
1788
+ return project_dir / ".map" / "presets"
1789
+
1790
+
1791
+ def _read_preset_manifest(preset_path: Path) -> dict[str, Any] | None:
1792
+ manifest_path = preset_path / "manifest.json"
1793
+ if not manifest_path.is_file():
1794
+ return None
1795
+ try:
1796
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
1797
+ except (OSError, json.JSONDecodeError):
1798
+ return None
1799
+
1800
+
1801
+ @preset_app.command("list")
1802
+ def preset_list(
1803
+ project_path: Optional[Path] = typer.Argument(
1804
+ None,
1805
+ help="Project root directory (defaults to current directory).",
1806
+ ),
1807
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
1808
+ ) -> None:
1809
+ """List installed MAP presets in a project's .map/presets/ directory."""
1810
+ target = project_path or Path.cwd()
1811
+ presets_root = _presets_dir(target)
1812
+
1813
+ if not presets_root.is_dir():
1814
+ if output_json:
1815
+ typer.echo(json.dumps({"presets": []}))
1816
+ else:
1817
+ console.print("[dim]No presets installed. Use 'mapify preset add --from <path>' to install one.[/dim]")
1818
+ return
1819
+
1820
+ presets: list[dict[str, Any]] = []
1821
+ for entry in sorted(presets_root.iterdir()):
1822
+ if not entry.is_dir():
1823
+ continue
1824
+ manifest = _read_preset_manifest(entry)
1825
+ if manifest is None:
1826
+ presets.append({"id": entry.name, "title": entry.name, "version": "?", "description": "(no manifest)"})
1827
+ else:
1828
+ presets.append({
1829
+ "id": manifest.get("id", entry.name),
1830
+ "title": manifest.get("title", entry.name),
1831
+ "version": manifest.get("version", "?"),
1832
+ "description": manifest.get("description", ""),
1833
+ })
1834
+
1835
+ if output_json:
1836
+ typer.echo(json.dumps({"presets": presets}))
1837
+ return
1838
+
1839
+ if not presets:
1840
+ console.print("[dim]No presets installed. Use 'mapify preset add --from <path>' to install one.[/dim]")
1841
+ return
1842
+
1843
+ table = Table(title="Installed MAP Presets", show_header=True, header_style="bold cyan")
1844
+ table.add_column("ID", style="cyan", no_wrap=True)
1845
+ table.add_column("Title")
1846
+ table.add_column("Version", style="dim")
1847
+ table.add_column("Description", style="dim")
1848
+ for p in presets:
1849
+ table.add_row(p["id"], p["title"], p["version"], p.get("description", ""))
1850
+ console.print(table)
1851
+
1852
+
1853
+ @preset_app.command("add")
1854
+ def preset_add(
1855
+ from_path: Path = typer.Option(
1856
+ ...,
1857
+ "--from",
1858
+ help="Path to a preset directory containing manifest.json.",
1859
+ show_default=False,
1860
+ ),
1861
+ project_path: Optional[Path] = typer.Argument(
1862
+ None,
1863
+ help="Project root directory (defaults to current directory).",
1864
+ ),
1865
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite if already installed."),
1866
+ ) -> None:
1867
+ """Install a MAP preset from a local directory into .map/presets/.
1868
+
1869
+ The source directory must contain a manifest.json with at least: id, title, version.
1870
+ """
1871
+ if not from_path.is_dir():
1872
+ console.print(f"[red]Error:[/red] '{from_path}' is not a directory.")
1873
+ raise typer.Exit(1)
1874
+
1875
+ manifest = _read_preset_manifest(from_path)
1876
+ if manifest is None:
1877
+ console.print(
1878
+ f"[red]Error:[/red] No valid manifest.json found in '{from_path}'.\n"
1879
+ "A preset directory must contain manifest.json with 'id', 'title', and 'version' keys."
1880
+ )
1881
+ raise typer.Exit(1)
1882
+
1883
+ missing = [k for k in _PRESET_MANIFEST_KEYS if k not in manifest]
1884
+ if missing:
1885
+ console.print(
1886
+ f"[red]Error:[/red] manifest.json is missing required keys: {', '.join(missing)}"
1887
+ )
1888
+ raise typer.Exit(1)
1889
+
1890
+ preset_id: str = manifest["id"]
1891
+ if not preset_id or "/" in preset_id or "\\" in preset_id or ".." in preset_id:
1892
+ console.print(
1893
+ f"[red]Error:[/red] Invalid preset id '{preset_id}' — must be a plain name (no path separators)."
1894
+ )
1895
+ raise typer.Exit(1)
1896
+
1897
+ target = project_path or Path.cwd()
1898
+ dest = _presets_dir(target) / preset_id
1899
+
1900
+ if dest.exists() and not force:
1901
+ console.print(
1902
+ f"[yellow]Preset '{preset_id}' is already installed.[/yellow] "
1903
+ "Use --force to overwrite."
1904
+ )
1905
+ raise typer.Exit(1)
1906
+
1907
+ if dest.exists():
1908
+ shutil.rmtree(dest)
1909
+
1910
+ shutil.copytree(str(from_path), str(dest))
1911
+ console.print(
1912
+ f"[green]Preset '{preset_id}'[/green] ({manifest.get('title', preset_id)} "
1913
+ f"v{manifest.get('version', '?')}) installed to {dest}."
1914
+ )
1915
+
1916
+
1917
+ def _preset_state_path(preset_dir: Path) -> Path:
1918
+ return preset_dir / ".state.json"
1919
+
1920
+
1921
+ def _read_preset_state(preset_dir: Path) -> dict[str, Any]:
1922
+ path = _preset_state_path(preset_dir)
1923
+ if not path.is_file():
1924
+ return {}
1925
+ try:
1926
+ return json.loads(path.read_text(encoding="utf-8"))
1927
+ except (OSError, json.JSONDecodeError):
1928
+ return {}
1929
+
1930
+
1931
+ def _write_preset_state(preset_dir: Path, state: dict[str, Any]) -> None:
1932
+ _preset_state_path(preset_dir).write_text(json.dumps(state, indent=2), encoding="utf-8")
1933
+
1934
+
1935
+ def _is_preset_enabled(preset_dir: Path) -> bool:
1936
+ return _read_preset_state(preset_dir).get("enabled", True)
1937
+
1938
+
1939
+ def _resolve_installed_preset(presets_root: Path, preset_id: str) -> Path | None:
1940
+ candidate = presets_root / preset_id
1941
+ return candidate if candidate.is_dir() else None
1942
+
1943
+
1944
+ @preset_app.command("remove")
1945
+ def preset_remove(
1946
+ preset_id: str = typer.Argument(..., help="ID of the preset to remove."),
1947
+ project_path: Optional[Path] = typer.Argument(
1948
+ None,
1949
+ help="Project root directory (defaults to current directory).",
1950
+ ),
1951
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt."),
1952
+ ) -> None:
1953
+ """Remove an installed MAP preset from .map/presets/."""
1954
+ target = project_path or Path.cwd()
1955
+ preset_dir = _resolve_installed_preset(_presets_dir(target), preset_id)
1956
+ if preset_dir is None:
1957
+ console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed.")
1958
+ raise typer.Exit(1)
1959
+
1960
+ if not yes:
1961
+ confirm = typer.confirm(f"Remove preset '{preset_id}'?", default=False)
1962
+ if not confirm:
1963
+ console.print("[dim]Aborted.[/dim]")
1964
+ raise typer.Exit(0)
1965
+
1966
+ shutil.rmtree(preset_dir)
1967
+ console.print(f"[green]Preset '{preset_id}' removed.[/green]")
1968
+
1969
+
1970
+ @preset_app.command("enable")
1971
+ def preset_enable(
1972
+ preset_id: str = typer.Argument(..., help="ID of the preset to enable."),
1973
+ project_path: Optional[Path] = typer.Argument(
1974
+ None,
1975
+ help="Project root directory (defaults to current directory).",
1976
+ ),
1977
+ ) -> None:
1978
+ """Enable a disabled MAP preset."""
1979
+ target = project_path or Path.cwd()
1980
+ preset_dir = _resolve_installed_preset(_presets_dir(target), preset_id)
1981
+ if preset_dir is None:
1982
+ console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed.")
1983
+ raise typer.Exit(1)
1984
+
1985
+ state = _read_preset_state(preset_dir)
1986
+ state["enabled"] = True
1987
+ _write_preset_state(preset_dir, state)
1988
+ console.print(f"[green]Preset '{preset_id}' enabled.[/green]")
1989
+
1990
+
1991
+ @preset_app.command("disable")
1992
+ def preset_disable(
1993
+ preset_id: str = typer.Argument(..., help="ID of the preset to disable."),
1994
+ project_path: Optional[Path] = typer.Argument(
1995
+ None,
1996
+ help="Project root directory (defaults to current directory).",
1997
+ ),
1998
+ ) -> None:
1999
+ """Disable a MAP preset without uninstalling it."""
2000
+ target = project_path or Path.cwd()
2001
+ preset_dir = _resolve_installed_preset(_presets_dir(target), preset_id)
2002
+ if preset_dir is None:
2003
+ console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed.")
2004
+ raise typer.Exit(1)
2005
+
2006
+ state = _read_preset_state(preset_dir)
2007
+ state["enabled"] = False
2008
+ _write_preset_state(preset_dir, state)
2009
+ console.print(f"[yellow]Preset '{preset_id}' disabled.[/yellow]")
2010
+
2011
+
2012
+ @preset_app.command("resolve")
2013
+ def preset_resolve(
2014
+ template_name: str = typer.Argument(..., help="Template name to resolve (e.g. 'map-efficient.md')."),
2015
+ project_path: Optional[Path] = typer.Argument(
2016
+ None,
2017
+ help="Project root directory (defaults to current directory).",
2018
+ ),
2019
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
2020
+ ) -> None:
2021
+ """Show which preset layers contribute to a template in resolution order.
2022
+
2023
+ Resolution order (highest priority first): project overrides → enabled presets → core templates.
2024
+ """
2025
+ target = project_path or Path.cwd()
2026
+ presets_root = _presets_dir(target)
2027
+
2028
+ layers: list[dict[str, Any]] = []
2029
+
2030
+ # Tier 1: project overrides
2031
+ project_override = target / ".map" / "overrides" / template_name
2032
+ if project_override.is_file():
2033
+ layers.append({"tier": "project-override", "path": str(project_override), "enabled": True})
2034
+
2035
+ # Tier 2: installed presets (sorted alphabetically for determinism)
2036
+ if presets_root.is_dir():
2037
+ for entry in sorted(presets_root.iterdir()):
2038
+ if not entry.is_dir():
2039
+ continue
2040
+ enabled = _is_preset_enabled(entry)
2041
+ template_path = entry / "templates" / template_name
2042
+ if template_path.is_file():
2043
+ manifest = _read_preset_manifest(entry)
2044
+ strategy = (manifest or {}).get("strategies", {}).get(template_name, "append")
2045
+ layers.append({
2046
+ "tier": "preset",
2047
+ "preset_id": entry.name,
2048
+ "path": str(template_path),
2049
+ "strategy": strategy,
2050
+ "enabled": enabled,
2051
+ })
2052
+
2053
+ # Tier 3: core template (shipped by mapify)
2054
+ try:
2055
+ core_path = get_templates_dir() / template_name
2056
+ if core_path.is_file():
2057
+ layers.append({"tier": "core", "path": str(core_path), "enabled": True})
2058
+ except Exception:
2059
+ pass
2060
+
2061
+ if output_json:
2062
+ typer.echo(json.dumps({"template": template_name, "layers": layers}))
2063
+ return
2064
+
2065
+ if not layers:
2066
+ console.print(f"[dim]No layers found for template '{template_name}'.[/dim]")
2067
+ return
2068
+
2069
+ console.print(f"[bold]Resolution layers for:[/bold] [cyan]{template_name}[/cyan]")
2070
+ for i, layer in enumerate(layers, 1):
2071
+ tier = layer["tier"]
2072
+ enabled_str = "" if layer.get("enabled", True) else " [dim](disabled)[/dim]"
2073
+ strategy_str = f" strategy=[cyan]{layer['strategy']}[/cyan]" if "strategy" in layer else ""
2074
+ preset_str = f" preset=[cyan]{layer['preset_id']}[/cyan]" if "preset_id" in layer else ""
2075
+ console.print(f" {i}. tier={tier}{preset_str}{strategy_str}{enabled_str} → {layer['path']}")
2076
+
2077
+
2078
+ # ---------------------------------------------------------------------------
2079
+ # Preset composition helpers (Slice 3)
2080
+ # ---------------------------------------------------------------------------
2081
+
2082
+ _COMPOSITION_STRATEGIES = frozenset({"replace", "prepend", "append", "wrap"})
2083
+ _WRAP_PLACEHOLDER = "{CORE_TEMPLATE}"
2084
+
2085
+
2086
+ def _preset_priority(preset_dir: Path) -> int:
2087
+ state = _read_preset_state(preset_dir)
2088
+ return int(state.get("priority", 50))
2089
+
2090
+
2091
+ def _compose_template(core_content: str, layer_content: str, strategy: str) -> str:
2092
+ """Apply a composition strategy to produce the final template content."""
2093
+ if strategy == "replace":
2094
+ return layer_content
2095
+ if strategy == "prepend":
2096
+ return layer_content + "\n" + core_content
2097
+ if strategy == "append":
2098
+ return core_content + "\n" + layer_content
2099
+ if strategy == "wrap":
2100
+ if _WRAP_PLACEHOLDER in layer_content:
2101
+ return layer_content.replace(_WRAP_PLACEHOLDER, core_content)
2102
+ return layer_content + "\n" + core_content
2103
+ return core_content
2104
+
2105
+
2106
+ def _build_resolution_order(presets_root: Path, template_name: str) -> list[dict[str, Any]]:
2107
+ """Return enabled preset layers for a template, sorted by priority descending."""
2108
+ layers: list[dict[str, Any]] = []
2109
+ if not presets_root.is_dir():
2110
+ return layers
2111
+ for entry in presets_root.iterdir():
2112
+ if not entry.is_dir() or not _is_preset_enabled(entry):
2113
+ continue
2114
+ template_path = entry / "templates" / template_name
2115
+ if not template_path.is_file():
2116
+ continue
2117
+ manifest = _read_preset_manifest(entry)
2118
+ strategy = (manifest or {}).get("strategies", {}).get(template_name, "append")
2119
+ layers.append({
2120
+ "preset_id": entry.name,
2121
+ "path": template_path,
2122
+ "strategy": strategy,
2123
+ "priority": _preset_priority(entry),
2124
+ })
2125
+ layers.sort(key=lambda x: x["priority"], reverse=True)
2126
+ return layers
2127
+
2128
+
2129
+ @preset_app.command("render")
2130
+ def preset_render(
2131
+ template_name: str = typer.Argument(..., help="Template name to render (e.g. 'map-efficient.md')."),
2132
+ project_path: Optional[Path] = typer.Argument(
2133
+ None,
2134
+ help="Project root directory (defaults to current directory).",
2135
+ ),
2136
+ output_json: bool = typer.Option(False, "--json", help="Output rendered content as JSON."),
2137
+ dry_run: bool = typer.Option(False, "--dry-run", help="Print composed content without writing to disk."),
2138
+ ) -> None:
2139
+ """Compose a template by layering enabled presets over the core template.
2140
+
2141
+ Strategies (applied highest-priority first):
2142
+ replace — preset content replaces the core entirely
2143
+ prepend — preset content is inserted above the core
2144
+ append — preset content is inserted below the core
2145
+ wrap — preset content wraps the core via {CORE_TEMPLATE} placeholder
2146
+ """
2147
+ target = project_path or Path.cwd()
2148
+ presets_root = _presets_dir(target)
2149
+
2150
+ # Start from project override if present, else core template
2151
+ project_override = target / ".map" / "overrides" / template_name
2152
+ if project_override.is_file():
2153
+ composed = project_override.read_text(encoding="utf-8")
2154
+ source = f"project-override:{project_override}"
2155
+ else:
2156
+ try:
2157
+ core_path = get_templates_dir() / template_name
2158
+ if core_path.is_file():
2159
+ composed = core_path.read_text(encoding="utf-8")
2160
+ source = f"core:{core_path}"
2161
+ else:
2162
+ composed = ""
2163
+ source = "core:(not found)"
2164
+ except Exception:
2165
+ composed = ""
2166
+ source = "core:(error)"
2167
+
2168
+ layers = _build_resolution_order(presets_root, template_name)
2169
+ applied: list[str] = []
2170
+ for layer in layers:
2171
+ layer_content = Path(layer["path"]).read_text(encoding="utf-8")
2172
+ strategy: str = layer["strategy"]
2173
+ if strategy not in _COMPOSITION_STRATEGIES:
2174
+ strategy = "append"
2175
+ composed = _compose_template(composed, layer_content, strategy)
2176
+ applied.append(f"{layer['preset_id']}({strategy})")
2177
+
2178
+ if output_json:
2179
+ typer.echo(json.dumps({
2180
+ "template": template_name,
2181
+ "source": source,
2182
+ "applied_layers": applied,
2183
+ "content": composed,
2184
+ }))
2185
+ return
2186
+
2187
+ if dry_run or True:
2188
+ console.print(f"[bold]Composed:[/bold] [cyan]{template_name}[/cyan]")
2189
+ if applied:
2190
+ console.print(f"[dim]Layers applied:[/dim] {' → '.join(applied)}")
2191
+ else:
2192
+ console.print("[dim]No preset layers matched; showing core/override content.[/dim]")
2193
+ console.print()
2194
+ console.print(composed)
2195
+
2196
+
2197
+ @preset_app.command("set-priority")
2198
+ def preset_set_priority(
2199
+ preset_id: str = typer.Argument(..., help="ID of the preset to reprioritize."),
2200
+ priority: int = typer.Argument(..., help="Priority value (higher = applied first). Default: 50."),
2201
+ project_path: Optional[Path] = typer.Argument(
2202
+ None,
2203
+ help="Project root directory (defaults to current directory).",
2204
+ ),
2205
+ ) -> None:
2206
+ """Set the composition priority of an installed preset.
2207
+
2208
+ Higher priority presets are applied first in the composition stack.
2209
+ When two presets target the same template, the one with higher priority
2210
+ has its strategy applied first, then lower-priority presets layer on top.
2211
+ """
2212
+ target = project_path or Path.cwd()
2213
+ preset_dir = _resolve_installed_preset(_presets_dir(target), preset_id)
2214
+ if preset_dir is None:
2215
+ console.print(f"[red]Error:[/red] Preset '{preset_id}' is not installed.")
2216
+ raise typer.Exit(1)
2217
+
2218
+ state = _read_preset_state(preset_dir)
2219
+ state["priority"] = priority
2220
+ _write_preset_state(preset_dir, state)
2221
+ console.print(f"[green]Preset '{preset_id}'[/green] priority set to {priority}.")
2222
+
2223
+
2224
+ # Prompt profile commands
2225
+
2226
+ _PROFILE_MANIFEST_KEYS = ("id", "title", "version")
2227
+
2228
+
2229
+ def _prompt_profiles_dir(project_dir: Path) -> Path:
2230
+ return project_dir / ".map" / "prompt-profiles"
2231
+
2232
+
2233
+ def _read_profile_manifest(profile_path: Path) -> dict[str, Any] | None:
2234
+ manifest_path = profile_path / "manifest.json"
2235
+ if not manifest_path.is_file():
2236
+ return None
2237
+ try:
2238
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
2239
+ except (OSError, json.JSONDecodeError):
2240
+ return None
2241
+
2242
+
2243
+ def _read_active_profile(profiles_root: Path) -> str | None:
2244
+ """Return the active profile id from active.json, or None."""
2245
+ active_path = profiles_root / "active.json"
2246
+ if not active_path.is_file():
2247
+ return None
2248
+ try:
2249
+ data = json.loads(active_path.read_text(encoding="utf-8"))
2250
+ return data.get("active") or None
2251
+ except (OSError, json.JSONDecodeError):
2252
+ return None
2253
+
2254
+
2255
+ @prompt_profile_app.command("list")
2256
+ def prompt_profile_list(
2257
+ project_path: Path = typer.Option(
2258
+ Path("."),
2259
+ "--project-path",
2260
+ "-p",
2261
+ help="Root of the target project (where .map/ lives).",
2262
+ resolve_path=True,
2263
+ ),
2264
+ output_json: bool = typer.Option(False, "--json", help="Emit JSON instead of a table."),
2265
+ ) -> None:
2266
+ """List installed MAP prompt profiles in a project's .map/prompt-profiles/ directory."""
2267
+ from rich.table import Table
2268
+
2269
+ profiles_root = _prompt_profiles_dir(project_path)
2270
+ active_id = _read_active_profile(profiles_root)
2271
+
2272
+ profiles: list[dict[str, Any]] = []
2273
+
2274
+ if profiles_root.is_dir():
2275
+ for entry in sorted(profiles_root.iterdir()):
2276
+ if not entry.is_dir():
2277
+ continue
2278
+ manifest = _read_profile_manifest(entry)
2279
+ if manifest is None:
2280
+ continue
2281
+ missing = [k for k in _PROFILE_MANIFEST_KEYS if k not in manifest]
2282
+ if missing:
2283
+ continue
2284
+ profiles.append({
2285
+ "id": manifest["id"],
2286
+ "title": manifest["title"],
2287
+ "version": manifest["version"],
2288
+ "description": manifest.get("description", ""),
2289
+ "targets": manifest.get("targets", []),
2290
+ "active": manifest["id"] == active_id,
2291
+ })
2292
+
2293
+ if output_json:
2294
+ console.print_json(json.dumps({"profiles": profiles, "active": active_id}))
2295
+ return
2296
+
2297
+ if not profiles:
2298
+ console.print("No prompt profiles found in .map/prompt-profiles/.")
2299
+ console.print(
2300
+ "Create a profile at .map/prompt-profiles/<id>/manifest.json "
2301
+ "with required keys: id, title, version."
2302
+ )
2303
+ return
2304
+
2305
+ table = Table(title="Prompt Profiles", box=None, show_header=True, header_style="bold")
2306
+ table.add_column("ID", style="cyan")
2307
+ table.add_column("Title")
2308
+ table.add_column("Version")
2309
+ table.add_column("Status")
2310
+ table.add_column("Description")
2311
+
2312
+ for profile in profiles:
2313
+ status = "active" if profile["active"] else "installed"
2314
+ table.add_row(
2315
+ profile["id"],
2316
+ profile["title"],
2317
+ profile["version"],
2318
+ status,
2319
+ profile["description"] or "",
2320
+ )
2321
+
2322
+ console.print(table)
2323
+
2324
+ if active_id and not any(p["id"] == active_id for p in profiles):
2325
+ console.print(
2326
+ f"[yellow]Warning:[/yellow] active profile '{active_id}' not found in "
2327
+ f".map/prompt-profiles/. The active.json pointer may be stale."
2328
+ )
2329
+
2330
+
1765
2331
  # Research localization eval commands
1766
2332
 
1767
2333
 
@@ -2422,6 +2988,222 @@ def skill_eval_view(
2422
2988
  _open_best_effort(html)
2423
2989
 
2424
2990
 
2991
+ # ---------------------------------------------------------------------------
2992
+ # skill-eval trajectory (issue #351: AgentLens-style outcome eval)
2993
+ # ---------------------------------------------------------------------------
2994
+
2995
+
2996
+ @skill_eval_app.command("trajectory")
2997
+ def skill_eval_trajectory(
2998
+ skill: str = typer.Argument(
2999
+ ..., help="Skill under evaluation, e.g. map-task"
3000
+ ),
3001
+ fixture: Optional[Path] = typer.Option(
3002
+ None,
3003
+ "--fixture",
3004
+ help="Path to a whole-skill fixture directory (manifest.json + repo/).",
3005
+ ),
3006
+ runs: int = typer.Option(
3007
+ 3,
3008
+ "--runs",
3009
+ min=1,
3010
+ help="Repeated runs per fixture (default 3) for variance / flaky detection.",
3011
+ ),
3012
+ variant: str = typer.Option(
3013
+ "good",
3014
+ "--variant",
3015
+ help="Seed variant: 'good' (baseline) or 'bad' (degrade seeded copy).",
3016
+ ),
3017
+ degrade: str = typer.Option(
3018
+ "body", "--degrade", help="What the 'bad' variant degrades: body|actor|monitor."
3019
+ ),
3020
+ timeout: float = typer.Option(
3021
+ 3600.0, "--timeout", help="Per-run claude -p timeout (seconds)."
3022
+ ),
3023
+ judge_timeout: float = typer.Option(
3024
+ 360.0, "--judge-timeout", help="Per-run batched judge claude -p timeout."
3025
+ ),
3026
+ no_judge: bool = typer.Option(
3027
+ False,
3028
+ "--no-judge",
3029
+ help="Skip the LLM judge (deterministic components only). Cheapest.",
3030
+ ),
3031
+ anchor: Optional[str] = typer.Option(
3032
+ None,
3033
+ "--anchor",
3034
+ help="Compare against a prior run: path to a .jsonl, or 'latest'.",
3035
+ ),
3036
+ out: Optional[Path] = typer.Option(
3037
+ None, "--out", help="Output .jsonl path (default .map/eval-runs/trajectory/...)."
3038
+ ),
3039
+ resume: bool = typer.Option(
3040
+ False, "--resume", help="Resume the latest run, skipping present run_ids."
3041
+ ),
3042
+ dry_run: bool = typer.Option(
3043
+ False,
3044
+ "--dry-run",
3045
+ help="Validate fixture + print planned runs; spend nothing, no dispatcher.",
3046
+ ),
3047
+ open_html: bool = typer.Option(
3048
+ False, "--open", help="Open the side-by-side report in the default browser."
3049
+ ),
3050
+ ) -> None:
3051
+ """Run a trajectory-level outcome eval over a full skill run (issue #351).
3052
+
3053
+ Seeds an isolated project, executes the whole skill body, scores six
3054
+ component metrics (formal/end_result/tool_use deterministic +
3055
+ instruction_compliance/pitfalls/reporting_trust from one batched judge
3056
+ call), aggregates repeated runs (median/variance/hard-pass/flaky), and
3057
+ optionally renders a candidate-vs-anchor side-by-side regression report.
3058
+
3059
+ Exit codes:
3060
+ 0 - Success (or dry-run completed)
3061
+ 1 - Runtime error (claude not found, or unexpected failure)
3062
+ 2 - Validation error (missing --fixture or malformed fixture)
3063
+ """
3064
+ from datetime import timezone
3065
+
3066
+ import mapify_cli.skills_eval.trajectory.judge as _judge
3067
+ import mapify_cli.skills_eval.trajectory.runner as _trunner
3068
+ from mapify_cli.skills_eval.trajectory.dispatcher import (
3069
+ ClaudeTrajectoryDispatcher,
3070
+ )
3071
+ from mapify_cli.skills_eval.trajectory.report import (
3072
+ build_report,
3073
+ render_comparison_to_path,
3074
+ )
3075
+ from mapify_cli.skills_eval.trajectory.repeated import aggregate_repeated
3076
+
3077
+ # SC-2: --fixture is required.
3078
+ if fixture is None:
3079
+ console.print("[bold red]Error:[/bold red] provide --fixture PATH")
3080
+ raise typer.Exit(2)
3081
+ if not fixture.is_dir():
3082
+ console.print(f"[bold red]Error:[/bold red] fixture dir not found: {fixture}")
3083
+ raise typer.Exit(2)
3084
+
3085
+ # Validate the manifest BEFORE dry-run and before any dispatcher.
3086
+ try:
3087
+ manifest = _trunner.load_fixture_manifest(fixture)
3088
+ except ValueError as exc:
3089
+ console.print(f"[bold red]Error:[/bold red] {exc}")
3090
+ raise typer.Exit(2)
3091
+
3092
+ root = Path.cwd()
3093
+ run_ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
3094
+
3095
+ if out is not None:
3096
+ out_path = out
3097
+ elif resume:
3098
+ latest = _trunner.latest_run_path(root, skill)
3099
+ out_path = latest if latest is not None else _trunner.default_run_path(
3100
+ root, skill, run_ts
3101
+ )
3102
+ else:
3103
+ out_path = _trunner.default_run_path(root, skill, run_ts)
3104
+
3105
+ # Dry-run: zero quota, NO dispatcher, NO claude required.
3106
+ if dry_run:
3107
+ planned = 1 * runs
3108
+ console.print(
3109
+ f"[bold]Dry-run:[/bold] fixture=[bold]{manifest['fixture']}[/bold] "
3110
+ f"skill=[bold]{skill}[/bold] variant=[cyan]{variant}[/cyan] "
3111
+ f"runs=[cyan]{planned}[/cyan] judge=[cyan]{'off' if no_judge else 'on'}[/cyan] "
3112
+ f"— spends 0 quota"
3113
+ )
3114
+ raise typer.Exit(0)
3115
+
3116
+ # HC-6: require claude BEFORE any invocation (real run or judge).
3117
+ if shutil.which("claude") is None:
3118
+ console.print(
3119
+ "[bold red]Error:[/bold red] requires-cmd: claude — "
3120
+ "install the claude CLI and ensure it is on PATH"
3121
+ )
3122
+ raise typer.Exit(1)
3123
+
3124
+ dispatcher: object = ClaudeTrajectoryDispatcher()
3125
+ judge_runner = None if no_judge else _judge.ClaudeJudgeRunner()
3126
+
3127
+ _trunner.run_matrix(
3128
+ fixture_dirs=[fixture],
3129
+ repo_root=root,
3130
+ dispatcher=dispatcher, # type: ignore[arg-type]
3131
+ runs=runs,
3132
+ out_path=out_path,
3133
+ ts=run_ts,
3134
+ judge_runner=judge_runner,
3135
+ judge_timeout=judge_timeout,
3136
+ run_timeout=timeout,
3137
+ variant=variant,
3138
+ degrade=degrade,
3139
+ resume=resume,
3140
+ )
3141
+
3142
+ records = _trunner.read_records(out_path)
3143
+ agg = aggregate_repeated(records)
3144
+ fa = agg.fixture(str(manifest["fixture"]))
3145
+ median_str = (
3146
+ f"{fa.composite_median:.3f}" if fa else "n/a"
3147
+ )
3148
+ hp_str = (
3149
+ f"{fa.hard_pass_count}/{fa.n}" if fa else "n/a"
3150
+ )
3151
+ flaky_str = (
3152
+ f" flaky=[cyan]{'; '.join(fa.flaky_reasons)}[/cyan]"
3153
+ if fa and fa.flaky
3154
+ else ""
3155
+ )
3156
+ console.print(
3157
+ f"\n[bold]Trajectory eval complete:[/bold] skill=[bold]{skill}[/bold] "
3158
+ f"fixture=[bold]{manifest['fixture']}[/bold] "
3159
+ f"composite_median=[cyan]{median_str}[/cyan] "
3160
+ f"hard_pass=[cyan]{hp_str}[/cyan]{flaky_str}"
3161
+ )
3162
+ console.print(f" records: [cyan]{out_path}[/cyan]")
3163
+
3164
+ # Side-by-side regression report against an anchor run.
3165
+ if anchor is not None:
3166
+ anchor_path = _resolve_anchor(anchor, root, skill)
3167
+ if anchor_path is None or not anchor_path.is_file():
3168
+ console.print(
3169
+ f"[bold red]Error:[/bold red] anchor run not found: {anchor}"
3170
+ )
3171
+ raise typer.Exit(1)
3172
+ anchor_records = _trunner.read_records(anchor_path)
3173
+ report = build_report(
3174
+ records,
3175
+ anchor_records,
3176
+ candidate_path=str(out_path),
3177
+ anchor_path=str(anchor_path),
3178
+ )
3179
+ html_path = out_path.with_suffix(".html")
3180
+ render_comparison_to_path(report, html_path)
3181
+ reg = report.n_regressions
3182
+ console.print(
3183
+ f" side-by-side: [cyan]{html_path}[/cyan] "
3184
+ f"({reg} regression(s) vs anchor)"
3185
+ )
3186
+ if open_html:
3187
+ _open_best_effort(html_path)
3188
+
3189
+ if dry_run is False and no_judge:
3190
+ console.print(" [dim]judge skipped (--no-judge)[/dim]")
3191
+
3192
+
3193
+ def _resolve_anchor(anchor: str, root: Path, skill: str) -> Optional[Path]:
3194
+ """Resolve ``--anchor`` to a .jsonl path ('latest' or an explicit path)."""
3195
+ if anchor == "latest":
3196
+ latest_dir = root / ".map" / "eval-runs" / "trajectory" / skill
3197
+ candidates = sorted(latest_dir.glob("*.jsonl")) if latest_dir.is_dir() else []
3198
+ # Exclude the candidate currently being written by picking the
3199
+ # previous-to-last when two exist; callers pass 'latest' meaning the
3200
+ # most recent PRIOR run.
3201
+ return candidates[-2] if len(candidates) >= 2 else (
3202
+ candidates[-1] if candidates else None
3203
+ )
3204
+ return Path(anchor)
3205
+
3206
+
2425
3207
  def main():
2426
3208
  app()
2427
3209