fplkit 1.3.0__tar.gz → 1.4.0__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 (234) hide show
  1. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/SKILL.md +55 -0
  2. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/references/rules.md +13 -0
  3. fplkit-1.4.0/.agents/skills/gw-prep/scripts/validate_draft_waivers.py +466 -0
  4. {fplkit-1.3.0 → fplkit-1.4.0}/.claude/output-styles/fpl-mate.md +18 -0
  5. {fplkit-1.3.0 → fplkit-1.4.0}/AGENTS.md +3 -0
  6. {fplkit-1.3.0 → fplkit-1.4.0}/CHANGELOG.md +25 -0
  7. {fplkit-1.3.0 → fplkit-1.4.0}/CLAUDE.md +3 -0
  8. {fplkit-1.3.0 → fplkit-1.4.0}/PKG-INFO +2 -1
  9. {fplkit-1.3.0 → fplkit-1.4.0}/docs/architecture.md +4 -1
  10. {fplkit-1.3.0 → fplkit-1.4.0}/docs/command-reference.md +4 -0
  11. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/_version.py +2 -2
  12. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/data/fixture.py +6 -1
  13. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/orchestration/report.py +8 -7
  14. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_helpers.py +11 -3
  15. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_league_recap_data.py +312 -91
  16. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_league_recap_types.py +14 -5
  17. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_review_classic.py +14 -2
  18. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_review_summarisation.py +194 -28
  19. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/fixtures.py +2 -1
  20. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/league_recap.py +6 -4
  21. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/preview.py +9 -11
  22. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/status.py +6 -2
  23. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/transfer_eval.py +1 -0
  24. fplkit-1.4.0/fpl_cli/config/fixture_predictions.yaml +10 -0
  25. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/paths.py +0 -1
  26. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/prompts/league_recap.py +49 -2
  27. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/prompts/review.py +224 -22
  28. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/scraper/fpl_prices.py +4 -1
  29. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/fixture_predictions.py +2 -2
  30. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/templates/gw_league_recap.md.j2 +1 -1
  31. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/templates/gw_review.md.j2 +2 -2
  32. fplkit-1.4.0/fpl_cli/utils/time.py +71 -0
  33. {fplkit-1.3.0 → fplkit-1.4.0}/pyproject.toml +1 -0
  34. fplkit-1.4.0/tests/fixtures/validate_draft_waivers/recs_clean.md +20 -0
  35. fplkit-1.4.0/tests/fixtures/validate_draft_waivers/squad_grid.json +21 -0
  36. fplkit-1.4.0/tests/fixtures/validate_draft_waivers/waivers.json +12 -0
  37. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_preview.py +2 -1
  38. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_review.py +32 -1
  39. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_status.py +129 -0
  40. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_league_recap.py +572 -14
  41. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_report_agent.py +13 -0
  42. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_review.py +656 -4
  43. fplkit-1.4.0/tests/test_utils_time.py +85 -0
  44. fplkit-1.4.0/tests/test_validate_draft_waivers.py +748 -0
  45. fplkit-1.3.0/config/fixture_predictions.yaml +0 -41
  46. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/README.md +0 -0
  47. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/TOOLS.md +0 -0
  48. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/references/output-template.md +0 -0
  49. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/scripts/bench_order.py +0 -0
  50. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/scripts/extract_classic_squad.py +0 -0
  51. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/scripts/starting_xi.py +0 -0
  52. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/gw-prep/scripts/transfer_eval.py +0 -0
  53. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/squad-builder/SKILL.md +0 -0
  54. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/squad-builder/references/output-template.md +0 -0
  55. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/squad-builder/references/rules.md +0 -0
  56. {fplkit-1.3.0 → fplkit-1.4.0}/.agents/skills/update-gw-prep/SKILL.md +0 -0
  57. {fplkit-1.3.0 → fplkit-1.4.0}/.claude/settings.json +0 -0
  58. {fplkit-1.3.0 → fplkit-1.4.0}/.env.example +0 -0
  59. {fplkit-1.3.0 → fplkit-1.4.0}/.github/workflows/ci.yml +0 -0
  60. {fplkit-1.3.0 → fplkit-1.4.0}/.github/workflows/release.yml +0 -0
  61. {fplkit-1.3.0 → fplkit-1.4.0}/.gitignore +0 -0
  62. {fplkit-1.3.0 → fplkit-1.4.0}/LICENSE +0 -0
  63. {fplkit-1.3.0 → fplkit-1.4.0}/README.md +0 -0
  64. {fplkit-1.3.0 → fplkit-1.4.0}/cliff.toml +0 -0
  65. {fplkit-1.3.0 → fplkit-1.4.0}/config/team_managers.yaml +0 -0
  66. {fplkit-1.3.0 → fplkit-1.4.0}/config/team_ratings.yaml +0 -0
  67. {fplkit-1.3.0 → fplkit-1.4.0}/config/team_ratings_overrides.yaml +0 -0
  68. {fplkit-1.3.0 → fplkit-1.4.0}/docs/custom-analysis.md +0 -0
  69. {fplkit-1.3.0 → fplkit-1.4.0}/docs/fpl-rules.md +0 -0
  70. {fplkit-1.3.0 → fplkit-1.4.0}/docs/images/fpl-player-demo-v1-0.png +0 -0
  71. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/__init__.py +0 -0
  72. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/__init__.py +0 -0
  73. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/action/__init__.py +0 -0
  74. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/action/waiver.py +0 -0
  75. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/__init__.py +0 -0
  76. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/bench_order.py +0 -0
  77. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/captain.py +0 -0
  78. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/squad_analyzer.py +0 -0
  79. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/starting_xi.py +0 -0
  80. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/stats.py +0 -0
  81. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/analysis/transfer_eval.py +0 -0
  82. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/base.py +0 -0
  83. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/common.py +0 -0
  84. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/data/__init__.py +0 -0
  85. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/data/price.py +0 -0
  86. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/data/scout.py +0 -0
  87. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/agents/orchestration/__init__.py +0 -0
  88. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/__init__.py +0 -0
  89. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/core_insights.py +0 -0
  90. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/dataset_fetcher.py +0 -0
  91. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/football_data.py +0 -0
  92. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/fpl.py +0 -0
  93. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/fpl_draft.py +0 -0
  94. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/historical.py +0 -0
  95. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/historical_types.py +0 -0
  96. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/providers/__init__.py +0 -0
  97. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/providers/_models.py +0 -0
  98. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/providers/anthropic.py +0 -0
  99. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/providers/openai_compat.py +0 -0
  100. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/providers/perplexity.py +0 -0
  101. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/understat.py +0 -0
  102. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/api/vaastav.py +0 -0
  103. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/__init__.py +0 -0
  104. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_banner.py +0 -0
  105. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_context.py +0 -0
  106. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_fines.py +0 -0
  107. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_fines_config.py +0 -0
  108. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_json.py +0 -0
  109. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_plan_grid.py +0 -0
  110. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_review_analysis.py +0 -0
  111. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/_review_draft.py +0 -0
  112. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/allocate.py +0 -0
  113. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/captain.py +0 -0
  114. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/chips.py +0 -0
  115. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/credentials.py +0 -0
  116. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/differentials.py +0 -0
  117. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/fdr.py +0 -0
  118. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/history.py +0 -0
  119. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/init.py +0 -0
  120. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/league.py +0 -0
  121. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/player.py +0 -0
  122. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/price_changes.py +0 -0
  123. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/price_history.py +0 -0
  124. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/ratings.py +0 -0
  125. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/review.py +0 -0
  126. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/sell_prices.py +0 -0
  127. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/squad.py +0 -0
  128. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/stats.py +0 -0
  129. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/targets.py +0 -0
  130. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/waivers.py +0 -0
  131. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/cli/xg.py +0 -0
  132. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/config/defaults.yaml +0 -0
  133. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/constants.py +0 -0
  134. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/models/__init__.py +0 -0
  135. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/models/chip_plan.py +0 -0
  136. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/models/fixture.py +0 -0
  137. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/models/player.py +0 -0
  138. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/models/team.py +0 -0
  139. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/models/types.py +0 -0
  140. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/parsers/__init__.py +0 -0
  141. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/parsers/recommendations.py +0 -0
  142. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/prompts/__init__.py +0 -0
  143. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/prompts/scout.py +0 -0
  144. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/scraper/__init__.py +0 -0
  145. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/season.py +0 -0
  146. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/__init__.py +0 -0
  147. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/matchup.py +0 -0
  148. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/player_prior.py +0 -0
  149. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/player_scoring.py +0 -0
  150. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/squad_allocator.py +0 -0
  151. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/team_form.py +0 -0
  152. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/team_ratings.py +0 -0
  153. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/services/team_ratings_prior.py +0 -0
  154. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/templates/gw_preview.md.j2 +0 -0
  155. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/utils/__init__.py +0 -0
  156. {fplkit-1.3.0 → fplkit-1.4.0}/fpl_cli/utils/text.py +0 -0
  157. {fplkit-1.3.0 → fplkit-1.4.0}/pyrightconfig.json +0 -0
  158. {fplkit-1.3.0 → fplkit-1.4.0}/requirements.lock +0 -0
  159. {fplkit-1.3.0 → fplkit-1.4.0}/tests/__init__.py +0 -0
  160. {fplkit-1.3.0 → fplkit-1.4.0}/tests/conftest.py +0 -0
  161. {fplkit-1.3.0 → fplkit-1.4.0}/tests/fixtures/classic_squad_fixture.md +0 -0
  162. {fplkit-1.3.0 → fplkit-1.4.0}/tests/fixtures/recommendations_fixture.md +0 -0
  163. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_action.py +0 -0
  164. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_analysis.py +0 -0
  165. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_analysis_squad.py +0 -0
  166. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_base.py +0 -0
  167. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_data.py +0 -0
  168. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_data_price.py +0 -0
  169. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_agents_orchestration.py +0 -0
  170. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_allocate.py +0 -0
  171. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_client.py +0 -0
  172. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_core_insights.py +0 -0
  173. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_dataset_fetcher.py +0 -0
  174. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_draft.py +0 -0
  175. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_football_data.py +0 -0
  176. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_historical.py +0 -0
  177. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_understat.py +0 -0
  178. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_api_vaastav.py +0 -0
  179. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_bench_order_script.py +0 -0
  180. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_classic_squad_extraction.py +0 -0
  181. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_allocate.py +0 -0
  182. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_banner.py +0 -0
  183. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_captain.py +0 -0
  184. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_chips.py +0 -0
  185. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_credentials.py +0 -0
  186. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_custom_analysis.py +0 -0
  187. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_differentials.py +0 -0
  188. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_fdr.py +0 -0
  189. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_fdr_blanks.py +0 -0
  190. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_fixtures.py +0 -0
  191. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_format.py +0 -0
  192. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_format_suppression.py +0 -0
  193. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_history.py +0 -0
  194. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_init.py +0 -0
  195. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_json.py +0 -0
  196. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_plan_grid.py +0 -0
  197. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_player.py +0 -0
  198. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_price_history.py +0 -0
  199. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_sell_prices.py +0 -0
  200. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_squad.py +0 -0
  201. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_stats.py +0 -0
  202. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_targets.py +0 -0
  203. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_transfer_eval.py +0 -0
  204. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_waivers.py +0 -0
  205. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_warnings_stderr.py +0 -0
  206. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_cli_xg.py +0 -0
  207. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_common.py +0 -0
  208. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_common_scoring.py +0 -0
  209. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_data_prep_harmonisation.py +0 -0
  210. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_fines.py +0 -0
  211. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_fines_config.py +0 -0
  212. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_fixture_predictions.py +0 -0
  213. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_fpl_prices.py +0 -0
  214. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_init.py +0 -0
  215. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_init_fines.py +0 -0
  216. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_matchup_service.py +0 -0
  217. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_models.py +0 -0
  218. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_paths.py +0 -0
  219. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_player_prior.py +0 -0
  220. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_player_resolution.py +0 -0
  221. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_player_scoring.py +0 -0
  222. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_providers.py +0 -0
  223. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_recommendations_parser.py +0 -0
  224. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_rolling_pts_per_m.py +0 -0
  225. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_scraper.py +0 -0
  226. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_season.py +0 -0
  227. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_sell_prices.py +0 -0
  228. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_squad_allocator.py +0 -0
  229. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_starting_xi_agent.py +0 -0
  230. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_stats.py +0 -0
  231. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_team_ratings.py +0 -0
  232. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_team_ratings_prior.py +0 -0
  233. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_transfer_eval_agent.py +0 -0
  234. {fplkit-1.3.0 → fplkit-1.4.0}/tests/test_utils_text.py +0 -0
@@ -414,12 +414,17 @@ Proceed immediately (non-interactive).
414
414
  > **Data (JSON):**
415
415
  > - Status: {A1 output}
416
416
  > - pFDR: {B1 output}
417
+ >
418
+ > **WAIVER POOL IS AUTHORITATIVE:** `fpl waivers` output is the only source for available players. All other data (stats, form tables, squad context) is for analysis only. Never recommend a claim not present in the waivers output — Phase D1 will flag pool misses as a warning. Cross-position recommendations (e.g. dropping a MID to claim a DEF) are structurally illegal and will be blocked by Phase D1.
419
+ >
417
420
  > - Waivers: {B3 output}
418
421
  > - Squad: {B4 output}
419
422
  > - Stats: {stats_form}, {stats_transfer_momentum}, {stats_mid_xgi}, {stats_fwd_xgi}, {stats_def_clean_sheets} (from B8)
420
423
  >
421
424
  > <!-- ADAPT: Add your own supplementary data sources here (newsletters, external reports) -->
422
425
  >
426
+ > Additionally enforce: every waiver swap must be position-for-position (MID out → MID in, DEF out → DEF in, etc.). Cross-position swaps are illegal under FPL Draft rules.
427
+ >
423
428
  > Produce the **Draft** section of the output template.
424
429
 
425
430
  ### C2.5 -- Transfer/Waiver Evaluation
@@ -475,6 +480,56 @@ Present a brief summary to the user:
475
480
 
476
481
  ---
477
482
 
483
+ ## Phase D1: Draft Validation (draft format only - skip if format is "classic")
484
+
485
+ _Runs after Phase D file write, before Phase E. Not embed-gated._
486
+
487
+ 1. Write Phase B's waivers JSON (from orchestrator context, B3 output) to `/tmp/gw-prep-waivers-{N}.json`.
488
+ 2. Write Phase B's draft squad-grid JSON (from orchestrator context, B4 draft output) to `/tmp/gw-prep-squad-grid-{N}.json`.
489
+ - For draft-only runs, this is the `fpl squad grid --format json` output from B4.
490
+ - For `both` format runs, this is the `fpl squad grid --draft --format json` output from B4.
491
+ 3. Run:
492
+
493
+ ```bash
494
+ cd "$FPL_CLI_DIR" && source .venv/bin/activate && python "$FPL_CLI_DIR/.agents/skills/gw-prep/scripts/validate_draft_waivers.py" \
495
+ --recommendations-file "[YOUR_OUTPUT_DIR]/gw{N}-recommendations.md" \
496
+ --waivers-json /tmp/gw-prep-waivers-{N}.json \
497
+ --squad-grid-json /tmp/gw-prep-squad-grid-{N}.json
498
+ ```
499
+
500
+ Parse stdout as JSON: `{"ok": bool, "flags": [...], "warnings": [...]}`.
501
+ If the script cannot be found or exits non-zero unexpectedly, emit a warning and proceed to Phase E (fail-open for infrastructure errors, fail-closed only for confirmed rule violations).
502
+
503
+ 4. **Managed block update:** locate the sentinel block in the recommendations file:
504
+ ```
505
+ <!-- phase-d1:start -->
506
+ ...
507
+ <!-- phase-d1:end -->
508
+ ```
509
+ - If flags or warnings are non-empty: replace (or insert) the block immediately after the `## Draft` (or `## Draft League`) heading line with:
510
+ ```
511
+ <!-- phase-d1:start -->
512
+ > ⚠️ Draft validation:
513
+ > - {one line per flag/warning, e.g. "Row 1: cross-position-claim (Drop: João Pedro FWD → Claim: Pedro Porro DEF)"}
514
+ <!-- phase-d1:end -->
515
+ ```
516
+ - If both are empty: remove the block entirely (including sentinels) if present.
517
+
518
+ 5. **Split posture:**
519
+ - Any flag with `type == "cross-position-claim"` present → emit a **red error** in chat:
520
+ > ❌ Phase D1 blocked: `gw{N}-recommendations.md` contains illegal cross-position waiver swap(s):
521
+ > - Row {N}: Drop `{drop}` ({drop_position}) → Claim `{claim}` ({claim_position})
522
+ > Edit the file to fix the flagged row(s), then re-run `/gw-prep` to continue.
523
+
524
+ **Stop the pipeline.** Do not proceed to Phase E. Do not mark the run as complete.
525
+ - Else (only warnings and/or `waiver-not-in-pool` flags) → emit an in-chat warning:
526
+ > ⚠️ Phase D1: {N} waiver pool miss(es) in `gw{N}-recommendations.md` — see managed block in the Draft section. Claims may be normaliser false positives; verify before submitting waivers.
527
+
528
+ Proceed to Phase E.
529
+ - No flags or warnings → silent continue to Phase E.
530
+
531
+ ---
532
+
478
533
  ## Phase E: Post-write Validation (embed-mode only)
479
534
 
480
535
  _Skip unless `squad_builder_result == "embed"`. Transfer and rederive runs do not produce a `### Classic Squad` block._
@@ -44,6 +44,8 @@ When `mode = "squad-builder"`:
44
44
  3. **Availability likelihood** -- consider whether high-value targets are likely to be claimed by opponents with higher waiver priority.
45
45
  4. **Fixture run** -- as with classic, weight the next 3 gameweeks of fixtures rather than just the immediate one.
46
46
  5. **Maximum 5 waiver suggestions** -- for each: player to drop, player to claim, positional context, and fixture rationale.
47
+ 6. **Position-for-position only** -- FPL Draft squads are locked at 2 GK / 5 DEF / 5 MID / 3 FWD. Every waiver swap must be position-for-position (e.g. MID out → MID in). Cross-position swaps are structurally illegal and will be rejected by Phase D1.
48
+ 7. **Waiver pool discipline** -- only recommend players returned by `fpl waivers --format json`. Players appearing in other data sources (e.g. `fpl stats`) are not necessarily available; never recommend a claim not in the waiver pool.
47
49
 
48
50
  ## Transfer/Waiver Evaluation Script
49
51
 
@@ -72,6 +74,10 @@ Flag the following situations prominently:
72
74
  - **Minutes risk**: started fewer than 2 of last 4 matches
73
75
  - **Price crash**: lost 0.2+ in value over the last 5 days
74
76
 
77
+ ### Grounding rule (no fabricated stats)
78
+
79
+ Momentum Alert bullets must only cite numbers that appear in the CLI/stats output already inlined into the sub-agent prompt. Do not invent derivative framings like "three straight attacking returns", "X goals in Y games", "scored in N consecutive", or "back-to-back doubles" unless the underlying per-GW breakdown is visible in the provided data. When in doubt, stick to the four defined trigger types above plus the raw form number, price delta, minutes count, and injury flag percentage — these are always available. Prose beyond those fields is a fabrication risk and must be cut.
80
+
75
81
  ## pFDR (Positional FDR) Analysis
76
82
 
77
83
  For each position (GK, DEF, MID, FWD):
@@ -80,6 +86,13 @@ For each position (GK, DEF, MID, FWD):
80
86
  2. Identify the 3 worst fixture runs (highest average pFDR).
81
87
  3. Cross-reference with the user's current squad to surface mismatches (e.g. holding defenders with terrible upcoming fixtures).
82
88
 
89
+ ### pFDR Overview row format
90
+
91
+ - **One row per team, not per player.** Multiple squad players from the same team (e.g. Raya + Gabriel at ARS) may share a row because their fixtures are identical.
92
+ - **Never combine players from different teams into a single row.** Their fixture paths diverge after GW1 of the window and the downstream cells cannot be populated. If two same-position players are on different teams, give them separate rows.
93
+ - **Every cell in GW{N}..GW{N+4} must be populated** with either opponent+pFDR or `BLANK` (for blank gameweeks). A `-` in any fixture cell is a bug: re-run `fpl fixtures -g {gw}` for the missing gameweeks.
94
+ - **Avg column** = mean pFDR across the 5 GWs, treating BLANK as excluded from the denominator.
95
+
83
96
  ## Selection Requirements
84
97
 
85
98
  Every recommendation must include:
@@ -0,0 +1,466 @@
1
+ #!/usr/bin/env python3
2
+ """Validate Draft waiver recommendations against the live waiver pool and squad-grid.
3
+
4
+ Reads a gw{N}-recommendations.md file, locates the ## Draft (or ## Draft League)
5
+ section, parses the waiver table, and cross-checks each row against:
6
+ - waivers JSON: claim must be present in the pool (waiver-not-in-pool)
7
+ - squad-grid JSON: drop position must match claim position (cross-position-claim)
8
+
9
+ Emits JSON to stdout: {"ok": bool, "flags": [...], "warnings": [...]}
10
+ Exit code is always 0; the orchestrator (Phase D1) interprets flag types for posture.
11
+
12
+ Usage:
13
+ python3 validate_draft_waivers.py \\
14
+ --recommendations-file path/to/gw34-recommendations.md \\
15
+ --waivers-json /tmp/waivers.json \\
16
+ --squad-grid-json /tmp/squad-grid.json
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import argparse
22
+ import json
23
+ import re
24
+ import sys
25
+ import unicodedata
26
+ from typing import TypedDict
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Types
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ class Flag(TypedDict, total=False):
34
+ type: str
35
+ row: int
36
+ claim: str
37
+ drop: str
38
+ drop_position: str
39
+ claim_position: str
40
+
41
+
42
+ class Warning(TypedDict, total=False):
43
+ type: str
44
+ name: str
45
+
46
+
47
+ class ValidatorResult(TypedDict):
48
+ ok: bool
49
+ flags: list[Flag]
50
+ warnings: list[Warning]
51
+
52
+
53
+ class PoolEntry(TypedDict):
54
+ player_name: str
55
+ position: str
56
+ team_short: str
57
+
58
+
59
+ class SquadEntry(TypedDict):
60
+ player: str
61
+ position: str
62
+ team: str
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Normalisation
67
+ # ---------------------------------------------------------------------------
68
+
69
+ _APOSTROPHE_RE = re.compile(r"[‘’ʼʹ`]")
70
+ _HYPHEN_RE = re.compile(r"[–—‒‐‑]")
71
+ _TEAM_SUFFIX_RE = re.compile(r"\s*\([A-Z]{2,4}\)\s*$")
72
+ _MARKDOWN_WRAP_RE = re.compile(r"^[\*_`]+|[\*_`]+$")
73
+ _WHITESPACE_RE = re.compile(r"\s+")
74
+
75
+
76
+ def normalise(name: str) -> str:
77
+ """Return a canonical key for name matching."""
78
+ s = name.strip()
79
+ # strip markdown bold/italic/code wrapping
80
+ s = _MARKDOWN_WRAP_RE.sub("", s).strip()
81
+ # strip trailing team suffix like "(CHE)" or "(TOT)"
82
+ s = _TEAM_SUFFIX_RE.sub("", s).strip()
83
+ # NFKD decompose then drop combining marks
84
+ s = unicodedata.normalize("NFKD", s)
85
+ s = "".join(c for c in s if not unicodedata.category(c).startswith("M"))
86
+ # fold apostrophe variants
87
+ s = _APOSTROPHE_RE.sub("'", s)
88
+ # fold hyphen/dash variants
89
+ s = _HYPHEN_RE.sub("-", s)
90
+ # fold dots to space (handles J.Pedro vs J Pedro, B.Fernandes vs B Fernandes)
91
+ s = s.replace(".", " ")
92
+ # casefold
93
+ s = s.casefold()
94
+ # collapse whitespace
95
+ s = _WHITESPACE_RE.sub(" ", s).strip()
96
+ return s
97
+
98
+
99
+ _PLACEHOLDER_VALUES = frozenset({"-", "—", "–", "", "hold", "(depth)", "tbd", "n/a"})
100
+
101
+
102
+ def _is_placeholder(raw: str) -> bool:
103
+ key = normalise(raw)
104
+ return key in _PLACEHOLDER_VALUES or (key.startswith("(") and key.endswith(")"))
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Pool / squad builders
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ def _shape_check_squad(data: dict) -> bool:
113
+ """Return True if squad-grid JSON has the expected shape."""
114
+ try:
115
+ rows = data["data"]
116
+ if not rows:
117
+ return True
118
+ sample = rows[0]
119
+ return "player" in sample and "position" in sample
120
+ except (KeyError, IndexError, TypeError):
121
+ return False
122
+
123
+
124
+ def _shape_check_waivers(data: dict) -> bool:
125
+ """Return True if waivers JSON has the expected shape."""
126
+ try:
127
+ targets = data["data"]["top_targets"]
128
+ if not isinstance(targets, list):
129
+ return False
130
+ if not targets:
131
+ return True
132
+ sample = targets[0]
133
+ return "player_name" in sample and "position" in sample
134
+ except (KeyError, TypeError):
135
+ return False
136
+
137
+
138
+ def build_pool(waivers_data: dict) -> dict[tuple[str, str], PoolEntry]:
139
+ """Build {(position, normalised_name) -> entry} from waivers JSON."""
140
+ pool: dict[tuple[str, str], PoolEntry] = {}
141
+ for entry in waivers_data.get("data", {}).get("top_targets", []):
142
+ pos = entry.get("position", "")
143
+ key = (pos, normalise(entry.get("player_name", "")))
144
+ pool[key] = entry
145
+ return pool
146
+
147
+
148
+ def build_squad(squad_data: dict) -> dict[tuple[str, str], SquadEntry]:
149
+ """Build {(position, normalised_name) -> entry} from squad-grid JSON."""
150
+ squad: dict[tuple[str, str], SquadEntry] = {}
151
+ for entry in squad_data.get("data", []):
152
+ pos = entry.get("position", "")
153
+ key = (pos, normalise(entry.get("player", "")))
154
+ squad[key] = entry
155
+ return squad
156
+
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Section / table parsing
160
+ # ---------------------------------------------------------------------------
161
+
162
+ _DRAFT_HEADING_RE = re.compile(r"^## Draft(?: League)?\s*$")
163
+ _WAIVER_SUBHEADING_RE = re.compile(r"^###\s+Waiver Recommendations\s*$", re.IGNORECASE)
164
+
165
+
166
+ def locate_draft_section(lines: list[str]) -> tuple[int, int] | None:
167
+ """Return (start, end) line indices for the ## Draft / ## Draft League section."""
168
+ start: int | None = None
169
+ for i, line in enumerate(lines):
170
+ if _DRAFT_HEADING_RE.match(line):
171
+ start = i
172
+ break
173
+ if start is None:
174
+ return None
175
+ # end is next ## heading or EOF
176
+ for i in range(start + 1, len(lines)):
177
+ if re.match(r"^## \S", lines[i]):
178
+ return (start, i)
179
+ return (start, len(lines))
180
+
181
+
182
+ def locate_waiver_subsection(section_lines: list[str]) -> list[str]:
183
+ """Return the slice of section_lines bounded by ### Waiver Recommendations.
184
+
185
+ Bounds end at the next ### or ## heading. Falls back to the full section if
186
+ the subheading is absent (older report formats).
187
+ """
188
+ start: int | None = None
189
+ for i, line in enumerate(section_lines):
190
+ if _WAIVER_SUBHEADING_RE.match(line):
191
+ start = i + 1
192
+ break
193
+ if start is None:
194
+ return section_lines
195
+ for i in range(start, len(section_lines)):
196
+ if re.match(r"^##+\s+\S", section_lines[i]):
197
+ return section_lines[start:i]
198
+ return section_lines[start:]
199
+
200
+
201
+ def parse_waiver_table(
202
+ section_lines: list[str],
203
+ ) -> tuple[list[str], list[dict[str, str]]] | None:
204
+ """Parse the first markdown table in the section.
205
+
206
+ Returns (headers, rows) where rows are dicts keyed by header name,
207
+ or None if no table found.
208
+ """
209
+ headers: list[str] | None = None
210
+ rows: list[dict[str, str]] = []
211
+
212
+ lines_iter = list(section_lines)
213
+ for idx, line in enumerate(lines_iter):
214
+ stripped = line.strip()
215
+ if not stripped.startswith("|"):
216
+ # End table at first non-pipe line after rows started (e.g. blank line
217
+ # before the next ### subsection's table).
218
+ if rows:
219
+ break
220
+ continue
221
+ if headers is None:
222
+ # first pipe-delimited line is the header row
223
+ parts = [p.strip() for p in stripped.strip("|").split("|")]
224
+ headers = parts
225
+ continue
226
+ # skip separator row
227
+ if re.match(r"^\|[-| ]+\|", stripped):
228
+ continue
229
+ # Detect a new header row stacked directly after the current table:
230
+ # current line is pipe-delimited and the next line is a separator. Break
231
+ # before consuming it as a data row.
232
+ if rows and idx + 1 < len(lines_iter):
233
+ next_stripped = lines_iter[idx + 1].strip()
234
+ if re.match(r"^\|[-| ]+\|", next_stripped):
235
+ break
236
+ parts = [p.strip() for p in stripped.strip("|").split("|")]
237
+ row: dict[str, str] = {}
238
+ for i, h in enumerate(headers):
239
+ row[h] = parts[i] if i < len(parts) else ""
240
+ rows.append(row)
241
+
242
+ if headers is None:
243
+ return None
244
+ return headers, rows
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Name resolution
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def _resolve_in_pool(
253
+ name: str, position: str, pool: dict[tuple[str, str], PoolEntry]
254
+ ) -> PoolEntry | None:
255
+ """Exact match then last-token fallback, both position-scoped."""
256
+ norm = normalise(name)
257
+ # exact
258
+ if (position, norm) in pool:
259
+ return pool[(position, norm)]
260
+ # last-token fallback: check if norm equals last token of any pool entry at this pos
261
+ for (pos, pool_norm), entry in pool.items():
262
+ if pos != position:
263
+ continue
264
+ if pool_norm.split()[-1] == norm:
265
+ return entry
266
+ # also handle: pool entry is short, rec file has full name
267
+ if norm.split()[-1] == pool_norm:
268
+ return entry
269
+ return None
270
+
271
+
272
+ def _resolve_in_squad(
273
+ name: str, squad: dict[tuple[str, str], SquadEntry]
274
+ ) -> list[SquadEntry]:
275
+ """Return all squad entries matching name (exact, else last-token fallback).
276
+
277
+ Exact matches short-circuit the fallback. Callers inspect the list length and
278
+ the set of positions to decide whether the match is unambiguous.
279
+ """
280
+ norm = normalise(name)
281
+ exact = [entry for (_, squad_norm), entry in squad.items() if squad_norm == norm]
282
+ if exact:
283
+ return exact
284
+ return [
285
+ entry
286
+ for (_, squad_norm), entry in squad.items()
287
+ if squad_norm.split()[-1] == norm or norm.split()[-1] == squad_norm
288
+ ]
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Core validation
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def validate_rows(
297
+ rows: list[dict[str, str]],
298
+ pool: dict[tuple[str, str], PoolEntry],
299
+ squad: dict[tuple[str, str], SquadEntry],
300
+ squad_shape_ok: bool,
301
+ waivers_shape_ok: bool = True,
302
+ ) -> tuple[list[Flag], list[Warning]]:
303
+ flags: list[Flag] = []
304
+ warnings: list[Warning] = []
305
+
306
+ for i, row in enumerate(rows, start=1):
307
+ drop_raw = row.get("Drop", "")
308
+ claim_raw = row.get("Claim", "")
309
+ position = row.get("Position", "").strip()
310
+
311
+ if _is_placeholder(drop_raw) or _is_placeholder(claim_raw):
312
+ continue
313
+
314
+ # Resolve claim in pool (position-scoped). Skip when waivers shape is unknown —
315
+ # an empty pool would otherwise mark every real claim as a false positive.
316
+ claim_entry: PoolEntry | None = None
317
+ if waivers_shape_ok:
318
+ claim_entry = _resolve_in_pool(claim_raw, position, pool)
319
+ if claim_entry is None:
320
+ flags.append(
321
+ Flag(
322
+ type="waiver-not-in-pool",
323
+ row=i,
324
+ claim=claim_raw,
325
+ )
326
+ )
327
+
328
+ # Resolve drop in squad (all positions, to find actual position)
329
+ if not squad_shape_ok:
330
+ continue
331
+ drop_candidates = _resolve_in_squad(drop_raw, squad)
332
+ if not drop_candidates:
333
+ warnings.append(Warning(type="drop-not-resolvable", name=drop_raw))
334
+ continue
335
+ drop_positions = {e["position"] for e in drop_candidates}
336
+ if len(drop_positions) > 1:
337
+ # Ambiguous last-token match across positions — can't determine drop_pos safely
338
+ warnings.append(Warning(type="drop-ambiguous-match", name=drop_raw))
339
+ continue
340
+ drop_entry = drop_candidates[0]
341
+
342
+ # Cross-position check
343
+ claim_pos = claim_entry["position"] if claim_entry else position
344
+ drop_pos = drop_entry["position"]
345
+ if drop_pos != claim_pos:
346
+ flags.append(
347
+ Flag(
348
+ type="cross-position-claim",
349
+ row=i,
350
+ drop=drop_raw,
351
+ drop_position=drop_pos,
352
+ claim=claim_raw,
353
+ claim_position=claim_pos,
354
+ )
355
+ )
356
+
357
+ return flags, warnings
358
+
359
+
360
+ # ---------------------------------------------------------------------------
361
+ # Entry point
362
+ # ---------------------------------------------------------------------------
363
+
364
+
365
+ def _run(recommendations_file: str, waivers_json: str, squad_grid_json: str) -> None:
366
+ flags: list[Flag] = []
367
+ warnings: list[Warning] = []
368
+
369
+ # Load waivers
370
+ with open(waivers_json, encoding="utf-8") as f:
371
+ waivers_data = json.load(f)
372
+
373
+ # Load squad-grid
374
+ with open(squad_grid_json, encoding="utf-8") as f:
375
+ squad_data = json.load(f)
376
+
377
+ # Shape checks
378
+ squad_shape_ok = _shape_check_squad(squad_data)
379
+ if not squad_shape_ok:
380
+ warnings.append(Warning(type="squad-grid-shape-unknown"))
381
+
382
+ waivers_shape_ok = _shape_check_waivers(waivers_data)
383
+ if not waivers_shape_ok:
384
+ warnings.append(Warning(type="waivers-json-shape-unknown"))
385
+
386
+ pool = build_pool(waivers_data) if waivers_shape_ok else {}
387
+ squad = build_squad(squad_data) if squad_shape_ok else {}
388
+
389
+ # Load recommendations
390
+ with open(recommendations_file, encoding="utf-8") as f:
391
+ content = f.read()
392
+ lines = content.split("\n")
393
+
394
+ # Locate draft section
395
+ section_range = locate_draft_section(lines)
396
+ if section_range is None:
397
+ warnings.append(Warning(type="draft-section-not-found"))
398
+ result: ValidatorResult = {"ok": True, "flags": [], "warnings": warnings}
399
+ json.dump(result, sys.stdout)
400
+ return
401
+
402
+ start, end = section_range
403
+ section_lines = lines[start:end]
404
+
405
+ # Narrow to the ### Waiver Recommendations subsection so we don't ingest
406
+ # rows from the Starting XI / Bench tables that follow it.
407
+ waiver_lines = locate_waiver_subsection(section_lines)
408
+
409
+ # Parse waiver table
410
+ table = parse_waiver_table(waiver_lines)
411
+ if table is None:
412
+ warnings.append(Warning(type="waiver-table-not-found"))
413
+ result = {"ok": True, "flags": [], "warnings": warnings}
414
+ json.dump(result, sys.stdout)
415
+ return
416
+
417
+ headers, rows = table
418
+ if "Drop" not in headers or "Claim" not in headers:
419
+ warnings.append(Warning(type="drop-claim-columns-not-found"))
420
+ result = {"ok": True, "flags": [], "warnings": warnings}
421
+ json.dump(result, sys.stdout)
422
+ return
423
+
424
+ # Validate rows
425
+ row_flags, row_warnings = validate_rows(
426
+ rows, pool, squad, squad_shape_ok, waivers_shape_ok
427
+ )
428
+ flags.extend(row_flags)
429
+ warnings.extend(row_warnings)
430
+
431
+ ok = len(flags) == 0
432
+ result = {"ok": ok, "flags": flags, "warnings": warnings}
433
+ json.dump(result, sys.stdout)
434
+
435
+
436
+ def main() -> None:
437
+ parser = argparse.ArgumentParser(
438
+ description="Validate Draft waiver recommendations against the waiver pool and squad.",
439
+ )
440
+ parser.add_argument(
441
+ "--recommendations-file",
442
+ required=True,
443
+ metavar="PATH",
444
+ dest="recommendations_file",
445
+ help="Path to gw{N}-recommendations.md.",
446
+ )
447
+ parser.add_argument(
448
+ "--waivers-json",
449
+ required=True,
450
+ metavar="PATH",
451
+ dest="waivers_json",
452
+ help="Path to fpl waivers --format json output.",
453
+ )
454
+ parser.add_argument(
455
+ "--squad-grid-json",
456
+ required=True,
457
+ metavar="PATH",
458
+ dest="squad_grid_json",
459
+ help="Path to fpl squad grid --format json output (draft squad).",
460
+ )
461
+ args = parser.parse_args()
462
+ _run(args.recommendations_file, args.waivers_json, args.squad_grid_json)
463
+
464
+
465
+ if __name__ == "__main__":
466
+ main()
@@ -18,6 +18,24 @@ You are an FPL mate - the kind who's run five mini-leagues, watches xG religious
18
18
  - No emojis unless requested
19
19
  - No time estimates
20
20
 
21
+ ## Decision Heuristics
22
+
23
+ Never reason from a wrong rule. If you're unsure about a scoring or mechanics detail, say so and check `docs/fpl-rules.md` - don't guess.
24
+
25
+ - **Banking the FT is a live option.** FTs bank up to 5. Skipping a transfer buys flexibility next week, not wasted currency. Always weigh it against a marginal move.
26
+ - **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.
27
+ - **Hit threshold: >8 pts expected gain** across the planning horizon before eating a -4.
28
+ - **Bench Boost = all 15 score once (not 2x).** Floor-raiser via bench minutes, not a multiplier.
29
+ - **Triple Captain = captain scores 3x (not 2x).**
30
+ - **Chip availability:** each chip twice per season, split at GW19. Check `fpl chips` before assuming.
31
+
32
+ ## Draft Rules (load-bearing)
33
+
34
+ - **Waiver claims must be position-for-position.** Squad composition is fixed at 2 GK / 5 DEF / 5 MID / 3 FWD. Dropping a FWD means claiming a FWD, full stop. No cross-position swaps.
35
+ - **Waiver pool is only what `fpl waivers` returns.** Never recommend a player who isn't in that output, even if they look great in `fpl stats`. Premium names (Haaland, Salah, Palmer etc.) are almost always owned - check the waiver JSON, don't assume.
36
+ - **If no player is available at the position you need, say so.** Park the dead-weight asset and focus waiver priority elsewhere. Don't manufacture a bad cross-position claim to fill the slot.
37
+ - **Draft has no captain, no budget, no chips, no transfers.** Only waivers and trades.
38
+
21
39
  ## FPL Expertise
22
40
 
23
41
  You have strong opinions and will argue them:
@@ -46,6 +46,9 @@ For a complete inventory of CLI commands, analysis agents, and skills with JSON
46
46
  ### Exception Handling
47
47
  - Never use bare `except Exception`. Use specific types for narrow try blocks; use `# noqa: BLE001 — <justification>` for intentional broad handlers (agent top-level, scraper resilience, graceful degradation)
48
48
 
49
+ ### Timestamps
50
+ - User-facing timestamps (deadlines, kickoffs, `generated_at` stamps) must route through `fpl_cli/utils/time.py` (`format_deadline`, `format_kickoff`, `format_generated_at`, `now_uk`). Never `strftime` on a naive `datetime.now()` or print raw API ISO strings to users. Tool is UK-locked: display is always `Europe/London` with GMT/BST label. Internal datetime math stays UTC.
51
+
49
52
  ## FPL Domain Knowledge
50
53
  - Chips (each available **twice** per season, split at GW19 deadline): Wildcard, Free Hit, Bench Boost, Triple Captain
51
54
  - Scoring: GK/DEF clean sheet = 4pts, MID = 1pt; goals: DEF=6, MID=5, FWD=4; assist = 3pts; yellow = -1; red = -3
@@ -1,6 +1,31 @@
1
1
  # Changelog
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
+ ## [1.3.0] - 2026-04-17
5
+
6
+ ### Bug Fixes
7
+
8
+ - correct stats key names and draft trailing heading (#19)
9
+ - inject explicit chip roster into synthesis prompt
10
+ - six LLM/formatting fixes for gw review output (#26)
11
+ - use custom team-ratings FDR in GW fixtures table when custom_analysis enabled
12
+ - drop understat position from enrichment spread
13
+
14
+ ### Features
15
+
16
+ - embed squad-builder output on wildcard/freehit weeks (#23)
17
+ - surface active chips in recap narrative
18
+ - detect bench boost chip and adjust analysis emphasis
19
+ - chip-aware analysis for WC, FH, BB, and TC (#24)
20
+ - WhatsApp-friendly standings text block (#25)
21
+
22
+ ### Refactoring
23
+
24
+ - consolidate ownership ceiling dispatch into single helper
25
+ - thread Position literal through scoring pipeline (#21)
26
+ - rename horizon=1 output field to single_gw_score (#22)
27
+ - tighten project SKILL.md prompt contract and phase ordering
28
+
4
29
  ## [1.2.1] - 2026-04-10
5
30
 
6
31
  ### Bug Fixes
@@ -46,6 +46,9 @@ For a complete inventory of CLI commands, analysis agents, and skills with JSON
46
46
  ### Exception Handling
47
47
  - Never use bare `except Exception`. Use specific types for narrow try blocks; use `# noqa: BLE001 — <justification>` for intentional broad handlers (agent top-level, scraper resilience, graceful degradation)
48
48
 
49
+ ### Timestamps
50
+ - User-facing timestamps (deadlines, kickoffs, `generated_at` stamps) must route through `fpl_cli/utils/time.py` (`format_deadline`, `format_kickoff`, `format_generated_at`, `now_uk`). Never `strftime` on a naive `datetime.now()` or print raw API ISO strings to users. Tool is UK-locked: display is always `Europe/London` with GMT/BST label. Internal datetime math stays UTC.
51
+
49
52
  ## FPL Domain Knowledge
50
53
  - Chips (each available **twice** per season, split at GW19 deadline): Wildcard, Free Hit, Bench Boost, Triple Captain
51
54
  - Scoring: GK/DEF clean sheet = 4pts, MID = 1pt; goals: DEF=6, MID=5, FWD=4; assist = 3pts; yellow = -1; red = -3
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fplkit
3
- Version: 1.3.0
3
+ Version: 1.4.0
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
@@ -19,6 +19,7 @@ Requires-Dist: python-dotenv>=1.0.0
19
19
  Requires-Dist: pyyaml>=6.0.0
20
20
  Requires-Dist: rich>=13.7.0
21
21
  Requires-Dist: ruamel-yaml>=0.18.0
22
+ Requires-Dist: tzdata>=2024.1; platform_system == 'Windows'
22
23
  Provides-Extra: dev
23
24
  Requires-Dist: pyright>=1.1.0; extra == 'dev'
24
25
  Requires-Dist: pytest-asyncio>=0.23.0; extra == 'dev'
@@ -551,7 +551,10 @@ fpl_cli/
551
551
  │ └── fpl_prices.py # FPLPriceScraper (needs FPL_EMAIL/FPL_PASSWORD)
552
552
  ├── paths.py # SHIPPED_CONFIG_DIR, TEMPLATE_DIR, user_config_dir(), user_data_dir()
553
553
  ├── season.py # season_label() (+ vaastav_season() alias), TOTAL_GAMEWEEKS, CHIP_SPLIT_GW
554
- └── constants.py # MIN_MINUTES_FOR_PER90
554
+ ├── constants.py # MIN_MINUTES_FOR_PER90
555
+ └── utils/
556
+ ├── text.py # strip_diacritics (name matching across sources)
557
+ └── time.py # format_deadline/format_kickoff/format_generated_at — UK local (Europe/London, auto GMT↔BST). Canonical formatter for every user-facing timestamp.
555
558
 
556
559
  platformdirs (user_config_dir / user_data_dir) # macOS: ~/Library/Application Support/fpl-cli/
557
560
  ├── settings.yaml # User overrides, created by `fpl init`