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.
Files changed (53) hide show
  1. mapify_cli/__init__.py +260 -2
  2. mapify_cli/minimality_report.py +372 -0
  3. mapify_cli/research_eval.py +480 -0
  4. mapify_cli/schemas.py +88 -0
  5. mapify_cli/templates/agents/actor.md +18 -10
  6. mapify_cli/templates/agents/monitor.md +2 -0
  7. mapify_cli/templates/agents/research-agent.md +4 -4
  8. mapify_cli/templates/agents/task-decomposer.md +38 -0
  9. mapify_cli/templates/codex/agents/decomposer.toml +38 -0
  10. mapify_cli/templates/codex/agents/monitor.toml +9 -0
  11. mapify_cli/templates/codex/skills/map-efficient/SKILL.md +22 -5
  12. mapify_cli/templates/codex/skills/map-explain/SKILL.md +89 -44
  13. mapify_cli/templates/codex/skills/map-plan/SKILL.md +10 -11
  14. mapify_cli/templates/codex/skills/map-understand/SKILL.md +112 -0
  15. mapify_cli/templates/map/scripts/map_orchestrator.py +219 -2
  16. mapify_cli/templates/map/scripts/map_step_runner.py +916 -5
  17. mapify_cli/templates/map/scripts/validate_spec_citations.py +1 -1
  18. mapify_cli/templates/skills/map-efficient/SKILL.md +3 -3
  19. mapify_cli/templates/skills/map-explain/SKILL.md +88 -45
  20. mapify_cli/templates/skills/map-plan/SKILL.md +13 -8
  21. mapify_cli/templates/skills/map-state/SKILL.md +4 -3
  22. mapify_cli/templates/skills/map-state/scripts/init-session.sh +4 -3
  23. mapify_cli/templates/skills/map-state/templates/findings.md +1 -1
  24. mapify_cli/templates/skills/map-tokenreport/SKILL.md +13 -9
  25. mapify_cli/templates/skills/map-understand/SKILL.md +128 -0
  26. mapify_cli/templates/skills/skill-rules.json +23 -0
  27. mapify_cli/templates_src/agents/actor.md.jinja +18 -10
  28. mapify_cli/templates_src/agents/monitor.md.jinja +2 -0
  29. mapify_cli/templates_src/agents/research-agent.md.jinja +4 -4
  30. mapify_cli/templates_src/agents/task-decomposer.md.jinja +38 -0
  31. mapify_cli/templates_src/codex/agents/decomposer.toml.jinja +38 -0
  32. mapify_cli/templates_src/codex/agents/monitor.toml.jinja +9 -0
  33. mapify_cli/templates_src/codex/skills/map-efficient/SKILL.md.jinja +22 -5
  34. mapify_cli/templates_src/codex/skills/map-explain/SKILL.md.jinja +89 -44
  35. mapify_cli/templates_src/codex/skills/map-plan/SKILL.md.jinja +10 -11
  36. mapify_cli/templates_src/codex/skills/map-understand/SKILL.md.jinja +112 -0
  37. mapify_cli/templates_src/map/scripts/map_orchestrator.py.jinja +219 -2
  38. mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +916 -5
  39. mapify_cli/templates_src/map/scripts/validate_spec_citations.py.jinja +1 -1
  40. mapify_cli/templates_src/skills/map-efficient/SKILL.md.jinja +3 -3
  41. mapify_cli/templates_src/skills/map-explain/SKILL.md.jinja +88 -45
  42. mapify_cli/templates_src/skills/map-plan/SKILL.md.jinja +13 -8
  43. mapify_cli/templates_src/skills/map-state/SKILL.md.jinja +4 -3
  44. mapify_cli/templates_src/skills/map-state/scripts/init-session.sh.jinja +4 -3
  45. mapify_cli/templates_src/skills/map-state/templates/findings.md.jinja +1 -1
  46. mapify_cli/templates_src/skills/map-tokenreport/SKILL.md.jinja +13 -9
  47. mapify_cli/templates_src/skills/map-understand/SKILL.md.jinja +128 -0
  48. mapify_cli/templates_src/skills/skill-rules.json.jinja +23 -0
  49. {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/METADATA +7 -4
  50. {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/RECORD +53 -47
  51. {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/WHEEL +0 -0
  52. {mapify_cli-3.15.2.dist-info → mapify_cli-3.17.0.dist-info}/entry_points.txt +0 -0
  53. {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.15.2"
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
+ }