redeploy 0.2.53__tar.gz → 0.2.55__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 (222) hide show
  1. {redeploy-0.2.53 → redeploy-0.2.55}/PKG-INFO +6 -6
  2. {redeploy-0.2.53 → redeploy-0.2.55}/README.md +5 -5
  3. {redeploy-0.2.53 → redeploy-0.2.55}/pyproject.toml +1 -1
  4. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/__init__.py +1 -1
  5. redeploy-0.2.55/redeploy/heal/__init__.py +37 -0
  6. redeploy-0.2.55/redeploy/heal/decider.py +85 -0
  7. redeploy-0.2.55/redeploy/heal/hint_provider.py +203 -0
  8. redeploy-0.2.55/redeploy/heal/log_writer.py +51 -0
  9. redeploy-0.2.55/redeploy/heal/loop_detector.py +61 -0
  10. redeploy-0.2.55/redeploy/heal/runner.py +224 -0
  11. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/heal.py +95 -64
  12. redeploy-0.2.55/redeploy/tests/test_heal.py +83 -0
  13. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/PKG-INFO +6 -6
  14. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/SOURCES.txt +8 -0
  15. redeploy-0.2.55/tests/test_heal_decider.py +63 -0
  16. redeploy-0.2.55/tests/test_heal_loop_detector.py +47 -0
  17. redeploy-0.2.53/redeploy/tests/test_heal.py +0 -26
  18. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/__init__.py +0 -0
  19. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/exceptions.py +0 -0
  20. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/executor.py +0 -0
  21. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/handlers.py +0 -0
  22. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/progress.py +0 -0
  23. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/rollback.py +0 -0
  24. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/state.py +0 -0
  25. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/state_apply.py +0 -0
  26. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/utils/__init__.py +0 -0
  27. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/apply/utils/run_container_build.py +0 -0
  28. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/audit.py +0 -0
  29. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/__init__.py +0 -0
  30. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/extractor.py +0 -0
  31. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/generators/__init__.py +0 -0
  32. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/generators/docker_compose.py +0 -0
  33. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/generators/migration.py +0 -0
  34. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/__init__.py +0 -0
  35. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/compose.py +0 -0
  36. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/hardware.py +0 -0
  37. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/infra.py +0 -0
  38. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/blueprint/sources/migration.py +0 -0
  39. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/__init__.py +0 -0
  40. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/__init__.py +0 -0
  41. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/audit.py +0 -0
  42. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/blueprint.py +0 -0
  43. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/bump_fix.py +0 -0
  44. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/detect.py +0 -0
  45. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/device_map.py +0 -0
  46. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/device_map_renderers.py +0 -0
  47. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/devices.py +0 -0
  48. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/diagnose.py +0 -0
  49. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/diff.py +0 -0
  50. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/exec_.py +0 -0
  51. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/export.py +0 -0
  52. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/hardware.py +0 -0
  53. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/import_.py +0 -0
  54. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/init.py +0 -0
  55. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/inspect.py +0 -0
  56. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/mcp_cmd.py +0 -0
  57. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/patterns.py +0 -0
  58. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/plan_apply.py +0 -0
  59. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/plugin.py +0 -0
  60. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/probe.py +0 -0
  61. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/prompt_cmd.py +0 -0
  62. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/push.py +0 -0
  63. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/state.py +0 -0
  64. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/status.py +0 -0
  65. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/target.py +0 -0
  66. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/__init__.py +0 -0
  67. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/commands.py +0 -0
  68. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/helpers.py +0 -0
  69. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/monorepo.py +0 -0
  70. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/release.py +0 -0
  71. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/version/scanner.py +0 -0
  72. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/commands/workflow.py +0 -0
  73. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/core.py +0 -0
  74. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/display.py +0 -0
  75. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli/query.py +0 -0
  76. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/cli.py +0 -0
  77. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/config_apply/__init__.py +0 -0
  78. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/config_apply/applier.py +0 -0
  79. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/config_apply/loader.py +0 -0
  80. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/data_sync.py +0 -0
  81. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/__init__.py +0 -0
  82. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/builtin/__init__.py +0 -0
  83. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/detector.py +0 -0
  84. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/hardware.py +0 -0
  85. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/hardware_rules.py +0 -0
  86. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/probes.py +0 -0
  87. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/remote.py +0 -0
  88. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/templates.py +0 -0
  89. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/detect/workflow.py +0 -0
  90. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/discovery.py +0 -0
  91. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl/__init__.py +0 -0
  92. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl/loader.py +0 -0
  93. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl/parser.py +0 -0
  94. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/__init__.py +0 -0
  95. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/context.py +0 -0
  96. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/decorators.py +0 -0
  97. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/docker_steps.py +0 -0
  98. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/exceptions.py +0 -0
  99. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/runner.py +0 -0
  100. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/dsl_python/steps.py +0 -0
  101. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/fleet.py +0 -0
  102. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/__init__.py +0 -0
  103. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/config_txt.py +0 -0
  104. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/__init__.py +0 -0
  105. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/hyperpixel.py +0 -0
  106. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/official.py +0 -0
  107. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/data/waveshare.py +0 -0
  108. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/fixes.py +0 -0
  109. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/__init__.py +0 -0
  110. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/autostart.py +0 -0
  111. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/browsers.py +0 -0
  112. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/compositors.py +0 -0
  113. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/kiosk/output_profiles.py +0 -0
  114. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/panels.py +0 -0
  115. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/hardware/raspi_config.py +0 -0
  116. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/__init__.py +0 -0
  117. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/base.py +0 -0
  118. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/config_hints.py +0 -0
  119. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/docker_compose.py +0 -0
  120. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/parsers/__init__.py +0 -0
  121. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/parsers/compose.py +0 -0
  122. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/iac/registry.py +0 -0
  123. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/integrations/__init__.py +0 -0
  124. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/integrations/op3_bridge.py +0 -0
  125. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/__init__.py +0 -0
  126. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/compiler.py +0 -0
  127. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/models.py +0 -0
  128. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/markpact/parser.py +0 -0
  129. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/mcp_server.py +0 -0
  130. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/models.py +0 -0
  131. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/observe.py +0 -0
  132. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/parse.py +0 -0
  133. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/patterns.py +0 -0
  134. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plan/__init__.py +0 -0
  135. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plan/planner.py +0 -0
  136. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/__init__.py +0 -0
  137. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/__init__.py +0 -0
  138. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/browser_reload.py +0 -0
  139. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/hardware_diagnostic.py +0 -0
  140. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/notify.py +0 -0
  141. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/process_control.py +0 -0
  142. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/plugins/builtin/systemd_reload.py +0 -0
  143. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/schema.py +0 -0
  144. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/spec_loader.py +0 -0
  145. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/ssh.py +0 -0
  146. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/steps/__init__.py +0 -0
  147. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/steps/builtins.py +0 -0
  148. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/steps/kiosk.py +0 -0
  149. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/__init__.py +0 -0
  150. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_audit.py +0 -0
  151. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_cli.py +0 -0
  152. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_data_sync.py +0 -0
  153. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_discovery.py +0 -0
  154. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_dsl.py +0 -0
  155. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_examples.py +0 -0
  156. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_executor.py +0 -0
  157. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_fleet.py +0 -0
  158. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_git_integration.py +0 -0
  159. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_hooks.py +0 -0
  160. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_iac.py +0 -0
  161. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_manifest.py +0 -0
  162. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_markpact_compiler.py +0 -0
  163. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_markpact_parser.py +0 -0
  164. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_models.py +0 -0
  165. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_observe.py +0 -0
  166. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_parse.py +0 -0
  167. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_patterns.py +0 -0
  168. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_planner_edge.py +0 -0
  169. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_plugins.py +0 -0
  170. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_probes.py +0 -0
  171. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_public_api.py +0 -0
  172. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_resume.py +0 -0
  173. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_spec_loader.py +0 -0
  174. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_ssh.py +0 -0
  175. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_templates.py +0 -0
  176. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_verify.py +0 -0
  177. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version.py +0 -0
  178. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_changelog.py +0 -0
  179. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_cli.py +0 -0
  180. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_manifest.py +0 -0
  181. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_version_sources.py +0 -0
  182. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/tests/test_workflow.py +0 -0
  183. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/verify.py +0 -0
  184. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/__init__.py +0 -0
  185. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/bump.py +0 -0
  186. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/changelog.py +0 -0
  187. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/commits.py +0 -0
  188. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/diff.py +0 -0
  189. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/git_integration.py +0 -0
  190. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/git_transaction.py +0 -0
  191. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/manifest.py +0 -0
  192. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/__init__.py +0 -0
  193. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/base.py +0 -0
  194. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/json_.py +0 -0
  195. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/plain.py +0 -0
  196. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/regex.py +0 -0
  197. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/toml_.py +0 -0
  198. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/sources/yaml_.py +0 -0
  199. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version/transaction.py +0 -0
  200. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy/version.py +0 -0
  201. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/dependency_links.txt +0 -0
  202. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/entry_points.txt +0 -0
  203. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/requires.txt +0 -0
  204. {redeploy-0.2.53 → redeploy-0.2.55}/redeploy.egg-info/top_level.txt +0 -0
  205. {redeploy-0.2.53 → redeploy-0.2.55}/setup.cfg +0 -0
  206. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_blueprint_sources.py +0 -0
  207. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_blueprint_sources_compose.py +0 -0
  208. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_cli_query.py +0 -0
  209. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_example_argocd_flux_plugin.py +0 -0
  210. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_example_gitops_ci_plugin.py +0 -0
  211. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_example_helm_kustomize_plugin.py +0 -0
  212. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_hardware_config.py +0 -0
  213. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_hardware_kiosk.py +0 -0
  214. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_hardware_rules.py +0 -0
  215. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_iac.py +0 -0
  216. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_iac_config_hints.py +0 -0
  217. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_iac_plugin_loading.py +0 -0
  218. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_import_plugin_template.py +0 -0
  219. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_parse.py +0 -0
  220. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_prompt_llm.py +0 -0
  221. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_redeploy.py +0 -0
  222. {redeploy-0.2.53 → redeploy-0.2.55}/tests/test_steps_kiosk.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: redeploy
3
- Version: 0.2.53
3
+ Version: 0.2.55
4
4
  Summary: Infrastructure migration toolkit: detect → plan → apply
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
@@ -32,17 +32,17 @@ Requires-Dist: mcp>=1.0; extra == "mcp"
32
32
 
33
33
  ## AI Cost Tracking
34
34
 
35
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.53-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-28.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
35
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.55-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
36
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-28.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
37
37
 
38
- - 🤖 **LLM usage:** $7.5000 (73 commits)
39
- - 👤 **Human dev:** ~$2802 (28.0h @ $100/h, 30min dedup)
38
+ - 🤖 **LLM usage:** $7.5000 (75 commits)
39
+ - 👤 **Human dev:** ~$2853 (28.5h @ $100/h, 30min dedup)
40
40
 
41
41
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
42
42
 
43
43
  ---
44
44
 
45
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.53-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
45
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.55-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
46
46
 
47
47
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
48
48
 
@@ -3,17 +3,17 @@
3
3
 
4
4
  ## AI Cost Tracking
5
5
 
6
- ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.53-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
- ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-28.0h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
6
+ ![PyPI](https://img.shields.io/badge/pypi-costs-blue) ![Version](https://img.shields.io/badge/version-0.2.55-blue) ![Python](https://img.shields.io/badge/python-3.9+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
7
+ ![AI Cost](https://img.shields.io/badge/AI%20Cost-$7.50-orange) ![Human Time](https://img.shields.io/badge/Human%20Time-28.5h-blue) ![Model](https://img.shields.io/badge/Model-openrouter%2Fqwen%2Fqwen3--coder--next-lightgrey)
8
8
 
9
- - 🤖 **LLM usage:** $7.5000 (73 commits)
10
- - 👤 **Human dev:** ~$2802 (28.0h @ $100/h, 30min dedup)
9
+ - 🤖 **LLM usage:** $7.5000 (75 commits)
10
+ - 👤 **Human dev:** ~$2853 (28.5h @ $100/h, 30min dedup)
11
11
 
12
12
  Generated on 2026-04-21 using [openrouter/qwen/qwen3-coder-next](https://openrouter.ai/qwen/qwen3-coder-next)
13
13
 
14
14
  ---
15
15
 
16
- ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.53-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
16
+ ![PyPI](https://img.shields.io/badge/pypi-redeploy-blue) ![Version](https://img.shields.io/badge/version-0.2.55-blue) ![Python](https://img.shields.io/badge/python-3.10+-blue) ![License](https://img.shields.io/badge/license-Apache--2.0-green)
17
17
 
18
18
  Infrastructure migration and device deploy toolkit — VPS, Raspberry Pi kiosk, Podman Quadlet, k3s.
19
19
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "redeploy"
7
- version = "0.2.53"
7
+ version = "0.2.55"
8
8
  description = "Infrastructure migration toolkit: detect → plan → apply"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -7,7 +7,7 @@ Public API (stable from 0.2.0, semver guaranteed)::
7
7
 
8
8
  Everything not listed in ``__all__`` is internal and may change without notice.
9
9
  """
10
- __version__ = "0.2.53"
10
+ __version__ = "0.2.55"
11
11
 
12
12
  # ── Core models ───────────────────────────────────────────────────────────────
13
13
  from .models import ( # noqa: F401
@@ -0,0 +1,37 @@
1
+ """redeploy.heal — Self-healing runner with LiteLLM auto-repair.
2
+
3
+ This package has been refactored from the monolithic ``redeploy.heal`` module.
4
+ For backward compatibility all public names remain importable from
5
+ ``redeploy.heal``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .loop_detector import HealAbort, HealLoopDetector
10
+ from .log_writer import write_repair_log
11
+ from .hint_provider import (
12
+ DIAG_COMMANDS,
13
+ KNOWN_CONSTRAINTS,
14
+ collect_diagnostics,
15
+ ask_llm,
16
+ apply_fix_to_spec,
17
+ parse_failed_step,
18
+ )
19
+ from .decider import Action, Decision, decide_after_failure, format_decision_message
20
+ from .runner import HealRunner
21
+
22
+ __all__ = [
23
+ "HealAbort",
24
+ "HealLoopDetector",
25
+ "write_repair_log",
26
+ "DIAG_COMMANDS",
27
+ "KNOWN_CONSTRAINTS",
28
+ "collect_diagnostics",
29
+ "ask_llm",
30
+ "apply_fix_to_spec",
31
+ "parse_failed_step",
32
+ "Action",
33
+ "Decision",
34
+ "decide_after_failure",
35
+ "format_decision_message",
36
+ "HealRunner",
37
+ ]
@@ -0,0 +1,85 @@
1
+ """Heal decision policy — retry / skip / abort logic."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+
11
+ class Action(Enum):
12
+ RETRY = "retry"
13
+ SKIP = "skip"
14
+ ABORT = "abort"
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class Decision:
19
+ action: Action
20
+ reason: str = ""
21
+
22
+
23
+ def decide_after_failure(
24
+ *,
25
+ attempt: int,
26
+ max_retries: int,
27
+ failed_step: Optional[str],
28
+ loop_detected: bool,
29
+ llm_error: bool,
30
+ spec_patched: bool,
31
+ ) -> Decision:
32
+ """Return the next action for the heal loop.
33
+
34
+ Parameters
35
+ ----------
36
+ attempt:
37
+ 1-based attempt counter inside the heal loop.
38
+ max_retries:
39
+ Maximum retries configured by the user.
40
+ failed_step:
41
+ Step identifier, or *None* if the parser could not identify it.
42
+ loop_detected:
43
+ *True* when :class:`HealLoopDetector` flagged a repeating hint.
44
+ llm_error:
45
+ *True* when the LLM call itself failed (not a fixable step failure).
46
+ spec_patched:
47
+ *True* when the LLM produced a patch and it was applied.
48
+
49
+ Returns
50
+ -------
51
+ Decision
52
+ """
53
+ if loop_detected:
54
+ return Decision(
55
+ Action.ABORT,
56
+ f"Loop detected: same hint for {failed_step!r} ≥ 3 times in a row",
57
+ )
58
+ if failed_step is None:
59
+ return Decision(Action.ABORT, "Cannot identify failed step — giving up")
60
+ if llm_error:
61
+ return Decision(
62
+ Action.ABORT if attempt >= max_retries else Action.RETRY,
63
+ "LLM error — retrying may help if transient",
64
+ )
65
+ if attempt >= max_retries:
66
+ return Decision(
67
+ Action.ABORT,
68
+ f"Max retries ({max_retries}) exhausted for {failed_step!r}",
69
+ )
70
+ if spec_patched:
71
+ return Decision(Action.RETRY, "Spec patched — re-running migration")
72
+ return Decision(
73
+ Action.SKIP,
74
+ "LLM did not produce an applicable patch — skipping step",
75
+ )
76
+
77
+
78
+ def format_decision_message(decision: Decision, step_id: str) -> str:
79
+ """Human-readable log / console message for a decision."""
80
+ emoji = {
81
+ Action.RETRY: "🔄",
82
+ Action.SKIP: "⏭️",
83
+ Action.ABORT: "🛑",
84
+ }
85
+ return f"{emoji[decision.action]} {decision.action.value.upper()}: {decision.reason}"
@@ -0,0 +1,203 @@
1
+ """LLM hint generation and spec patching utilities."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import textwrap
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Diagnostics catalogue -- targeted SSH commands per failed step ID
12
+ # ---------------------------------------------------------------------------
13
+ DIAG_COMMANDS: dict[str, list[str]] = {
14
+ "restart-chromium": [
15
+ "pgrep -fa chromium | head -5 || echo NO_CHROMIUM",
16
+ "ls /run/user/1000/wayland-* 2>/dev/null || echo NO_WAYLAND",
17
+ "systemctl --user status kiosk-chromium.service --no-pager -n 10 2>&1 || true",
18
+ "cat ~/c2004/logs/kiosk.log 2>/dev/null | tail -20 || echo NO_LOG",
19
+ ],
20
+ "assert-screen-kiosk-url": [
21
+ "pgrep -fa chromium | head -3 || echo NO_CHROMIUM",
22
+ "cat ~/c2004/scripts/kiosk-launch.sh 2>/dev/null || echo NO_SCRIPT",
23
+ "cat ~/c2004/logs/kiosk.log 2>/dev/null | tail -30 || echo NO_LOG",
24
+ ],
25
+ "assert-screen-backend-healthy": [
26
+ "systemctl --user status c2004-backend.service --no-pager -n 20",
27
+ "journalctl --user -u c2004-backend.service --no-pager -n 20 2>/dev/null",
28
+ "curl -sv http://localhost:8000/api/v3/health 2>&1 | tail -10",
29
+ ],
30
+ "e2e-svg-icons": [
31
+ "podman exec c2004-frontend ls /usr/share/nginx/html/icons/ 2>/dev/null | head -10",
32
+ "curl -sI http://localhost:8100/icons/sprite.svg | head -8",
33
+ "podman exec c2004-frontend cat /etc/nginx/conf.d/default.conf 2>/dev/null | head -30",
34
+ ],
35
+ "e2e-api-endpoints": [
36
+ "systemctl --user status c2004-backend.service --no-pager -n 5",
37
+ "curl -sv http://localhost:8000/api/v3/health 2>&1 | tail -15",
38
+ "journalctl --user -u c2004-backend.service --no-pager -n 15 2>/dev/null",
39
+ ],
40
+ "e2e-kiosk-page-load": [
41
+ "curl -sf http://localhost:8100/ 2>/dev/null | head -20",
42
+ "podman exec c2004-frontend ls /usr/share/nginx/html/ | head -10",
43
+ ],
44
+ "_default": [
45
+ "systemctl --user list-units --type=service 2>/dev/null | grep -v '@' | tail -15",
46
+ "podman ps -a --format 'table {{.Names}}\\t{{.Status}}' 2>/dev/null",
47
+ "ss -tlnp 2>/dev/null | grep -E '8000|8100|8202' || echo PORTS_FREE",
48
+ "journalctl --user --no-pager -n 10 2>/dev/null",
49
+ ],
50
+ }
51
+
52
+ # Known constraints injected into every LLM prompt
53
+ KNOWN_CONSTRAINTS = """
54
+ Known constraints for this deployment target (Raspberry Pi 5, Podman Quadlet, labwc Wayland):
55
+ - pkill/killall chromium via SSH = exit 255 (kills Wayland session and drops SSH)
56
+ - Chromium requires: WAYLAND_DISPLAY=wayland-0 XDG_RUNTIME_DIR=/run/user/1000
57
+ - Use systemd-run --user --collect to start GUI apps without blocking SSH
58
+ - I2C device 0x45 on bus 11 is owned by kernel waveshare driver -- use || true
59
+ - Redirecting /dev/null in heredoc via SSH causes exit 255
60
+ - SQLite DBs at: /data/main/identification.db, /data/menu/menu.db, /data/scenario.db
61
+ - alembic binary may not exist in container -- use python3 -c with sqlite3 module
62
+ - Quadlet unit files go to ~/.config/containers/systemd/
63
+ - Services: c2004-backend, c2004-frontend, c2004-firmware, c2004-reverse-proxy
64
+ """
65
+
66
+
67
+ def _ssh(host: str, command: str) -> tuple[int, str]:
68
+ import subprocess
69
+ result = subprocess.run(
70
+ ["ssh", "-o", "ConnectTimeout=10", "-o", "BatchMode=yes", host, command],
71
+ capture_output=True, text=True, timeout=30,
72
+ )
73
+ return result.returncode, ((result.stdout or "") + (result.stderr or "")).strip()
74
+
75
+
76
+ def collect_diagnostics(host: str, failed_step: str) -> str:
77
+ """Run targeted SSH diagnostics for a failed step, return combined output."""
78
+ cmds = DIAG_COMMANDS.get(failed_step, DIAG_COMMANDS["_default"])
79
+ parts = []
80
+ for cmd in cmds:
81
+ rc, out = _ssh(host, cmd)
82
+ parts.append(f"$ {cmd}\n{out}")
83
+ return "\n\n".join(parts)
84
+
85
+
86
+ def ask_llm(
87
+ failed_step: str,
88
+ step_output: str,
89
+ diag: str,
90
+ spec_text: str,
91
+ fix_hint: str = "",
92
+ ) -> str:
93
+ """Ask LiteLLM to propose a fixed YAML block for the failed step."""
94
+ try:
95
+ import litellm
96
+ except ImportError:
97
+ return ""
98
+
99
+ model = os.getenv("LLM_MODEL", "openrouter/qwen/qwen3-coder-next")
100
+ api_key = os.getenv("OPENROUTER_API_KEY") or os.getenv("OPENAI_API_KEY")
101
+ api_base = "https://openrouter.ai/api/v1" if "openrouter" in model else None
102
+
103
+ hint_section = f"\n## User-reported issue:\n{fix_hint}\n" if fix_hint else ""
104
+
105
+ prompt = textwrap.dedent(f"""
106
+ You are an expert in Raspberry Pi 5 deployments with Podman Quadlet, labwc Wayland compositor and Chromium kiosk.
107
+
108
+ ## Failed step: `{failed_step}`
109
+ {hint_section}
110
+ ### Step output (error):
111
+ ```
112
+ {step_output[:2000]}
113
+ ```
114
+
115
+ ### SSH diagnostics collected after failure:
116
+ ```
117
+ {diag[:3000]}
118
+ ```
119
+
120
+ ### Current spec file (markpact YAML steps):
121
+ ```yaml
122
+ {spec_text[:5000]}
123
+ ```
124
+
125
+ {KNOWN_CONSTRAINTS}
126
+
127
+ ## Task:
128
+ Fix ONLY the step `{failed_step}`. Return ONLY the corrected YAML block:
129
+
130
+ ```yaml
131
+ - id: {failed_step}
132
+ action: ssh_cmd
133
+ description: "..."
134
+ command: |
135
+ <corrected command>
136
+ ```
137
+
138
+ Nothing else. No explanation outside the code block.
139
+ """).strip()
140
+
141
+ try:
142
+ kwargs: dict = dict(
143
+ model=model,
144
+ messages=[{"role": "user", "content": prompt}],
145
+ temperature=0.1,
146
+ max_tokens=1500,
147
+ )
148
+ if api_key:
149
+ kwargs["api_key"] = api_key
150
+ if api_base:
151
+ kwargs["api_base"] = api_base
152
+
153
+ resp = litellm.completion(**kwargs)
154
+ return resp.choices[0].message.content.strip()
155
+ except Exception as e:
156
+ return f"# LLM error: {e}"
157
+
158
+
159
+ def apply_fix_to_spec(spec_path: Path, failed_step: str, llm_response: str) -> bool:
160
+ """Extract YAML block from LLM response and patch it into the spec file."""
161
+ m = re.search(r"```ya?ml\s*(.*?)```", llm_response, re.DOTALL)
162
+ if m:
163
+ new_block = m.group(1).strip()
164
+ else:
165
+ m = re.search(
166
+ rf"(- id: {re.escape(failed_step)}.+?)(?=\n - id:|\Z)",
167
+ llm_response, re.DOTALL
168
+ )
169
+ if not m:
170
+ return False
171
+ new_block = m.group(1).strip()
172
+
173
+ text = spec_path.read_text()
174
+ pattern = rf"( - id: {re.escape(failed_step)}\n(?:(?! - id:).)*)"
175
+ match = re.search(pattern, text, re.DOTALL)
176
+ if not match:
177
+ return False
178
+
179
+ indented = "\n".join(" " + line if line else "" for line in new_block.splitlines())
180
+ spec_path.write_text(text[: match.start()] + indented + "\n" + text[match.end():])
181
+ return True
182
+
183
+
184
+ def parse_failed_step(executor_summary: str, executor=None) -> tuple[Optional[str], str]:
185
+ """Extract (step_id, step_output) from executor state or summary string."""
186
+ if executor is not None:
187
+ state = getattr(executor, "state", None)
188
+ if state and state.failed_step_id:
189
+ results = getattr(executor, "_results", {})
190
+ step_out = results.get(state.failed_step_id, {})
191
+ if isinstance(step_out, dict):
192
+ out = step_out.get("output") or step_out.get("error") or ""
193
+ else:
194
+ out = str(step_out)
195
+ return state.failed_step_id, out
196
+
197
+ m = re.search(
198
+ r"Step failed: \[([^\]]+)\].*?exit=\d+:?\s*(.*?)(?=\n\d{{2}}:\d{{2}}|\Z)",
199
+ executor_summary, re.DOTALL
200
+ )
201
+ if m:
202
+ return m.group(1), m.group(2).strip()
203
+ return None, ""
@@ -0,0 +1,51 @@
1
+ """REPAIR_LOG.md writing utilities."""
2
+ from __future__ import annotations
3
+
4
+ import datetime
5
+ from pathlib import Path
6
+
7
+
8
+ _LOG_HEADER = "# Repair Log\n\nAuto-repairs by ``redeploy run --heal``.\n\n"
9
+
10
+
11
+ def write_repair_log(
12
+ spec_path: Path,
13
+ version: str,
14
+ repairs: list[dict],
15
+ ) -> None:
16
+ """Append an entry to *REPAIR_LOG.md* adjacent to the spec file.
17
+
18
+ Parameters
19
+ ----------
20
+ spec_path:
21
+ Path to the spec that was healed.
22
+ version:
23
+ Current project version (written into the log heading).
24
+ repairs:
25
+ List of repair dicts produced by :class:`HealRunner`.
26
+ """
27
+ log_path = spec_path.parent / "REPAIR_LOG.md"
28
+ now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M")
29
+ lines = [f"## {version} -- {now}\n"]
30
+ if repairs:
31
+ lines.append("### LLM repairs\n")
32
+ for r in repairs:
33
+ lines.append(f"- **`{r['step']}`**: {r.get('summary', 'fixed')}\n")
34
+ if r.get("diag_hint"):
35
+ lines.append(f" - hint: `{r['diag_hint'][:120]}`\n")
36
+ else:
37
+ lines.append("Deployment completed without repairs.\n")
38
+ lines.append("\n")
39
+ entry = "".join(lines)
40
+
41
+ if log_path.exists():
42
+ existing = log_path.read_text()
43
+ if "# " in existing:
44
+ idx = existing.index("\n", existing.index("# ")) + 1
45
+ content = existing[:idx] + "\n" + entry + existing[idx:]
46
+ else:
47
+ content = entry + existing
48
+ else:
49
+ content = _LOG_HEADER + entry
50
+
51
+ log_path.write_text(content)
@@ -0,0 +1,61 @@
1
+ """Heal loop detection — prevents non-converging auto-repair cycles."""
2
+ from __future__ import annotations
3
+
4
+ from collections import defaultdict
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ class HealAbort(Exception):
9
+ """Raised when a heal loop is detected and retries must stop."""
10
+
11
+ def __init__(self, step_id: str, hint: str, repeats: int) -> None:
12
+ self.step_id = step_id
13
+ self.hint = hint
14
+ self.repeats = repeats
15
+ super().__init__(
16
+ f"Heal loop detected for {step_id!r}: "
17
+ f"same hint repeated {repeats}x"
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class HealLoopDetector:
23
+ """Detect repeated non-converging heal hints for a given step.
24
+
25
+ Parameters
26
+ ----------
27
+ max_identical_hints:
28
+ Number of consecutive identical hints that trigger a loop abort.
29
+ Default is 3 because after two identical retries the third
30
+ almost never brings new information.
31
+ """
32
+
33
+ max_identical_hints: int = 3
34
+ _history: dict[str, list[str]] = field(default_factory=lambda: defaultdict(list))
35
+
36
+ def observe(self, step_id: str, hint: str) -> bool:
37
+ """Return *True* when the latest hints indicate a heal loop.
38
+
39
+ A loop is defined as ``max_identical_hints`` identical non-empty
40
+ hints in a row for the same failed step.
41
+ """
42
+ normalized = (hint or "").strip()
43
+ if not normalized:
44
+ return False
45
+
46
+ history = self._history[step_id]
47
+ history.append(normalized)
48
+
49
+ if len(history) < self.max_identical_hints:
50
+ return False
51
+
52
+ recent = history[-self.max_identical_hints :]
53
+ return all(item == recent[0] for item in recent)
54
+
55
+ def reset(self, step_id: str) -> None:
56
+ """Clear history for *step_id* (e.g. after a successful retry)."""
57
+ self._history.pop(step_id, None)
58
+
59
+ def reset_all(self) -> None:
60
+ """Clear all history."""
61
+ self._history.clear()