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.
- mapify_cli/__init__.py +783 -1
- mapify_cli/config/project_config.py +47 -0
- mapify_cli/grace_eval/__init__.py +42 -0
- mapify_cli/grace_eval/schema.py +437 -0
- mapify_cli/grace_eval/sweep.py +136 -0
- mapify_cli/memory/recall.py +66 -2
- mapify_cli/schemas.py +346 -0
- mapify_cli/scope_classifier.py +111 -0
- mapify_cli/skills_eval/trajectory/__init__.py +42 -0
- mapify_cli/skills_eval/trajectory/bundle.py +220 -0
- mapify_cli/skills_eval/trajectory/dispatcher.py +160 -0
- mapify_cli/skills_eval/trajectory/eval_schema.py +394 -0
- mapify_cli/skills_eval/trajectory/gates.py +255 -0
- mapify_cli/skills_eval/trajectory/judge.py +360 -0
- mapify_cli/skills_eval/trajectory/repeated.py +163 -0
- mapify_cli/skills_eval/trajectory/report.py +314 -0
- mapify_cli/skills_eval/trajectory/runner.py +462 -0
- mapify_cli/skills_eval/trajectory/seeding.py +213 -0
- mapify_cli/templates/codex/skills/map-plan/SKILL.md +81 -4
- mapify_cli/templates/map/scripts/classify_scope.py +144 -0
- mapify_cli/templates/map/scripts/map_orchestrator.py +55 -0
- mapify_cli/templates/map/scripts/map_step_runner.py +421 -0
- mapify_cli/templates/map/scripts/wayfind_runner.py +1574 -0
- mapify_cli/templates/skills/map-architecture/SKILL.md +217 -0
- mapify_cli/templates/skills/map-plan/SKILL.md +93 -6
- mapify_cli/templates/skills/map-wayfind/SKILL.md +131 -0
- mapify_cli/templates/skills/map-wayfind/wayfind-reference.md +132 -0
- mapify_cli/templates/skills/skill-rules.json +45 -0
- mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja +81 -4
- mapify_cli/templates_src/map/scripts/classify_scope.py.jinja +144 -0
- mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja +55 -0
- mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +421 -0
- mapify_cli/templates_src/map/scripts/wayfind_runner.py.jinja +1574 -0
- mapify_cli/templates_src/skills/map-architecture/SKILL.md.jinja +217 -0
- mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja +93 -6
- mapify_cli/templates_src/skills/map-wayfind/SKILL.md.jinja +131 -0
- mapify_cli/templates_src/skills/map-wayfind/wayfind-reference.md.jinja +132 -0
- mapify_cli/templates_src/skills/skill-rules.json.jinja +45 -0
- {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/METADATA +3 -1
- {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/RECORD +43 -19
- {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/WHEEL +0 -0
- {mapify_cli-3.22.0.dist-info → mapify_cli-3.23.0.dist-info}/entry_points.txt +0 -0
- {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.
|
|
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
|
|