fplkit 2.7.0__tar.gz → 2.7.1__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 (317) hide show
  1. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/TOOLS.md +1 -1
  2. {fplkit-2.7.0 → fplkit-2.7.1}/CHANGELOG.md +23 -0
  3. {fplkit-2.7.0 → fplkit-2.7.1}/PKG-INFO +1 -1
  4. {fplkit-2.7.0 → fplkit-2.7.1}/docs/architecture.md +4 -4
  5. {fplkit-2.7.0 → fplkit-2.7.1}/docs/command-reference.md +7 -4
  6. {fplkit-2.7.0 → fplkit-2.7.1}/docs/fpl-rules.md +10 -4
  7. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/_version.py +2 -2
  8. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/fpl_draft.py +97 -16
  9. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_league_recap_data.py +14 -7
  10. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_league_recap_types.py +21 -18
  11. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_classic.py +6 -1
  12. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_draft.py +5 -3
  13. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_summarisation.py +13 -5
  14. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/league.py +24 -2
  15. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/review.py +46 -9
  16. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/text.py +42 -0
  17. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_draft.py +129 -6
  18. fplkit-2.7.1/tests/test_cli_league.py +161 -0
  19. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_review.py +67 -0
  20. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_recap.py +95 -5
  21. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_review.py +114 -0
  22. fplkit-2.7.1/tests/test_utils_text.py +106 -0
  23. fplkit-2.7.0/tests/test_cli_league.py +0 -67
  24. fplkit-2.7.0/tests/test_utils_text.py +0 -54
  25. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/README.md +0 -0
  26. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/build-test-plan/SKILL.md +0 -0
  27. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/build-test-plan/references/plan-template.md +0 -0
  28. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/build-test-plan/references/workspace-env.md +0 -0
  29. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/create-pr/SKILL.md +0 -0
  30. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/SKILL.md +0 -0
  31. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/references/entity-normalisation.md +0 -0
  32. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/references/output-template.md +0 -0
  33. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/references/rules.md +0 -0
  34. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/_bootstrap.py +0 -0
  35. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  36. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
  37. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/normalise_entities.py +0 -0
  38. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  39. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  40. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +0 -0
  41. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/preview-ingest/SKILL.md +0 -0
  42. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/release/SKILL.md +0 -0
  43. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/release-notes/SKILL.md +0 -0
  44. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/squad-builder/SKILL.md +0 -0
  45. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/squad-builder/references/output-template.md +0 -0
  46. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/squad-builder/references/rules.md +0 -0
  47. {fplkit-2.7.0 → fplkit-2.7.1}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  48. {fplkit-2.7.0 → fplkit-2.7.1}/.claude/output-styles/fpl-mate.md +0 -0
  49. {fplkit-2.7.0 → fplkit-2.7.1}/.claude/settings.json +0 -0
  50. {fplkit-2.7.0 → fplkit-2.7.1}/.env.example +0 -0
  51. {fplkit-2.7.0 → fplkit-2.7.1}/.github/workflows/ci.yml +0 -0
  52. {fplkit-2.7.0 → fplkit-2.7.1}/.github/workflows/pr-title.yml +0 -0
  53. {fplkit-2.7.0 → fplkit-2.7.1}/.github/workflows/release.yml +0 -0
  54. {fplkit-2.7.0 → fplkit-2.7.1}/.gitignore +0 -0
  55. {fplkit-2.7.0 → fplkit-2.7.1}/AGENTS.md +0 -0
  56. {fplkit-2.7.0 → fplkit-2.7.1}/CLAUDE.md +0 -0
  57. {fplkit-2.7.0 → fplkit-2.7.1}/CONTRIBUTING.md +0 -0
  58. {fplkit-2.7.0 → fplkit-2.7.1}/LICENSE +0 -0
  59. {fplkit-2.7.0 → fplkit-2.7.1}/README.md +0 -0
  60. {fplkit-2.7.0 → fplkit-2.7.1}/cliff.toml +0 -0
  61. {fplkit-2.7.0 → fplkit-2.7.1}/config/team_ratings_overrides.yaml +0 -0
  62. {fplkit-2.7.0 → fplkit-2.7.1}/docs/custom-analysis.md +0 -0
  63. {fplkit-2.7.0 → fplkit-2.7.1}/docs/images/fpl-player-demo-v1-0.png +0 -0
  64. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/__init__.py +0 -0
  65. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/__init__.py +0 -0
  66. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/action/__init__.py +0 -0
  67. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/action/waiver.py +0 -0
  68. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/__init__.py +0 -0
  69. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/bench_order.py +0 -0
  70. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/captain.py +0 -0
  71. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  72. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  73. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/stats.py +0 -0
  74. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
  75. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/base.py +0 -0
  76. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/common.py +0 -0
  77. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/__init__.py +0 -0
  78. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/fixture.py +0 -0
  79. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/price.py +0 -0
  80. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/data/scout.py +0 -0
  81. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/orchestration/__init__.py +0 -0
  82. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/agents/orchestration/report.py +0 -0
  83. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/__init__.py +0 -0
  84. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/contract.py +0 -0
  85. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/core_insights.py +0 -0
  86. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/dataset_fetcher.py +0 -0
  87. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/football_data.py +0 -0
  88. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/fpl.py +0 -0
  89. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/historical.py +0 -0
  90. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/historical_types.py +0 -0
  91. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/__init__.py +0 -0
  92. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/_http.py +0 -0
  93. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/_models.py +0 -0
  94. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/anthropic.py +0 -0
  95. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/openai_compat.py +0 -0
  96. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/providers/perplexity.py +0 -0
  97. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/understat.py +0 -0
  98. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/api/vaastav.py +0 -0
  99. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/__init__.py +0 -0
  100. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_banner.py +0 -0
  101. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_context.py +0 -0
  102. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_fines.py +0 -0
  103. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_fines_config.py +0 -0
  104. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_helpers.py +0 -0
  105. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_json.py +0 -0
  106. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_league_recap_history.py +0 -0
  107. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_plan_grid.py +0 -0
  108. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/_review_analysis.py +0 -0
  109. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/allocate.py +0 -0
  110. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/captain.py +0 -0
  111. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/chips.py +0 -0
  112. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/credentials.py +0 -0
  113. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/differentials.py +0 -0
  114. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/doctor.py +0 -0
  115. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/doctor_providers.py +0 -0
  116. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/fdr.py +0 -0
  117. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/fixtures.py +0 -0
  118. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/history.py +0 -0
  119. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/init.py +0 -0
  120. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/intel.py +0 -0
  121. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/league_fines.py +0 -0
  122. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/league_recap.py +0 -0
  123. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/player.py +0 -0
  124. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/preview.py +0 -0
  125. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/price_changes.py +0 -0
  126. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/price_history.py +0 -0
  127. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/ratings.py +0 -0
  128. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/returnees.py +0 -0
  129. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/review.py +0 -0
  130. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/sell_prices.py +0 -0
  131. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/squad.py +0 -0
  132. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/stats.py +0 -0
  133. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/status.py +0 -0
  134. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/targets.py +0 -0
  135. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/transfer_eval.py +0 -0
  136. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/waivers.py +0 -0
  137. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/cli/xg.py +0 -0
  138. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/defaults.yaml +0 -0
  139. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/fixture_predictions.yaml +0 -0
  140. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/previews/EXAMPLE.yaml +0 -0
  141. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/config/team_managers.yaml +0 -0
  142. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/constants.py +0 -0
  143. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/__init__.py +0 -0
  144. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/chip_plan.py +0 -0
  145. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/fixture.py +0 -0
  146. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/league_history.py +0 -0
  147. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/player.py +0 -0
  148. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/team.py +0 -0
  149. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/models/types.py +0 -0
  150. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/parsers/__init__.py +0 -0
  151. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/parsers/recommendations.py +0 -0
  152. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/paths.py +0 -0
  153. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/__init__.py +0 -0
  154. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/league_recap.py +0 -0
  155. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/returnees.py +0 -0
  156. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/prompts/scout.py +0 -0
  157. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/scraper/__init__.py +0 -0
  158. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/scraper/fpl_prices.py +0 -0
  159. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/season.py +0 -0
  160. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/__init__.py +0 -0
  161. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/fixture_predictions.py +0 -0
  162. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history.py +0 -0
  163. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history_counters.py +0 -0
  164. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history_fines.py +0 -0
  165. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/league_history_notes.py +0 -0
  166. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/matchup.py +0 -0
  167. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/player_clubs.py +0 -0
  168. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/player_prior.py +0 -0
  169. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/returnee_radar.py +0 -0
  170. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/__init__.py +0 -0
  171. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/constants.py +0 -0
  172. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/data_prep.py +0 -0
  173. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/display.py +0 -0
  174. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/evaluation.py +0 -0
  175. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/ownership.py +0 -0
  176. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/shrinkage.py +0 -0
  177. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/signals.py +0 -0
  178. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/single_gw.py +0 -0
  179. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/scoring/value_quality.py +0 -0
  180. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/season_previews.py +0 -0
  181. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/squad_allocator.py +0 -0
  182. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/team_form.py +0 -0
  183. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/team_ratings.py +0 -0
  184. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/services/team_ratings_prior.py +0 -0
  185. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/templates/gw_league_recap.md.j2 +0 -0
  186. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  187. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/templates/gw_review.md.j2 +0 -0
  188. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/__init__.py +0 -0
  189. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/files.py +0 -0
  190. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/gameweek.py +0 -0
  191. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/markdown.py +0 -0
  192. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/teams.py +0 -0
  193. {fplkit-2.7.0 → fplkit-2.7.1}/fpl_cli/utils/time.py +0 -0
  194. {fplkit-2.7.0 → fplkit-2.7.1}/pyproject.toml +0 -0
  195. {fplkit-2.7.0 → fplkit-2.7.1}/pyrightconfig.json +0 -0
  196. {fplkit-2.7.0 → fplkit-2.7.1}/scripts/backtest_early_season_prior_blend.py +0 -0
  197. {fplkit-2.7.0 → fplkit-2.7.1}/scripts/calibrate_promoted_prior.py +0 -0
  198. {fplkit-2.7.0 → fplkit-2.7.1}/scripts/calibrate_quality_ceilings.py +0 -0
  199. {fplkit-2.7.0 → fplkit-2.7.1}/tests/__init__.py +0 -0
  200. {fplkit-2.7.0 → fplkit-2.7.1}/tests/conftest.py +0 -0
  201. {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/classic_squad_fixture.md +0 -0
  202. {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/recommendations_fixture.md +0 -0
  203. {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/validate_draft_waivers/recs_clean.md +0 -0
  204. {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/validate_draft_waivers/squad_grid.json +0 -0
  205. {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/validate_draft_waivers/waivers.json +0 -0
  206. {fplkit-2.7.0 → fplkit-2.7.1}/tests/fixtures/waiver_ranked_golden.json +0 -0
  207. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_action.py +0 -0
  208. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_analysis.py +0 -0
  209. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_analysis_squad.py +0 -0
  210. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_base.py +0 -0
  211. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_data.py +0 -0
  212. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_data_price.py +0 -0
  213. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_agents_orchestration.py +0 -0
  214. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_allocate.py +0 -0
  215. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_client.py +0 -0
  216. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_core_insights.py +0 -0
  217. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_dataset_fetcher.py +0 -0
  218. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_football_data.py +0 -0
  219. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_historical.py +0 -0
  220. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_understat.py +0 -0
  221. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_api_vaastav.py +0 -0
  222. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_bench_order_script.py +0 -0
  223. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_calibrate_quality_ceilings.py +0 -0
  224. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_classic_squad_extraction.py +0 -0
  225. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_allocate.py +0 -0
  226. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_banner.py +0 -0
  227. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_captain.py +0 -0
  228. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_chips.py +0 -0
  229. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_context_settings.py +0 -0
  230. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_credentials.py +0 -0
  231. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_custom_analysis.py +0 -0
  232. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_differentials.py +0 -0
  233. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_doctor.py +0 -0
  234. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_doctor_providers.py +0 -0
  235. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_failure_parity.py +0 -0
  236. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_failure_streams.py +0 -0
  237. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fdr.py +0 -0
  238. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fdr_blanks.py +0 -0
  239. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fixtures.py +0 -0
  240. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_format.py +0 -0
  241. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_format_suppression.py +0 -0
  242. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_fpl_block.py +0 -0
  243. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_history.py +0 -0
  244. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_init.py +0 -0
  245. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_intel.py +0 -0
  246. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_json.py +0 -0
  247. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_json_contract.py +0 -0
  248. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_league_fines.py +0 -0
  249. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_league_recap.py +0 -0
  250. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_plan_grid.py +0 -0
  251. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_player.py +0 -0
  252. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_preview.py +0 -0
  253. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_price_changes.py +0 -0
  254. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_price_history.py +0 -0
  255. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_returnees.py +0 -0
  256. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_sell_prices.py +0 -0
  257. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_settings_warning.py +0 -0
  258. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_squad.py +0 -0
  259. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_stats.py +0 -0
  260. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_status.py +0 -0
  261. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_targets.py +0 -0
  262. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_transfer_eval.py +0 -0
  263. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_waivers.py +0 -0
  264. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_warnings_stderr.py +0 -0
  265. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_cli_xg.py +0 -0
  266. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_common.py +0 -0
  267. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_common_scoring.py +0 -0
  268. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_data_prep_harmonisation.py +0 -0
  269. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_dependencies.py +0 -0
  270. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_doc_consistency.py +0 -0
  271. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fines.py +0 -0
  272. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fines_config.py +0 -0
  273. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fixture_predictions.py +0 -0
  274. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_fpl_prices.py +0 -0
  275. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_gw_prep_script_bootstrap.py +0 -0
  276. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_init.py +0 -0
  277. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_init_fines.py +0 -0
  278. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_counters.py +0 -0
  279. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_fines.py +0 -0
  280. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_notes.py +0 -0
  281. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_league_history_store.py +0 -0
  282. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_matchup_service.py +0 -0
  283. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_models.py +0 -0
  284. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_normalise_entities.py +0 -0
  285. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_output_style_sync.py +0 -0
  286. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_paths.py +0 -0
  287. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_player_clubs.py +0 -0
  288. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_player_prior.py +0 -0
  289. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_player_resolution.py +0 -0
  290. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_preseason.py +0 -0
  291. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_prompt_tables.py +0 -0
  292. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_providers.py +0 -0
  293. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_recommendations_parser.py +0 -0
  294. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_report_agent.py +0 -0
  295. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_report_paths.py +0 -0
  296. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_returnee_radar.py +0 -0
  297. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_rolling_pts_per_m.py +0 -0
  298. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_scoring.py +0 -0
  299. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_scraper.py +0 -0
  300. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_season.py +0 -0
  301. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_season_awareness.py +0 -0
  302. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_season_previews.py +0 -0
  303. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_sell_prices.py +0 -0
  304. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_skill_fdr_quality_signals.py +0 -0
  305. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_skill_stats_thresholds.py +0 -0
  306. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_squad_allocator.py +0 -0
  307. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_starting_xi_agent.py +0 -0
  308. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_starting_xi_script.py +0 -0
  309. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_stats.py +0 -0
  310. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_team_ratings.py +0 -0
  311. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_team_ratings_prior.py +0 -0
  312. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_transfer_eval_agent.py +0 -0
  313. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_transfer_eval_script.py +0 -0
  314. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_utils_gameweek.py +0 -0
  315. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_utils_markdown.py +0 -0
  316. {fplkit-2.7.0 → fplkit-2.7.1}/tests/test_utils_time.py +0 -0
  317. {fplkit-2.7.0 → fplkit-2.7.1}/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 | 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 | 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 |
@@ -1,6 +1,29 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
+ ## [2.7.0] - 2026-09-08
5
+
6
+ ### Bug Fixes
7
+
8
+ - never record rank movement out of a league's first gameweek (#325)
9
+ - stop the gameweek review narrative inventing its own counts (#328)
10
+ - keep the settled league position, not a mid-settlement one (#331)
11
+ - keep players who have never appeared off the review's Blankers list (#333)
12
+ - stop draft recaps reading an outbid manager as one who never tried (#334)
13
+ - mark the reader's own row in the saved review's Nearby Rivals table (#335)
14
+ - give managers level on points the same position in the review (#338)
15
+ - record the waiver claims a draft manager lost in the league ledger (#339)
16
+ - fine every manager who ties for last, not just one of them (#340)
17
+
18
+ ### Features
19
+
20
+ - surface contested waiver claims in the draft recap (#341)
21
+
22
+ ### Other
23
+
24
+ - No AFCON this season
25
+ - add back mention of AFCON transfers
26
+
4
27
  ## [2.6.1] - 2026-09-07
5
28
 
6
29
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: fplkit
3
- Version: 2.7.0
3
+ Version: 2.7.1
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
@@ -547,7 +547,7 @@ fpl_cli/
547
547
  │ ├── contract.py # Runtime tripwires: CSV header checks + empty-output warnings for dataset drift
548
548
  │ ├── dataset_fetcher.py # DatasetFetcher (disk cache with ETag/TTL for GitHub CSVs)
549
549
  │ ├── fpl.py # FPLClient (main API, caches bootstrap-static)
550
- │ ├── fpl_draft.py # FPLDraftClient + match_draft_to_main() + the transaction `result` vocabulary (`is_accepted_transaction()` / `is_lost_claim()`, issue #329). The feed carries denied claims alongside accepted ones and the two denial codes differ: `di` is a claim a rival won -- a genuine attempt -- while `do` means the manager's own earlier accepted claim had already consumed the drop, a conditional-chain cascade that is never an attempt. Consumers filter through these two predicates rather than comparing `result` themselves, so a move ledger never counts a denied row and an activity question never misses one
550
+ │ ├── fpl_draft.py # FPLDraftClient + match_draft_to_main() + the transaction `result` vocabulary (`is_accepted_transaction()` / `is_denied_on_the_incoming_player()` / `resolve_lost_claims()`, issues #329, #342). The feed carries denied claims alongside accepted ones, and the two denial codes say which side of the claim was already gone rather than who took it: `di` is the incoming player, `do` the nominated drop. Neither is a defeat on its own -- the engine processes claims league-wide in `index` order, so a manager's own winning claim removes the incoming player exactly as a rival's does, and a manager who wins a player he also listed lower down gets a `di` for the player he just won. What a manager actually lost is therefore only answerable from his whole gameweek, which is `resolve_lost_claims()`: it drops a `di` only where a claim of that manager's had *already taken* the player -- ordering, not end-of-gameweek ownership, since free agency runs after the whole waiver batch and a pickup there cannot have caused an earlier denial -- and returns a player he claimed several times once, at the best priority he spent, the same two rules `contested_draft_claims` states. Consumers go through these rather than comparing `result` themselves, so a move ledger never counts a denied row, an activity question never misses one, and a race a manager won is never reported as one he lost
551
551
  │ ├── understat.py # UnderstatClient + match_fpl_to_understat(), understat_club_rows() (the club gate the matcher and `fpl doctor --providers` share), understat_join_warnings(), understat_name_join_stats() (the name-level join rate, the signal a silently unmatched player had none of), decode_entities() (Understat escapes its payload for HTML, so it is decoded once at the fetch boundary), matches_in_season() (the season guard behind get_team())
552
552
  │ ├── historical_types.py # Shared dataclasses (SeasonHistory — incl. the four optional per-90 DEF/GK rates only Core-Insights publishes, PlayerProfile, GwTrendProfile) + compute_trend/compute_acceleration/compute_reliability
553
553
  │ ├── vaastav.py # VaastavClient (the two oldest seasons of the window via DatasetFetcher)
@@ -594,7 +594,7 @@ fpl_cli/
594
594
  │ └── types.py # TypedDicts: CaptainCandidate, WaiverTarget, EnrichedPlayer, etc.
595
595
  ├── prompts/
596
596
  │ ├── scout.py # ScoutAgent system/user prompts
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`. 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
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
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
600
600
  ├── parsers/
@@ -608,7 +608,7 @@ fpl_cli/
608
608
  ├── gameweek.py # is_opening_gameweek(gw) — shared GW1 check (transfers, waivers and league tables don't exist yet)
609
609
  ├── 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
610
  ├── 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)
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
612
  └── time.py # format_deadline/format_kickoff/format_generated_at — UK local (Europe/London, auto GMT↔BST). Canonical formatter for every user-facing timestamp.
613
613
 
614
614
  platformdirs (user_config_dir / user_data_dir) # macOS: ~/Library/Application Support/fpl-cli/
@@ -712,5 +712,5 @@ A settings block present but empty — every line under it commented out — par
712
712
  - **One allocation and one merge for the historical sources.** `historical_season_windows()` (`api/historical.py`) is the only place that decides which of the four seasons a profile spans come from Core-Insights (the newest two, all it publishes) and which from vaastav (the two before), and `make_historical_provider()` and `fpl doctor --providers` both read it. `merge_season_histories()` then keeps one row per `(element_code, season)` with Core-Insights outranking vaastav and announces any repeat, so provenance is a decision rather than two window expressions happening to be disjoint: before #101 a widened window would have doubled a season in every per-season mean and taken two of the reliability window's three slots without raising.
713
713
  - **Season data frozen in code is surfaced, not guarded.** Every other season-derived artefact rolls or invalidates itself — the historical window rolls at the July cutover, a stale `player_prior.yaml` or `returnee_snapshot.json` is discarded and rebuilt. The calibrated quality ceilings cannot: they are measurements of a completed season's player cohort, and only re-running `scripts/calibrate_quality_ceilings.py --write` against a newly completed season can replace them. So the generated block records `CALIBRATION_SEASON` beside the fingerprint and `fpl doctor` compares it against the newest completed season (#128). It reports, rather than fails, on purpose: anchors from last season are correct for the whole season being played, so the mid-season state is not a defect, and a rollover changes the truth of the block with no commit behind it — a CI guard would have nothing to fire on until someone touched the code, which is exactly the case it is not for.
714
714
  - **Who a player *was* is remembered; where he played is re-derived.** A replay resolves every pick against today's bootstrap, so a player transferred or renamed since lands on a past gameweek's row wearing his current club and current name (issue #169). `_carry_recorded_identity` answers that by keeping what the gameweek already recorded, which works only where something *was* recorded — and two cases have nothing: a first capture, and a coarse row being upgraded, since the coarse tier stores headline numbers and no squad at all. The second is the documented workflow, so a mid-season first run plus `--backfill-detail` writes today's clubs across a whole season (issue #177). `services/player_clubs.py` closes the club half from the gameweek itself instead of from a prior row: the live payload records which fixtures each player's club played, a fixture names its two clubs, and the intersection is where he was — settled outright by a double, narrowed to a pair by a single, and paid for with one `element-summary` request only where today's club is absent from that pair, which is proof he moved and the one case the pair cannot resolve. Because it is *derived* rather than remembered, it also supersedes a recorded club, where name and position are only ever carried: a row a first capture or a coarse upgrade wrote holds today's club as fact, so carrying it forward would preserve the mistake permanently — the same earliest-not-winner lesson as #169, applied to a value the gameweek can recompute. Which makes the *precision* of "derived" load-bearing in both directions. A derivation must supersede a record even when it agrees with today's club, or a player who left and came back keeps a stale row forever; and an assumption must never be called a derivation, or today's club overwrites a correctly recorded one — which is why the ambiguous single-fixture case answers nothing rather than answering "he has not moved". Name and position stay carry-forward-only because fixtures say nothing about them, and a blanking club has nothing to intersect; both fall back to the recorded value, then to today's. A lost waiver claim names two players the way a move does and a replay restamps both the same way, so it is carried by the same rule — read off the earliest line that recorded any claims rather than the earliest squad line, since a gameweek captured before schema version 6 holds its squad on a line that records nothing about them (issue #332).
715
- - **Absence from the accepted set is not inactivity.** The draft transaction feed carries every claim the league processed, and a claim a rival won is evidence the manager was active — but the ledger held completed moves alone, so a manager whose whole gameweek was one lost claim was written into the permanent record as having sat it out, and the recap named real people as idle on that basis (issues #329, #332). The row now carries `lost_claims` beside `transactions` rather than a result code inside it: nothing moved, so a lost claim has no points and no net, and folding it into a list every consumer reads as completed moves — the waiver streaks sum `net` over it, the identity carry pairs it slot for slot — would have made each of them learn to skip entries. Only the lost-to-a-rival denial is recorded; the feed's other denial means the manager's own earlier accepted claim had already used the drop, which is the cascade behind a success and never an attempt. The field is three-state on purpose: a list is what the feed held, `[]` included, and `None` says nothing was recorded — a classic row, an unknown row, or a row written before the field existed. That last state is what makes the backfill work without a migration: `coverage()` counts such rows on draft partitions, `_gaps` hands those gameweeks to the detailed tier behind `--backfill-detail`, the replay re-records them from the feed while it still serves the season, and because `[]` differs in content from `None` the superseding line lands once and the target converges. Presence is carried as well as identity: the feed is a log, so a finished gameweek's claims are all in it and a replay that finds fewer has lost some — a player it can no longer place, or a gameweek the feed has stopped serving — and `_carry_recorded_claims` keeps the longest recorded list rather than letting the remnant supersede it, while a longer replayed list still lands as the correction it is.
715
+ - **Absence from the accepted set is not inactivity.** The draft transaction feed carries every claim the league processed, and a claim a rival won is evidence the manager was active — but the ledger held completed moves alone, so a manager whose whole gameweek was one lost claim was written into the permanent record as having sat it out, and the recap named real people as idle on that basis (issues #329, #332). The row now carries `lost_claims` beside `transactions` rather than a result code inside it: nothing moved, so a lost claim has no points and no net, and folding it into a list every consumer reads as completed moves — the waiver streaks sum `net` over it, the identity carry pairs it slot for slot — would have made each of them learn to skip entries. Only genuine defeats are recorded, which neither denial code identifies by itself: `do` is the cascade behind a success, and `di` covers one too, since the manager's own winning claim removes the incoming player just as a rival's does (issue #342). `resolve_lost_claims()` settles both from the manager's whole gameweek, on the order the league processed it rather than on who owned the player by the end — so a waiver lost to a rival who then dropped him, and signed in free agency afterwards, stays the defeat it was — and dedupes a target he offered against several drops down to the one player he wanted. The field is three-state on purpose: a list is what the feed held, `[]` included, and `None` says nothing was recorded — a classic row, an unknown row, or a row written before the field existed. That last state is what makes the backfill work without a migration: `coverage()` counts such rows on draft partitions, `_gaps` hands those gameweeks to the detailed tier behind `--backfill-detail`, the replay re-records them from the feed while it still serves the season, and because `[]` differs in content from `None` the superseding line lands once and the target converges. Presence is carried as well as identity: the feed is a log, so a finished gameweek's claims are all in it and a replay that finds fewer has lost some — a player it can no longer place, or a gameweek the feed has stopped serving — and `_carry_recorded_claims` keeps the longest recorded list rather than letting the remnant supersede it, while a longer replayed list still lands as the correction it is.
716
716
  - **A row that knows less never supersedes one that knows more.** The ledger is append-only and reads take the newest row per manager, so a capture that writes a null over a recorded value destroys it as surely as an edit would — and permanently. Draft is where that bites: it has no per-manager history endpoint and its standings always describe the current gameweek, so a *replayed* draft gameweek derives no league position and no cumulative total at all. Writing those rows out cost the ledger every position that gameweek held, which is the one input the streak and season-count projections have, and each replay left a full set of dead lines behind (issue #223). Three things stop it. `_carry_recorded_standings` fills a null from the *latest-captured* line that recorded the field — read off the whole file rather than off `resolved_gameweek`'s winner, and ordered by `captured_at` rather than by position in it, since `append_rows` skips on tier rank alone and so does not guarantee the two agree — the same read-past-the-winner move `_carry_recorded_identity` makes, and for the same reason: by the time anyone notices, the degraded row is the winner. What keeps a degraded row out is the value being non-null, not the line being earliest, and that holds whichever way the pass runs; direction only settles which recorded value wins when several disagree, and there the later one is right — a gameweek captured repeatedly while bonus and late results were still landing holds a stale position in its first capture and the settled one in its last (issue #320). Identity stays earliest-first, because a name and a position do not settle over a gameweek the way a table does. `_fill_draft_standings` reconstructs what the ledger can supply on its own, summing a cumulative total from earlier gameweeks and ranking from it (at the league's start gameweek the total *is* the gameweek score, so `gross_points` alone settles that table), and derives a whole table or none of it. Every manager needs a total, because ranking the subset that summed cleanly would renumber everyone below whoever was left out; and no row may already carry a position, because `_assign_cohort_ranks` fills only the nulls, so a cohort part-carried and part-re-derived is ranked against two tables at once and lands two managers on the same place with nobody on the next. Draft breaks a head-to-head tie on points-for, which a cumulative total cannot reproduce, so those two rankings disagree in the ordinary case rather than only on a tie — and a null, which every streak condition holds across, beats a wrong position recorded as fact. `_repair_recorded_standings` then sweeps already-damaged gameweeks, ascending, on every run: it re-fetches nothing, so it needs neither `--backfill-detail` nor a request, and it is the only path that heals a ledger damaged before this existed. Filling only nulls is what makes all three idempotent — a genuine correction still lands, and a replay that reproduces a gameweek exactly now writes no line at all.
@@ -750,7 +750,7 @@ fpl review --dry-run # Build prompts without calling LLMs
750
750
 
751
751
  **Classic:** team summary, player-by-player breakdown (captain doubled/tripled), transfer assessment, league standings with nearby rivals, best/worst performers.
752
752
 
753
- **League position:** your own position and the `Pos` column of Nearby Rivals are competition-ranked from the cumulative totals, the same numbering `fpl league-recap` writes to the ledger: managers level on points share a place and the next distinct total skips the places they consumed (1, 2, 2, 4). The standings API hands out strictly sequential ranks instead, splitting a tie on fewest transfers season-to-date — a figure no column here shows — so the same gameweek used to place the same tie differently depending on which report you read.
753
+ **League position:** your own position and the `Pos` column of Nearby Rivals are competition-ranked from the cumulative totals, the same numbering `fpl league`'s table shows and `fpl league-recap` writes to the ledger: managers level on points share a place and the next distinct total skips the places they consumed (1, 2, 2, 4). The standings API hands out strictly sequential ranks instead, splitting a tie on fewest transfers season-to-date — a figure no column here shows — so the same gameweek used to place the same tie differently depending on which report you read.
754
754
 
755
755
  **Draft:** squad breakdown, transaction assessment, league standings, best/worst performers.
756
756
 
@@ -1043,9 +1043,12 @@ priority it was made at. They sit apart from `transactions`, which stays the lis
1043
1043
  that happened — nothing moved here, so there are no points on either side and no net —
1044
1044
  because a manager whose whole gameweek was one claim they lost has no completed move,
1045
1045
  and a ledger holding moves alone wrote them into the permanent record as having sat the
1046
- week out. Only claims lost to a rival are recorded: a claim denied because the manager's
1047
- own earlier accepted claim had already dropped the nominated player is the cascade
1048
- behind a claim that succeeded, not an attempt, and never appears (see
1046
+ week out. Only claims a rival won are recorded, which the feed's denial codes do not
1047
+ identify on their own: a claim denied because the manager's own earlier accepted claim
1048
+ had already dropped the nominated player is the cascade behind a success, and so is a
1049
+ claim denied because he had just won the incoming player himself — both are read against
1050
+ his whole gameweek and neither appears. A player he claimed several times against
1051
+ different drops is one claim, at the best priority he spent on him (see
1049
1052
  [Conditional chains and outcomes](fpl-rules.md#waivers)). A replay that finds fewer
1050
1053
  claims than the gameweek recorded keeps the recorded list and says so
1051
1054
  (`league_history_claims_carried`): a claim whose player can no longer be placed is
@@ -189,15 +189,21 @@ Processed **24 hours before the GW deadline**. After processing, **free agency**
189
189
 
190
190
  **Locked players** (unavailable for waivers): players added to the game within 24 hours of the waiver deadline, or players recently dropped from another squad. Locked players become available at the next waiver deadline.
191
191
 
192
- **Conditional chains and outcomes.** A manager submits several claims in their own priority order, and claims may share a drop, so only one of a group is ever expected to land. Each submitted claim ends in one of three states, and the last two are not the same thing:
192
+ **Conditional chains and outcomes.** A manager submits several claims in their own priority order. Claims may share a drop, and may name one target against several drops, so only one of a group is ever expected to land. Claims are processed league-wide in a single order, and each ends in one of three states:
193
193
 
194
194
  | Outcome | Meaning |
195
195
  |---|---|
196
196
  | Accepted | The claim was processed; the incoming player joined the squad and the nominated player was dropped |
197
- | Lost to a rival | A manager with higher priority claimed the same player. A genuine attempt, and the manager ends with no move from it |
198
- | Drop already used | An earlier accepted claim by the same manager had already dropped the nominated player, so this one could not be processed. The cascade behind a claim that succeeded, not an attempt that failed - it cannot occur without that earlier accepted claim |
197
+ | Incoming player gone | Someone had already taken him by the time this claim processed, so it could not be filled |
198
+ | Drop already used | An earlier accepted claim by the same manager had already dropped the nominated player, so this one could not be processed. It cannot occur without that earlier accepted claim |
199
199
 
200
- Only accepted claims changed a squad, so only they belong in a record of moves. But a manager who submitted claims and lost them all was active, and reading their absence from the accepted set as inactivity misreports them.
200
+ Neither denial says on its own whether the manager lost anything, because "someone" in the second row includes the manager himself. A manager who wins a player at one priority and listed him again lower down gets an "incoming player gone" for the player he just won — the same self-cascade the third row describes, arriving on the other side of the claim. Only a denial where a **different** manager took the player is a defeat.
201
+
202
+ So a claim's outcome is only readable against the manager's whole gameweek, never from the claim alone. Two rules follow: a denial for a player one of the manager's own claims had **already taken** is his own cascade, not a loss; and a player he claimed several times and lost was wanted once, not once per drop he offered.
203
+
204
+ The first rule turns on order, not on who ends the gameweek owning the player. Waivers process as one batch and free agency opens only afterwards, so a manager who loses a waiver to a rival, and signs the same player once that rival drops him, still lost the waiver.
205
+
206
+ Only accepted claims changed a squad, so only they belong in a record of moves. But a manager who submitted claims and genuinely lost them all was active, and reading their absence from the accepted set as inactivity misreports them.
201
207
 
202
208
  ### Free Agents
203
209
  Unclaimed players can be picked up first-come, first-served in the **24-hour window before each GW deadline**. Free agent pickups don't affect waiver priority.
@@ -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.0'
22
- __version_tuple__ = version_tuple = (2, 7, 0)
21
+ __version__ = version = '2.7.1'
22
+ __version_tuple__ = version_tuple = (2, 7, 1)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -17,22 +17,28 @@ logger = logging.getLogger(__name__)
17
17
  BASE_URL = "https://draft.premierleague.com/api"
18
18
 
19
19
  # The Draft API's transaction `result` codes. Every row the league processed
20
- # carries one, and the two denial codes do not mean the same thing -- reading
21
- # either as "no move happened" is right, reading either as "no attempt was
22
- # made" is wrong for one of them and right for the other (issue #329).
20
+ # carries one, and the two denial codes say which side of the claim was
21
+ # already gone -- not who took it (issues #329, #342).
23
22
  #
24
23
  # `a` -- the league processed the claim into a completed move.
25
- # `di` -- denied because a rival won the incoming player. A genuine
26
- # competitive loss: the manager tried and was beaten to him.
27
- # `do` -- denied because the outgoing player had already been used as the
28
- # drop in an earlier accepted claim by the same manager. Managers
29
- # submit several claims sharing one drop slot expecting only one to
30
- # land, so this is the cascade behind a claim that succeeded, not an
31
- # attempt that failed. It cannot occur without that earlier accepted
32
- # claim, so a `do` row never stands alone as a manager's only
33
- # activity.
24
+ # `di` -- denied on the *incoming* side: the player was gone when this claim
25
+ # processed. Usually a rival won him, which is a genuine competitive
26
+ # loss. But the engine processes claims globally in `index` order and
27
+ # a manager's own earlier accepted claim removes him exactly as a
28
+ # rival's does, so a manager who wins a player and also listed him
29
+ # lower down gets a `di` for the player he just won.
30
+ # `do` -- denied on the *outgoing* side: the player nominated as the drop had
31
+ # already been used as the drop in an earlier accepted claim by the
32
+ # same manager. It cannot occur without that earlier accepted claim,
33
+ # so a `do` row never stands alone as a manager's only activity.
34
+ #
35
+ # Both denials therefore cover a self-cascade as well as a real defeat, which
36
+ # is why the code alone cannot say whether a manager lost anything: managers
37
+ # submit several claims sharing one drop slot, or naming one target against
38
+ # several drops, expecting only one to land. `resolve_lost_claims()` answers
39
+ # that from the manager's whole gameweek; a single row cannot.
34
40
  DRAFT_TXN_ACCEPTED = "a"
35
- DRAFT_TXN_LOST_TO_RIVAL = "di"
41
+ DRAFT_TXN_DENIED_PLAYER_GONE = "di"
36
42
  DRAFT_TXN_DROP_ALREADY_USED = "do"
37
43
 
38
44
 
@@ -45,14 +51,89 @@ def is_accepted_transaction(txn: Mapping[str, Any]) -> bool:
45
51
  return txn.get("result") == DRAFT_TXN_ACCEPTED
46
52
 
47
53
 
48
- def is_lost_claim(txn: Mapping[str, Any]) -> bool:
49
- """Whether this row is a claim the manager submitted and a rival won.
54
+ def is_denied_on_the_incoming_player(txn: Mapping[str, Any]) -> bool:
55
+ """Whether this row was denied because the incoming player was gone.
50
56
 
51
57
  True for `di` alone. A `do` row is deliberately excluded: counting it as
52
58
  a failed attempt would misreport the manager whose *winning* claim caused
53
59
  it, which is the misattribution this classification exists to prevent.
60
+
61
+ Row-local, and so not the same question as "did this manager lose the
62
+ player" -- his own winning claim makes him gone too. Callers wanting that
63
+ want `resolve_lost_claims()`.
64
+ """
65
+ return txn.get("result") == DRAFT_TXN_DENIED_PLAYER_GONE
66
+
67
+
68
+ def draft_claim_priority_rank(priority: int | None) -> tuple[int, int]:
69
+ """Sort key placing a numbered priority before none at all, 1 first."""
70
+ return (1, 0) if priority is None else (0, priority)
71
+
72
+
73
+ def _processing_position(txn: Mapping[str, Any], arrival: int) -> tuple[int, int, int]:
74
+ """Where one row sits in the order the league processed the gameweek.
75
+
76
+ Waivers run first, as a single batch the engine works through in `index`
77
+ order; free agency opens once that batch is done and its rows carry no
78
+ index at all. `arrival` is the row's place in the feed, which orders the
79
+ rows an index cannot separate.
80
+ """
81
+ index = txn.get("index")
82
+ return (0, index, arrival) if isinstance(index, int) else (1, 0, arrival)
83
+
84
+
85
+ def resolve_lost_claims(txns: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
86
+ """The claims one manager genuinely lost, from all their rows for one
87
+ gameweek -- the question no single row can answer (issue #342).
88
+
89
+ Two things a row-local read gets wrong, both consequences of managers
90
+ submitting conditional chains rather than independent claims:
91
+
92
+ - A `di` for a player this manager had *already taken* is his own cascade,
93
+ not a defeat. He listed the same target twice with different drops, one
94
+ landed, and every later claim for him was then denied on the incoming
95
+ side. Reporting it inverts the outcome for the manager who won the race.
96
+ - A player he claimed several times and lost was wanted once, so he is
97
+ returned once, at the best priority he spent on him. Otherwise one
98
+ target across three drops reads as three separate defeats.
99
+
100
+ "Already taken" is a question about order, not merely about owning him by
101
+ the end of the gameweek: only a claim that landed *before* the denial can
102
+ have caused it. Free agency opens after the whole waiver batch, so a
103
+ manager who loses a waiver to a rival and signs the same player as a free
104
+ agent once the rival drops him still lost that waiver, and the loss is
105
+ still recorded -- clearing it on the later pickup would erase a real
106
+ defeat and contradict the case `contested_draft_claims` documents.
107
+
108
+ Both rules are the ones that function already states and works to; this
109
+ brings the stored claims into line with them, so the two layers cannot
110
+ disagree about what a manager lost. Order follows the feed, by the row
111
+ kept for each player.
54
112
  """
55
- return txn.get("result") == DRAFT_TXN_LOST_TO_RIVAL
113
+ # The earliest point at which a claim of this manager's took each player.
114
+ taken_at: dict[Any, tuple[int, int, int]] = {}
115
+ for arrival, txn in enumerate(txns):
116
+ if not is_accepted_transaction(txn):
117
+ continue
118
+ player = txn.get("element_in")
119
+ at = _processing_position(txn, arrival)
120
+ if player not in taken_at or at < taken_at[player]:
121
+ taken_at[player] = at
122
+
123
+ best: dict[Any, dict[str, Any]] = {}
124
+ for arrival, txn in enumerate(txns):
125
+ player = txn.get("element_in")
126
+ if player is None or not is_denied_on_the_incoming_player(txn):
127
+ continue
128
+ taken = taken_at.get(player)
129
+ if taken is not None and taken < _processing_position(txn, arrival):
130
+ continue
131
+ known = best.get(player)
132
+ if known is None or draft_claim_priority_rank(txn.get("priority")) < draft_claim_priority_rank(
133
+ known.get("priority"),
134
+ ):
135
+ best[player] = txn
136
+ return list(best.values())
56
137
 
57
138
 
58
139
  class FPLDraftClient:
@@ -2033,8 +2033,8 @@ async def collect_draft_recap_data(
2033
2033
  from fpl_cli.api.fpl_draft import (
2034
2034
  FPLDraftClient,
2035
2035
  is_accepted_transaction,
2036
- is_lost_claim,
2037
2036
  match_draft_to_main,
2037
+ resolve_lost_claims,
2038
2038
  )
2039
2039
  from fpl_cli.models.player import POSITION_MAP
2040
2040
 
@@ -2073,15 +2073,22 @@ async def collect_draft_recap_data(
2073
2073
  # but who was active cannot -- a manager whose only claim a rival won
2074
2074
  # has no accepted row and used to be indistinguishable from one who
2075
2075
  # submitted nothing (issue #329). So both are kept, separately.
2076
+ #
2077
+ # Bucketed by manager *before* either question is asked: what a
2078
+ # manager lost is only answerable from his whole gameweek, since a
2079
+ # claim denied on a player he went on to win is his own cascade
2080
+ # rather than a defeat (issue #342).
2076
2081
  txn_response = await draft_client.get_league_transactions(draft_league_id)
2077
2082
  all_txns: list[dict[str, Any]] = txn_response.get("transactions", [])
2078
2083
  gw_txns = [t for t in all_txns if t.get("event") == gw]
2079
- txns_by_entry = _bucket_draft_txns_by_league_entry(
2080
- [t for t in gw_txns if is_accepted_transaction(t)], league_entries,
2081
- )
2082
- lost_by_entry = _bucket_draft_txns_by_league_entry(
2083
- [t for t in gw_txns if is_lost_claim(t)], league_entries,
2084
- )
2084
+ by_entry = _bucket_draft_txns_by_league_entry(gw_txns, league_entries)
2085
+ txns_by_entry = {
2086
+ le_id: [t for t in rows if is_accepted_transaction(t)]
2087
+ for le_id, rows in by_entry.items()
2088
+ }
2089
+ lost_by_entry = {
2090
+ le_id: resolve_lost_claims(rows) for le_id, rows in by_entry.items()
2091
+ }
2085
2092
 
2086
2093
  # Fetch picks for each manager
2087
2094
  sem = asyncio.Semaphore(_PICKS_CONCURRENCY)
@@ -6,6 +6,7 @@ from collections.abc import Iterable, Sequence
6
6
  from dataclasses import dataclass
7
7
  from typing import NotRequired, TypedDict
8
8
 
9
+ from fpl_cli.api.fpl_draft import draft_claim_priority_rank
9
10
  from fpl_cli.utils.text import ordinal_word
10
11
 
11
12
 
@@ -656,11 +657,6 @@ def _contested_player_key(
656
657
  return (move["player_in"], move["player_in_team"])
657
658
 
658
659
 
659
- def _priority_rank(priority: int | None) -> tuple[int, int]:
660
- """Sort key placing a numbered priority before none at all, 1 first."""
661
- return (1, 0) if priority is None else (0, priority)
662
-
663
-
664
660
  def _contest_winner(
665
661
  accepted: Sequence[tuple[int, str]], claimants: Iterable[RecapContestedClaimant],
666
662
  ) -> int | None:
@@ -688,22 +684,27 @@ def contested_draft_claims(
688
684
  contested first: by claimant count, then by how early the beaten
689
685
  managers ranked him (two first-choice claims outrank one), then by name.
690
686
 
691
- Regrouped from the manager rows rather than fetched: a `di` row in the
692
- feed means a rival won the player, so the gameweek's lost claims, bucketed
693
- by player, are the races, and the accepted move that brought the same
694
- player in names the winner (issue #330). Read off the raw move list, not
687
+ Regrouped from the manager rows rather than fetched: a stored lost claim
688
+ is one a rival won, so the gameweek's lost claims, bucketed by player, are
689
+ the races, and the accepted move that brought the same player in names the
690
+ winner (issue #330). Only the stored claims carry that meaning -- the
691
+ feed's `di` code says the incoming player was gone, which the manager's
692
+ own winning claim also causes; `resolve_lost_claims()` settles that
693
+ upstream (issue #342). Read off the raw move list, not
695
694
  the awards' chain-contracted one, so a manager who won a player and moved
696
695
  him on again the same gameweek still won him.
697
696
 
698
697
  A manager who lost several claims on one player -- a conditional chain
699
698
  offering different drops for him -- wanted him once, and is named once,
700
699
  at the highest priority they gave him. The winner is never also among
701
- the beaten: once a manager's first claim for a player lands, any later
702
- claim of theirs for him is denied on the incoming side (a `di`, since it
703
- is the incoming player that is gone), and a manager who lost the waiver
704
- and signed him as a free agent later the same gameweek carries both
705
- rows too. Either way they wanted him once and got him, and a player
706
- nobody else wanted is then no race at all. Empty for classic, whose rows
700
+ the beaten: a manager who lost the waiver and signed him as a free agent
701
+ later the same gameweek carries both rows. Either way they wanted him
702
+ once and got him, and a player nobody else wanted is then no race at all.
703
+
704
+ Both rules now hold of the stored claims before this runs, since
705
+ `resolve_lost_claims()` applies them at ingestion -- these are kept as
706
+ the guarantee this function owns, so a caller handing it claims from
707
+ anywhere still gets one row per manager per race. Empty for classic, whose rows
707
708
  carry no claims.
708
709
  """
709
710
  players: dict[_ContestedPlayerKey, RecapDraftLostClaim] = {}
@@ -719,7 +720,9 @@ def contested_draft_claims(
719
720
  for claim in m.get("lost_claims") or []:
720
721
  key = _contested_player_key(claim)
721
722
  known = best.get(key)
722
- if known is None or _priority_rank(claim["priority"]) < _priority_rank(known["priority"]):
723
+ if known is None or draft_claim_priority_rank(claim["priority"]) < draft_claim_priority_rank(
724
+ known["priority"],
725
+ ):
723
726
  best[key] = claim
724
727
  for key, claim in best.items():
725
728
  players.setdefault(key, claim)
@@ -733,7 +736,7 @@ def contested_draft_claims(
733
736
  winner = _contest_winner(accepted.get(key, []), wanted.values())
734
737
  beaten = sorted(
735
738
  (c for position, c in wanted.items() if position != winner),
736
- key=lambda c: (_priority_rank(c["priority"]), c["manager_name"]),
739
+ key=lambda c: (draft_claim_priority_rank(c["priority"]), c["manager_name"]),
737
740
  )
738
741
  if not beaten:
739
742
  continue
@@ -752,7 +755,7 @@ def contested_draft_claims(
752
755
 
753
756
  contests.sort(key=lambda c: (
754
757
  -c["claimants"],
755
- tuple(_priority_rank(loser["priority"]) for loser in c["losers"]),
758
+ tuple(draft_claim_priority_rank(loser["priority"]) for loser in c["losers"]),
756
759
  c["player"],
757
760
  c.get("player_code") or 0,
758
761
  ))
@@ -443,8 +443,13 @@ async def _review_classic_league(
443
443
  # here records, leaving the same gameweek placing the same tie two
444
444
  # different ways depending on whether you read the review or the
445
445
  # recap's ledger (#337, the defect #163 and #230 fixed elsewhere).
446
+ #
447
+ # An entry with no total is omitted rather than passed a placeholder,
448
+ # per the helper's contract -- a substituted 0 would tie it with a
449
+ # manager who really has scored 0.
446
450
  league_positions = derive_point_in_time_positions([
447
- (e["entry"], e.get("total", 0)) for e in standings if e.get("entry") is not None
451
+ (e["entry"], e["total"]) for e in standings
452
+ if e.get("entry") is not None and e.get("total") is not None
448
453
  ])
449
454
  user_entry = next((e for e in standings if e.get("entry") == entry_id), None)
450
455
  if user_entry:
@@ -57,8 +57,8 @@ async def _review_draft(
57
57
  from fpl_cli.api.fpl_draft import (
58
58
  FPLDraftClient,
59
59
  is_accepted_transaction,
60
- is_lost_claim,
61
60
  match_draft_to_main,
61
+ resolve_lost_claims,
62
62
  )
63
63
 
64
64
  draft_league_data = None
@@ -275,8 +275,10 @@ async def _review_draft(
275
275
  # but a week whose only waiver activity was a claim the
276
276
  # user lost is not a week they sat out, and reporting it
277
277
  # as "no waivers this week" is the misattribution in
278
- # issue #329 pointed at the user's own review.
279
- lost_txns = [t for t in my_txns if is_lost_claim(t)]
278
+ # issue #329 pointed at the user's own review. Resolved
279
+ # against the reader's whole gameweek, so a claim denied
280
+ # on a player he went on to win is not one he lost (#342).
281
+ lost_txns = resolve_lost_claims(my_txns)
280
282
 
281
283
  # Collapse same-GW churn using net squad delta (Counter diff).
282
284
  # Pair residual adds and drops by (position, web_name) for like-for-like rows.
@@ -25,7 +25,7 @@ from fpl_cli.services.scoring import ATTACKING_POSITIONS
25
25
  from fpl_cli.services.team_ratings import api_difficulty_scale, fdr_columns_footer
26
26
  from fpl_cli.utils.gameweek import is_opening_gameweek
27
27
  from fpl_cli.utils.teams import describe_team_set_mismatch
28
- from fpl_cli.utils.text import strip_diacritics
28
+ from fpl_cli.utils.text import normalise_name
29
29
 
30
30
  if TYPE_CHECKING:
31
31
  from fpl_cli.prompts.review import SynthesisCompleteness
@@ -1273,12 +1273,20 @@ async def _review_llm_summarise(
1273
1273
 
1274
1274
 
1275
1275
  def _normalise_name(name: str) -> str:
1276
- """Normalise a player name for fuzzy matching."""
1277
- import re
1278
- name = strip_diacritics(name).strip().lower()
1276
+ """Normalise a player name for fuzzy matching.
1277
+
1278
+ One side of every comparison here is a name an LLM typed into the
1279
+ recommendations markdown and the other is a `web_name` off the API, so the
1280
+ fold has to survive the styling the writer applied on the way -- the
1281
+ apostrophe family and whitespace runs included, via `normalise_name`. It
1282
+ matters more here than in the table guard that shares it: a miss there
1283
+ dropped a row, while a miss here reports a captain you *did* field as a
1284
+ recommendation you ignored, and prices the difference against himself.
1285
+ """
1286
+ name = normalise_name(name)
1279
1287
  name = re.sub(r"\s*\(.*?\)\s*$", "", name) # strip parentheticals
1280
1288
  name = re.sub(r"^[a-z]\.\s*", "", name) # strip leading initials
1281
- return name
1289
+ return name.strip()
1282
1290
 
1283
1291
 
1284
1292
  def _names_match(a: str, b: str) -> bool:
@@ -13,6 +13,7 @@ from rich.table import Table
13
13
 
14
14
  from fpl_cli.cli._context import Format, console, error_console, fpl_config, get_format, get_settings
15
15
  from fpl_cli.cli._helpers import _entry_league_meta, _fetch_standings_with_costs
16
+ from fpl_cli.cli._league_recap_data import derive_point_in_time_positions
16
17
 
17
18
  logger = logging.getLogger(__name__)
18
19
 
@@ -58,10 +59,31 @@ def league_command(ctx: click.Context) -> None:
58
59
  league_name = standings_data.get("league", {}).get("name", "Classic League")
59
60
  standings = standings_data.get("standings", {}).get("results", [])
60
61
 
62
+ # Classic `rank` is strictly sequential, so it splits a
63
+ # points tie on fewest transfers season-to-date -- a figure
64
+ # no column here shows and the ledger does not record.
65
+ # Position the table the way `fpl review` and the ledger
66
+ # do, so the same tie isn't numbered two ways depending on
67
+ # which command the reader runs (#344, the defect #337
68
+ # fixed one surface along). An entry below this page can
69
+ # share a place with the last one on it without changing
70
+ # any number here: a shared place consumes the ones behind
71
+ # it, never the ones above.
72
+ #
73
+ # An entry with no total is omitted rather than passed a
74
+ # placeholder, per the helper's contract: a substituted 0
75
+ # would tie it with a manager who really has scored 0, and
76
+ # an explicit null would take the whole section down here
77
+ # rather than at the row it belongs to.
78
+ league_positions = derive_point_in_time_positions([
79
+ (e["entry"], e["total"]) for e in standings
80
+ if e.get("entry") is not None and e.get("total") is not None
81
+ ])
82
+
61
83
  # Find user's entry
62
84
  user_entry = next((e for e in standings if e.get("entry") == entry_id), None)
63
85
  if user_entry:
64
- user_rank = user_entry.get("rank", "?")
86
+ user_rank = league_positions.get(entry_id, "?")
65
87
  user_total = user_entry.get("total", 0)
66
88
  user_gw_pts = user_entry.get("event_total", 0)
67
89
  # `standings` is one 50-entry page; a league bigger than
@@ -87,7 +109,7 @@ def league_command(ctx: click.Context) -> None:
87
109
  table.add_column("Total", justify="right")
88
110
 
89
111
  for entry in standings:
90
- rank = str(entry.get("rank", "?"))
112
+ rank = str(league_positions.get(entry.get("entry"), "?"))
91
113
  name = entry.get("player_name", "Unknown")
92
114
  gw_pts = entry.get("event_total", 0)
93
115
  total = entry.get("total", 0)