atomicguard 2.26.1__tar.gz → 2.28.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 (170) hide show
  1. {atomicguard-2.26.1/src/atomicguard.egg-info → atomicguard-2.28.0}/PKG-INFO +2 -1
  2. {atomicguard-2.26.1 → atomicguard-2.28.0}/pyproject.toml +22 -1
  3. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/__init__.py +1 -1
  4. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/agent.py +2 -0
  5. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/builder.py +64 -3
  6. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/workflow.py +42 -5
  7. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/cli/run.py +1 -1
  8. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/cli/runner.py +1 -1
  9. atomicguard-2.28.0/src/atomicguard/contrib/__init__.py +9 -0
  10. {atomicguard-2.26.1/src/atomicguard/infrastructure → atomicguard-2.28.0/src/atomicguard/contrib}/effectors/bash_executor.py +5 -0
  11. atomicguard-2.28.0/src/atomicguard/contrib/generators/__init__.py +0 -0
  12. atomicguard-2.28.0/src/atomicguard/contrib/generators/command_template.py +184 -0
  13. atomicguard-2.28.0/src/atomicguard/contrib/guards/__init__.py +0 -0
  14. atomicguard-2.28.0/src/atomicguard/contrib/guards/pattern_guard.py +40 -0
  15. atomicguard-2.28.0/src/atomicguard/contrib/guards/rendered_command_guard.py +78 -0
  16. atomicguard-2.28.0/src/atomicguard/contrib/guards/script_result_guard.py +158 -0
  17. {atomicguard-2.26.1/src/atomicguard/infrastructure → atomicguard-2.28.0/src/atomicguard/contrib}/registries.py +3 -3
  18. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/__init__.py +53 -0
  19. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/cache.py +78 -0
  20. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/embedding_classifier.py +173 -0
  21. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/examples/__init__.py +0 -0
  22. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/examples/demo.py +135 -0
  23. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/examples/smoke_embedding.py +140 -0
  24. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/handlers/__init__.py +6 -0
  25. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/handlers/tmux_forward_handler.py +65 -0
  26. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/handlers/workflow_handler.py +106 -0
  27. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/protocols.py +104 -0
  28. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/registry_seed.py +107 -0
  29. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/results.py +46 -0
  30. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/switchboard.py +120 -0
  31. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/__init__.py +0 -0
  32. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/conftest.py +84 -0
  33. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/test_embedding_classifier.py +225 -0
  34. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/test_protocols.py +73 -0
  35. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/test_registry_seed.py +47 -0
  36. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/test_switchboard.py +232 -0
  37. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/test_tmux_forward_handler.py +100 -0
  38. atomicguard-2.28.0/src/atomicguard/contrib/switchboard/tests/test_workflow_handler.py +121 -0
  39. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/dynamic/test_runner.py +11 -5
  40. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/guards/__init__.py +1 -5
  41. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/precommit.py +4 -4
  42. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/persistence/sqlite.py +15 -0
  43. atomicguard-2.28.0/src/atomicguard/tools/__init__.py +0 -0
  44. {atomicguard-2.26.1 → atomicguard-2.28.0/src/atomicguard.egg-info}/PKG-INFO +2 -1
  45. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard.egg-info/SOURCES.txt +33 -5
  46. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard.egg-info/requires.txt +1 -0
  47. atomicguard-2.26.1/src/atomicguard/infrastructure/generators/command_template.py +0 -82
  48. {atomicguard-2.26.1 → atomicguard-2.28.0}/LICENSE +0 -0
  49. {atomicguard-2.26.1 → atomicguard-2.28.0}/README.md +0 -0
  50. {atomicguard-2.26.1 → atomicguard-2.28.0}/setup.cfg +0 -0
  51. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/__main__.py +0 -0
  52. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/__init__.py +0 -0
  53. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/action_pair.py +0 -0
  54. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/agent_rl.py +0 -0
  55. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/feedback_summarizer.py +0 -0
  56. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/policy_export.py +0 -0
  57. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/application/runner.py +0 -0
  58. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/cli/__init__.py +0 -0
  59. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/cli/_queue.py +0 -0
  60. {atomicguard-2.26.1/src/atomicguard/tools → atomicguard-2.28.0/src/atomicguard/contrib/effectors}/__init__.py +0 -0
  61. {atomicguard-2.26.1/src/atomicguard/infrastructure → atomicguard-2.28.0/src/atomicguard/contrib}/guards/all_passed_guard.py +0 -0
  62. {atomicguard-2.26.1/src/atomicguard/infrastructure → atomicguard-2.28.0/src/atomicguard/contrib}/guards/exit_code_guard.py +0 -0
  63. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/__init__.py +0 -0
  64. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/exceptions.py +0 -0
  65. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/extraction.py +0 -0
  66. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/feedback_summarizer.py +0 -0
  67. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/gym.py +0 -0
  68. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/interfaces.py +0 -0
  69. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/models.py +0 -0
  70. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/multiagent.py +0 -0
  71. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/policy_export.py +0 -0
  72. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/prompts.py +0 -0
  73. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/rl/__init__.py +0 -0
  74. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/rl/actions.py +0 -0
  75. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/rl/models.py +0 -0
  76. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/rl/reward.py +0 -0
  77. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/rl/state.py +0 -0
  78. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/rl/transitions.py +0 -0
  79. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/runner.py +0 -0
  80. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/domain/workflow.py +0 -0
  81. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/__init__.py +0 -0
  82. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/composite/__init__.py +0 -0
  83. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/composite/base.py +0 -0
  84. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/dynamic/__init__.py +0 -0
  85. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/interactive/__init__.py +0 -0
  86. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/interactive/human.py +0 -0
  87. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/static/__init__.py +0 -0
  88. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/static/imports.py +0 -0
  89. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/guards/static/syntax.py +0 -0
  90. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/gym/__init__.py +0 -0
  91. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/gym/__main__.py +0 -0
  92. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/__init__.py +0 -0
  93. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/effectors/__init__.py +0 -0
  94. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/effectors/multi_dispatch_effector.py +0 -0
  95. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/effectors/tmux_broadcast_effector.py +0 -0
  96. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/effectors/tmux_session_effector.py +0 -0
  97. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/effectors/workflow_effector.py +0 -0
  98. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/generators/__init__.py +0 -0
  99. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/generators/subprocess_generator.py +0 -0
  100. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/guards/container_subprocess_guard.py +0 -0
  101. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/guards/diff_empty_guard.py +0 -0
  102. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/guards/human_review_adapter.py +0 -0
  103. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/__init__.py +0 -0
  104. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/diff_parser.py +0 -0
  105. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/precommit_data/__init__.py +0 -0
  106. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/precommit_data/dataset.py +0 -0
  107. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/precommit_data/schemas.py +0 -0
  108. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/gym/precommit_generators.py +0 -0
  109. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/human_guard_db.py +0 -0
  110. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/human_guard_stdin.py +0 -0
  111. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/llm/__init__.py +0 -0
  112. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/llm/factory.py +0 -0
  113. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/llm/huggingface.py +0 -0
  114. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/llm/mock.py +0 -0
  115. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/llm/ollama.py +0 -0
  116. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/persistence/__init__.py +0 -0
  117. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/persistence/filesystem.py +0 -0
  118. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/persistence/memory.py +0 -0
  119. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/policy_registry.py +0 -0
  120. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/queue_inprocess.py +0 -0
  121. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/queue_sqlite.py +0 -0
  122. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/registry.py +0 -0
  123. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/rl/__init__.py +0 -0
  124. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/rl/checkpoint.py +0 -0
  125. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/rl/environment.py +0 -0
  126. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/rl/policy.py +0 -0
  127. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/rl/replay_buffer.py +0 -0
  128. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/rl/rewards.py +0 -0
  129. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/infrastructure/status_callback.py +0 -0
  130. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/run_policy.py +0 -0
  131. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/schemas/__init__.py +0 -0
  132. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/tools/__main__.py +0 -0
  133. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/tools/export_training.py +0 -0
  134. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/tools/export_workflow.py +0 -0
  135. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/training/__init__.py +0 -0
  136. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/training/curriculum.py +0 -0
  137. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/training/trainer.py +0 -0
  138. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/visualization/__init__.py +0 -0
  139. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/visualization/html_exporter.py +0 -0
  140. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/visualization/workflow_config_exporter.py +0 -0
  141. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/__init__.py +0 -0
  142. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/__main__.py +0 -0
  143. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/config_loader.py +0 -0
  144. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/dag_reader.py +0 -0
  145. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/dependencies.py +0 -0
  146. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/discovery.py +0 -0
  147. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/experiment_locator.py +0 -0
  148. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/policy_loader.py +0 -0
  149. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/rl_loader.py +0 -0
  150. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/__init__.py +0 -0
  151. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/ap_catalogue_pages.py +0 -0
  152. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/gym_pages.py +0 -0
  153. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/library_pages.py +0 -0
  154. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/ollama_pages.py +0 -0
  155. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/pages.py +0 -0
  156. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/policy_pages.py +0 -0
  157. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/rl_pages.py +0 -0
  158. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/run_pages.py +0 -0
  159. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/spark_pages.py +0 -0
  160. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/training_pages.py +0 -0
  161. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/workflow_pages.py +0 -0
  162. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/routes/ws_routes.py +0 -0
  163. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/server.py +0 -0
  164. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/training_launcher.py +0 -0
  165. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/utils.py +0 -0
  166. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard/web/ws.py +0 -0
  167. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard.egg-info/dependency_links.txt +0 -0
  168. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard.egg-info/entry_points.txt +0 -0
  169. {atomicguard-2.26.1 → atomicguard-2.28.0}/src/atomicguard.egg-info/top_level.txt +0 -0
  170. {atomicguard-2.26.1 → atomicguard-2.28.0}/tests/test_run_policy.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atomicguard
3
- Version: 2.26.1
3
+ Version: 2.28.0
4
4
  Summary: A Dual-State Agent Framework for reliable LLM code generation with guard-validated loops
5
5
  Author-email: Matthew Thompson <thompsonson@gmail.com>
6
6
  Maintainer-email: Matthew Thompson <thompsonson@gmail.com>
@@ -31,6 +31,7 @@ Requires-Dist: openhands-ai>=0.27.0
31
31
  Requires-Dist: pydantic-ai>=1.0.0
32
32
  Requires-Dist: pyflakes>=3.0
33
33
  Requires-Dist: click>=8.1.0
34
+ Requires-Dist: pyyaml>=6.0
34
35
  Provides-Extra: web
35
36
  Requires-Dist: fastapi>=0.115.0; extra == "web"
36
37
  Requires-Dist: uvicorn[standard]>=0.34.0; extra == "web"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "atomicguard"
3
- version = "2.26.1"
3
+ version = "2.28.0"
4
4
  description = "A Dual-State Agent Framework for reliable LLM code generation with guard-validated loops"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -40,6 +40,7 @@ dependencies = [
40
40
  "pydantic-ai>=1.0.0",
41
41
  "pyflakes>=3.0",
42
42
  "click>=8.1.0",
43
+ "pyyaml>=6.0",
43
44
  ]
44
45
 
45
46
  [project.urls]
@@ -180,6 +181,9 @@ ignore = [
180
181
  ]
181
182
 
182
183
  [tool.ruff.lint.per-file-ignores]
184
+ # Vendored Raschka source — keep the diff minimal; don't lint upstream's
185
+ # unused params or other style choices we don't own.
186
+ "agents/double_oh_seven/agent.py" = ["ARG001", "ARG002", "ARG003"]
183
187
  # Concrete reward functions must match RewardFunctionInterface.compute()
184
188
  # signature — unused params are required by the interface contract.
185
189
  "src/atomicguard/infrastructure/rl/rewards.py" = ["ARG002"]
@@ -227,6 +231,14 @@ mypy_path = "src"
227
231
  module = "tests.*"
228
232
  disallow_untyped_defs = false
229
233
 
234
+ [[tool.mypy.overrides]]
235
+ module = "atomicguard.contrib.switchboard.tests.*"
236
+ ignore_errors = true
237
+
238
+ [[tool.mypy.overrides]]
239
+ module = "atomicguard.contrib.switchboard.examples.*"
240
+ ignore_errors = true
241
+
230
242
  [[tool.mypy.overrides]]
231
243
  module = ["atomicguard.web.*", "atomicguard.web.routes.*"]
232
244
  warn_return_any = false
@@ -236,6 +248,15 @@ disallow_untyped_defs = false
236
248
  module = ["examples.*"]
237
249
  ignore_errors = true
238
250
 
251
+ [[tool.mypy.overrides]]
252
+ # Vendored Raschka source — don't typecheck upstream code we don't own.
253
+ module = "agents.double_oh_seven.agent"
254
+ ignore_errors = true
255
+
256
+ [[tool.mypy.overrides]]
257
+ module = "agents.double_oh_seven.tests.*"
258
+ ignore_errors = true
259
+
239
260
  [dependency-groups]
240
261
  dev = [
241
262
  "mypy>=1.19.1",
@@ -82,7 +82,7 @@ from atomicguard.infrastructure.persistence import (
82
82
  # Visualization
83
83
  from atomicguard.visualization import export_workflow_config_html, export_workflow_html
84
84
 
85
- __version__ = "2.26.1"
85
+ __version__ = "2.28.0"
86
86
 
87
87
  __all__ = [
88
88
  # Version
@@ -335,6 +335,7 @@ class DualStateAgent:
335
335
  escalation_feedback=escalation_feedback,
336
336
  dependency_types=dep_types,
337
337
  dependency_artifact_contexts=dep_contexts,
338
+ workflow_id=self._workflow_id,
338
339
  )
339
340
 
340
341
  def _refine_context(
@@ -371,4 +372,5 @@ class DualStateAgent:
371
372
  escalation_feedback=escalation_feedback,
372
373
  dependency_types=dep_types,
373
374
  dependency_artifact_contexts=dep_contexts,
375
+ workflow_id=self._workflow_id,
374
376
  )
@@ -8,15 +8,26 @@ adapter-agnostic factory that takes explicit registries.
8
8
  from __future__ import annotations
9
9
 
10
10
  import logging
11
+ import re
11
12
  from typing import TYPE_CHECKING
12
13
 
13
14
  from atomicguard.application.action_pair import ActionPair
14
- from atomicguard.application.workflow import StepCallback, WorkflowOrchestrator
15
+ from atomicguard.application.workflow import (
16
+ PreStepCallback,
17
+ StepCallback,
18
+ WorkflowOrchestrator,
19
+ )
15
20
  from atomicguard.domain.prompts import PromptTemplate
16
21
  from atomicguard.guards.composite import CompositeGuard
17
22
 
23
+ # Matches `{dep.<ap_id>.<field>}` placeholders in task / feedback / context
24
+ # strings. Used at orchestrator-build time to validate that every referenced
25
+ # producer AP is declared in `requires` — catches cross-AP coupling bugs
26
+ # before runtime instead of as a misleading KeyError mid-execution.
27
+ _DEP_PLACEHOLDER_RE = re.compile(r"\{dep\.(\w+)\.\w+\}")
28
+
18
29
  if TYPE_CHECKING:
19
- from atomicguard.application.workflow import StepCallback
30
+ from atomicguard.application.workflow import PreStepCallback, StepCallback
20
31
  from atomicguard.domain.interfaces import (
21
32
  ArtifactDAGInterface,
22
33
  EffectorInterface,
@@ -40,6 +51,7 @@ def build_orchestrator(
40
51
  artifact_dag: ArtifactDAGInterface,
41
52
  step_callback: StepCallback | None = None,
42
53
  effector_registry: dict[str, EffectorInterface] | None = None,
54
+ pre_step_callback: PreStepCallback | None = None,
43
55
  ) -> WorkflowOrchestrator:
44
56
  """Build a configured WorkflowOrchestrator from a definition and registries.
45
57
 
@@ -49,8 +61,12 @@ def build_orchestrator(
49
61
  generator_registry: Map of generator name -> concrete instance.
50
62
  guard_registry: Map of guard name -> concrete instance.
51
63
  artifact_dag: Artifact storage implementation.
52
- step_callback: Optional callback passed to the orchestrator.
64
+ step_callback: Optional callback passed to the orchestrator (fires
65
+ after each step's verdict).
53
66
  effector_registry: Map of effector name -> concrete instance (optional).
67
+ pre_step_callback: Optional callback passed to the orchestrator
68
+ (fires just before each step's ``agent.execute()``). Used to
69
+ display per-AP progress on long-running workflows.
54
70
 
55
71
  Returns:
56
72
  A fully configured ``WorkflowOrchestrator`` ready to ``execute()``.
@@ -61,11 +77,14 @@ def build_orchestrator(
61
77
  """
62
78
  effector_registry = effector_registry or {}
63
79
 
80
+ _validate_dep_references(definition, ap_context)
81
+
64
82
  orchestrator = WorkflowOrchestrator(
65
83
  artifact_dag,
66
84
  rmax=definition.rmax,
67
85
  constraints=definition.constraints,
68
86
  step_callback=step_callback,
87
+ pre_step_callback=pre_step_callback,
69
88
  )
70
89
 
71
90
  for ap_def in definition.action_pairs:
@@ -107,6 +126,48 @@ def build_orchestrator(
107
126
  return orchestrator
108
127
 
109
128
 
129
+ def _validate_dep_references(
130
+ definition: WorkflowDefinition,
131
+ ap_context: dict[str, APContextEntry],
132
+ ) -> None:
133
+ """Reject workflows where a `{dep.X.field}` reference points outside `requires`.
134
+
135
+ Scans every AP's `task` template for `{dep.<ap_id>.<field>}` placeholders
136
+ and asserts that the referenced AP id is declared in the consuming AP's
137
+ `requires` list. Fails fast at orchestrator-build time with a precise
138
+ error rather than producing a misleading runtime KeyError when
139
+ `CommandTemplateGenerator` looks up the missing dependency.
140
+
141
+ Type-based references (`{type.<artifact_type>.<field>}`) are independent
142
+ of `requires` by design and are not checked here.
143
+
144
+ Raises:
145
+ ValueError: when any AP's task references a `{dep.X.*}` for an X
146
+ not in that AP's `requires`.
147
+ """
148
+ errors: list[str] = []
149
+ for ap_def in definition.action_pairs:
150
+ ctx = ap_context.get(ap_def.ap_id)
151
+ if ctx is None or not ctx.task:
152
+ continue
153
+ referenced = set(_DEP_PLACEHOLDER_RE.findall(ctx.task))
154
+ if not referenced:
155
+ continue
156
+ declared = set(ap_def.requires or [])
157
+ missing = referenced - declared
158
+ if missing:
159
+ for ref in sorted(missing):
160
+ errors.append(
161
+ f"AP '{ap_def.ap_id}' task references {{dep.{ref}.<field>}} "
162
+ f"but does not declare '{ref}' in `requires` "
163
+ f"(declared: {sorted(declared)})."
164
+ )
165
+ if errors:
166
+ raise ValueError(
167
+ "Workflow has unresolved dep references:\n " + "\n ".join(errors)
168
+ )
169
+
170
+
110
171
  def _resolve_generator(
111
172
  ap_def: ActionPairDefinition,
112
173
  registry: dict[str, GeneratorInterface],
@@ -67,6 +67,14 @@ logger = logging.getLogger(__name__)
67
67
  # Callback type: (ap_id, artifact, guard_result) -> None
68
68
  StepCallback = Callable[[str, "Artifact", "GuardResult"], None]
69
69
 
70
+ # Pre-step callback: (ap_id,) -> None. Invoked once just before each
71
+ # AP's agent.execute() runs (after deps are resolved, before the
72
+ # generator/effector/guard chain starts). Fires once per workflow-level
73
+ # entry into the step — inner retries inside the DualStateAgent loop are
74
+ # not surfaced here, but an Extension-09 backtrack re-entry to the same
75
+ # AP will re-fire the callback (which is the right signal).
76
+ PreStepCallback = Callable[[str], None]
77
+
70
78
 
71
79
  class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
72
80
  """
@@ -83,6 +91,7 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
83
91
  constraints: str = "",
84
92
  agent_factory: AgentFactory | None = None,
85
93
  step_callback: StepCallback | None = None,
94
+ pre_step_callback: PreStepCallback | None = None,
86
95
  ):
87
96
  """
88
97
  Args:
@@ -94,12 +103,18 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
94
103
  step_callback: Optional callback invoked after each step completes
95
104
  (both pass and fail). Signature:
96
105
  ``(ap_id: str, artifact: Artifact, guard_result: GuardResult) -> None``
106
+ pre_step_callback: Optional callback invoked just before each
107
+ step's ``agent.execute()`` runs. Signature: ``(ap_id: str) -> None``.
108
+ Useful for displaying per-AP progress in long-running workflows
109
+ where the user otherwise sees no output between the previous
110
+ step's verdict and this step's.
97
111
  """
98
112
  self._dag = artifact_dag
99
113
  self._rmax = rmax
100
114
  self._constraints = constraints
101
115
  self._agent_factory = agent_factory
102
116
  self._step_callback = step_callback
117
+ self._pre_step_callback = pre_step_callback
103
118
  self._steps: list[WorkflowStep] = []
104
119
  self._steps_by_id: dict[str, WorkflowStep] = {}
105
120
  self._workflow_state = WorkflowState()
@@ -293,6 +308,11 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
293
308
  step_escalation_feedback = tuple(
294
309
  self._escalation_feedback.get(step.guard_id, [])
295
310
  )
311
+ # Invoke pre-step callback before the AP actually runs, so
312
+ # consumers can show "step X starting" output before the
313
+ # (potentially slow) generator/effector/guard chain executes.
314
+ if self._pre_step_callback is not None:
315
+ self._pre_step_callback(step.guard_id)
296
316
  artifact = agent.execute(
297
317
  specification,
298
318
  dependencies,
@@ -304,14 +324,14 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
304
324
  )
305
325
 
306
326
  # Invoke step callback on success
307
- if (
308
- self._step_callback is not None
309
- and artifact.guard_result is not None
310
- ):
311
- self._step_callback(step.guard_id, artifact, artifact.guard_result)
327
+ self._fire_step_callback(step.guard_id, artifact)
312
328
 
313
329
  except StagnationDetected as e:
314
330
  # Level 2: WorkflowOrchestrator backtracking
331
+ # Fire post-step callback to close the entry (paired with the
332
+ # pre_step_callback fired above). On the continue sub-path the
333
+ # next workflow entry will fire a fresh pre+post pair.
334
+ self._fire_step_callback(step.guard_id, e.artifact)
315
335
  if self._escalation_count[step.guard_id] < step.e_max:
316
336
  logger.info(
317
337
  "[%s] Escalation %d/%d triggered. Targets: %s",
@@ -355,6 +375,7 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
355
375
 
356
376
  except EscalationRequired as e:
357
377
  # Level 4: Human intervention (fatal guard)
378
+ self._fire_step_callback(step.guard_id, e.artifact)
358
379
  return WorkflowResult(
359
380
  status=WorkflowStatus.ESCALATION,
360
381
  artifacts=self._artifacts,
@@ -364,6 +385,11 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
364
385
  )
365
386
 
366
387
  except RmaxExhausted as e:
388
+ # Last attempt's artifact is the head of the most recent
389
+ # provenance entry; provenance is non-empty when this is raised.
390
+ last_artifact = e.provenance[-1][0] if e.provenance else None
391
+ if last_artifact is not None:
392
+ self._fire_step_callback(step.guard_id, last_artifact)
367
393
  return WorkflowResult(
368
394
  status=WorkflowStatus.FAILED,
369
395
  artifacts=self._artifacts,
@@ -476,6 +502,17 @@ class WorkflowOrchestrator(ActionPairPoolInterface, WorkflowExecutorInterface):
476
502
 
477
503
  return dependents
478
504
 
505
+ def _fire_step_callback(self, ap_id: str, artifact: "Artifact") -> None:
506
+ """Invoke step_callback for an AP entry if both callback and guard_result exist.
507
+
508
+ Centralises the None-guard so success and failure paths share the same
509
+ contract: exactly one call per workflow-level entry, whenever a
510
+ guard_result is available to report.
511
+ """
512
+ if self._step_callback is None or artifact.guard_result is None:
513
+ return
514
+ self._step_callback(ap_id, artifact, artifact.guard_result)
515
+
479
516
  def _inject_failure_context(self, target_id: str, summary: str) -> None:
480
517
  """Add failure summary to constraints for target re-execution (Definition 48).
481
518
 
@@ -247,7 +247,7 @@ def _execute_in_process(
247
247
 
248
248
  # Build registries
249
249
  click.echo(f"\nModel: {model} ({provider})")
250
- from atomicguard.infrastructure.registries import (
250
+ from atomicguard.contrib.registries import (
251
251
  build_generator_registry,
252
252
  build_guard_registry,
253
253
  )
@@ -155,7 +155,7 @@ def _execute_job(
155
155
 
156
156
  try:
157
157
  # Build registries from the job's model/provider
158
- from atomicguard.infrastructure.registries import (
158
+ from atomicguard.contrib.registries import (
159
159
  build_generator_registry,
160
160
  build_guard_registry,
161
161
  )
@@ -0,0 +1,9 @@
1
+ """
2
+ contrib — reusable building blocks for AtomicGuard agents.
3
+
4
+ Generic guards, effectors, and generators that are useful across agents
5
+ but do not belong in the core framework. Agents import from here;
6
+ core (domain, application, infrastructure) never imports from here.
7
+
8
+ One-way dependency: contrib → core, never core → contrib.
9
+ """
@@ -101,6 +101,11 @@ class BashCommandExecutor(EffectorInterface):
101
101
  "command": command,
102
102
  }
103
103
  ),
104
+ # Preserve artifact_type from the plan so type-based dependency
105
+ # resolution ({type.<artifact_type>.<field>}) can find this artifact
106
+ # in R. Without this, the effector's output artifact loses the type
107
+ # tag the action_pair attached to the plan.
108
+ artifact_type=plan.artifact_type,
104
109
  )
105
110
 
106
111
  def undo(self, plan: Artifact) -> bool: # noqa: ARG002
@@ -0,0 +1,184 @@
1
+ """
2
+ CommandTemplateGenerator: Deterministic generator that resolves {spec.*},
3
+ {dep.<ap_id>.<field>}, and {type.<artifact_type>.<field>} placeholders.
4
+
5
+ For command-based APs, the task field in the prompt template IS the command
6
+ template. The generator resolves placeholders from the specification and from
7
+ upstream artifact content (parsed as JSON).
8
+
9
+ Three placeholder forms:
10
+ {spec.<key>} — workflow specification key/value pair
11
+ {dep.<ap_id>.<field>} — direct dep by AP id (legacy)
12
+ {type.<artifact_type>.<field>} — by artifact_type, scanning R for the
13
+ most recent ACCEPTED artifact whose
14
+ producer declared this artifact_type.
15
+ Independent of `requires` declarations.
16
+ """
17
+
18
+ import json
19
+ import re
20
+ import uuid
21
+ from datetime import datetime
22
+
23
+ from atomicguard.domain.interfaces import GeneratorInterface
24
+ from atomicguard.domain.models import (
25
+ ActionPairId,
26
+ Artifact,
27
+ ArtifactId,
28
+ ArtifactStatus,
29
+ Context,
30
+ ContextSnapshot,
31
+ WorkflowId,
32
+ )
33
+ from atomicguard.domain.prompts import PromptTemplate
34
+
35
+
36
+ class CommandTemplateGenerator(GeneratorInterface):
37
+ """Resolves {spec.*}, {dep.<ap_id>.<field>}, and {type.<artifact_type>.<field>}
38
+ placeholders in the task template.
39
+
40
+ {spec.<key>} — resolved from the specification string (key=value pairs).
41
+ {dep.<ap_id>.<field>} — resolved by parsing the named dependency artifact's
42
+ content as JSON and extracting <field>. Requires <ap_id> to be in the
43
+ AP's `requires`. If the JSON value is a list, the first element is used.
44
+ {type.<artifact_type>.<field>} — resolved by scanning R for the most recent
45
+ ACCEPTED artifact with matching artifact_type. Does NOT require the
46
+ producing AP to be in `requires`; type-based hydration sidesteps the
47
+ ordering-vs-visibility conflation. The producer AP must declare the
48
+ artifact_type via `output_artifact_type` (or equivalently the AP's
49
+ artifact_type field on its definition).
50
+ """
51
+
52
+ def generate(
53
+ self,
54
+ context: Context,
55
+ template: PromptTemplate,
56
+ action_pair_id: str = "unknown",
57
+ workflow_id: str = "unknown",
58
+ workflow_ref: str | None = None,
59
+ ) -> Artifact:
60
+ command = template.task
61
+
62
+ # Resolve {spec.*} placeholders from the specification
63
+ placeholders = re.findall(r"\{spec\.(\w+)\}", command)
64
+ if placeholders:
65
+ spec_values = self._parse_specification(context.specification)
66
+ for key in placeholders:
67
+ if key not in spec_values:
68
+ msg = f"Missing specification key: spec.{key}"
69
+ raise KeyError(msg)
70
+ command = command.replace(f"{{spec.{key}}}", spec_values[key])
71
+
72
+ # Resolve {dep.<ap_id>.<field>} placeholders from direct dependency artifacts
73
+ dep_placeholders = re.findall(r"\{dep\.(\w+)\.(\w+)\}", command)
74
+ if dep_placeholders and context.ambient and context.ambient.repository:
75
+ dep_map = dict(context.dependency_artifacts)
76
+ for ap_id, field in dep_placeholders:
77
+ artifact_id = dep_map.get(ap_id)
78
+ if artifact_id is None:
79
+ msg = f"Missing dependency artifact: dep.{ap_id}"
80
+ raise KeyError(msg)
81
+ artifact = context.ambient.repository.get_artifact(artifact_id)
82
+ value = self._extract_json_field(artifact.content, field, ap_id)
83
+ command = command.replace(f"{{dep.{ap_id}.{field}}}", value)
84
+
85
+ # Resolve {type.<artifact_type>.<field>} placeholders by scanning R
86
+ type_placeholders = re.findall(r"\{type\.(\w+)\.(\w+)\}", command)
87
+ if type_placeholders and context.ambient and context.ambient.repository:
88
+ for artifact_type, field in type_placeholders:
89
+ producer = self._find_artifact_by_type(context, artifact_type)
90
+ if producer is None:
91
+ msg = (
92
+ f"No ACCEPTED artifact with artifact_type={artifact_type!r} "
93
+ f"found in R. Producer AP may not have run yet, or its "
94
+ f"definition does not declare artifact_type={artifact_type!r}."
95
+ )
96
+ raise KeyError(msg)
97
+ value = self._extract_json_field(
98
+ producer.content, field, f"type.{artifact_type}"
99
+ )
100
+ command = command.replace(f"{{type.{artifact_type}.{field}}}", value)
101
+
102
+ return Artifact(
103
+ artifact_id=ArtifactId(str(uuid.uuid4())),
104
+ workflow_id=WorkflowId(workflow_id),
105
+ content=command,
106
+ previous_attempt_id=None,
107
+ parent_action_pair_id=None,
108
+ action_pair_id=ActionPairId(action_pair_id),
109
+ created_at=datetime.now().isoformat(),
110
+ attempt_number=1,
111
+ status=ArtifactStatus.PENDING,
112
+ guard_result=None,
113
+ context=ContextSnapshot(
114
+ workflow_id=workflow_id,
115
+ specification=context.specification,
116
+ constraints=context.ambient.constraints if context.ambient else "",
117
+ feedback_history=(),
118
+ ),
119
+ workflow_ref=workflow_ref,
120
+ )
121
+
122
+ @staticmethod
123
+ def _parse_specification(spec: str) -> dict[str, str]:
124
+ """Parse specification as key=value pairs (one per line)."""
125
+ result = {}
126
+ for line in spec.strip().splitlines():
127
+ line = line.strip()
128
+ if "=" in line:
129
+ key, _, value = line.partition("=")
130
+ result[key.strip()] = value.strip()
131
+ return result
132
+
133
+ @staticmethod
134
+ def _find_artifact_by_type(context: Context, artifact_type: str) -> Artifact | None:
135
+ """Find the most recent ACCEPTED artifact in R with a matching artifact_type.
136
+
137
+ Independent of `requires` — scans the full repository for the current
138
+ workflow_id. If multiple matches exist, returns the most recent by
139
+ `created_at`. Returns None if no match is found.
140
+ """
141
+ if context.ambient is None or context.ambient.repository is None:
142
+ return None
143
+ if context.workflow_id is None:
144
+ return None
145
+ artifacts = context.ambient.repository.get_by_workflow(context.workflow_id)
146
+ matching = [
147
+ a
148
+ for a in artifacts
149
+ if a.artifact_type == artifact_type and a.status == ArtifactStatus.ACCEPTED
150
+ ]
151
+ if not matching:
152
+ return None
153
+ return max(matching, key=lambda a: a.created_at)
154
+
155
+ @staticmethod
156
+ def _extract_json_field(content: str, field: str, ap_id: str) -> str:
157
+ """Parse content as JSON and extract field. Lists yield their first element."""
158
+ try:
159
+ parsed = json.loads(content)
160
+ except json.JSONDecodeError as e:
161
+ msg = f"dep.{ap_id} artifact content is not valid JSON: {e}"
162
+ raise ValueError(msg) from e
163
+
164
+ if isinstance(parsed, list):
165
+ if not parsed:
166
+ msg = f"dep.{ap_id} artifact is an empty JSON array"
167
+ raise ValueError(msg)
168
+ parsed = parsed[0]
169
+
170
+ if not isinstance(parsed, dict):
171
+ msg = f"dep.{ap_id} artifact JSON is not an object or array of objects"
172
+ raise ValueError(msg)
173
+
174
+ if field not in parsed:
175
+ msg = f"dep.{ap_id} artifact has no field '{field}'"
176
+ raise KeyError(msg)
177
+
178
+ value = parsed[field]
179
+ # Nested arrays/objects are re-serialised as JSON so consumers (e.g.
180
+ # shell scripts piping into jq) get a valid JSON string rather than
181
+ # Python's repr. Primitives are str-coerced as before.
182
+ if isinstance(value, (list, dict)):
183
+ return json.dumps(value)
184
+ return str(value)
@@ -0,0 +1,40 @@
1
+ """PatternGuard — checks for required/forbidden substrings in artifact content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from atomicguard.domain.interfaces import GuardInterface
6
+ from atomicguard.domain.models import Artifact, GuardResult
7
+
8
+
9
+ class PatternGuard(GuardInterface):
10
+ """Passes if all required patterns are found and no forbidden patterns are found."""
11
+
12
+ def __init__(
13
+ self,
14
+ *,
15
+ required: list[str] | None = None,
16
+ forbidden: list[str] | None = None,
17
+ ):
18
+ self._required = required or []
19
+ self._forbidden = forbidden or []
20
+
21
+ def validate(self, artifact: Artifact, **deps: Artifact) -> GuardResult: # noqa: ARG002
22
+ content = artifact.content
23
+
24
+ for pattern in self._forbidden:
25
+ if pattern in content:
26
+ return GuardResult(
27
+ passed=False,
28
+ feedback=f"Forbidden pattern found: {pattern!r}",
29
+ guard_name="PatternGuard",
30
+ )
31
+
32
+ for pattern in self._required:
33
+ if pattern not in content:
34
+ return GuardResult(
35
+ passed=False,
36
+ feedback=f"Required pattern not found: {pattern!r}",
37
+ guard_name="PatternGuard",
38
+ )
39
+
40
+ return GuardResult(passed=True, guard_name="PatternGuard")
@@ -0,0 +1,78 @@
1
+ """RenderedCommandGuard — pre-guard for command-based APs.
2
+
3
+ Validates that the generator's rendered command is well-formed BEFORE the
4
+ effector runs. Catches:
5
+
6
+ - Empty rendered command (template was empty, or all placeholders blanked)
7
+ - Unresolved template placeholders: `{spec.X}`, `{dep.X.Y}`, `{type.X.Y}`
8
+ that survived the generator. These indicate a generator bug or a missing
9
+ spec/dependency value the generator should have raised on.
10
+
11
+ Layers as ``G_pre`` in the formal AP tuple ⟨ρ, a_gen, G_pre, E, G_post⟩.
12
+ The corresponding ``G_post`` (e.g. ScriptResultGuard + a domain guard)
13
+ validates the effector's output.
14
+
15
+ Why this matters: failures caught at G_pre cost zero subprocess execution.
16
+ A malformed command running through ``BashCommandExecutor`` still uses a
17
+ slot of the AP's r_max budget and produces a confusing failure mode (the
18
+ shell sees `{spec.foo}` literally and probably exits non-zero with an
19
+ unhelpful message). Catching it before the effector gives a precise
20
+ diagnosis.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from typing import Any
27
+
28
+ from atomicguard.domain.interfaces import GuardInterface
29
+ from atomicguard.domain.models import Artifact, GuardResult
30
+
31
+ # Match any of the three placeholder forms used by CommandTemplateGenerator.
32
+ _UNRESOLVED = re.compile(r"\{(?:spec|dep|type)\.[\w.]+\}")
33
+
34
+
35
+ class RenderedCommandGuard(GuardInterface):
36
+ """Pre-guard for command-template-based APs.
37
+
38
+ Pass when ``artifact.content`` is a non-empty string with no
39
+ ``{spec.*}``, ``{dep.*.*}``, or ``{type.*.*}`` placeholders remaining.
40
+ Fail with a precise diagnostic otherwise.
41
+ """
42
+
43
+ def __init__(self, **_kwargs: Any) -> None:
44
+ pass
45
+
46
+ def validate(self, artifact: Artifact, **dependencies: Artifact) -> GuardResult: # noqa: ARG002
47
+ command = artifact.content or ""
48
+ stripped = command.strip()
49
+ if not stripped:
50
+ return GuardResult(
51
+ passed=False,
52
+ feedback=(
53
+ "Rendered command is empty. The generator produced an empty "
54
+ "task template — check ap_context.json `task` field for the "
55
+ "current AP."
56
+ ),
57
+ guard_name="RenderedCommandGuard",
58
+ )
59
+
60
+ unresolved = _UNRESOLVED.findall(command)
61
+ if unresolved:
62
+ unique = sorted(set(unresolved))
63
+ return GuardResult(
64
+ passed=False,
65
+ feedback=(
66
+ f"Rendered command contains unresolved placeholders: "
67
+ f"{unique}. The generator should have raised KeyError; "
68
+ f"if it didn't, the placeholder syntax is malformed or "
69
+ f"the regex in CommandTemplateGenerator is out of sync."
70
+ ),
71
+ guard_name="RenderedCommandGuard",
72
+ )
73
+
74
+ return GuardResult(
75
+ passed=True,
76
+ feedback="",
77
+ guard_name="RenderedCommandGuard",
78
+ )