mapify-cli 3.15.2__py3-none-any.whl → 3.17.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 +260 -2
- mapify_cli/minimality_report.py +372 -0
- mapify_cli/research_eval.py +480 -0
- mapify_cli/schemas.py +88 -0
- mapify_cli/templates/agents/actor.md +18 -10
- mapify_cli/templates/agents/monitor.md +2 -0
- mapify_cli/templates/agents/research-agent.md +4 -4
- mapify_cli/templates/agents/task-decomposer.md +38 -0
- mapify_cli/templates/codex/agents/decomposer.toml +38 -0
- mapify_cli/templates/codex/agents/monitor.toml +9 -0
- mapify_cli/templates/codex/skills/map-efficient/SKILL.md +22 -5
- mapify_cli/templates/codex/skills/map-explain/SKILL.md +89 -44
- mapify_cli/templates/codex/skills/map-plan/SKILL.md +10 -11
- mapify_cli/templates/codex/skills/map-understand/SKILL.md +112 -0
- mapify_cli/templates/map/scripts/map_orchestrator.py +219 -2
- mapify_cli/templates/map/scripts/map_step_runner.py +916 -5
- mapify_cli/templates/map/scripts/validate_spec_citations.py +1 -1
- mapify_cli/templates/skills/map-efficient/SKILL.md +3 -3
- mapify_cli/templates/skills/map-explain/SKILL.md +88 -45
- mapify_cli/templates/skills/map-plan/SKILL.md +13 -8
- mapify_cli/templates/skills/map-state/SKILL.md +4 -3
- mapify_cli/templates/skills/map-state/scripts/init-session.sh +4 -3
- mapify_cli/templates/skills/map-state/templates/findings.md +1 -1
- mapify_cli/templates/skills/map-tokenreport/SKILL.md +13 -9
- mapify_cli/templates/skills/map-understand/SKILL.md +128 -0
- mapify_cli/templates/skills/skill-rules.json +23 -0
- mapify_cli/templates_src/agents/actor.md.jinja +18 -10
- mapify_cli/templates_src/agents/monitor.md.jinja +2 -0
- mapify_cli/templates_src/agents/research-agent.md.jinja +4 -4
- mapify_cli/templates_src/agents/task-decomposer.md.jinja +38 -0
- mapify_cli/templates_src/codex/agents/decomposer.toml.jinja +38 -0
- mapify_cli/templates_src/codex/agents/monitor.toml.jinja +9 -0
- mapify_cli/templates_src/codex/skills/map-efficient/SKILL.md.jinja +22 -5
- mapify_cli/templates_src/codex/skills/map-explain/SKILL.md.jinja +89 -44
- mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja +10 -11
- mapify_cli/templates_src/codex/skills/map-understand/SKILL.md.jinja +112 -0
- mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja +219 -2
- mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +916 -5
- mapify_cli/templates_src/map/scripts/validate_spec_citations.py.jinja +1 -1
- mapify_cli/templates_src/skills/map-efficient/SKILL.md.jinja +3 -3
- mapify_cli/templates_src/skills/map-explain/SKILL.md.jinja +88 -45
- mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja +13 -8
- mapify_cli/templates_src/skills/map-state/SKILL.md.jinja +4 -3
- mapify_cli/templates_src/skills/map-state/scripts/init-session.sh.jinja +4 -3
- mapify_cli/templates_src/skills/map-state/templates/findings.md.jinja +1 -1
- mapify_cli/templates_src/skills/map-tokenreport/SKILL.md.jinja +13 -9
- mapify_cli/templates_src/skills/map-understand/SKILL.md.jinja +128 -0
- mapify_cli/templates_src/skills/skill-rules.json.jinja +23 -0
- {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/METADATA +7 -4
- {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/RECORD +53 -47
- {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/WHEEL +0 -0
- {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/entry_points.txt +0 -0
- {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/licenses/LICENSE +0 -0
mapify_cli/__init__.py
CHANGED
|
@@ -23,7 +23,7 @@ Or install globally:
|
|
|
23
23
|
mapify check
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
|
-
__version__ = "3.
|
|
26
|
+
__version__ = "3.17.0"
|
|
27
27
|
|
|
28
28
|
import os
|
|
29
29
|
import subprocess
|
|
@@ -32,7 +32,7 @@ import shutil
|
|
|
32
32
|
import ssl
|
|
33
33
|
from datetime import datetime
|
|
34
34
|
from pathlib import Path
|
|
35
|
-
from typing import Optional, List, Dict, Any
|
|
35
|
+
from typing import Optional, List, Dict, Any, Mapping
|
|
36
36
|
|
|
37
37
|
import typer
|
|
38
38
|
import httpx
|
|
@@ -145,6 +145,13 @@ skill_eval_app = typer.Typer(
|
|
|
145
145
|
|
|
146
146
|
app.add_typer(skill_eval_app, name="skill-eval")
|
|
147
147
|
|
|
148
|
+
research_eval_app = typer.Typer(
|
|
149
|
+
name="research-eval",
|
|
150
|
+
help="Evaluate research-agent localization quality without provider calls",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
app.add_typer(research_eval_app, name="research-eval")
|
|
154
|
+
|
|
148
155
|
|
|
149
156
|
def version_callback(value: bool):
|
|
150
157
|
"""Callback to show version and exit."""
|
|
@@ -1265,6 +1272,150 @@ def doctor(debug: bool = typer.Option(False, "--debug", help="Enable debug loggi
|
|
|
1265
1272
|
console.print(f" • {path_name}")
|
|
1266
1273
|
|
|
1267
1274
|
|
|
1275
|
+
def _format_percent(value: Any) -> str:
|
|
1276
|
+
if value is None:
|
|
1277
|
+
return "n/a"
|
|
1278
|
+
try:
|
|
1279
|
+
return f"{float(value):.1%}"
|
|
1280
|
+
except (TypeError, ValueError):
|
|
1281
|
+
return "n/a"
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _render_minimality_report(report: Mapping[str, Any]) -> None:
|
|
1285
|
+
summary = report.get("summary")
|
|
1286
|
+
if not isinstance(summary, Mapping):
|
|
1287
|
+
summary = {}
|
|
1288
|
+
branches = report.get("branches")
|
|
1289
|
+
branch_rows = branches if isinstance(branches, list) else []
|
|
1290
|
+
|
|
1291
|
+
console.print("[bold]Minimality rollout report[/bold]")
|
|
1292
|
+
console.print(
|
|
1293
|
+
f"Current config minimality: [cyan]{report.get('current_config_minimality', 'off')}[/cyan]"
|
|
1294
|
+
)
|
|
1295
|
+
console.print(
|
|
1296
|
+
f"Decision: [bold]{summary.get('decision', 'insufficient_data')}[/bold] "
|
|
1297
|
+
f"(ready_for_phase3={summary.get('ready_for_phase3', False)})"
|
|
1298
|
+
)
|
|
1299
|
+
console.print(
|
|
1300
|
+
"Runs: "
|
|
1301
|
+
f"{summary.get('complete_opt_in_runs', 0)} opt-in complete, "
|
|
1302
|
+
f"{summary.get('complete_off_runs', 0)} off-baseline complete, "
|
|
1303
|
+
f"{summary.get('complete_runs_missing_historical_minimality', 0)} inferred"
|
|
1304
|
+
)
|
|
1305
|
+
console.print(
|
|
1306
|
+
"Averages: "
|
|
1307
|
+
f"retry {float(summary.get('avg_retry_events_opt_in', 0.0)):.2f} opt-in vs "
|
|
1308
|
+
f"{float(summary.get('avg_retry_events_off', 0.0)):.2f} off; "
|
|
1309
|
+
f"guard rework {float(summary.get('avg_guard_rework_opt_in', 0.0)):.2f} opt-in vs "
|
|
1310
|
+
f"{float(summary.get('avg_guard_rework_off', 0.0)):.2f} off; "
|
|
1311
|
+
f"YAGNI reversal {_format_percent(summary.get('user_reversal_rate'))}"
|
|
1312
|
+
)
|
|
1313
|
+
|
|
1314
|
+
reasons = summary.get("reasons")
|
|
1315
|
+
if isinstance(reasons, list) and reasons:
|
|
1316
|
+
console.print()
|
|
1317
|
+
console.print("[bold]Reasons[/bold]")
|
|
1318
|
+
for reason in reasons:
|
|
1319
|
+
console.print(f" - {reason}")
|
|
1320
|
+
|
|
1321
|
+
next_actions = summary.get("next_actions")
|
|
1322
|
+
if isinstance(next_actions, list) and next_actions:
|
|
1323
|
+
console.print()
|
|
1324
|
+
console.print("[bold]Next actions[/bold]")
|
|
1325
|
+
for action in next_actions:
|
|
1326
|
+
console.print(f" - {action}")
|
|
1327
|
+
|
|
1328
|
+
cohort_branches = summary.get("cohort_branches")
|
|
1329
|
+
if isinstance(cohort_branches, Mapping):
|
|
1330
|
+
cohort_rows = (
|
|
1331
|
+
("Off baseline", cohort_branches.get("off_baseline")),
|
|
1332
|
+
("Opt-in", cohort_branches.get("opt_in")),
|
|
1333
|
+
(
|
|
1334
|
+
"Missing historical minimality",
|
|
1335
|
+
cohort_branches.get("missing_historical_minimality"),
|
|
1336
|
+
),
|
|
1337
|
+
)
|
|
1338
|
+
if any(isinstance(branches, list) and branches for _, branches in cohort_rows):
|
|
1339
|
+
console.print()
|
|
1340
|
+
console.print("[bold]Cohort branches[/bold]")
|
|
1341
|
+
for label, branches in cohort_rows:
|
|
1342
|
+
if isinstance(branches, list) and branches:
|
|
1343
|
+
console.print(f" {label}: " + ", ".join(map(str, branches)))
|
|
1344
|
+
|
|
1345
|
+
manual_review_gate = summary.get("manual_review_gate")
|
|
1346
|
+
if (
|
|
1347
|
+
isinstance(manual_review_gate, Mapping)
|
|
1348
|
+
and manual_review_gate.get("required") is True
|
|
1349
|
+
):
|
|
1350
|
+
console.print()
|
|
1351
|
+
console.print("[bold]Manual review gate[/bold]")
|
|
1352
|
+
candidate_branches = manual_review_gate.get("candidate_branches")
|
|
1353
|
+
if isinstance(candidate_branches, list) and candidate_branches:
|
|
1354
|
+
console.print(
|
|
1355
|
+
" Candidate opt-in branches: "
|
|
1356
|
+
+ ", ".join(map(str, candidate_branches))
|
|
1357
|
+
)
|
|
1358
|
+
checklist = manual_review_gate.get("checklist")
|
|
1359
|
+
if isinstance(checklist, list) and checklist:
|
|
1360
|
+
console.print(" Checklist:")
|
|
1361
|
+
for item in checklist:
|
|
1362
|
+
console.print(f" - {item}")
|
|
1363
|
+
|
|
1364
|
+
if branch_rows:
|
|
1365
|
+
table = Table(title="Branch Samples", show_header=True, header_style="bold cyan")
|
|
1366
|
+
table.add_column("Branch")
|
|
1367
|
+
table.add_column("Status")
|
|
1368
|
+
table.add_column("Minimality")
|
|
1369
|
+
table.add_column("Source")
|
|
1370
|
+
table.add_column("Retries", justify="right")
|
|
1371
|
+
table.add_column("Guard", justify="right")
|
|
1372
|
+
table.add_column("YAGNI", justify="right")
|
|
1373
|
+
for row in branch_rows:
|
|
1374
|
+
if not isinstance(row, Mapping):
|
|
1375
|
+
continue
|
|
1376
|
+
table.add_row(
|
|
1377
|
+
str(row.get("branch", "")),
|
|
1378
|
+
str(row.get("terminal_status", "")),
|
|
1379
|
+
str(row.get("minimality", "")),
|
|
1380
|
+
str(row.get("minimality_source", "")),
|
|
1381
|
+
str(row.get("retry_events", 0)),
|
|
1382
|
+
str(row.get("guard_rework_events", 0)),
|
|
1383
|
+
f"{row.get('restored_yagni_count', 0)}/{row.get('total_yagni_recommendations', 0)}",
|
|
1384
|
+
)
|
|
1385
|
+
console.print()
|
|
1386
|
+
console.print(table)
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
@app.command("minimality-report")
|
|
1390
|
+
def minimality_report(
|
|
1391
|
+
project_path: Path = typer.Option(
|
|
1392
|
+
Path("."),
|
|
1393
|
+
"--path",
|
|
1394
|
+
"-p",
|
|
1395
|
+
help="Project root containing .map/ artifacts",
|
|
1396
|
+
),
|
|
1397
|
+
min_complete_runs: int = typer.Option(
|
|
1398
|
+
3,
|
|
1399
|
+
"--min-runs",
|
|
1400
|
+
min=1,
|
|
1401
|
+
help="Minimum complete runs required per off/opt-in cohort",
|
|
1402
|
+
),
|
|
1403
|
+
json_output: bool = typer.Option(
|
|
1404
|
+
False,
|
|
1405
|
+
"--json",
|
|
1406
|
+
help="Print the machine-readable report as JSON",
|
|
1407
|
+
),
|
|
1408
|
+
) -> None:
|
|
1409
|
+
"""Summarize local minimality telemetry for the Phase 3 default-flip gate."""
|
|
1410
|
+
from mapify_cli.minimality_report import build_minimality_rollout_report
|
|
1411
|
+
|
|
1412
|
+
report = build_minimality_rollout_report(project_path, min_complete_runs)
|
|
1413
|
+
if json_output:
|
|
1414
|
+
console.print_json(data=report)
|
|
1415
|
+
return
|
|
1416
|
+
_render_minimality_report(report)
|
|
1417
|
+
|
|
1418
|
+
|
|
1268
1419
|
def _mapify_install_kind() -> str:
|
|
1269
1420
|
"""Classify how this mapify CLI is installed.
|
|
1270
1421
|
|
|
@@ -1388,6 +1539,113 @@ def upgrade():
|
|
|
1388
1539
|
)
|
|
1389
1540
|
|
|
1390
1541
|
|
|
1542
|
+
# Research localization eval commands
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
@research_eval_app.command("score")
|
|
1546
|
+
def research_eval_score(
|
|
1547
|
+
output_file: Path = typer.Argument(
|
|
1548
|
+
...,
|
|
1549
|
+
help="ResearchEvidence JSON/text output file to score",
|
|
1550
|
+
),
|
|
1551
|
+
expected_file: Path = typer.Argument(
|
|
1552
|
+
...,
|
|
1553
|
+
help="JSON list, or object with expected_locations, of target file ranges",
|
|
1554
|
+
),
|
|
1555
|
+
repo_root: Optional[Path] = typer.Option(
|
|
1556
|
+
None,
|
|
1557
|
+
"--repo-root",
|
|
1558
|
+
help="Fixture repository root for path and line-range validation",
|
|
1559
|
+
),
|
|
1560
|
+
fail_under_file_f1: float = typer.Option(
|
|
1561
|
+
0.0,
|
|
1562
|
+
"--fail-under-file-f1",
|
|
1563
|
+
min=0.0,
|
|
1564
|
+
max=1.0,
|
|
1565
|
+
help="Exit 1 when file-level F1 is below this threshold",
|
|
1566
|
+
),
|
|
1567
|
+
fail_under_line_f1: float = typer.Option(
|
|
1568
|
+
0.0,
|
|
1569
|
+
"--fail-under-line-f1",
|
|
1570
|
+
min=0.0,
|
|
1571
|
+
max=1.0,
|
|
1572
|
+
help="Exit 1 when line-overlap F1 is below this threshold",
|
|
1573
|
+
),
|
|
1574
|
+
overbroad_line_threshold: int = typer.Option(
|
|
1575
|
+
50,
|
|
1576
|
+
"--overbroad-line-threshold",
|
|
1577
|
+
min=1,
|
|
1578
|
+
help="Count predicted locations above this line span as over-broad",
|
|
1579
|
+
),
|
|
1580
|
+
fail_on_malformed: bool = typer.Option(
|
|
1581
|
+
True,
|
|
1582
|
+
"--fail-on-malformed/--allow-malformed",
|
|
1583
|
+
help="Exit 1 when parsed output contains malformed locations",
|
|
1584
|
+
),
|
|
1585
|
+
) -> None:
|
|
1586
|
+
"""Score research-agent localization against known fixture targets.
|
|
1587
|
+
|
|
1588
|
+
Exit codes:
|
|
1589
|
+
0 - Score meets thresholds
|
|
1590
|
+
1 - Score below threshold or malformed output found
|
|
1591
|
+
2 - Input files are missing or malformed
|
|
1592
|
+
"""
|
|
1593
|
+
import json
|
|
1594
|
+
|
|
1595
|
+
from mapify_cli.research_eval import (
|
|
1596
|
+
load_expected_locations,
|
|
1597
|
+
score_research_output,
|
|
1598
|
+
score_to_dict,
|
|
1599
|
+
)
|
|
1600
|
+
|
|
1601
|
+
try:
|
|
1602
|
+
output = output_file.read_text(encoding="utf-8")
|
|
1603
|
+
except OSError as exc:
|
|
1604
|
+
console.print(f"[bold red]Error:[/bold red] cannot read output file: {exc}")
|
|
1605
|
+
raise typer.Exit(2)
|
|
1606
|
+
|
|
1607
|
+
try:
|
|
1608
|
+
expected = load_expected_locations(expected_file)
|
|
1609
|
+
except (OSError, ValueError) as exc:
|
|
1610
|
+
console.print(f"[bold red]Error:[/bold red] cannot load expected targets: {exc}")
|
|
1611
|
+
raise typer.Exit(2)
|
|
1612
|
+
|
|
1613
|
+
root = repo_root.resolve() if repo_root else Path.cwd()
|
|
1614
|
+
score = score_research_output(
|
|
1615
|
+
output,
|
|
1616
|
+
expected,
|
|
1617
|
+
repo_root=root,
|
|
1618
|
+
overbroad_line_threshold=overbroad_line_threshold,
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
failed_reasons: list[str] = []
|
|
1622
|
+
if score.file_level.f1 < fail_under_file_f1:
|
|
1623
|
+
failed_reasons.append(
|
|
1624
|
+
f"file_level.f1 {score.file_level.f1:.3f} < {fail_under_file_f1:.3f}"
|
|
1625
|
+
)
|
|
1626
|
+
if score.line_level.f1 < fail_under_line_f1:
|
|
1627
|
+
failed_reasons.append(
|
|
1628
|
+
f"line_level.f1 {score.line_level.f1:.3f} < {fail_under_line_f1:.3f}"
|
|
1629
|
+
)
|
|
1630
|
+
if fail_on_malformed and score.malformed_count > 0:
|
|
1631
|
+
failed_reasons.append(f"malformed_count {score.malformed_count} > 0")
|
|
1632
|
+
|
|
1633
|
+
payload = {
|
|
1634
|
+
"passed": not failed_reasons,
|
|
1635
|
+
"failed_reasons": failed_reasons,
|
|
1636
|
+
"thresholds": {
|
|
1637
|
+
"fail_under_file_f1": fail_under_file_f1,
|
|
1638
|
+
"fail_under_line_f1": fail_under_line_f1,
|
|
1639
|
+
"fail_on_malformed": fail_on_malformed,
|
|
1640
|
+
"overbroad_line_threshold": overbroad_line_threshold,
|
|
1641
|
+
},
|
|
1642
|
+
"score": score_to_dict(score),
|
|
1643
|
+
}
|
|
1644
|
+
print(json.dumps(payload, indent=2, sort_keys=True))
|
|
1645
|
+
if failed_reasons:
|
|
1646
|
+
raise typer.Exit(1)
|
|
1647
|
+
|
|
1648
|
+
|
|
1391
1649
|
# Skill-eval commands
|
|
1392
1650
|
|
|
1393
1651
|
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Minimality rollout telemetry for the Phase 3 default flip gate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Mapping, Sequence
|
|
9
|
+
|
|
10
|
+
from mapify_cli.config.project_config import VALID_MINIMALITY, load_map_config
|
|
11
|
+
|
|
12
|
+
OPT_IN_MINIMALITY_LEVELS = frozenset({"lite", "full", "ultra"})
|
|
13
|
+
MANUAL_REVIEW_CHECKLIST = (
|
|
14
|
+
"Compare each opt-in run against the original user request for dropped "
|
|
15
|
+
"explicit or implied requirements.",
|
|
16
|
+
"Inspect simplifications for terse or cryptic code that hurts maintainability.",
|
|
17
|
+
"Confirm Actor retries addressed only BLOCKER feedback, not NON-BLOCKING "
|
|
18
|
+
"scope expansion.",
|
|
19
|
+
"Verify map:simplification markers name a real ceiling and safe upgrade path.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _utc_timestamp() -> str:
|
|
24
|
+
return (
|
|
25
|
+
datetime.now(timezone.utc)
|
|
26
|
+
.replace(microsecond=0)
|
|
27
|
+
.isoformat()
|
|
28
|
+
.replace("+00:00", "Z")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _read_json_object(path: Path) -> dict[str, Any]:
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
35
|
+
except (OSError, json.JSONDecodeError):
|
|
36
|
+
return {}
|
|
37
|
+
return data if isinstance(data, dict) else {}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _as_mapping(value: object) -> Mapping[str, object]:
|
|
41
|
+
return value if isinstance(value, Mapping) else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _as_list(value: object) -> list[object]:
|
|
45
|
+
return value if isinstance(value, list) else []
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _as_int(value: object) -> int:
|
|
49
|
+
if isinstance(value, bool):
|
|
50
|
+
return 0
|
|
51
|
+
if isinstance(value, int):
|
|
52
|
+
return value
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
try:
|
|
55
|
+
return int(value)
|
|
56
|
+
except ValueError:
|
|
57
|
+
return 0
|
|
58
|
+
return 0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sum_int_values(value: object) -> int:
|
|
62
|
+
return sum(_as_int(item) for item in _as_mapping(value).values())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _derive_terminal_status(
|
|
66
|
+
run_health: Mapping[str, object], step_state: Mapping[str, object]
|
|
67
|
+
) -> str:
|
|
68
|
+
status = run_health.get("terminal_status")
|
|
69
|
+
if isinstance(status, str) and status:
|
|
70
|
+
return status
|
|
71
|
+
if step_state.get("workflow_status") == "WORKFLOW_COMPLETE":
|
|
72
|
+
return "complete"
|
|
73
|
+
if step_state.get("current_step_phase") == "COMPLETE":
|
|
74
|
+
return "complete"
|
|
75
|
+
return "pending"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _branch_minimality(
|
|
79
|
+
run_health: Mapping[str, object], current_config_minimality: str
|
|
80
|
+
) -> tuple[str, str]:
|
|
81
|
+
level = run_health.get("minimality")
|
|
82
|
+
if isinstance(level, str) and level in VALID_MINIMALITY:
|
|
83
|
+
return level, "run_health_report"
|
|
84
|
+
return current_config_minimality, "current_config"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _blueprint_body(payload: Mapping[str, object]) -> Mapping[str, object]:
|
|
88
|
+
body = payload.get("blueprint")
|
|
89
|
+
return body if isinstance(body, Mapping) else payload
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _blueprint_counts(blueprint_payload: Mapping[str, object]) -> dict[str, int]:
|
|
93
|
+
body = _blueprint_body(blueprint_payload)
|
|
94
|
+
subtasks = _as_list(body.get("subtasks"))
|
|
95
|
+
deferred_yagni = _as_list(body.get("deferred_yagni"))
|
|
96
|
+
restored_yagni_count = 0
|
|
97
|
+
active_pruneable_count = 0
|
|
98
|
+
|
|
99
|
+
for item in subtasks:
|
|
100
|
+
if not isinstance(item, Mapping):
|
|
101
|
+
continue
|
|
102
|
+
if isinstance(item.get("restored_from_deferred_yagni"), str):
|
|
103
|
+
restored_yagni_count += 1
|
|
104
|
+
if item.get("pruneable") is True:
|
|
105
|
+
active_pruneable_count += 1
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"deferred_yagni_count": len(deferred_yagni),
|
|
109
|
+
"restored_yagni_count": restored_yagni_count,
|
|
110
|
+
"active_pruneable_count": active_pruneable_count,
|
|
111
|
+
"total_yagni_recommendations": len(deferred_yagni) + restored_yagni_count,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _branch_sample(
|
|
116
|
+
branch_dir: Path, current_config_minimality: str
|
|
117
|
+
) -> dict[str, object] | None:
|
|
118
|
+
run_health_path = branch_dir / "run_health_report.json"
|
|
119
|
+
step_state_path = branch_dir / "step_state.json"
|
|
120
|
+
blueprint_path = branch_dir / "blueprint.json"
|
|
121
|
+
|
|
122
|
+
if not (
|
|
123
|
+
run_health_path.is_file()
|
|
124
|
+
or step_state_path.is_file()
|
|
125
|
+
or blueprint_path.is_file()
|
|
126
|
+
):
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
run_health = _read_json_object(run_health_path)
|
|
130
|
+
step_state = _read_json_object(step_state_path)
|
|
131
|
+
blueprint = _read_json_object(blueprint_path)
|
|
132
|
+
signals = _as_mapping(run_health.get("resiliency_signals"))
|
|
133
|
+
if not signals:
|
|
134
|
+
signals = step_state
|
|
135
|
+
|
|
136
|
+
minimality, minimality_source = _branch_minimality(
|
|
137
|
+
run_health, current_config_minimality
|
|
138
|
+
)
|
|
139
|
+
retry_events = _as_int(signals.get("retry_count")) + _sum_int_values(
|
|
140
|
+
signals.get("subtask_retry_counts")
|
|
141
|
+
)
|
|
142
|
+
counts = _blueprint_counts(blueprint)
|
|
143
|
+
total_recommendations = counts["total_yagni_recommendations"]
|
|
144
|
+
reversal_rate = (
|
|
145
|
+
counts["restored_yagni_count"] / total_recommendations
|
|
146
|
+
if total_recommendations
|
|
147
|
+
else None
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"branch": branch_dir.name,
|
|
152
|
+
"workflow": str(run_health.get("workflow") or step_state.get("workflow") or ""),
|
|
153
|
+
"terminal_status": _derive_terminal_status(run_health, step_state),
|
|
154
|
+
"minimality": minimality,
|
|
155
|
+
"minimality_source": minimality_source,
|
|
156
|
+
"has_run_health_report": bool(run_health),
|
|
157
|
+
"retry_events": retry_events,
|
|
158
|
+
"max_subtask_retry_count": _as_int(signals.get("max_subtask_retry_count")),
|
|
159
|
+
"guard_rework_events": _sum_int_values(signals.get("guard_rework_counts")),
|
|
160
|
+
"deferred_yagni_count": counts["deferred_yagni_count"],
|
|
161
|
+
"restored_yagni_count": counts["restored_yagni_count"],
|
|
162
|
+
"active_pruneable_count": counts["active_pruneable_count"],
|
|
163
|
+
"total_yagni_recommendations": total_recommendations,
|
|
164
|
+
"user_reversal_rate": reversal_rate,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _branch_dirs(map_dir: Path) -> list[Path]:
|
|
169
|
+
if not map_dir.is_dir():
|
|
170
|
+
return []
|
|
171
|
+
return sorted(
|
|
172
|
+
path for path in map_dir.iterdir() if path.is_dir() and path.name != "scripts"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _average(samples: Sequence[Mapping[str, object]], key: str) -> float:
|
|
177
|
+
if not samples:
|
|
178
|
+
return 0.0
|
|
179
|
+
return sum(_as_int(sample.get(key)) for sample in samples) / len(samples)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _summarize(
|
|
183
|
+
samples: list[dict[str, object]], min_complete_runs: int
|
|
184
|
+
) -> dict[str, object]:
|
|
185
|
+
complete = [
|
|
186
|
+
sample for sample in samples if sample.get("terminal_status") == "complete"
|
|
187
|
+
]
|
|
188
|
+
historical = [
|
|
189
|
+
sample
|
|
190
|
+
for sample in complete
|
|
191
|
+
if sample.get("minimality_source") == "run_health_report"
|
|
192
|
+
]
|
|
193
|
+
complete_off = [
|
|
194
|
+
sample for sample in historical if sample.get("minimality") == "off"
|
|
195
|
+
]
|
|
196
|
+
complete_opt_in = [
|
|
197
|
+
sample
|
|
198
|
+
for sample in historical
|
|
199
|
+
if sample.get("minimality") in OPT_IN_MINIMALITY_LEVELS
|
|
200
|
+
]
|
|
201
|
+
inferred_level_samples = [
|
|
202
|
+
sample
|
|
203
|
+
for sample in complete
|
|
204
|
+
if sample.get("minimality_source") != "run_health_report"
|
|
205
|
+
]
|
|
206
|
+
complete_off_branches = [str(sample.get("branch", "")) for sample in complete_off]
|
|
207
|
+
complete_opt_in_branches = [
|
|
208
|
+
str(sample.get("branch", "")) for sample in complete_opt_in
|
|
209
|
+
]
|
|
210
|
+
missing_historical_minimality_branches = [
|
|
211
|
+
str(sample.get("branch", "")) for sample in inferred_level_samples
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
restored_total = sum(
|
|
215
|
+
_as_int(sample.get("restored_yagni_count")) for sample in complete_opt_in
|
|
216
|
+
)
|
|
217
|
+
recommendation_total = sum(
|
|
218
|
+
_as_int(sample.get("total_yagni_recommendations")) for sample in complete_opt_in
|
|
219
|
+
)
|
|
220
|
+
reversal_rate = (
|
|
221
|
+
restored_total / recommendation_total if recommendation_total else None
|
|
222
|
+
)
|
|
223
|
+
avg_retry_off = _average(complete_off, "retry_events")
|
|
224
|
+
avg_retry_opt_in = _average(complete_opt_in, "retry_events")
|
|
225
|
+
avg_guard_off = _average(complete_off, "guard_rework_events")
|
|
226
|
+
avg_guard_opt_in = _average(complete_opt_in, "guard_rework_events")
|
|
227
|
+
opt_in_gap = max(0, min_complete_runs - len(complete_opt_in))
|
|
228
|
+
off_gap = max(0, min_complete_runs - len(complete_off))
|
|
229
|
+
|
|
230
|
+
reasons: list[str] = []
|
|
231
|
+
next_actions: list[str] = []
|
|
232
|
+
decision = "candidate"
|
|
233
|
+
|
|
234
|
+
if not samples:
|
|
235
|
+
decision = "insufficient_data"
|
|
236
|
+
reasons.append("No branch workspaces found under .map/.")
|
|
237
|
+
next_actions.append(
|
|
238
|
+
"Run MAP workflows with minimality off and opt-in levels so .map/ "
|
|
239
|
+
"contains run-health samples."
|
|
240
|
+
)
|
|
241
|
+
if inferred_level_samples:
|
|
242
|
+
decision = "insufficient_data"
|
|
243
|
+
reasons.append(
|
|
244
|
+
"Some complete reports lack historical minimality; regenerate run_health_report.json with this version."
|
|
245
|
+
)
|
|
246
|
+
next_actions.append(
|
|
247
|
+
"Regenerate complete run_health_report.json files with this mapify "
|
|
248
|
+
"version so each sample records historical minimality."
|
|
249
|
+
)
|
|
250
|
+
if len(complete_opt_in) < min_complete_runs:
|
|
251
|
+
decision = "insufficient_data"
|
|
252
|
+
reasons.append(
|
|
253
|
+
f"Need at least {min_complete_runs} complete opt-in runs; found {len(complete_opt_in)}."
|
|
254
|
+
)
|
|
255
|
+
next_actions.append(
|
|
256
|
+
f"Collect {opt_in_gap} more complete run(s) with minimality lite, "
|
|
257
|
+
"full, or ultra."
|
|
258
|
+
)
|
|
259
|
+
if len(complete_off) < min_complete_runs:
|
|
260
|
+
decision = "insufficient_data"
|
|
261
|
+
reasons.append(
|
|
262
|
+
f"Need at least {min_complete_runs} complete off-baseline runs; found {len(complete_off)}."
|
|
263
|
+
)
|
|
264
|
+
next_actions.append(
|
|
265
|
+
f"Collect {off_gap} more complete baseline run(s) with minimality off."
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
if decision == "candidate":
|
|
269
|
+
if reversal_rate is not None and reversal_rate > 0.2:
|
|
270
|
+
decision = "hold"
|
|
271
|
+
reasons.append(
|
|
272
|
+
f"User reversal rate is {reversal_rate:.1%}, above the 20% Phase 4 guardrail."
|
|
273
|
+
)
|
|
274
|
+
next_actions.append(
|
|
275
|
+
"Review restored deferred-YAGNI items and narrow pruning rules "
|
|
276
|
+
"before considering the default flip."
|
|
277
|
+
)
|
|
278
|
+
if avg_retry_opt_in > avg_retry_off:
|
|
279
|
+
decision = "hold"
|
|
280
|
+
reasons.append(
|
|
281
|
+
f"Average retry events regressed ({avg_retry_opt_in:.2f} opt-in vs {avg_retry_off:.2f} off)."
|
|
282
|
+
)
|
|
283
|
+
next_actions.append(
|
|
284
|
+
"Inspect opt-in retry feedback for BLOCKER/NON-BLOCKING "
|
|
285
|
+
"oscillation before collecting more samples."
|
|
286
|
+
)
|
|
287
|
+
if avg_guard_opt_in > avg_guard_off:
|
|
288
|
+
decision = "hold"
|
|
289
|
+
reasons.append(
|
|
290
|
+
f"Average guard rework regressed ({avg_guard_opt_in:.2f} opt-in vs {avg_guard_off:.2f} off)."
|
|
291
|
+
)
|
|
292
|
+
next_actions.append(
|
|
293
|
+
"Inspect guard rework events from opt-in runs and fix the "
|
|
294
|
+
"recurring guard failure before promotion."
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if decision == "candidate":
|
|
298
|
+
reasons.append(
|
|
299
|
+
"Local telemetry is compatible with a Phase 3 default-flip candidate."
|
|
300
|
+
)
|
|
301
|
+
next_actions.append(
|
|
302
|
+
"Sample the candidate opt-in runs for clarity/underscope "
|
|
303
|
+
"regressions before flipping the global default."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
manual_review_required = decision == "candidate"
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
"decision": decision,
|
|
310
|
+
"ready_for_phase3": decision == "candidate",
|
|
311
|
+
"reasons": reasons,
|
|
312
|
+
"next_actions": next_actions,
|
|
313
|
+
"branch_count": len(samples),
|
|
314
|
+
"complete_run_count": len(complete),
|
|
315
|
+
"complete_off_runs": len(complete_off),
|
|
316
|
+
"complete_opt_in_runs": len(complete_opt_in),
|
|
317
|
+
"complete_runs_missing_historical_minimality": len(inferred_level_samples),
|
|
318
|
+
"sample_gaps": {
|
|
319
|
+
"off_baseline_runs": off_gap,
|
|
320
|
+
"opt_in_runs": opt_in_gap,
|
|
321
|
+
"historical_minimality_runs": len(inferred_level_samples),
|
|
322
|
+
},
|
|
323
|
+
"cohort_branches": {
|
|
324
|
+
"off_baseline": complete_off_branches,
|
|
325
|
+
"opt_in": complete_opt_in_branches,
|
|
326
|
+
"missing_historical_minimality": missing_historical_minimality_branches,
|
|
327
|
+
},
|
|
328
|
+
"avg_retry_events_off": avg_retry_off,
|
|
329
|
+
"avg_retry_events_opt_in": avg_retry_opt_in,
|
|
330
|
+
"avg_guard_rework_off": avg_guard_off,
|
|
331
|
+
"avg_guard_rework_opt_in": avg_guard_opt_in,
|
|
332
|
+
"user_reversal_rate": reversal_rate,
|
|
333
|
+
"manual_review_gate": {
|
|
334
|
+
"required": manual_review_required,
|
|
335
|
+
"candidate_branches": [
|
|
336
|
+
str(sample.get("branch", "")) for sample in complete_opt_in
|
|
337
|
+
],
|
|
338
|
+
"checklist": (
|
|
339
|
+
list(MANUAL_REVIEW_CHECKLIST) if manual_review_required else []
|
|
340
|
+
),
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def build_minimality_rollout_report(
|
|
346
|
+
project_path: Path, min_complete_runs: int = 3
|
|
347
|
+
) -> dict[str, object]:
|
|
348
|
+
"""Build a local telemetry report for the minimality Phase 3 gate.
|
|
349
|
+
|
|
350
|
+
The report is read-only. It treats run-health reports that carry their own
|
|
351
|
+
``minimality`` field as historical truth. Older reports are still listed,
|
|
352
|
+
but the Phase 3 decision stays ``insufficient_data`` until enough complete
|
|
353
|
+
off and opt-in runs have historical levels recorded.
|
|
354
|
+
"""
|
|
355
|
+
project_root = Path(project_path).resolve()
|
|
356
|
+
current_config_minimality = load_map_config(project_root).minimality
|
|
357
|
+
min_runs = max(1, min_complete_runs)
|
|
358
|
+
samples = [
|
|
359
|
+
sample
|
|
360
|
+
for branch_dir in _branch_dirs(project_root / ".map")
|
|
361
|
+
if (sample := _branch_sample(branch_dir, current_config_minimality)) is not None
|
|
362
|
+
]
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
"schema_version": "1.0",
|
|
366
|
+
"generated_at": _utc_timestamp(),
|
|
367
|
+
"project_path": str(project_root),
|
|
368
|
+
"current_config_minimality": current_config_minimality,
|
|
369
|
+
"min_complete_runs": min_runs,
|
|
370
|
+
"summary": _summarize(samples, min_runs),
|
|
371
|
+
"branches": samples,
|
|
372
|
+
}
|