fplkit 2.7.2__tar.gz → 2.7.4__tar.gz

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 (318) hide show
  1. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/TOOLS.md +2 -2
  2. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/references/rules.md +1 -1
  3. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/release/SKILL.md +1 -0
  4. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/squad-builder/references/rules.md +1 -1
  5. {fplkit-2.7.2 → fplkit-2.7.4}/.claude/output-styles/fpl-mate.md +1 -1
  6. {fplkit-2.7.2 → fplkit-2.7.4}/CHANGELOG.md +22 -0
  7. {fplkit-2.7.2 → fplkit-2.7.4}/PKG-INFO +2 -1
  8. {fplkit-2.7.2 → fplkit-2.7.4}/docs/architecture.md +6 -5
  9. {fplkit-2.7.2 → fplkit-2.7.4}/docs/command-reference.md +29 -7
  10. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/_version.py +2 -2
  11. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/orchestration/report.py +35 -26
  12. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/providers/anthropic.py +1 -1
  13. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_fines.py +2 -2
  14. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_helpers.py +200 -2
  15. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_types.py +17 -1
  16. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_review_classic.py +135 -188
  17. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_review_draft.py +189 -202
  18. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_review_summarisation.py +16 -15
  19. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/league.py +16 -79
  20. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/league_recap.py +114 -13
  21. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/waivers.py +2 -1
  22. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/config/defaults.yaml +1 -1
  23. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/league_history.py +17 -7
  24. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/prompts/league_recap.py +103 -12
  25. fplkit-2.7.4/fpl_cli/prompts/league_recap_checks.py +958 -0
  26. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/league_history_counters.py +145 -62
  27. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/league_history_notes.py +90 -38
  28. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/templates/gw_league_recap.md.j2 +11 -0
  29. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/templates/gw_review.md.j2 +38 -14
  30. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/text.py +21 -6
  31. {fplkit-2.7.2 → fplkit-2.7.4}/pyproject.toml +3 -0
  32. fplkit-2.7.4/tests/test_cli_league.py +376 -0
  33. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_league_recap.py +335 -4
  34. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_review.py +629 -7
  35. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_waivers.py +9 -0
  36. fplkit-2.7.4/tests/test_formation_guidance_sync.py +114 -0
  37. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_league_history_counters.py +171 -26
  38. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_league_history_notes.py +148 -9
  39. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_league_recap.py +691 -9
  40. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_providers.py +8 -8
  41. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_report_agent.py +177 -0
  42. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_review.py +68 -0
  43. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_scoring.py +23 -1
  44. fplkit-2.7.2/tests/test_cli_league.py +0 -161
  45. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/README.md +0 -0
  46. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/build-test-plan/SKILL.md +0 -0
  47. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
  48. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
  49. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/create-pr/SKILL.md +0 -0
  50. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/SKILL.md +0 -0
  51. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
  52. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/references/output-template.md +0 -0
  53. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
  54. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  55. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
  56. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
  57. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  58. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  59. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
  60. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/preview-ingest/SKILL.md +0 -0
  61. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/release-notes/SKILL.md +0 -0
  62. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/squad-builder/SKILL.md +0 -0
  63. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/squad-builder/references/output-template.md +0 -0
  64. {fplkit-2.7.2 → fplkit-2.7.4}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  65. {fplkit-2.7.2 → fplkit-2.7.4}/.claude/settings.json +0 -0
  66. {fplkit-2.7.2 → fplkit-2.7.4}/.env.example +0 -0
  67. {fplkit-2.7.2 → fplkit-2.7.4}/.github/workflows/ci.yml +0 -0
  68. {fplkit-2.7.2 → fplkit-2.7.4}/.github/workflows/pr-title.yml +0 -0
  69. {fplkit-2.7.2 → fplkit-2.7.4}/.github/workflows/release.yml +0 -0
  70. {fplkit-2.7.2 → fplkit-2.7.4}/.gitignore +0 -0
  71. {fplkit-2.7.2 → fplkit-2.7.4}/AGENTS.md +0 -0
  72. {fplkit-2.7.2 → fplkit-2.7.4}/CLAUDE.md +0 -0
  73. {fplkit-2.7.2 → fplkit-2.7.4}/CONTRIBUTING.md +0 -0
  74. {fplkit-2.7.2 → fplkit-2.7.4}/LICENSE +0 -0
  75. {fplkit-2.7.2 → fplkit-2.7.4}/README.md +0 -0
  76. {fplkit-2.7.2 → fplkit-2.7.4}/cliff.toml +0 -0
  77. {fplkit-2.7.2 → fplkit-2.7.4}/config/team_ratings_overrides.yaml +0 -0
  78. {fplkit-2.7.2 → fplkit-2.7.4}/docs/custom-analysis.md +0 -0
  79. {fplkit-2.7.2 → fplkit-2.7.4}/docs/fpl-rules.md +0 -0
  80. {fplkit-2.7.2 → fplkit-2.7.4}/docs/images/fpl-player-demo-v1-0.png +0 -0
  81. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/__init__.py +0 -0
  82. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/__init__.py +0 -0
  83. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/action/__init__.py +0 -0
  84. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/action/waiver.py +0 -0
  85. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/__init__.py +0 -0
  86. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/bench_order.py +0 -0
  87. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/captain.py +0 -0
  88. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  89. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  90. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/stats.py +0 -0
  91. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
  92. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/base.py +0 -0
  93. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/common.py +0 -0
  94. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/data/__init__.py +0 -0
  95. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/data/fixture.py +0 -0
  96. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/data/price.py +0 -0
  97. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/data/scout.py +0 -0
  98. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/agents/orchestration/__init__.py +0 -0
  99. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/__init__.py +0 -0
  100. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/contract.py +0 -0
  101. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/core_insights.py +0 -0
  102. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/dataset_fetcher.py +0 -0
  103. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/football_data.py +0 -0
  104. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/fpl.py +0 -0
  105. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/fpl_draft.py +0 -0
  106. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/historical.py +0 -0
  107. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/historical_types.py +0 -0
  108. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/providers/__init__.py +0 -0
  109. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/providers/_http.py +0 -0
  110. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/providers/_models.py +0 -0
  111. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/providers/openai_compat.py +0 -0
  112. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/providers/perplexity.py +0 -0
  113. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/understat.py +0 -0
  114. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/api/vaastav.py +0 -0
  115. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/__init__.py +0 -0
  116. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_banner.py +0 -0
  117. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_context.py +0 -0
  118. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_fines_config.py +0 -0
  119. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_json.py +0 -0
  120. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_data.py +0 -0
  121. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_league_recap_history.py +0 -0
  122. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_plan_grid.py +0 -0
  123. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/_review_analysis.py +0 -0
  124. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/allocate.py +0 -0
  125. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/captain.py +0 -0
  126. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/chips.py +0 -0
  127. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/credentials.py +0 -0
  128. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/differentials.py +0 -0
  129. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/doctor.py +0 -0
  130. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/doctor_providers.py +0 -0
  131. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/fdr.py +0 -0
  132. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/fixtures.py +0 -0
  133. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/history.py +0 -0
  134. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/init.py +0 -0
  135. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/intel.py +0 -0
  136. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/league_fines.py +0 -0
  137. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/player.py +0 -0
  138. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/preview.py +0 -0
  139. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/price_changes.py +0 -0
  140. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/price_history.py +0 -0
  141. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/ratings.py +0 -0
  142. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/returnees.py +0 -0
  143. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/review.py +0 -0
  144. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/sell_prices.py +0 -0
  145. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/squad.py +0 -0
  146. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/stats.py +0 -0
  147. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/status.py +0 -0
  148. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/targets.py +0 -0
  149. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/transfer_eval.py +0 -0
  150. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/cli/xg.py +0 -0
  151. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/config/fixture_predictions.yaml +0 -0
  152. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
  153. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/config/team_managers.yaml +0 -0
  154. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/constants.py +0 -0
  155. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/__init__.py +0 -0
  156. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/chip_plan.py +0 -0
  157. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/fixture.py +0 -0
  158. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/player.py +0 -0
  159. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/team.py +0 -0
  160. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/models/types.py +0 -0
  161. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/parsers/__init__.py +0 -0
  162. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/parsers/recommendations.py +0 -0
  163. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/paths.py +0 -0
  164. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/prompts/__init__.py +0 -0
  165. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/prompts/returnees.py +0 -0
  166. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/prompts/review.py +0 -0
  167. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/prompts/scout.py +0 -0
  168. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/scraper/__init__.py +0 -0
  169. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/scraper/fpl_prices.py +0 -0
  170. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/season.py +0 -0
  171. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/__init__.py +0 -0
  172. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/fixture_predictions.py +0 -0
  173. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/league_history.py +0 -0
  174. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/league_history_fines.py +0 -0
  175. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/matchup.py +0 -0
  176. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/player_clubs.py +0 -0
  177. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/player_prior.py +0 -0
  178. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/returnee_radar.py +0 -0
  179. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/__init__.py +0 -0
  180. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/constants.py +0 -0
  181. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/data_prep.py +0 -0
  182. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/display.py +0 -0
  183. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/evaluation.py +0 -0
  184. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/ownership.py +0 -0
  185. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/shrinkage.py +0 -0
  186. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/signals.py +0 -0
  187. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/single_gw.py +0 -0
  188. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/scoring/value_quality.py +0 -0
  189. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/season_previews.py +0 -0
  190. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/squad_allocator.py +0 -0
  191. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/team_form.py +0 -0
  192. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/team_ratings.py +0 -0
  193. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/services/team_ratings_prior.py +0 -0
  194. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  195. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/__init__.py +0 -0
  196. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/files.py +0 -0
  197. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/gameweek.py +0 -0
  198. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/markdown.py +0 -0
  199. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/teams.py +0 -0
  200. {fplkit-2.7.2 → fplkit-2.7.4}/fpl_cli/utils/time.py +0 -0
  201. {fplkit-2.7.2 → fplkit-2.7.4}/pyrightconfig.json +0 -0
  202. {fplkit-2.7.2 → fplkit-2.7.4}/scripts/backtest_early_season_prior_blend.py +0 -0
  203. {fplkit-2.7.2 → fplkit-2.7.4}/scripts/calibrate_promoted_prior.py +0 -0
  204. {fplkit-2.7.2 → fplkit-2.7.4}/scripts/calibrate_quality_ceilings.py +0 -0
  205. {fplkit-2.7.2 → fplkit-2.7.4}/tests/__init__.py +0 -0
  206. {fplkit-2.7.2 → fplkit-2.7.4}/tests/conftest.py +0 -0
  207. {fplkit-2.7.2 → fplkit-2.7.4}/tests/fixtures/classic_squad_fixture.md +0 -0
  208. {fplkit-2.7.2 → fplkit-2.7.4}/tests/fixtures/recommendations_fixture.md +0 -0
  209. {fplkit-2.7.2 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
  210. {fplkit-2.7.2 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
  211. {fplkit-2.7.2 → fplkit-2.7.4}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
  212. {fplkit-2.7.2 → fplkit-2.7.4}/tests/fixtures/waiver_ranked_golden.json +0 -0
  213. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_action.py +0 -0
  214. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_analysis.py +0 -0
  215. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_analysis_squad.py +0 -0
  216. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_base.py +0 -0
  217. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_data.py +0 -0
  218. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_data_price.py +0 -0
  219. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_agents_orchestration.py +0 -0
  220. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_allocate.py +0 -0
  221. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_client.py +0 -0
  222. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_core_insights.py +0 -0
  223. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_dataset_fetcher.py +0 -0
  224. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_draft.py +0 -0
  225. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_football_data.py +0 -0
  226. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_historical.py +0 -0
  227. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_understat.py +0 -0
  228. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_api_vaastav.py +0 -0
  229. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_bench_order_script.py +0 -0
  230. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_calibrate_quality_ceilings.py +0 -0
  231. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_classic_squad_extraction.py +0 -0
  232. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_allocate.py +0 -0
  233. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_banner.py +0 -0
  234. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_captain.py +0 -0
  235. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_chips.py +0 -0
  236. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_context_settings.py +0 -0
  237. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_credentials.py +0 -0
  238. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_custom_analysis.py +0 -0
  239. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_differentials.py +0 -0
  240. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_doctor.py +0 -0
  241. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_doctor_providers.py +0 -0
  242. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_failure_parity.py +0 -0
  243. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_failure_streams.py +0 -0
  244. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_fdr.py +0 -0
  245. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_fdr_blanks.py +0 -0
  246. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_fixtures.py +0 -0
  247. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_format.py +0 -0
  248. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_format_suppression.py +0 -0
  249. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_fpl_block.py +0 -0
  250. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_history.py +0 -0
  251. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_init.py +0 -0
  252. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_intel.py +0 -0
  253. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_json.py +0 -0
  254. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_json_contract.py +0 -0
  255. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_league_fines.py +0 -0
  256. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_plan_grid.py +0 -0
  257. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_player.py +0 -0
  258. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_preview.py +0 -0
  259. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_price_changes.py +0 -0
  260. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_price_history.py +0 -0
  261. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_returnees.py +0 -0
  262. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_sell_prices.py +0 -0
  263. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_settings_warning.py +0 -0
  264. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_squad.py +0 -0
  265. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_stats.py +0 -0
  266. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_status.py +0 -0
  267. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_targets.py +0 -0
  268. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_transfer_eval.py +0 -0
  269. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_warnings_stderr.py +0 -0
  270. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_cli_xg.py +0 -0
  271. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_common.py +0 -0
  272. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_common_scoring.py +0 -0
  273. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_data_prep_harmonisation.py +0 -0
  274. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_dependencies.py +0 -0
  275. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_doc_consistency.py +0 -0
  276. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_fines.py +0 -0
  277. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_fines_config.py +0 -0
  278. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_fixture_predictions.py +0 -0
  279. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_fpl_prices.py +0 -0
  280. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_gw_prep_script_bootstrap.py +0 -0
  281. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_init.py +0 -0
  282. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_init_fines.py +0 -0
  283. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_league_history_fines.py +0 -0
  284. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_league_history_store.py +0 -0
  285. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_matchup_service.py +0 -0
  286. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_models.py +0 -0
  287. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_normalise_entities.py +0 -0
  288. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_output_style_sync.py +0 -0
  289. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_paths.py +0 -0
  290. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_player_clubs.py +0 -0
  291. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_player_prior.py +0 -0
  292. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_player_resolution.py +0 -0
  293. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_preseason.py +0 -0
  294. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_prompt_tables.py +0 -0
  295. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_recommendations_parser.py +0 -0
  296. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_report_paths.py +0 -0
  297. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_returnee_radar.py +0 -0
  298. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_rolling_pts_per_m.py +0 -0
  299. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_scraper.py +0 -0
  300. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_season.py +0 -0
  301. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_season_awareness.py +0 -0
  302. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_season_previews.py +0 -0
  303. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_sell_prices.py +0 -0
  304. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_skill_fdr_quality_signals.py +0 -0
  305. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_skill_stats_thresholds.py +0 -0
  306. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_squad_allocator.py +0 -0
  307. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_starting_xi_agent.py +0 -0
  308. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_starting_xi_script.py +0 -0
  309. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_stats.py +0 -0
  310. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_team_ratings.py +0 -0
  311. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_team_ratings_prior.py +0 -0
  312. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_transfer_eval_agent.py +0 -0
  313. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_transfer_eval_script.py +0 -0
  314. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_utils_gameweek.py +0 -0
  315. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_utils_markdown.py +0 -0
  316. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_utils_text.py +0 -0
  317. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_utils_time.py +0 -0
  318. {fplkit-2.7.2 → fplkit-2.7.4}/tests/test_validate_draft_waivers.py +0 -0
@@ -39,7 +39,7 @@ with `2>/dev/null` without losing the table, whichever command it runs.
39
39
  | `player` | Look up a player's stats, xG, ownership and fixture run. Before GW10 `quality_score` blends last season's pedigree in and JSON `metadata.warnings` says so (`early_season_prior_informed`, or `early_season_small_sample` when history could not be loaded), same as `fpl stats --value`; `understat_team_unmatched` joins it for any club Understat carried no players for, whose whole squad loses xG enrichment. JSON includes `ep_next` (predicted pts next GW) and `ep_this` (current GW), emitting `null` when FPL has no projection; panel shows `xPts` (ep_next) when available, omits the segment when FPL has no projection. `--detail` (`-d`) shows GW-by-GW performance and, for FWD/MID with sufficient history, xGI sustainability (per-match GI-xGI divergence -> form modifier). With `custom_analysis`: JSON adds `info.adjusted_npxg_per_90` (fixture-adjusted) and `info.raw_npxg_per_90` (Understat season avg); panel shows `adj. npxG/90: X.XXX (raw: Y.YYY)` | Yes | General | No | direct-api |
40
40
  | `stats` | List players with filtering and sorting. `--value` adds quality/value per £m columns; `--window N` sets rolling lookback (3-10) for `rolling_pts_per_m`. Before GW10, `--value` quality scores blend last season's pts/90 pedigree with this season's observation and the command says so (stderr notice quoting the observation's weight band; JSON `metadata.warnings` code `early_season_prior_informed`); when last season's history cannot be loaded they are pure observation and the notice says that instead (`early_season_small_sample`, before GW6). `understat_team_unmatched` is raised once per club Understat carried no players for, whose whole squad scores without xG. GK ceilings scale with the calendar-possible sample, reaching full scale at GW6. Sortable by `ep_next`/`ep_this` (FPL predicted points; `ep_this` is only meaningful before the gameweek's first kickoff — FPL rolls it forward to `ep_next` as each player's match finishes); both in JSON output (emit `null` when FPL has no projection; table renders `—`). Players with no projection sort to the bottom in either direction | Yes | General | No | direct-api |
41
41
  | `history` | Show historical player performance across seasons. A provider that refuses exits 1 in both output formats, carrying the upstream's own reason: the error envelope used to say only `Failed to fetch historical data` while the cause went to stderr, and table mode printed the cause to stdout and exited 0 | Yes | General | No | direct-api |
42
- | `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break | No | General | No | direct-api |
42
+ | `league` | Show live league standings for Classic and Draft leagues. Classic positions are competition-ranked from cumulative totals — managers level on points share a place and the next distinct total skips the places they consumed — matching `fpl review` and the league history ledger rather than the standings API's sequential rank, which splits such a tie on fewest transfers season-to-date, a figure no column shows. Draft keeps the league's own rank, which already applies the head-to-head points-for tie-break. The Best/Worst GW Performers lists (top three; bottom five classic, bottom three draft) are the ones `fpl review` prints: managers level on the gameweek share a place (`3=`) and a tie at the cut keeps every manager in it, a hit reads `29 net pts (33 gross, -4 hit)`, and a classic user above the bottom five gets a `Your GW rank: R of N` line beside the list | No | General | No | direct-api |
43
43
  | `fdr` | Analyse fixture difficulty - easy runs, blanks, doubles. Three payloads, not one: the default call runs the agent and returns the full analysis plus `data.ratings_warning` (the team-ratings quality verdict, and with `--my-squad` also `data.predictions_stale` / `data.prediction_warnings` / `data.squad_exposure`); with custom analysis off it returns raw 1-5 FPL API difficulty (`data.easy_fixture_runs` only, `metadata.custom_analysis: false`, no ratings and so no warning); `--blanks` bypasses the agent for a schedule-only payload carrying neither the FDR analysis nor `ratings_warning`. `--blanks` mixes confirmed fixtures with predictions; when those predictions are stale, table mode warns on stderr and JSON adds a `fixture_predictions_stale` entry to `metadata.warnings` | Yes | General | No | via-agent |
44
44
  | `xg` | Analyse underlying stats: xG, xA, overperformers. The minutes floor and the window scale to the gameweeks played (`min(60 x window, 45 x gameweeks played)`, so 90 minutes over 2 GWs rather than an unreachable 360 over 6); the configured bar binds from GW9, or GW11 for `--all`. JSON `metadata` carries `window_label`, `gameweeks_played` and `min_minutes`, and `metadata.warnings` an `early_season_minutes_floor` notice while the bar is scaled, plus `understat_team_unmatched` for any club Understat carried no players for. When nothing qualifies, `data.empty_reason` says whether the floor (`below_minutes_floor`) or the data (`no_minutes_played`) caused it | Yes | General | No | via-agent |
45
45
  | `price-changes` | Show price changes and transfer activity | No | General | No | via-agent |
@@ -47,7 +47,7 @@ with `2>/dev/null` without losing the table, whichever command it runs.
47
47
  | `returnees` | Track injured and suspended players due back soon, so a returnee can be claimed in draft before they are fit or planned around in classic. Reads the FPL availability news, filters to returns inside a window (`--window N`, 1-38) and through a source-aware quality bar, and diffs against the stored watchlist for week-over-week changes. Most flagged players carry no parseable return date, so date-unknown entries are the norm rather than the exception; `--enrich` searches the web for fresher timing on those and on stale-news entries (needs a Perplexity key; skips with a note without one) and shows it beside the FPL news, never over it. `--all` bypasses the quality bar and deliberately does not persist the snapshot. JSON `metadata` carries `window`, `escalation_window`, `stash_upgrade_margin`, `transitions_available`, `transitions_baseline_gameweek` (the gameweek `transition` is measured against — every run in a gameweek diffs against the last state stored before it, so a repeat run reports the same changes), `quality_bar_available`, `quality_bar_applied` and `enrichment_requested`/`_available`/`_note`/`_count`/`_rate_limited` (true when the search provider rate-limited the run even after retrying — the one failure a re-run is likely to clear); `data` is `{entries, departures}`, each entry carrying `quality.basis`/`quality.meets_stash`, `expected_return`/`return_gameweek`/`return_source`, `escalation_eligible`/`escalation_basis`, `transition`, and `enrichment` (null unless enriched) | Yes | General | No | direct-api |
48
48
  | `preview` | Run full pre-gameweek analysis and generate report. `--save` writes to `<output dir>/<season>/gw{N}-preview.md`; `--scout` writes to `<research dir>/ai-scout-reports/<season>/gw{N}-scout-preview[-referenced].md` | No | General | No | via-agent |
49
49
  | `review` | Review a completed gameweek - squad performance and standings. `--save` writes to `<output dir>/<season>/gw{N}-review.md`; `--compare-recs` reads `gw{N}-recommendations.md` from the same season directory. Under `--summarise` the personal analysis is handed next gameweek's fixtures — every club's opponent and venue plus each squad player's positional FDR (1-7 team ratings with custom analysis on, the FPL API's 1-5 difficulty with it off, carrying the same scale footer and unrated-club caveat the FDR tables print, plus any warning that the ratings cannot support difficulty) — so its "Next Week" calls are grounded in fixtures rather than in one week of points; with no next gameweek to look at, that section is narrowed to fixture-independent observations and points at `fpl gw-prep`, and a gameweek already played is marked retrospective. The answer is then checked back against that block — a cited FDR the block never printed, or any fixture claim made without one — and against the sections its prompt asked for: an answer missing a `## ` heading, ending mid-sentence, or that the provider says it stopped early on is retried once, and a still-incomplete one is warned about on stderr (detail in `data/debug/synthesis_corrections.txt` under `--debug`) and carries the same list as a callout in the saved report. The summary is an add-on the review does not depend on: the two provider roles resolve independently, and a role with no usable key is skipped with the reason on stderr (`Community narrative skipped: ...` / `Personal analysis skipped: ...`) while the review prints, saves and exits 0 — so one key produces that half rather than nothing, and a saved report names each skipped half in a warning callout of its own. A gameweek it cannot resolve - one still being played, an id the season does not have, a season with nothing finished yet - is refused with the specific reason on stderr and exit 1; stdout carries the review or nothing | No | General | No | via-agent |
50
- | `league-recap` | Recap a completed gameweek - awards, standings, and banter. Saves to `<output dir>/<season>/gw{N}-league-recap[-draft].md`, opening on a fixed `# Gameweek N Recap: <league name>` title the report writes itself, editorial or not; the editorial is asked for a `## ` headline beneath it and never a title, and whatever heading it opens with anyway is demoted to that level or, when it only restates the title, dropped, so the model's heading is never the document's (issue #349). Records every run into the league history ledger (`<data dir>/league_history/`) as a side effect, backfilling classic gaps at a coarse tier automatically; `--backfill-detail` rebuilds earlier gameweeks in full (one request per manager per gameweek). A rebuild keeps the name and position already recorded for each player instead of restamping them from today's bootstrap, and never drops a code the gameweek already resolved; it reads the earliest recorded row rather than the newest, so a gameweek an older version already restamped is repaired rather than re-copied. The club is not carried but *derived*, from the gameweek's own fixtures: FPL's live data records which fixtures each player's club played, so a double gameweek settles his club outright and a single narrows it to two, with one `element-summary` request spent only where today's club is absent from that pair (proof he has moved); today's club being one of the two is left unanswered rather than assumed, so a recorded club still wins there. That reaches the two cases a carry structurally cannot — a first capture and a coarse-to-detailed upgrade, neither of which has a recorded squad — and it supersedes a stale recorded club rather than preserving it, with `league_history_club_rederived` naming how many were replaced. A club that blanked has no fixture to intersect and falls back to the recorded club, then today's; name and position are not derivable from fixtures at all and stay carry-only. Everything the gameweek itself answers (points, cards, pick flags, blank status) is still re-derived, and a `league_history_identity_carried` warning names how many players were kept. A rebuild likewise never writes away a league position or cumulative total it cannot re-derive: draft has no per-manager history endpoint and its standings describe a later gameweek, so a replayed draft gameweek keeps the figures already recorded (`league_history_standings_carried`) instead of nulling them, which used to cost the ledger every position that gameweek held and collapse the streaks built on them. Where they were already lost, every run restores them from the ledger itself — the last superseded line that recorded them (a gameweek captured repeatedly while it was still settling holds a stale position in its first capture and the settled one in its last), or a cumulative total re-summed from earlier gameweeks — with no flag and no request (`league_history_standings_repaired`); a draft gameweek's table is derivable from `gross_points` alone at the league's start gameweek. A draft row also records the waiver claims its manager lost to a rival (`lost_claims`, kept apart from `transactions` because nothing moved — no points, no net, and never the denial that is the cascade of the manager's own accepted claim), so an outbid manager is not written into the permanent record as one who never tried; a gameweek captured before that field existed reads it back as unrecorded rather than as "lost none", is named by the coverage report, and is re-recorded by `--backfill-detail` while the league's transaction feed still serves it; a replay that finds fewer claims than the gameweek recorded keeps the recorded list (`league_history_claims_carried`). Those claims also make the waiver wire's races visible (issue #330): the editorial's waiver roster closes with every player more than one manager claimed, who won him and who was beaten to him at what priority, and a week where at least three managers claimed the same player (`MOST_CONTESTED_MIN_CLAIMANTS`) and one of them can be seen to have won him gets a Most Contested award beside Waiver Genius and Waiver Disaster — derived from the manager rows by `contested_draft_claims()`, never recorded, never a report section of its own, and absent from `--format json`, whose rows already carry the `lost_claims` and `transactions` they are regrouped from. Biggest Bench Haul (both formats) ranks on the best legal XI's gain over the XI that counted — one keeper, the eight legal formations, the doubled player held in place — never on the bench's raw sum, which counted a keeper who could not have played and outfielders no formation had room for (issue #350); its detail names the swap, a manager with bench points but no better XI is no candidate, and the raw sum stays on the row as `bench_points` (the API's `points_on_bench`) because the coarse tier can record nothing else. Coverage gaps and capture warnings go to stderr; the command exits 0 even when the store is unreadable. `--format json` emits one row per manager (the captured ledger row shape) plus `metadata.coverage`, `season_phase`, `notes_pack`, `synthesis_summary`, `warnings` (always present, each entry a stable `code` plus rewritable `message` - codes listed in [Command Reference](../docs/command-reference.md#league-history)) and `first_capture_store_path` (always present; the partition directory on a first capture, `null` after). Fines are ruled *and recorded* against every captured gameweek, backfilled ones included: the detailed replay rules every configured rule, the coarse tier rules only what cohort points support (`last-place`, `below-threshold`) and records that `red-card` went unruled. `last-place` is ruled against every manager level on the gameweek's lowest score rather than whichever of them the cohort's arrival order reached first; because rulings are frozen at capture and the ledger is append-only, gameweeks recorded before that was true are swept on every run and the recorded ruling appended against the managers they left out, with no flag and no request (`league_history_tied_last_place_repaired`) — and where the row data cannot settle who was level, since the gameweek does not record whether it was ruled on gross or net points, the manager is named by `league_history_tied_last_place_ambiguous` rather than fined on a guess. Season totals print under `Season Fines` on console and as a `# Season Fines` report section, but only at the two milestone gameweeks (GW19 and the finale) - every other week those surfaces stay a this-week view and `league-fines` answers the season question on demand. The `## Season Fines` prompt section and `metadata.season_fines` are ungated: the editorial gets the running totals every week as optional colour (never a required beat), and a scripted consumer sees no calendar-driven appearance and disappearance. Bottom-half gameweeks and green-arrow droughts no longer surface as streaks at all (their run only drives the season count). Streak conditions also keep reset-surviving season occurrence counts ("their sixth gameweek win of the season"), each surfaced on its own rule (see [Command Reference](../docs/command-reference.md#league-recap) for the per-count table): wins and last places on multiples of 3 plus any second-half first, captain blanks and waiver hauls/backfires on multiples of 5, hits on multiples of 3, weeks on top on multiples of 5, bottom-half gameweeks on multiples of 10 in the second half only, and green-arrow droughts on an unbroken run of 5 or 10 (gameweeks spent leading the league never count towards a drought — the leader could not have climbed). Some counts carry same-week peers alongside the milestone — past a fixed floor for counts that stay small, or within a relative window for counts that climb all season. A firing count reaches the report's `## Season Counts` subsection and the editorial's League History section; at the two milestone gameweeks both surfaces carry every nonzero count as the season set-piece. Every line states its span and any not-judged gameweeks beside the number, and every nonzero count is emitted in `metadata.notes_pack.season_count_entries` regardless. A recap of the most recently finished gameweek keeps working once the next gameweek has started: the league table describes that later gameweek from its deadline onward, so the run takes the replay path (figures from each manager's own gameweek history, positions and cumulative totals derived rather than read off the table) and raises a `league_standings_moved_on` warning, instead of reconciling two different gameweeks' numbers against each other and exiting 1 with the ledger row unwritten. `--summarise` with no usable synthesis key no longer aborts the run: the recap, report and ledger capture all proceed, and a `synthesis_provider_unavailable` warning says the editorial was skipped; an editorial the provider stopped early on is written out as it stands with the reason on stderr and a `synthesis_stopped_early` warning in JSON. A gameweek that cannot be resolved at all exits 1 in both output formats - it used to exit 1 under `--format json` with a generic `"Could not resolve a gameweek to recap."` and 0 in table mode - and the resolver's own reason (`Gameweek 7 is not yet finished`) now reaches the error envelope and the stderr prose alike. On the league's opening gameweek only (GW1, or its own `start_event`), each classic manager's FPL-wide prior-season record — seasons played, last season's points/rank/top-% finish, best season — is fetched from the manager-history endpoint (one request per manager, that gameweek only, and only with `--save`/`--output` or `--summarise`; a league created after GW1 shares the fetch with its league-start offset) and rendered as a `# Prior Seasons` report section and a locked `## Prior Seasons` prompt section with an explicit count; tenure is phrased FPL-wide because entry and league IDs are reissued each season, and a manager with no record or a failed fetch is named as such rather than omitted. Nothing of it reaches the ledger or the JSON rows; draft has no such endpoint and no such section | Yes | General | No | via-agent |
50
+ | `league-recap` | Recap a completed gameweek - awards, standings, and banter. Saves to `<output dir>/<season>/gw{N}-league-recap[-draft].md`, opening on a fixed `# Gameweek N Recap: <league name>` title the report writes itself, editorial or not; the editorial is asked for a `## ` headline beneath it and never a title, and whatever heading it opens with anyway is demoted to that level or, when it only restates the title, dropped, so the model's heading is never the document's (issue #349). Records every run into the league history ledger (`<data dir>/league_history/`) as a side effect, backfilling classic gaps at a coarse tier automatically; `--backfill-detail` rebuilds earlier gameweeks in full (one request per manager per gameweek). A rebuild keeps the name and position already recorded for each player instead of restamping them from today's bootstrap, and never drops a code the gameweek already resolved; it reads the earliest recorded row rather than the newest, so a gameweek an older version already restamped is repaired rather than re-copied. The club is not carried but *derived*, from the gameweek's own fixtures: FPL's live data records which fixtures each player's club played, so a double gameweek settles his club outright and a single narrows it to two, with one `element-summary` request spent only where today's club is absent from that pair (proof he has moved); today's club being one of the two is left unanswered rather than assumed, so a recorded club still wins there. That reaches the two cases a carry structurally cannot — a first capture and a coarse-to-detailed upgrade, neither of which has a recorded squad — and it supersedes a stale recorded club rather than preserving it, with `league_history_club_rederived` naming how many were replaced. A club that blanked has no fixture to intersect and falls back to the recorded club, then today's; name and position are not derivable from fixtures at all and stay carry-only. Everything the gameweek itself answers (points, cards, pick flags, blank status) is still re-derived, and a `league_history_identity_carried` warning names how many players were kept. A rebuild likewise never writes away a league position or cumulative total it cannot re-derive: draft has no per-manager history endpoint and its standings describe a later gameweek, so a replayed draft gameweek keeps the figures already recorded (`league_history_standings_carried`) instead of nulling them, which used to cost the ledger every position that gameweek held and collapse the streaks built on them. Where they were already lost, every run restores them from the ledger itself — the last superseded line that recorded them (a gameweek captured repeatedly while it was still settling holds a stale position in its first capture and the settled one in its last), or a cumulative total re-summed from earlier gameweeks — with no flag and no request (`league_history_standings_repaired`); a draft gameweek's table is derivable from `gross_points` alone at the league's start gameweek. A draft row also records the waiver claims its manager lost to a rival (`lost_claims`, kept apart from `transactions` because nothing moved — no points, no net, and never the denial that is the cascade of the manager's own accepted claim), so an outbid manager is not written into the permanent record as one who never tried; a gameweek captured before that field existed reads it back as unrecorded rather than as "lost none", is named by the coverage report, and is re-recorded by `--backfill-detail` while the league's transaction feed still serves it; a replay that finds fewer claims than the gameweek recorded keeps the recorded list (`league_history_claims_carried`). Those claims also make the waiver wire's races visible (issue #330): the editorial's waiver roster closes with every player more than one manager claimed, who won him and who was beaten to him at what priority, and a week where at least three managers claimed the same player (`MOST_CONTESTED_MIN_CLAIMANTS`) and one of them can be seen to have won him gets a Most Contested award beside Waiver Genius and Waiver Disaster — derived from the manager rows by `contested_draft_claims()`, never recorded, never a report section of its own, and absent from `--format json`, whose rows already carry the `lost_claims` and `transactions` they are regrouped from. The editorial is told to write each race as its own clause and never fold two into one ("respectively"), and is checked against the races afterwards (issue #357): a manager it names as beaten to, or winning, a contested player in a role that player's line does not give them triggers one regeneration told which claims were wrong (a retry that errors keeps the first attempt), and a kept editorial that still fails is saved as written with the wrong claims named on stderr, in a `synthesis_contested_misattribution` JSON warning, and in a callout above the editorial in the report. The same retry and reporting cover two checks on either format (issue #359): a manager's net overall, pre-hit swing or another move's swing pinned on one named move (`synthesis_net_misattribution`), and a tie word ("joint-lowest", "tied for last", "joint-seventh") on a gameweek score or league position no two managers hold (`synthesis_unsupported_tie`). The prompt's GW Standings section names the week's highest and lowest scores, every shared score and position (Pos, and Prev where any is shared) and any season totals the league's tie-break placed apart, so "joint" has a line to be held to, and the roster lines call the manager's net "overall". Biggest Bench Haul (both formats) ranks on the best legal XI's gain over the XI that counted — one keeper, the eight legal formations, the doubled player held in place — never on the bench's raw sum, which counted a keeper who could not have played and outfielders no formation had room for (issue #350); its detail names the swap, a manager with bench points but no better XI is no candidate, and the raw sum stays on the row as `bench_points` (the API's `points_on_bench`) because the coarse tier can record nothing else. Coverage gaps and capture warnings go to stderr; the command exits 0 even when the store is unreadable. `--format json` emits one row per manager (the captured ledger row shape) plus `metadata.coverage`, `season_phase`, `notes_pack`, `synthesis_summary`, `warnings` (always present, each entry a stable `code` plus rewritable `message` - codes listed in [Command Reference](../docs/command-reference.md#league-history)) and `first_capture_store_path` (always present; the partition directory on a first capture, `null` after). Fines are ruled *and recorded* against every captured gameweek, backfilled ones included: the detailed replay rules every configured rule, the coarse tier rules only what cohort points support (`last-place`, `below-threshold`) and records that `red-card` went unruled. `last-place` is ruled against every manager level on the gameweek's lowest score rather than whichever of them the cohort's arrival order reached first; because rulings are frozen at capture and the ledger is append-only, gameweeks recorded before that was true are swept on every run and the recorded ruling appended against the managers they left out, with no flag and no request (`league_history_tied_last_place_repaired`) — and where the row data cannot settle who was level, since the gameweek does not record whether it was ruled on gross or net points, the manager is named by `league_history_tied_last_place_ambiguous` rather than fined on a guess. Season totals print under `Season Fines` on console and as a `# Season Fines` report section, but only at the two milestone gameweeks (GW19 and the finale) - every other week those surfaces stay a this-week view and `league-fines` answers the season question on demand. The `## Season Fines` prompt section and `metadata.season_fines` are ungated: the editorial gets the running totals every week as optional colour (never a required beat), and a scripted consumer sees no calendar-driven appearance and disappearance. Bottom-half gameweeks and green-arrow droughts no longer surface as streaks at all (their run only drives the season count). Streak conditions also keep reset-surviving season occurrence counts ("their sixth gameweek win of the season"), each surfaced on its own rule (see [Command Reference](../docs/command-reference.md#league-recap) for the per-count table): wins and last places on multiples of 3 plus any second-half first, captain blanks and waiver hauls/backfires on multiples of 5, hits on multiples of 3, weeks on top on multiples of 5, bottom-half gameweeks on multiples of 10 in the second half only, and green-arrow droughts on an unbroken run of 5 or 10 (gameweeks spent leading the league never count towards a drought — the leader could not have climbed). Some counts carry same-week peers alongside the milestone — past a fixed floor for counts that stay small, or within a relative window for counts that climb all season. A firing count reaches the report's `## Season Counts` subsection and the editorial's League History section; at the two milestone gameweeks both surfaces carry every nonzero count as the season set-piece. Every line states its span and any not-judged gameweeks beside the number, and every nonzero count is emitted in `metadata.notes_pack.season_count_entries` regardless. A recap of the most recently finished gameweek keeps working once the next gameweek has started: the league table describes that later gameweek from its deadline onward, so the run takes the replay path (figures from each manager's own gameweek history, positions and cumulative totals derived rather than read off the table) and raises a `league_standings_moved_on` warning, instead of reconciling two different gameweeks' numbers against each other and exiting 1 with the ledger row unwritten. `--summarise` with no usable synthesis key no longer aborts the run: the recap, report and ledger capture all proceed, and a `synthesis_provider_unavailable` warning says the editorial was skipped; an editorial the provider stopped early on is written out as it stands with the reason on stderr and a `synthesis_stopped_early` warning in JSON. A gameweek that cannot be resolved at all exits 1 in both output formats - it used to exit 1 under `--format json` with a generic `"Could not resolve a gameweek to recap."` and 0 in table mode - and the resolver's own reason (`Gameweek 7 is not yet finished`) now reaches the error envelope and the stderr prose alike. On the league's opening gameweek only (GW1, or its own `start_event`), each classic manager's FPL-wide prior-season record — seasons played, last season's points/rank/top-% finish, best season — is fetched from the manager-history endpoint (one request per manager, that gameweek only, and only with `--save`/`--output` or `--summarise`; a league created after GW1 shares the fetch with its league-start offset) and rendered as a `# Prior Seasons` report section and a locked `## Prior Seasons` prompt section with an explicit count; tenure is phrased FPL-wide because entry and league IDs are reissued each season, and a manager with no record or a failed fetch is named as such rather than omitted. Nothing of it reaches the ledger or the JSON rows; draft has no such endpoint and no such section | Yes | General | No | via-agent |
51
51
  | `league-fines` | Show who has been fined this season and how much of it was actually ruled, folded out of the league history ledger. Reads local files only - no network - so it works for any season still on disk (`--season 2025-26`). Nothing is re-ruled: a fine is counted exactly as its gameweek recorded it — including one `league-recap` appended against a manager who tied for last and was left out at capture. Counts, never money (`penalty` is free text; settlement and escalation are not modelled). Every gameweek that could not be ruled is named beneath the table - never captured, unreadable, captured before rulings were recorded, captured with no rules configured, or captured at a fidelity that could not rule a given rule. `--format json` emits one entry per recorded manager, fined or not, each with `counts` (zero included), `total`, `fined_gameweeks`, `ruled_gameweeks`, `unruled_gameweeks`, `first_recorded_gameweek`, `last_recorded_gameweek` and `is_fully_ruled`; `metadata` carries `season`, `fpl_format`, `league_id`, `gameweek`, `start_gameweek`, `rule_types`, `total_fines` and `qualifiers`. Exits 1 with the shared error envelope when no league id is configured for the format | Yes | General | No | direct-api |
52
52
  | `captain` | Analyse and rank captain options for next gameweek. Your squad is the default, so an absent `classic_entry_id` exits 1 with an `error` envelope naming `--global` rather than returning the global list as though it were your options; `metadata.my_squad_mode` says which list the payload holds, and `metadata.warnings` carries `captain_global_fallback` when there is no squad to rank yet (before the first deadline) or `captain_no_next_gameweek` when there is no next gameweek at all (finished season, or the next one unpublished). An entry ID that does not resolve is a failure, not a fallback, worded exactly as `squad` words it (`get_own_squad_picks`, `agents/common.py`). JSON candidates include `adjusted_npxg_per_90` and `raw_npxg_per_90` when fixture adjustment is active. Consistency tiebreaker (CV-xGI percentile) phased in GW6-10. Players ruled out of the gameweek (0% chance, or no minutes from GW6) are held out of the GW1-9 shrinkage rather than pulled toward the position mean | Yes | Classic | Yes | via-agent |
53
53
  | `differentials` | Find differential picks - high potential, low ownership. Inverted consistency bonus (volatile players score higher, phased in GW6-10). Before GW10 the score's quality baseline carries the prior blend (last season's pedigree weighted by confidence) rather than position-mean shrinkage, so a quiet-starting elite is not ranked below a one-game wonder; the ownership, matchup and consistency bonuses stay pure observation; JSON `metadata.warnings` carries the early-season notice while the blend is live (`early_season_prior_informed`, or `early_season_small_sample` when history could not be loaded) plus `understat_team_unmatched` for any club Understat carried no players for, table mode prints them to stderr. Players ruled out of the gameweek (0% chance, or no minutes from GW6) are held out of the blend rather than handed last season's standing. The gameweek stamped into JSON `metadata` is fetched after the agent succeeds, not before it runs, so an unreachable API is reported as the agent's failure in both output formats rather than as a client error under `--format json` and an agent error in table mode | Yes | Classic | Yes | via-agent |
@@ -110,7 +110,7 @@ Every recommendation must include:
110
110
 
111
111
  For starting XI selection:
112
112
  - **11 players exactly:** 1 GK + 10 outfield
113
- - **Valid formations:** 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-3-2, 5-4-1
113
+ - **Valid formations:** 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-2-3, 5-3-2, 5-4-1
114
114
  - **Positions are fixed:** Each player's Pos must match their actual position. Never reassign a player to fit a formation.
115
115
  - **Bench:** 4 players for both Classic and Draft
116
116
  - **Bench ordering:** Must use the bench-order script output. Do not manually order bench players.
@@ -56,6 +56,7 @@ or stale build.
56
56
  `head_sha` matches `origin/main` and `conclusion="success"`. For
57
57
  per-job detail on a failing run, re-call with
58
58
  `method="list_workflow_jobs"` and the run ID as `resource_id`.
59
+ - The default synthesis model is still current: `llm.synthesis.model` in `fpl_cli/config/defaults.yaml` and `DEFAULT_MODEL` in `fpl_cli/api/providers/anthropic.py` should name the newest Sonnet. If a newer one exists, flag it to the user (bumping needs a live-key run of the synthesis commands and a recheck of `max_tokens`, #316) rather than bumping silently.
59
60
  - Local checks pass, mirroring CI (run in parallel; check each exit status
60
61
  individually — a `| tail` pipe hides failures):
61
62
 
@@ -1,7 +1,7 @@
1
1
  ## Squad Constraints
2
2
  - **15 players:** 2 GK, 5 DEF, 5 MID, 3 FWD
3
3
  - **Max 3 from any team**
4
- - **Valid formations:** 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-3-2, 5-4-1
4
+ - **Valid formations:** 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-2-3, 5-3-2, 5-4-1
5
5
  - **Full stats required:** Run `fpl player "{name}" -f` for every player in the final squad and every serious alternative considered. Never recommend without data.
6
6
 
7
7
  ## pFDR (Positional FDR)
@@ -27,7 +27,7 @@ Never reason from a wrong rule. If you're unsure about a scoring or mechanics de
27
27
  - **Bench Boost = all 15 score once (not 2x).** Floor-raiser via bench minutes, not a multiplier.
28
28
  - **Triple Captain = captain scores 3x (not 2x).**
29
29
  - **Chip availability:** each chip twice per season, split at GW19. Check `fpl chips` before assuming.
30
- - **Formation is a variable.** Valid: 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-3-2, 5-4-1. Evaluate transfers against the 15-man squad, not a fixed XI - a same-position swap can mean "both play via reshape", not "one replaces the other". Compare all candidate sells at that position before committing.
30
+ - **Formation is a variable.** Valid: 3-4-3, 3-5-2, 4-3-3, 4-4-2, 4-5-1, 5-2-3, 5-3-2, 5-4-1. Evaluate transfers against the 15-man squad, not a fixed XI - a same-position swap can mean "both play via reshape", not "one replaces the other". Compare all candidate sells at that position before committing.
31
31
 
32
32
  ## Draft Rules (load-bearing)
33
33
 
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
+ ## [2.7.3] - 2026-10-09
5
+
6
+ ### Bug Fixes
7
+
8
+ - default synthesis model moves to claude-sonnet-5-5 (#362)
9
+ - stop streak notes calling a fully recorded gameweek "not recorded" (#363)
10
+ - show correct ranks and scores in the gameweek review report (#365)
11
+ - review no longer drops the transfers summary on a net-zero total (#366)
12
+ - draft review no longer misreports rendering errors as a failed fetch (#369)
13
+ - stop gw-prep, squad-builder and FPL Mate ruling out a legal 5-2-3 (#370)
14
+ - flag a recap editorial that misnames who lost a contested waiver (#368)
15
+ - draft review no longer crashes on bracketed player or manager names (#373)
16
+ - classic review no longer crashes on bracketed names (#378)
17
+ - flag recap editorials that misplace a transfer net or invent a tie (#374)
18
+
19
+ ## [2.7.2] - 2026-09-16
20
+
21
+ ### Bug Fixes
22
+
23
+ - open every league recap on a title the editorial cannot rewrite (#351)
24
+ - award Biggest Bench on the best XI's gain, not the raw bench sum (#352)
25
+
4
26
  ## [2.7.1] - 2026-09-09
5
27
 
6
28
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fplkit
3
- Version: 2.7.2
3
+ Version: 2.7.4
4
4
  Summary: CLI tool for Fantasy Premier League analysis - classic and draft formats
5
5
  Project-URL: Homepage, https://github.com/rossgroomio/fpl-cli
6
6
  Project-URL: Repository, https://github.com/rossgroomio/fpl-cli
@@ -21,6 +21,7 @@ Requires-Dist: rich>=13.7.0
21
21
  Requires-Dist: ruamel-yaml>=0.18.0
22
22
  Requires-Dist: tzdata>=2024.1; platform_system == 'Windows'
23
23
  Provides-Extra: dev
24
+ Requires-Dist: markdown-it-py>=2.2.0; extra == 'dev'
24
25
  Requires-Dist: packaging>=23.0; extra == 'dev'
25
26
  Requires-Dist: pyright>=1.1.0; extra == 'dev'
26
27
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
@@ -389,9 +389,9 @@ Services live in `fpl_cli/services/` and provide the computation layer between a
389
389
  | `returnee_radar` | Injury returnee radar behind `fpl returnees`. Parses the FPL `news` grammar into a `ReturnSignal` (four shapes, only two of them dated), filters on a window and a **source-aware** quality bar, and diffs the result against a persisted snapshot for week-over-week transitions. The bar is source-aware because `player_prior` only assigns `source="history"` at 450+ minutes in the previous season and caps `prior_strength` at 0.5 on the `"price"` fallback, so one flat threshold would exclude exactly the players who missed most of last season through injury: history-sourced players are gated on `prior_strength`, price-sourced ones are scored through `value_quality` over their most recent season carrying real minutes, and the within-position price percentile is the last resort. That season supplies the DEF/GK weight terms (defensive contribution, saves, clean sheets, xGC per 90) from the `SeasonHistory` row when its source published them, and otherwise shrinks the ceiling via `ceiling_attainability()` rather than reading the gap as a measured zero — without which no keeper and effectively no defender could clear the bar (#132). `run_radar()` is the public entry point (snapshot I/O included); `build_radar()` is the pure core. The `SnapshotStore` behind it holds two states — the baseline every run in the current gameweek diffs against, and the current gameweek's own, promoted to baseline when the gameweek moves on — so a second run in one gameweek reports the same transitions as the first instead of diffing against what that run just stored (#225). **Fetches nothing** — the caller injects historical profiles and Understat season data, because `prepare_scoring_data` discards the profiles it built priors from and skips fetching them at all on a cache hit. Optional AI-search enrichment (`--enrich`) attaches an `EnrichedReturn` beside the FPL signal, never over it, and only a *cited* enriched date can decide an escalation |
390
390
  | `team_form` | Rolling team form stats (last 6 matches, venue splits) |
391
391
  | `league_history` | Durable league-history ledger: one NDJSON file per gameweek under `<data dir>/league_history/<season>/<format>-<league id>/`, written as a side effect of `league-recap`. Append-only — an identical row writes nothing, a differing one appends a superseding line, and readers resolve by highest fidelity tier then latest capture, with an unknown row ranking below every tier. Each row carries its own schema version: an older one is upgraded in memory on read (the line on disk is never rewritten, so a store shared with an older install stays readable by it), a newer one is skipped with a warning and preserved byte-for-byte — and every line the store writes is stamped with the writing install's version whatever version the row was read at, since a superseding line copied off a version-5 row but carrying a version-6 field would be validated by a version-5 install rather than skipped, and rejected as a corrupt file. Two deliberate inversions of house convention: loading **fails closed** (unlike `chip_plan`, which resets on a corrupt file) because the API cannot rebuild a past gameweek — scoped to that one gameweek, whose coverage entry carries the file path and the `mv` that retires it, so a caller reports it as `league_history_store_unreadable` rather than as a coverage gap `--backfill-detail` would refuse to fill, and reports it once per run however many of a recap's readers hit the same file — and season is a **partition key** (unlike `team_ratings`, which discards a previous season) because per-gameweek granularity is destroyed at the July rollover. The consequence of partitioning is that the store only ever grows — nothing prunes a prior season, by design — at roughly a few megabytes per league per season |
392
- | `league_history_counters` | Declarative streak-condition registry (9 conditions: `weeks_on_top`, `bottom_half_run`, `gw_win_streak`, `gw_loss_streak`, `green_arrow_drought` shared; `captain_blank_run`, `hit_run` classic-only; `waiver_win_run`, `waiver_burn_run` draft-only) and the rebuildable counters projection it drives. Each entry declares the minimum run worth surfacing as a streak, `None` for the two conditions (bottom half, green-arrow drought) whose run only drives their season count's firing rule. Each predicate returns extend/reset/hold for one manager's row — hold on an unknown row, a fixture-less blank, a gameweek the condition does not apply to, or one it structurally could not have ruled (a green-arrow drought holds for a gameweek that began in first place, where climbing was impossible), so neither a capture gap nor an impossibility ever lies about a streak in either direction. Alongside each currently-open run, the state carries season-wide occurrence totals (issue #164): every extending gameweek counts once and survives a reset, holds are tallied separately as the count's "not judged" qualifier, and the last-occurrence and first-evaluated gameweeks bound what a consumer may claim — so "their sixth gameweek win of the season" is derivable, not just the open run. Each entry also declares a `CountSurfacePolicy`, the condition's own rule for when that total is worth rendering weekly (U9 applies it; the milestone set-pieces and `--format json` ignore it). The projection is a disposable cache (never a second source of truth) under `<data dir>/league_history_counters/<season>/<format>-<league id>/counters.json`: it advances one gameweek at a time when its stamp is exactly one behind, and **fails open** to a full rebuild from `league_history`'s rows otherwise — missing, unreadable, wrong-version, or a stamp that isn't stamp+1 (which is also what makes the season totals retroactive: the version bump that added them rebuilds silently from rows already on disk) |
392
+ | `league_history_counters` | Declarative streak-condition registry (9 conditions: `weeks_on_top`, `bottom_half_run`, `gw_win_streak`, `gw_loss_streak`, `green_arrow_drought` shared; `captain_blank_run`, `hit_run` classic-only; `waiver_win_run`, `waiver_burn_run` draft-only) and the rebuildable counters projection it drives. Each entry declares the minimum run worth surfacing as a streak, `None` for the two conditions (bottom half, green-arrow drought) whose run only drives their season count's firing rule. Each predicate returns extend/reset/hold/inapplicable for one manager's row, and the last two both leave the run untouched, so neither a capture gap nor an impossibility ever lies about a streak in either direction. They differ in what the record says: hold is a gameweek it cannot rule (an unknown row, a field the tier never captured), inapplicable one it rules completely where the condition had nothing to judge (a draft manager with no completed waiver moves, whether they made no claims or lost every one, a captain with no fixture, a cohort level on points, a green-arrow drought gameweek begun in first place). Each run counts them separately, and each condition that can return inapplicable names it in its own words (`inapplicable_label`), so a complete gameweek is never reported as missing (issue #358). Alongside each currently-open run, the state carries season-wide occurrence totals (issue #164): every extending gameweek counts once and survives a reset, holds (not inapplicable gameweeks, which were judged) are tallied separately as the count's "not judged" qualifier, and the last-occurrence and first-evaluated gameweeks bound what a consumer may claim — so "their sixth gameweek win of the season" is derivable, not just the open run. Each entry also declares a `CountSurfacePolicy`, the condition's own rule for when that total is worth rendering weekly (U9 applies it; the milestone set-pieces and `--format json` ignore it). The projection is a disposable cache (never a second source of truth) under `<data dir>/league_history_counters/<season>/<format>-<league id>/counters.json`: it advances one gameweek at a time when its stamp is exactly one behind, and **fails open** to a full rebuild from `league_history`'s rows otherwise — missing, unreadable, wrong-version, or a stamp that isn't stamp+1 (which is also what makes the season totals retroactive: the version bump that added them rebuilds silently from rows already on disk) |
393
393
  | `league_history_fines` | Season fine tally (`build_season_fines_tally()`): a pure fold over `league_history`'s rows into per-manager, per-rule counts, with no cache and no second source of truth. Counts only what a row already recorded, so a settings change moves future rulings and leaves history alone — a guarantee `_freeze_recorded_fines` (`cli/_league_recap_history.py`) carries into backfill, where a repair would otherwise re-rule an unchanged cohort under whatever config is current at repair time. Every gameweek that could not be ruled is named as a qualifier rather than folded in as a zero — never captured, unreadable, captured but reaching nobody (every row unknown), holding no record of what was ruled (schema version < 4, or an evaluation that raised), recording a ruling on no rules at all, or captured at a fidelity that could not rule a given rule. Each qualifier states only what the ledger can prove: where two causes are indistinguishable from a row, neither is named. A mid-season joiner keeps their real, lower totals and is qualified; a manager who has since left keeps the fines already ruled against them, bounded to the gameweeks they were recorded for. Rulings are tracked per rule as well as per manager (`unruled_gameweeks_for()`), since a coarse gameweek rules `last-place` and structurally cannot rule `red-card` — the recap prompt asks that question before it will call a fine a manager's first of the season. `last-place` is the one cohort-relative rule, so it is the one a tie falls on: every manager level on the gameweek's lowest score is fined, and `_repair_tied_last_place` sweeps gameweeks recorded before that was true, appending the ruling the gameweek already wrote against the managers it left out rather than re-ruling anything (issue #336) |
394
- | `league_history_notes` | Notes pack (`build_notes_pack()`), season-phase marker (`derive_season_phase()`, standalone) and the `is_season_milestone()` predicate the recap gates its *printed* season fines table on (its prompt section is ungated) - the phase's *first* gameweek and the finale, deliberately not the whole 13-gameweek midpoint phase, since a once-a-season set-piece fired thirteen times is wallpaper built from `league_history_counters`'s projection plus a bounded trailing window of raw ledger rows (`TRAILING_WINDOW_GAMEWEEKS = 6`, reused as the run-in phase's own length). Every open streak renders as an observed count over its true span rather than "in a row" once any gameweek held (e.g. "3 in the last 11, with 8 not recorded"); each entry declares which of console/report/prompt it reaches — a reportable streak reaches all three, coverage statements reach report+prompt, the season-phase marker is prompt-only (scene-setting for the editorial, never printed in the report — issue #187), and a below-minimum streak is retained (exposed via `--format json`) with no surfaces. Season-count entries (issue #164) render each condition's season occurrence total over the manager's own evaluated span, with holds stated as "not judged either way", surfaced per the registry's own declarative `CountSurfacePolicy` (a step the total must land on, run-length milestones read off the open run rather than the total, a first-occurrence rule gated on the season's half, a whole-condition second-half gate, and a ride-along rule for same-gameweek peers — an absolute floor for counts that stay small, a window relative to the nearest firing total for counts that climb all season). Evaluation is two-pass because firing is cross-manager: who fired their condition this gameweek, then which same-gameweek incrementers ride along beside them. Every nonzero count is retained for `--format json`, and at the two `is_season_milestone()` gameweeks the whole nonzero set carries report+prompt — the report's `## Season Counts` set-piece and the retrospective editorial's season-spanning facts. A "since GW X" qualifier is stated only when a partition's or a specific manager's own recorded coverage genuinely begins later than the league's start gameweek, never merely because a trailing window was read. Only the finale phase rescans every captured gameweek (via `rebuild_counters_through`, never the cached `compute_counters_through`) so weekly cost stays flat as the season progresses |
394
+ | `league_history_notes` | Notes pack (`build_notes_pack()`), season-phase marker (`derive_season_phase()`, standalone) and the `is_season_milestone()` predicate the recap gates its *printed* season fines table on (its prompt section is ungated) - the phase's *first* gameweek and the finale, deliberately not the whole 13-gameweek midpoint phase, since a once-a-season set-piece fired thirteen times is wallpaper built from `league_history_counters`'s projection plus a bounded trailing window of raw ledger rows (`TRAILING_WINDOW_GAMEWEEKS = 6`, reused as the run-in phase's own length). Every open streak renders as an observed count over its true span rather than "in a row" once its window is wider than its run (e.g. "3 in the last 11, with 8 not recorded"), and the trailing clause separates the gameweeks the record lacks ("not recorded": unknown, uncaptured or absent) from the ones the condition had nothing to judge in, named by the condition ("with no completed moves in 1"); each entry declares which of console/report/prompt it reaches — a reportable streak reaches all three, coverage statements reach report+prompt, the season-phase marker is prompt-only (scene-setting for the editorial, never printed in the report — issue #187), and a below-minimum streak is retained (exposed via `--format json`) with no surfaces. Season-count entries (issue #164) render each condition's season occurrence total over the manager's own evaluated span, with holds stated as "not judged either way", surfaced per the registry's own declarative `CountSurfacePolicy` (a step the total must land on, run-length milestones read off the open run rather than the total, a first-occurrence rule gated on the season's half, a whole-condition second-half gate, and a ride-along rule for same-gameweek peers — an absolute floor for counts that stay small, a window relative to the nearest firing total for counts that climb all season). Evaluation is two-pass because firing is cross-manager: who fired their condition this gameweek, then which same-gameweek incrementers ride along beside them. Every nonzero count is retained for `--format json`, and at the two `is_season_milestone()` gameweeks the whole nonzero set carries report+prompt — the report's `## Season Counts` set-piece and the retrospective editorial's season-spanning facts. A "since GW X" qualifier is stated only when a partition's or a specific manager's own recorded coverage genuinely begins later than the league's start gameweek, never merely because a trailing window was read. Only the finale phase rescans every captured gameweek (via `rebuild_counters_through`, never the cached `compute_counters_through`) so weekly cost stays flat as the season progresses |
395
395
 
396
396
  ## LLM Provider Abstraction
397
397
 
@@ -421,7 +421,7 @@ classDiagram
421
421
  }
422
422
 
423
423
  class AnthropicProvider {
424
- +DEFAULT_MODEL: claude-sonnet-5
424
+ +DEFAULT_MODEL: claude-sonnet-5-5
425
425
  +query(prompt, system_prompt) LLMResponse
426
426
  +post_process(content) str
427
427
  +close()
@@ -596,7 +596,8 @@ fpl_cli/
596
596
  │ ├── scout.py # ScoutAgent system/user prompts
597
597
  │ ├── review.py # Review research + synthesis prompts. Squad and transfer lines in the synthesis data block carry each player's full club name, and the hard constraints bind club affiliation and blanket scored/blanked claims to that supplied data — an LLM's own club knowledge goes stale every transfer window. The synthesis prompt also carries a `<next_gameweek>` block — every club's next fixture and each squad player's positional FDR, built by `_review_next_gameweek` / `_format_next_gameweek` — and the "Next Week" instruction switches on whether it is there: grounded advice when it is, fixture-independent observations pointing at `fpl gw-prep` when it is not, because a results-only review was issuing confident fixture-dependent calls with no fixture data behind them (#191), with `check_next_week_grounding` checking the answer back against that block -- a cited FDR figure the block never printed, or any fixture claim on a run that had no block -- so the grounding claim is not left resting on prompt prose alone, the way the research half's rules are backed by `validate_research_teams`, which folds both the supplied name and the model's cell through `normalise_name` before the allowlist search so a reformat is not read as an invention, and leaves a `[table trimmed: N unlisted row(s) removed]` line under a table it did shorten — the strip notice otherwise reaching stderr and `--debug` only, both gone by the time the markdown is read (#343). Also the synthesis half's post-generation guard (`check_synthesis_completeness`, `required_synthesis_sections`), which reads the required `## ` headings back off the prompt that was actually sent rather than restating them as a constant. The research half's counts are grounded the same way: `fixture_aggregates()` computes the fixture, goal, clean-sheet and goalless-draw totals once, `FixtureAggregates.summary_line()` pins them at the top of the GW Results block as the narrative's only permitted source, and `validate_research_counts` checks the narrative back against the same object — a count the prompt pins cannot drift from the count the check enforces. It rewrites a contradicting figure in place rather than scrubbing the sentence, and corrects one only when neither its sentence nor the one before it names anyone the count could belong to — a club, a nickname, a player, a pronoun standing in for one — because a round-wide total is exactly the sentence that names nobody. The guard errs towards leaving prose alone: a missed correction ships the model's own words, while a wrong one fabricates a claim in prose that was true. That blind spot is why the check was needed at all — the narrative that reported six clean sheets as four (#324) named no player, so all three name-based validators passed it
598
598
  │ ├── returnees.py # Return-intel search prompt for `fpl returnees --enrich` (one player per query, so a citation list belongs to a single player)
599
- │ └── league_recap.py # League recap synthesis prompts, including the anchored League History section, its never-infer-history rule (emitted even when the pack is empty), and the season-phase framing instruction. The GW Standings section leads with an explicit "Previous gameweek's leader" fact (issue #189) so a "topped the table"/"fell from the top" claim is checkable against a stated name instead of inferred from the Prev column or the size of a fall, and the rules forbid merging two managers' separate League History entries into a shared record or "club". `collect_player_clubs()` regroups the club each squad/transfer row already resolved off its `team_id` by the name the prose uses, feeding both a `## Player Clubs` roster and the club printed inline on each captain group, so club claims bind to supplied data instead of recall; a name the data gives two clubs for is dropped rather than guessed at, since the prose can't tell the two players apart. Transfers get the same full-roster treatment as captains and chips (issue #71): `format_recap_transfers_context()` lists every manager who transferred with each move, the hit and the post-hit net, names who made none, and keeps a manager whose moves the collector could not capture among the movers with the moves marked unknown, so the rule pins transfer narration to that section rather than to the two award winners — the only transfer data the model used to see, which it read as the whole story. `format_recap_waivers_context()` is the draft half (issue #301): every waiver and free-agent move as it was made, never chain-contracted (that is the awards' display compression, not a record of activity), each tagged by kind through the shared `draft_transaction_kind_label()` / `draft_transaction_kind_counts()` in `_league_recap_types.py` so the roster and the waiver awards never describe one move in two vocabularies; the rule names that section as draft's source of truth and only falls back to "say nothing about moves" when neither roster is present. It states three groups rather than two (issue #329): movers, the managers who claimed a player and lost him to a rival, and the managers who did neither. A waiver is a competition, so an outbid manager ends the gameweek with no accepted move and used to be indistinguishable from one who submitted nothing -- the editorial read that absence as inactivity and assigned a motive for it, so the rule now forbids calling anyone in the outbid group idle. The section closes with the gameweek's contested players (issue #330) — one sentence per player more than one manager claimed, from `contested_draft_claims()` / `format_contested_claim()` in `_league_recap_types.py`, the same sentence the Most Contested award prints for a pile-up of `MOST_CONTESTED_MIN_CLAIMANTS` or more that a recapped manager won (a race nobody can be seen to have won is context, never the award), with a rule pinning who-wanted-whom to those lines and limiting what may be said of anyone's waiver order to the winner having stood higher in it. `format_recap_prior_seasons_context()` (issue #131) is the opener's one source of history: each classic manager's FPL-wide seasons before this one, folded once by `summarise_prior_seasons()` (`_league_recap_types.py`) so the saved report and the prompt read the same lines, with an explicit count, the no-record and unfetched managers named rather than omitted, and a rule pinning tenure to FPL-wide phrasing — entry and league IDs are reissued each season, so "Nth season in this league" is unknowable and the section says so. The report's title is not the model's (issue #349): `recap_title()` (`_league_recap_types.py`) is the one H1, read by the template that renders it and the prompt that names it as already written; the prompt asks for a `## ` headline beneath it and no title, and `normalise_recap_editorial()` holds the reply to that shape in one fence-aware pass over every heading — one that only restates the title is dropped wherever it sits, the opening one is kept at H2 with the title's parts taken off (emphasis unwrapped through `unwrap_emphasis()` in `utils/markdown.py`, balanced pairs only, so what is printed again stays well-formed), a later `# ` is demoted, and a title-shaped part is removed only behind a colon, pipe or spaced dash so a hook's own words are never edited — since an instruction is not a contract and the model had mirrored, restyled, renamed and skipped the heading in turn
599
+ │ ├── league_recap.py # League recap synthesis prompts, including the anchored League History section, its never-infer-history rule (emitted even when the pack is empty), and the season-phase framing instruction. The GW Standings section leads with an explicit "Previous gameweek's leader" fact (issue #189) so a "topped the table"/"fell from the top" claim is checkable against a stated name instead of inferred from the Prev column or the size of a fall, and the rules forbid merging two managers' separate League History entries into a shared record or "club". `collect_player_clubs()` regroups the club each squad/transfer row already resolved off its `team_id` by the name the prose uses, feeding both a `## Player Clubs` roster and the club printed inline on each captain group, so club claims bind to supplied data instead of recall; a name the data gives two clubs for is dropped rather than guessed at, since the prose can't tell the two players apart. Transfers get the same full-roster treatment as captains and chips (issue #71): `format_recap_transfers_context()` lists every manager who transferred with each move, the hit and the post-hit net, names who made none, and keeps a manager whose moves the collector could not capture among the movers with the moves marked unknown, so the rule pins transfer narration to that section rather than to the two award winners — the only transfer data the model used to see, which it read as the whole story. `format_recap_waivers_context()` is the draft half (issue #301): every waiver and free-agent move as it was made, never chain-contracted (that is the awards' display compression, not a record of activity), each tagged by kind through the shared `draft_transaction_kind_label()` / `draft_transaction_kind_counts()` in `_league_recap_types.py` so the roster and the waiver awards never describe one move in two vocabularies; the rule names that section as draft's source of truth and only falls back to "say nothing about moves" when neither roster is present. It states three groups rather than two (issue #329): movers, the managers who claimed a player and lost him to a rival, and the managers who did neither. A waiver is a competition, so an outbid manager ends the gameweek with no accepted move and used to be indistinguishable from one who submitted nothing -- the editorial read that absence as inactivity and assigned a motive for it, so the rule now forbids calling anyone in the outbid group idle. The section closes with the gameweek's contested players (issue #330) — one sentence per player more than one manager claimed, from `contested_draft_claims()` / `format_contested_claim()` in `_league_recap_types.py`, the same sentence the Most Contested award prints for a pile-up of `MOST_CONTESTED_MIN_CLAIMANTS` or more that a recapped manager won (a race nobody can be seen to have won is context, never the award), with a rule pinning who-wanted-whom to those lines and limiting what may be said of anyone's waiver order to the winner having stood higher in it. `format_recap_prior_seasons_context()` (issue #131) is the opener's one source of history: each classic manager's FPL-wide seasons before this one, folded once by `summarise_prior_seasons()` (`_league_recap_types.py`) so the saved report and the prompt read the same lines, with an explicit count, the no-record and unfetched managers named rather than omitted, and a rule pinning tenure to FPL-wide phrasing — entry and league IDs are reissued each season, so "Nth season in this league" is unknowable and the section says so. The report's title is not the model's (issue #349): `recap_title()` (`_league_recap_types.py`) is the one H1, read by the template that renders it and the prompt that names it as already written; the prompt asks for a `## ` headline beneath it and no title, and `normalise_recap_editorial()` holds the reply to that shape in one fence-aware pass over every heading — one that only restates the title is dropped wherever it sits, the opening one is kept at H2 with the title's parts taken off (emphasis unwrapped through `unwrap_emphasis()` in `utils/markdown.py`, balanced pairs only, so what is printed again stays well-formed), a later `# ` is demoted, and a title-shaped part is removed only behind a colon, pipe or spaced dash so a hook's own words are never edited — since an instruction is not a contract and the model had mirrored, restyled, renamed and skipped the heading in turn. For the same reason the finished editorial is checked against its own data afterwards, by `league_recap_checks.py` beside this module; what the checks hold "joint" to is stated here, in `_standings_tie_lines()`: the week's highest and lowest scores, every gameweek score and Pos position more than one manager holds, any shared Prev position, and any season totals the league's tie-break placed apart (a head-to-head draft table ranks managers level on league points by points scored)
600
+ │ └── league_recap_checks.py # Post-generation checks on the league-recap editorial, kept out of the prompt module so it stays about prompts. `EDITORIAL_CHECKS` is the one registry -- each check's `RECAP_WARNING_*` code, the lead of its JSON warning (`editorial_warning_message()`, one sentence per claim), its retry instruction and the checker -- so a new check is one record. `check_contested_attributions()` (issue #357) reads who a sentence says won or was beaten to a contested player; `check_net_attributions()` (issue #359) reads a "net" figure said of one named move and flags it when it is another figure on that manager's roster line, attaching it to whichever of the move and its manager is named nearer before it; `check_tie_claims()` (issue #359) reads a tie word fused to a rank ("joint-lowest", "tied for last", "joint-seventh") against the GW Pts column, or the table's Pos, Prev and Total columns. All three read names the same way: `check_recap_editorial()` builds one `RecapNames` per editorial -- a longest-first alternation over every name the data carries (every squad, captain, move and lost claim's players, the managers and their unique short forms) -- and each check takes it as a required argument and keeps only the matches it is after, so a manager's surname is never read inside a hyphenated player ("White" in "Gibbs-White") nor a player inside a manager's full name (issue #379). Each reads only the clauses it can pin down and lets the rest pass, so a problem is a sentence the data contradicts and a false alarm (which costs a retry and a warning above a correct editorial) is the failure it is built against. `check_recap_editorial()` runs them all; the command regenerates once with `get_recap_editorial_retry_prompt()` (the wrong claims plus an instruction per kind that failed), saves a still-failing editorial as written with one JSON warning per code and the claims in a callout above it, and ships the editorial unchecked if a check itself raises
600
601
  ├── parsers/
601
602
  │ └── recommendations.py # Parse gw{N}-recommendations.md into structured decisions
602
603
  ├── scraper/
@@ -608,7 +609,7 @@ fpl_cli/
608
609
  ├── gameweek.py # is_opening_gameweek(gw) — shared GW1 check (transfers, waivers and league tables don't exist yet)
609
610
  ├── markdown.py # HeadingMatcher, find_section, section_body, leaf_body, fence_flags — fence-aware markdown section location tolerant of LLM heading drift; shared by the gw-prep validator scripts and fpl_cli.prompts.review. Also unescape_specials/find_entities — repair and reporting for markdown that arrived HTML-escaped
610
611
  ├── teams.py # describe_team_set_mismatch — diff a per-team config against the live team list (promotion/relegation drift)
611
- ├── text.py # strip_diacritics (name matching across sources). normalise_name folds the rest of what a retyped name varies by — the apostrophe family onto U+0027, whitespace runs (NFKC, so non-breaking spaces too) and the space after an initial ("B. Fernandes" → "B.Fernandes") — on top of the diacritic strip and lowercasing. Apply it to both sides of a comparison. Shared by everywhere the review matches an LLM-written name against an API one: `validate_research_teams` / `ensure_top_performer_first` in prompts/review.py, and `_normalise_name` in cli/_review_summarisation.py, which layers its parenthetical and leading-initial strips on top for `--compare-recs`. Neither a `\b`-anchored regex nor an equality test survives any of these differences: they cost a valid row in the review's Disappointments table, and would read a captain that was in fact fielded as a recommendation ignored (#343). Also ordinal_suffix/ordinal_word
612
+ ├── text.py # strip_diacritics (name matching across sources). normalise_name folds the rest of what a retyped name varies by — the apostrophe family onto U+0027, whitespace runs (NFKC, so non-breaking spaces too) and the space after an initial ("B. Fernandes" → "B.Fernandes") — on top of the diacritic strip and lowercasing. Apply it to both sides of a comparison. Shared by everywhere the review matches an LLM-written name against an API one: `validate_research_teams` / `ensure_top_performer_first` in prompts/review.py, and `_normalise_name` in cli/_review_summarisation.py, which layers its parenthetical and leading-initial strips on top for `--compare-recs`. Neither a `\b`-anchored regex nor an equality test survives any of these differences: they cost a valid row in the review's Disappointments table, and would read a captain that was in fact fielded as a recommendation ignored (#343). Also ordinal_suffix/ordinal_word, and ORDINAL_WORDS (to the twentieth, for reading ordinals back out of prose; ordinal_word still spells out only to the tenth)
612
613
  └── time.py # format_deadline/format_kickoff/format_generated_at — UK local (Europe/London, auto GMT↔BST). Canonical formatter for every user-facing timestamp.
613
614
 
614
615
  platformdirs (user_config_dir / user_data_dir) # macOS: ~/Library/Application Support/fpl-cli/
@@ -760,6 +760,15 @@ A waiver claim a rival won is reported under **Claims Lost** rather than discard
760
760
 
761
761
  **LLM summary** (`--summarise`): Community narrative via research provider, personal analysis via synthesis provider. The summary is an add-on: the review itself needs no key, so a role whose provider has no usable key is skipped with the reason on stderr (`Community narrative skipped: ...` / `Personal analysis skipped: ...`) and the review still prints, still saves its report and still exits **0**. The two roles resolve independently, so one key buys the half it belongs to rather than nothing. A saved report generated from such a run names each skipped half and why in a warning callout above the summary, so a section absent for want of a key never reads as one deliberately left out.
762
762
 
763
+ **Trimmed tables:** the community narrative's Standout Performers and Disappointments tables are
764
+ checked against the player lists the research prompt was given. A row naming a player who is not
765
+ on those lists is removed, and the table then carries `[table trimmed: N unlisted row(s)
766
+ removed]` beneath it, counted per table. "Unlisted" means not on the supplied lists, and the
767
+ guard removed the rows, not the model, so a shortened table is not the model's own choice.
768
+ Style differences (spacing, apostrophes, accents, case) are never grounds for removal. Under
769
+ `--debug`, each removed row is named in `data/debug/research_corrections.txt` as
770
+ `<player>: stripped (not in provided list)`.
771
+
763
772
  **Next Week:** the personal analysis is given next gameweek's fixtures — every club's opponent
764
773
  and venue, and each of your own players' FDR for their own position — so the section's start,
765
774
  bench and transfer calls are grounded in what the squad actually plays rather than extrapolated
@@ -826,6 +835,13 @@ a season with nothing finished yet — is refused with the reason on **stderr**
826
835
  with the reason on stdout, as did `league-recap`, which shares the resolver. A missing provider
827
836
  key was refused the same way until it stopped being a refusal at all — see the LLM summary above.
828
837
 
838
+ **Notices** — the line that says an entry id is not configured or not found in the draft league,
839
+ the pair that says why a past gameweek has no league table, the note that a new mini-league's
840
+ standings are not published yet, and the `Fetching your team data...` progress line — go to
841
+ **stderr**, for classic and draft alike. A section heading stays on stdout only with the content it
842
+ introduces: for a past gameweek, or a mini-league whose table is not published yet, there is no
843
+ League section on stdout at all and stderr says why.
844
+
829
845
  ### League Recap
830
846
 
831
847
  Entertainment-first post-gameweek report for the whole league.
@@ -934,9 +950,9 @@ names the gap and forbids numbering the fine instead. `--format json` is
934
950
  ungated too — `metadata.season_fines` is emitted every
935
951
  week, so a scripted consumer never sees it appear and disappear on a calendar it cannot see.
936
952
 
937
- **LLM editorial** (`--summarise`): Newsletter-style narrative via synthesis provider. Names names, calls out decisions. Captain picks, chips and transfers each reach it as a full per-manager roster with an explicit count, never through the awards alone — the awards name only the best and worst, so a manager whose pick or move was neither would otherwise be invented or miscounted. The transfer roster carries every move with its points swing, the hit and the post-hit net, names the managers who made none, and lists a manager whose moves could not be fetched as having moved with the moves unknown rather than as standing still. A draft recap gets the same roster for its waivers and free-agent signings: every move as it was made (a rebuild chain is listed move by move, not as the endpoint pair the award prints), each tagged `[waiver]` or `[free agent]` with the labels the waiver awards use, the net per manager, the count of movers, who claimed a player and lost him to a rival — with the priority they spent on him — and who did neither, then every contested player with who won him and who was beaten to him — every race, the two-way ones the Most Contested award never headlines included, in the sentence that award prints (the one thing it may say of anyone's waiver position is that the winner stood higher in it) — so the editorial can say who else wanted a player and no longer sees only the Waiver Genius and Waiver Disaster and is no longer told to say nothing about moves because draft has no `## Transfers` section. The editorial is an add-on: if the synthesis provider has no usable API key the recap still renders, still saves its report and still captures the ledger, with the reason on stderr and a `synthesis_provider_unavailable` warning in JSON. `synthesis_summary` is `null` on such a run — the warning is what distinguishes it from a run that never asked for an editorial. An editorial the provider stopped generating early (a token ceiling, a refusal) is written out as it stands, with the reason on stderr and a `synthesis_stopped_early` warning in JSON — a truncated editorial is otherwise indistinguishable from a whole one.
953
+ **LLM editorial** (`--summarise`): Newsletter-style narrative via synthesis provider. Names names, calls out decisions. Captain picks, chips and transfers each reach it as a full per-manager roster with an explicit count, never through the awards alone — the awards name only the best and worst, so a manager whose pick or move was neither would otherwise be invented or miscounted. The transfer roster carries every move with its points swing, the hit and the post-hit net, names the managers who made none, and lists a manager whose moves could not be fetched as having moved with the moves unknown rather than as standing still. A draft recap gets the same roster for its waivers and free-agent signings: every move as it was made (a rebuild chain is listed move by move, not as the endpoint pair the award prints), each tagged `[waiver]` or `[free agent]` with the labels the waiver awards use, the net per manager, the count of movers, who claimed a player and lost him to a rival — with the priority they spent on him — and who did neither, then every contested player with who won him and who was beaten to him — every race, the two-way ones the Most Contested award never headlines included, in the sentence that award prints (the one thing it may say of anyone's waiver position is that the winner stood higher in it) — so the editorial can say who else wanted a player and no longer sees only the Waiver Genius and Waiver Disaster and is no longer told to say nothing about moves because draft has no `## Transfers` section. The editorial is an add-on: if the synthesis provider has no usable API key the recap still renders, still saves its report and still captures the ledger, with the reason on stderr and a `synthesis_provider_unavailable` warning in JSON. `synthesis_summary` is `null` on such a run — the warning is what distinguishes it from a run that never asked for an editorial. An editorial the provider stopped generating early (a token ceiling, a refusal) is written out as it stands, with the reason on stderr and a `synthesis_stopped_early` warning in JSON — a truncated editorial is otherwise indistinguishable from a whole one. A draft editorial is also checked against the contested races it was given: wherever it says a manager was beaten to a contested player ("beaten to", "missed out on", "lost out on", "lost the race for", "pipped to", "was beat by … to") or beat a rival to him ("beat", "pipped", "edged out", "outbid"), that manager must hold that role on the player's line. The voice comes from the sentence — an auxiliary ("was", "got") or a "by" makes "pipped" and "beat" passive — and a list of managers set against a list of players is read position by position with "respectively" and as every pairing without it. The prompt forbids folding two races into one clause, because that is how a race's winner was once named as beaten in the next (issue #357). An editorial that fails the check is regenerated once, with the wrong claims and the lines they contradict appended to the prompt; the less damaged attempt is kept (a complete one over a cut-off one, then fewer wrong claims), and a retry that fails outright keeps the first attempt. If the kept editorial still fails, the wrong claims are named on stderr, in a `synthesis_contested_misattribution` warning in JSON, and in a warning callout above the editorial in the saved report. The check reads only clauses it can pin to roles — a race verb joined to the player by race phrasing ("to", "for", "on", "the race for") — so a negation, a possessive ("King's bonus"), a points swing ("lost 3 points on King") or a phrasing it does not recognise passes unchecked rather than raising a false alarm. Every editorial, classic or draft, is checked the same way for two more kinds of claim its data contradicts (issue #359). A roster line carries the manager's net overall beside each move's own swing, and a net figure ("-6 net", "net of -6") said of one named move — both its players in the clause — that is not that move's swing but another figure on the same line (the net overall, the swing before the hit, or another move's swing) is flagged, unless the clause makes it the manager's: a word that marks it ("overall", "across both", "his transfers"), the week or the hit beside the figure ("ended the week -6 net", "-6 net after the hit"), the manager named nearer before the figure than the move ("Gibbs-White for Wirtz went -4, and Alice was -6 net"), or the move's own swing stated in the same clause. And a tie word fused to a rank ("joint-lowest", "tied for last", "joint-seventh") is held to who holds that rank: a gameweek score is ranked on the GW Pts column, a league position on the Pos, Prev and Total columns (a head-to-head draft table places managers level on league points apart by points scored, and the Total column still shows them level), and a phrase that names neither (or both) passes if either reading does. To give that rule a line to point at, the prompt's GW Standings section now names the week's highest and lowest scores, says whether each was one manager's alone, and lists every gameweek score and Pos position more than one manager holds, any shared Prev position, and any season totals the league's tie-break placed apart; the roster lines call the manager's net "overall", the word the Transfer Disaster award already used. Both checks share the contested check's single retry (told only about the kinds of claim that failed) and its reporting — stderr, a `synthesis_net_misattribution` or `synthesis_unsupported_tie` warning in JSON, and a line in the report's callout — and both read only clauses they can pin down: a clause naming another manager, two of one manager's moves, or a figure that is nobody's on the line passes, as does a tie about a captain, a bench or a player, a negated one, a word that is not a rank where it stands ("tied last week", "shared first blood"), or a numbered rank among gameweek scores. The checks are advisory: one that raises is skipped with a note on stderr and the editorial saved unchecked, and each warning's message gives one sentence per claim.
938
954
 
939
- **Streaks:** notable open streaks print under `Streaks:` on console — leaders only, so console stays a highlights view — and in full as a `# League History` section in the saved report. Each is reported as an observed count over its true span (e.g. "3 in the last 11, with 8 not recorded") rather than a bare "in a row" once any gameweek went uncaptured. A streak surfaces once its run reaches the condition's own minimum: 2 gameweeks for weeks on top, gameweek wins, last-place finishes, captain blanks and transfer hits; 3 for waiver hauls and backfires. Bottom-half gameweeks and green-arrow droughts never surface as streaks at all — both restate where the table already shows a manager is, so their run exists to drive the season count's firing rule rather than to be read on its own.
955
+ **Streaks:** notable open streaks print under `Streaks:` on console — leaders only, so console stays a highlights view — and in full as a `# League History` section in the saved report. Each is reported as an observed count over its true span (e.g. "3 in the last 11, with 8 not recorded") rather than a bare "in a row" once any gameweek in it did not count towards the run. "Not recorded" is kept for gameweeks the history genuinely lacks; a fully recorded gameweek the streak had nothing to judge in is named for what it was instead — "with no completed moves in 1" for a draft manager whose gameweek moved nobody, whether they made no claims or lost every one, "with no captain fixture in 1" for a captain whose club did not play — and leaves the run open without breaking it. A streak surfaces once its run reaches the condition's own minimum: 2 gameweeks for weeks on top, gameweek wins, last-place finishes, captain blanks and transfer hits; 3 for waiver hauls and backfires. Bottom-half gameweeks and green-arrow droughts never surface as streaks at all — both restate where the table already shows a manager is, so their run exists to drive the season count's firing rule rather than to be read on its own.
940
956
 
941
957
  **Prior seasons:** on the league's opening gameweek — GW1, or the league's own
942
958
  `start_event` for one created later — the recap fetches each manager's FPL history
@@ -1155,8 +1171,10 @@ notes pack — an unreadable store, or no league id configured for the format.
1155
1171
 
1156
1172
  Every capture problem is reported on stderr as prose and, under `--format json`, in
1157
1173
  `metadata.warnings` as a `{"code", "message"}` pair. The prose is rewritable; the codes
1158
- are stable, so scripts should key on those. Two non-capture codes share the list, since
1159
- they share the channel: `synthesis_provider_unavailable` and `league_standings_moved_on`.
1174
+ are stable, so scripts should key on those. Six non-capture codes share the list, since
1175
+ they share the channel: `synthesis_provider_unavailable`, `synthesis_stopped_early`,
1176
+ `synthesis_contested_misattribution`, `synthesis_net_misattribution`,
1177
+ `synthesis_unsupported_tie` and `league_standings_moved_on`.
1160
1178
 
1161
1179
  | Code | Raised when |
1162
1180
  |---|---|
@@ -1178,11 +1196,15 @@ they share the channel: `synthesis_provider_unavailable` and `league_standings_m
1178
1196
  | `league_history_standings_repaired` | A gameweek whose recorded positions an *earlier* run had already erased was restored from the ledger itself — the last superseded line that recorded them, or, on draft, a cumulative total re-summed from earlier gameweeks. The *last* one, not the first: a gameweek captured several times while bonus and late results were still landing recorded a stale position early and the settled one last, and only the latest recorded value is the one the damage destroyed. Nothing is re-fetched, so this needs neither `--backfill-detail` nor a network call, and it stops once the damage is repaired. The count is the managers whose league position or cumulative total actually changed — a row rewritten only to restate a fine's player names is not one of them |
1179
1197
  | `synthesis_provider_unavailable` | `--summarise` was asked for but the synthesis provider had no usable key; everything else in the recap, the capture included, ran normally |
1180
1198
  | `synthesis_stopped_early` | The provider reported that the editorial stopped for a reason other than finishing (a token ceiling, a refusal), so `synthesis_summary` may be cut off mid-sentence. Everything else in the recap is unaffected |
1199
+ | `synthesis_contested_misattribution` | Draft only. The editorial still puts a manager in the wrong role of a contested waiver race after one retry — beaten to a player the race's line does not name them as beaten to, or winning one someone else won. The message quotes each wrong claim beside the contested line it contradicts. `synthesis_summary` is the editorial as written; the saved report carries the same lines in a warning callout above it |
1200
+ | `synthesis_net_misattribution` | Either format. The editorial still pins a figure from a manager's roster line on the wrong move after one retry — the net overall (or the swing before the hit, or another move's swing) said of one named move whose own swing differs. The message quotes each figure, says whose it is, and gives the move's own swing. Saved as written, with the same lines in the report's callout |
1201
+ | `synthesis_unsupported_tie` | Either format. The editorial still calls a gameweek score or league position "joint", "tied", "level" or "shared" after one retry where no two managers hold it. The message quotes the tie and says who holds the rank alone (or that nobody does). Saved as written, with the same lines in the report's callout |
1181
1202
  | `league_standings_moved_on` | The recapped gameweek is the most recently finished one, but a later gameweek has started, so the league table no longer describes it. The gameweek is recapped and recorded from each manager's own gameweek history instead — see [When to run it](#league-recap) |
1182
1203
 
1183
1204
  None of these change the exit code — `league-recap` exits 0 whenever the recap itself
1184
- rendered, and a skipped or truncated editorial (`synthesis_provider_unavailable`,
1185
- `synthesis_stopped_early`) is no exception.
1205
+ rendered, and a skipped, truncated or self-contradicting editorial (`synthesis_provider_unavailable`,
1206
+ `synthesis_stopped_early`, `synthesis_contested_misattribution`, `synthesis_net_misattribution`,
1207
+ `synthesis_unsupported_tie`) is no exception.
1186
1208
 
1187
1209
  Four things do exit 1, all emitting the shared `{"command", "error"}` envelope on stdout
1188
1210
  under `--format json` (see [JSON Output](#json-output)) and the same message as red prose
@@ -1518,7 +1540,7 @@ llm:
1518
1540
  search_recency_filter: week
1519
1541
  synthesis:
1520
1542
  provider: anthropic
1521
- model: claude-sonnet-5
1543
+ model: claude-sonnet-5-5
1522
1544
  timeout: 60
1523
1545
  query_defaults:
1524
1546
  max_tokens: 8000
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '2.7.2'
22
- __version_tuple__ = version_tuple = (2, 7, 2)
21
+ __version__ = version = '2.7.4'
22
+ __version_tuple__ = version_tuple = (2, 7, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -3,7 +3,7 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
- from collections.abc import Sequence
6
+ from collections.abc import Mapping, Sequence
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
@@ -11,6 +11,7 @@ import jinja2
11
11
  from jinja2 import Environment, FileSystemLoader, select_autoescape
12
12
 
13
13
  from fpl_cli.agents.base import Agent, AgentResult, AgentStatus
14
+ from fpl_cli.cli._helpers import performer_score, your_gw_rank_line
14
15
  from fpl_cli.cli._league_recap_types import (
15
16
  RecapManagerEntry,
16
17
  draft_transaction_kind_label,
@@ -18,10 +19,20 @@ from fpl_cli.cli._league_recap_types import (
18
19
  )
19
20
  from fpl_cli.paths import TEMPLATE_DIR
20
21
  from fpl_cli.services.team_ratings import fdr_columns_footer
21
- from fpl_cli.utils.text import ordinal_suffix
22
+ from fpl_cli.utils.text import md_table_cell, ordinal_suffix
22
23
  from fpl_cli.utils.time import format_generated_at
23
24
 
24
25
 
26
+ def _performers_table(performers: Sequence[Mapping[str, Any]]) -> list[str]:
27
+ """Best/Worst GW Performers rows as a markdown table, blank line after."""
28
+ rows = [
29
+ f"| {p.get('rank_str', '?')} | {'You' if p.get('is_user') else md_table_cell(p.get('name', 'Unknown'))}"
30
+ f" | {performer_score(p)} |"
31
+ for p in performers
32
+ ]
33
+ return ["| Rank | Manager | Pts |", "|------|---------|-----|", *rows, ""]
34
+
35
+
25
36
  def build_report_environment() -> Environment:
26
37
  """The Jinja environment every report template is rendered through.
27
38
 
@@ -43,6 +54,14 @@ def build_report_environment() -> Environment:
43
54
  # the label is derived at render -- here and in the prompt -- from the
44
55
  # same mapping rather than from a second stored field that could drift
45
56
  env.filters["kind_label"] = draft_transaction_kind_label
57
+ # Performer ranks render as a table cell, never as a list marker: markdown
58
+ # renumbers `19.` and cannot parse a tie's `3=.` at all (#360). The score
59
+ # and the user's rank note share their wording with the terminal and the
60
+ # synthesis prompt.
61
+ env.globals["performer_score"] = performer_score
62
+ env.globals["your_gw_rank_line"] = your_gw_rank_line
63
+ # Free-text names in a table cell: a `|` would split the row
64
+ env.filters["md_cell"] = md_table_cell
46
65
  return env
47
66
 
48
67
 
@@ -512,29 +531,23 @@ class ReportAgent(Agent):
512
531
  else:
513
532
  diff = r.get("total", 0) - user_total
514
533
  diff_str = f"+{diff}" if diff > 0 else str(diff)
515
- name = r.get("manager_name", "Unknown")
534
+ name = md_table_cell(r.get("manager_name", "Unknown"))
516
535
  lines.append(f"| {r.get('rank')} | {name} | {r.get('total'):,} | {diff_str} |")
517
536
  if cl.get("nearby_rivals_omitted"):
518
- lines.append(f"*...and {cl['nearby_rivals_omitted']} more within 25*")
537
+ lines.extend(["", f"*...and {cl['nearby_rivals_omitted']} more within 25*"])
538
+ lines.append("")
519
539
 
540
+ net_suffix = " (Net)" if cl.get("use_net_points") else ""
520
541
  if cl.get("best_performers"):
521
- lines.append("### Best GW Performers")
522
- for p in cl["best_performers"]:
523
- rank = p.get("rank_str", "?")
524
- lines.append(f"{rank}. {p['name']} - {p['points']} pts")
542
+ lines.append(f"### Best GW Performers{net_suffix}")
543
+ lines.extend(_performers_table(cl["best_performers"]))
525
544
 
526
545
  if cl.get("worst_performers"):
527
- lines.append("### Worst GW Performers (Net Points)")
528
- for p in cl["worst_performers"]:
529
- rank = p.get("rank_str", "?")
530
- name = "You" if p.get("is_user") else p.get("name", "Unknown")
531
- gross = p.get("gross_points", p.get("points", 0))
532
- cost = p.get("transfer_cost", 0)
533
- net = p.get("net_points", gross)
534
- if cost > 0:
535
- lines.append(f"{rank}. {name} - {gross} gross, -{cost} hit = {net} net")
536
- else:
537
- lines.append(f"{rank}. {name} - {net} pts")
546
+ lines.append(f"### Worst GW Performers{net_suffix}")
547
+ lines.extend(_performers_table(cl["worst_performers"]))
548
+ if cl.get("user_context_row"):
549
+ note = your_gw_rank_line(cl.get("user_gw_rank"), cl.get("gw_field_size"), cl["user_context_row"])
550
+ lines.extend([f"*{note}*", ""])
538
551
  if cl.get("transfer_impact"):
539
552
  lines.append(f"\n⚠ {cl['transfer_impact']}")
540
553
  lines.append("")
@@ -606,15 +619,11 @@ class ReportAgent(Agent):
606
619
 
607
620
  if dl.get("best_performers"):
608
621
  lines.append("### Best GW Performers")
609
- for p in dl["best_performers"]:
610
- rank = p.get("rank_str", "?")
611
- lines.append(f"{rank}. {p['name']} - {p['points']} pts")
622
+ lines.extend(_performers_table(dl["best_performers"]))
612
623
 
613
624
  if dl.get("worst_performers"):
614
625
  lines.append("### Worst GW Performers")
615
- for p in dl["worst_performers"]:
616
- rank = p.get("rank_str", "?")
617
- lines.append(f"{rank}. {p['name']} - {p['points']} pts")
626
+ lines.extend(_performers_table(dl["worst_performers"]))
618
627
  lines.append("")
619
628
 
620
629
  # Results
@@ -624,7 +633,7 @@ class ReportAgent(Agent):
624
633
  ])
625
634
  if data.get("fixtures"):
626
635
  for f in data["fixtures"]:
627
- lines.append(f"**{f['home_team']} {f['home_score']}-{f['away_score']} {f['away_team']}**")
636
+ lines.extend([f"**{f['home_team']} {f['home_score']}-{f['away_score']} {f['away_team']}**", ""])
628
637
  if f.get("goals"):
629
638
  lines.append(f"- Goals: {f['goals']}")
630
639
  if f.get("assists"):
@@ -23,7 +23,7 @@ _PROVIDER_LABEL = "Anthropic"
23
23
  class AnthropicProvider:
24
24
  """LLM provider for the Anthropic Messages API."""
25
25
 
26
- DEFAULT_MODEL: ClassVar[str] = "claude-sonnet-5"
26
+ DEFAULT_MODEL: ClassVar[str] = "claude-sonnet-5-5"
27
27
  DEFAULT_TIMEOUT: ClassVar[float] = 60.0
28
28
  API_KEY_ENV_VAR: ClassVar[str] = "ANTHROPIC_API_KEY"
29
29
  KEY_SETUP_URL: ClassVar[str] = "https://console.anthropic.com/"
@@ -25,8 +25,8 @@ class FinesLeagueData(TypedDict):
25
25
  # bottom would move last place up to wherever its slice began. Everything
26
26
  # above that is free, and narrowing is the handler's job rather than the
27
27
  # caller's -- `status` and `league-recap` pass the whole table, `review`
28
- # passes the bottom five it is already displaying (with the user's own
29
- # row appended from further up), and `_joint_last` finds the tie in any
28
+ # passes the bottom five (and ties) it is already displaying, and
29
+ # `_joint_last` finds the tie in any
30
30
  # of them. Every manager level on the lowest score must be here, each
31
31
  # with their own `is_user`: a single-element list decided by `min()`
32
32
  # picked one of a tied pair by arrival order (issue #336).