simplicio-loop 3.25.1__tar.gz → 3.31.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. {simplicio_loop-3.25.1/simplicio_loop.egg-info → simplicio_loop-3.31.1}/PKG-INFO +1 -1
  2. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/README.md +20 -0
  3. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/pyproject.toml +1 -1
  4. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/__init__.py +2 -2
  5. simplicio_loop-3.31.1/simplicio_loop/agent_contract.py +79 -0
  6. simplicio_loop-3.31.1/simplicio_loop/attempt_journal.py +251 -0
  7. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/cli.py +68 -2
  8. simplicio_loop-3.31.1/simplicio_loop/driver_contract.py +101 -0
  9. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/evidence.py +6 -2
  10. simplicio_loop-3.31.1/simplicio_loop/execution_board.py +167 -0
  11. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/oracle.py +32 -0
  12. simplicio_loop-3.31.1/simplicio_loop/plan_contract.py +95 -0
  13. simplicio_loop-3.31.1/simplicio_loop/progress.py +227 -0
  14. simplicio_loop-3.31.1/simplicio_loop/recovery.py +393 -0
  15. simplicio_loop-3.31.1/simplicio_loop/remote_queue.py +426 -0
  16. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/runner.py +137 -9
  17. simplicio_loop-3.31.1/simplicio_loop/runtime_adapter.py +235 -0
  18. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1/simplicio_loop.egg-info}/PKG-INFO +1 -1
  19. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop.egg-info/SOURCES.txt +23 -0
  20. simplicio_loop-3.31.1/tests/test_agent_contract.py +33 -0
  21. simplicio_loop-3.31.1/tests/test_agent_identity.py +46 -0
  22. simplicio_loop-3.31.1/tests/test_attempt_journal.py +72 -0
  23. simplicio_loop-3.31.1/tests/test_cli_oracle.py +48 -0
  24. simplicio_loop-3.31.1/tests/test_driver_contract.py +34 -0
  25. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_evidence_receipt.py +28 -0
  26. simplicio_loop-3.31.1/tests/test_execution_board_e2e.py +42 -0
  27. simplicio_loop-3.31.1/tests/test_independent_watcher.py +66 -0
  28. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_operator_worktree_batch.py +55 -0
  29. simplicio_loop-3.31.1/tests/test_plan_contract.py +56 -0
  30. simplicio_loop-3.31.1/tests/test_progress.py +101 -0
  31. simplicio_loop-3.31.1/tests/test_recovery.py +155 -0
  32. simplicio_loop-3.31.1/tests/test_remote_queue.py +92 -0
  33. simplicio_loop-3.31.1/tests/test_remote_queue_live.py +105 -0
  34. simplicio_loop-3.31.1/tests/test_runtime_adapter.py +115 -0
  35. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_task_backlog.py +40 -0
  36. simplicio_loop-3.31.1/tests/test_task_to_delivery_golden_corpus.py +32 -0
  37. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/LICENSE +0 -0
  38. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/MANIFEST.in +0 -0
  39. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/PYPI.md +0 -0
  40. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/setup.cfg +0 -0
  41. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/README.md +0 -0
  42. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/action_gate.py +0 -0
  43. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/hooks.claude.json +0 -0
  44. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/hooks.json +0 -0
  45. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/loop_capture.py +0 -0
  46. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/loop_stop.py +0 -0
  47. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/orient_clamp.py +0 -0
  48. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/orient_rewrite.py +0 -0
  49. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/pre-commit.py +0 -0
  50. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/simplicio_dashboard.py +0 -0
  51. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/hooks/simplicio_watch.py +0 -0
  52. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/scripts/cross_agent_wiki.py +0 -0
  53. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/scripts/hierarchical_planner.py +0 -0
  54. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-autoresearch/SKILL.md +0 -0
  55. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-compress/SKILL.md +0 -0
  56. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-learn/SKILL.md +0 -0
  57. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/SKILL.md +0 -0
  58. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/agentsview-adapter.md +0 -0
  59. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/azure-devops-adapter.md +0 -0
  60. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/bound-operators.md +0 -0
  61. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/claims-gate.md +0 -0
  62. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/cross-agent-wiki.md +0 -0
  63. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/cross-repo-integration.md +0 -0
  64. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/extension-points.md +0 -0
  65. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/hierarchical-planner.md +0 -0
  66. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/lmcache-adapter.md +0 -0
  67. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/orchestration.md +0 -0
  68. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/quality-safety-delivery.md +0 -0
  69. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/run-journal-stall-detector.md +0 -0
  70. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/spindle-handoff.md +0 -0
  71. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/standing-loop-247.md +0 -0
  72. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/token-capture.md +0 -0
  73. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/token-economy.md +0 -0
  74. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/triage-verify-detail.md +0 -0
  75. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/understand-anything-adapter.md +0 -0
  76. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/video-evidence.md +0 -0
  77. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-loop/references/web-evidence.md +0 -0
  78. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-orient/SKILL.md +0 -0
  79. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-review/SKILL.md +0 -0
  80. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/SKILL.md +0 -0
  81. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/agentsview-adapter.md +0 -0
  82. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/azure-devops-adapter.md +0 -0
  83. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/extension-points.md +0 -0
  84. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/lmcache-adapter.md +0 -0
  85. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/orchestration.md +0 -0
  86. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/quality-safety-delivery.md +0 -0
  87. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/standing-loop-247.md +0 -0
  88. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/token-capture.md +0 -0
  89. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/token-economy.md +0 -0
  90. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/understand-anything-adapter.md +0 -0
  91. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/video-evidence.md +0 -0
  92. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/skills/simplicio-tasks/references/web-evidence.md +0 -0
  93. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/tests/_selfrun.py +0 -0
  94. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/tests/test_cross_agent_wiki.py +0 -0
  95. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/_bundle/tests/test_loop_e2e.py +0 -0
  96. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/delivery.py +0 -0
  97. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/drain.py +0 -0
  98. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/ops_ledger.py +0 -0
  99. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/phase_events.py +0 -0
  100. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/source_state.py +0 -0
  101. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop/task_contract.py +0 -0
  102. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop.egg-info/dependency_links.txt +0 -0
  103. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop.egg-info/entry_points.txt +0 -0
  104. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop.egg-info/requires.txt +0 -0
  105. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/simplicio_loop.egg-info/top_level.txt +0 -0
  106. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_action_gate.py +0 -0
  107. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_agentsview_adapter.py +0 -0
  108. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_autoresearch.py +0 -0
  109. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_az_boards_adapter.py +0 -0
  110. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_check_e2e_demo_contract.py +0 -0
  111. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_check_e2e_installed.py +0 -0
  112. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_claims_audit.py +0 -0
  113. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_claims_manifest_unit.py +0 -0
  114. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_clean_env_contract.py +0 -0
  115. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_completion_oracle.py +0 -0
  116. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_completion_oracle_matrix.py +0 -0
  117. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_cross_agent_wiki.py +0 -0
  118. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_dashboard_hook.py +0 -0
  119. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_delivery_source_identity.py +0 -0
  120. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_doctor_smoke.py +0 -0
  121. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_drain.py +0 -0
  122. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_drain_cli.py +0 -0
  123. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_e2e_demo_audit.py +0 -0
  124. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_evidence_chain.py +0 -0
  125. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_fan_out_flow.py +0 -0
  126. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_fan_out_scheduler.py +0 -0
  127. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_fan_out_unit.py +0 -0
  128. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_flow_audit.py +0 -0
  129. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_hbp_topics.py +0 -0
  130. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_hierarchical_planner.py +0 -0
  131. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_hooks_coverage.py +0 -0
  132. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_impact_audit.py +0 -0
  133. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_install_lib.py +0 -0
  134. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_learn_pipeline_removed.py +0 -0
  135. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_ledger_cli.py +0 -0
  136. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_locked_append.py +0 -0
  137. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_loop_e2e.py +0 -0
  138. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_mirror_parity.py +0 -0
  139. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_operator_batch.py +0 -0
  140. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_ops_ledger.py +0 -0
  141. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_phase_events.py +0 -0
  142. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_planes_golden_eval.py +0 -0
  143. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_precommit_bundle_sync.py +0 -0
  144. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_repo_conventions_architecture.py +0 -0
  145. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_runner_cli.py +0 -0
  146. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_schema_verify_integration.py +0 -0
  147. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_schema_verify_unit.py +0 -0
  148. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_system_check.py +0 -0
  149. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_task_contract_cli.py +0 -0
  150. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_task_contract_unit.py +0 -0
  151. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_token_budget.py +0 -0
  152. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_toon_codec.py +0 -0
  153. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_verify_adapters_integration.py +0 -0
  154. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_watcher_binding.py +0 -0
  155. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_worker_cli_contract.py +0 -0
  156. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_worker_selftests.py +0 -0
  157. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_worker_smoke.py +0 -0
  158. {simplicio_loop-3.25.1 → simplicio_loop-3.31.1}/tests/test_worktree_queue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: simplicio-loop
3
- Version: 3.25.1
3
+ Version: 3.31.1
4
4
  Summary: The Universal Looping AI Orchestrator — a runtime-agnostic super-plugin (7 skills) that drains any queue of work end-to-end on any LLM/runtime.
5
5
  Author-email: Wesley Simplicio <wesleybob4@gmail.com>
6
6
  License: MIT
@@ -55,6 +55,9 @@
55
55
 
56
56
  - **Contract first** — acceptance criteria, dependencies, risks, source state, and the completion oracle are explicit before execution.
57
57
  - **Parallel without corruption** — ready tasks run in isolated lanes/worktrees and converge through an operational ledger.
58
+ - **Automatic fan-out by default** — `batch` provisions one owned worktree per independent,
59
+ authorized task; overlap, missing evidence, or unavailable isolation falls back to a
60
+ visible serial lane. See [`docs/AUTO_FAN_OUT.md`](docs/AUTO_FAN_OUT.md).
58
61
  - **Proof before completion** — tests, impact/flow checks, watcher challenges, delivery receipts, and HBP evidence reject false done states.
59
62
  - **Memory that changes behavior** — the journal, stall detector, checkpoints, and cross-agent wiki prevent oscillation and make handoffs durable.
60
63
 
@@ -124,6 +127,23 @@ company work. `simplicio-runtime` is the unified entrypoint going forward, but i
124
127
  reuse this loop's evidence-gated converge/drain discipline, durable attempt journal, and worker
125
128
  coordination patterns instead of creating a separate task semantics.
126
129
 
130
+ ### 👁️ Progresso visual, honesto e portátil
131
+
132
+ Cada execução pode ser acompanhada por texto, Markdown, JSON ou uma animação ANSI. Todas as
133
+ superfícies consomem o mesmo evento `simplicio.progress/v1`, incluindo ícones de etapa, gates de
134
+ evidence/watcher/oracle, lanes de worktrees e eventos de `worker_claimed` até `delivery_reconciled`.
135
+
136
+ ```powershell
137
+ simplicio-loop progress <run-id> --format text --once
138
+ simplicio-loop progress <run-id> --format markdown --once # LLM/chat
139
+ simplicio-loop progress <run-id> --format json --once # dashboard/adapters
140
+ simplicio-loop progress <run-id> --format text --ascii --no-animation # log/PowerShell
141
+ ```
142
+
143
+ `100%` só aparece com receipt do oracle pronto (`COMPLETE`/`DRAINED`); uma fase `done` sem prova
144
+ fica quase concluída e mostra o blocker. Consulte o contrato completo em
145
+ [`docs/PROGRESS_PROTOCOL.md`](docs/PROGRESS_PROTOCOL.md).
146
+
127
147
  ---
128
148
 
129
149
  ## 🤖 LLM front door
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "simplicio-loop"
7
- version = "3.25.1"
7
+ version = "3.31.1"
8
8
  description = "The Universal Looping AI Orchestrator — a runtime-agnostic super-plugin (7 skills) that drains any queue of work end-to-end on any LLM/runtime."
9
9
  readme = "PYPI.md"
10
10
  requires-python = ">=3.8"
@@ -13,6 +13,6 @@ try:
13
13
  try:
14
14
  __version__ = _v("simplicio-loop")
15
15
  except PackageNotFoundError:
16
- __version__ = "3.25.1"
16
+ __version__ = "3.31.1"
17
17
  except Exception: # pragma: no cover
18
- __version__ = "3.25.1"
18
+ __version__ = "3.31.1"
@@ -0,0 +1,79 @@
1
+ """Identity, context-isolation and receipt bindings for distributed workers.
2
+
3
+ The queue identifies *who* owns a lease; this module defines the smaller
4
+ contract that may cross a device boundary. A context pack contains only the
5
+ assigned work item and allow-listed source references. Prompts, transcripts,
6
+ environment variables and another worker's private state are never copied.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from copy import deepcopy
11
+ from typing import Any, Mapping, Sequence
12
+
13
+ SCHEMA = "simplicio.agent-context/v1"
14
+ RECEIPT_SCHEMA = "simplicio.agent-receipt/v1"
15
+ IDENTITY_FIELDS = ("agent_id", "runtime", "device_id", "session_id")
16
+ CAPABILITIES = frozenset(("claim", "heartbeat", "fencing", "receipts", "events", "evidence", "completion"))
17
+
18
+
19
+ class AgentContractError(ValueError):
20
+ """Raised when an identity, capability set, or context pack is unsafe."""
21
+
22
+
23
+ def validate_identity(identity: Mapping[str, Any], *, capabilities: Sequence[str] | None = None) -> dict[str, Any]:
24
+ if not isinstance(identity, Mapping):
25
+ raise AgentContractError("agent identity must be an object")
26
+ result = {field: str(identity.get(field) or "").strip() for field in IDENTITY_FIELDS}
27
+ missing = [field for field, value in result.items() if not value]
28
+ if missing:
29
+ raise AgentContractError("agent identity missing: " + ", ".join(missing))
30
+ raw_caps = list(capabilities if capabilities is not None else (identity.get("capabilities") or []))
31
+ if any(not isinstance(cap, str) or not cap.strip() for cap in raw_caps):
32
+ raise AgentContractError("capabilities must contain non-empty strings")
33
+ normalized = [cap.strip() for cap in raw_caps]
34
+ if len(set(normalized)) != len(normalized):
35
+ raise AgentContractError("duplicate capabilities are not allowed")
36
+ unknown = sorted(set(normalized).difference(CAPABILITIES))
37
+ if unknown:
38
+ raise AgentContractError("unsupported capabilities: " + ", ".join(unknown))
39
+ result["capabilities"] = normalized
40
+ result["protocol"] = str(identity.get("protocol") or "simplicio-distributed/v1")
41
+ return result
42
+
43
+
44
+ def build_context_pack(*, task_id: str, goal: str, identity: Mapping[str, Any],
45
+ acs: Sequence[str] = (), source_refs: Sequence[str] = (),
46
+ depends_on: Sequence[str] = (), allowed_paths: Sequence[str] = ()) -> dict[str, Any]:
47
+ """Build a deterministic, minimum-necessary context for one worker."""
48
+ normalized = validate_identity(identity)
49
+ allow = {str(path).strip() for path in allowed_paths if str(path).strip()}
50
+ refs = [str(path).strip() for path in source_refs if str(path).strip() and str(path).strip() in allow]
51
+ return {
52
+ "schema": SCHEMA,
53
+ "task_id": str(task_id).strip(),
54
+ "goal": str(goal).strip(),
55
+ "acs": [str(ac).strip() for ac in acs if str(ac).strip()],
56
+ "source_refs": refs,
57
+ "depends_on": [str(dep).strip() for dep in depends_on if str(dep).strip()],
58
+ "assigned_to": {field: normalized[field] for field in IDENTITY_FIELDS},
59
+ "capabilities": list(normalized["capabilities"]),
60
+ }
61
+
62
+
63
+ def bind_receipt(receipt: Mapping[str, Any], identity: Mapping[str, Any], *, context_pack: Mapping[str, Any] | None = None) -> dict[str, Any]:
64
+ """Return a receipt carrying immutable agent identity and isolated context."""
65
+ normalized = validate_identity(identity)
66
+ result = deepcopy(dict(receipt))
67
+ existing = result.get("agent")
68
+ if existing is not None and validate_identity(existing) != normalized:
69
+ raise AgentContractError("receipt agent identity mismatch")
70
+ result["schema"] = result.get("schema") or RECEIPT_SCHEMA
71
+ result["agent"] = normalized
72
+ if context_pack is not None:
73
+ if context_pack.get("assigned_to") != {field: normalized[field] for field in IDENTITY_FIELDS}:
74
+ raise AgentContractError("context pack is assigned to another agent")
75
+ result["context"] = deepcopy(dict(context_pack))
76
+ return result
77
+
78
+
79
+ __all__ = ["AgentContractError", "CAPABILITIES", "IDENTITY_FIELDS", "RECEIPT_SCHEMA", "SCHEMA", "bind_receipt", "build_context_pack", "validate_identity"]
@@ -0,0 +1,251 @@
1
+ """Typed, append-only attempt observations for Loop/Runtime reconciliation.
2
+
3
+ ``loop_journal.py`` is intentionally human-friendly and accepts legacy rows. This
4
+ module is the machine-facing companion: every row has stable identity, causal
5
+ lineage, a hash-chain link and an explicit claim class. It is transport agnostic
6
+ and can therefore be exported to Runtime or replayed after a provider handoff.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import time
14
+ from pathlib import Path
15
+ from typing import Any, Dict, Iterable, List, Mapping, Optional
16
+
17
+ from .ops_ledger import _locks
18
+
19
+ SCHEMA = "simplicio.loop-observation/v1"
20
+ EVENT_KINDS = frozenset((
21
+ "hypothesis", "action", "tool_execution", "validation", "failure",
22
+ "observation", "decision",
23
+ ))
24
+ CLAIM_TYPES = frozenset(("MEASURED", "UNVERIFIED", "ESTIMATED"))
25
+
26
+
27
+ class AttemptJournalError(ValueError):
28
+ """Raised when journal data cannot be safely appended or replayed."""
29
+
30
+
31
+ def _canonical(value: Mapping[str, Any]) -> str:
32
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
33
+
34
+
35
+ def _hash(value: Mapping[str, Any]) -> str:
36
+ return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
37
+
38
+
39
+ def _text(value: Any, name: str) -> str:
40
+ if not isinstance(value, str) or not value.strip():
41
+ raise AttemptJournalError("%s must be a non-empty string" % name)
42
+ return value.strip()
43
+
44
+
45
+ def build_observation(*, run_id: str, work_item_id: str, attempt_id: str,
46
+ actor: str, kind: str, payload: Mapping[str, Any],
47
+ sequence: int, event_id: str, causation_id: Optional[str] = None,
48
+ claim_type: str = "UNVERIFIED", ac_ids: Optional[Iterable[str]] = None,
49
+ observed_at: Optional[str] = None) -> Dict[str, Any]:
50
+ """Build one validated typed observation envelope.
51
+
52
+ ``event_id`` is caller-owned so retries can reuse it. Reusing an ID with a
53
+ changed envelope is rejected by :class:`AttemptJournal` rather than silently
54
+ creating a second attempt.
55
+ """
56
+ if isinstance(sequence, bool) or not isinstance(sequence, int) or sequence < 1:
57
+ raise AttemptJournalError("sequence must be a positive integer")
58
+ kind = _text(kind, "kind")
59
+ if kind not in EVENT_KINDS:
60
+ raise AttemptJournalError("unsupported observation kind: %s" % kind)
61
+ if not isinstance(payload, Mapping):
62
+ raise AttemptJournalError("payload must be an object")
63
+ claim_type = _text(claim_type, "claim_type").upper()
64
+ if claim_type not in CLAIM_TYPES:
65
+ raise AttemptJournalError("unsupported claim_type: %s" % claim_type)
66
+ criteria = list(ac_ids or ())
67
+ if any(not isinstance(item, str) or not item.strip() for item in criteria):
68
+ raise AttemptJournalError("ac_ids must contain non-empty strings")
69
+ event: Dict[str, Any] = {
70
+ "schema": SCHEMA,
71
+ "sequence": sequence,
72
+ "event_id": _text(event_id, "event_id"),
73
+ "run_id": _text(run_id, "run_id"),
74
+ "work_item_id": _text(work_item_id, "work_item_id"),
75
+ "attempt_id": _text(attempt_id, "attempt_id"),
76
+ "actor": _text(actor, "actor"),
77
+ "kind": kind,
78
+ "causation_id": _text(causation_id or event_id, "causation_id"),
79
+ "claim_type": claim_type,
80
+ "ac_ids": criteria,
81
+ "payload": dict(payload),
82
+ }
83
+ if kind == "failure":
84
+ # Providers may supply the loop_journal fingerprint; otherwise derive a
85
+ # provider-independent digest from the failure payload. This is the
86
+ # identity used to decide whether a resumed retry is genuinely new.
87
+ supplied = payload.get("failure_fingerprint", payload.get("fingerprint"))
88
+ event["failure_fingerprint"] = _text(supplied, "failure_fingerprint") if supplied else _hash(payload)[:12]
89
+ if observed_at is not None:
90
+ event["observed_at"] = _text(observed_at, "observed_at")
91
+ return event
92
+
93
+
94
+ def validate_observation(event: Mapping[str, Any]) -> Dict[str, Any]:
95
+ if not isinstance(event, Mapping) or event.get("schema") != SCHEMA:
96
+ raise AttemptJournalError("unsupported observation schema")
97
+ required = ("event_id", "run_id", "work_item_id", "attempt_id", "actor",
98
+ "kind", "causation_id", "claim_type", "ac_ids", "payload")
99
+ for field in required:
100
+ if field not in event:
101
+ raise AttemptJournalError("observation missing %s" % field)
102
+ checked = build_observation(
103
+ run_id=event["run_id"], work_item_id=event["work_item_id"],
104
+ attempt_id=event["attempt_id"], actor=event["actor"], kind=event["kind"],
105
+ payload=event["payload"], sequence=event["sequence"], event_id=event["event_id"],
106
+ causation_id=event.get("causation_id"), claim_type=event["claim_type"],
107
+ ac_ids=event.get("ac_ids"), observed_at=event.get("observed_at"),
108
+ )
109
+ if event.get("kind") == "failure" and event.get("failure_fingerprint") != checked.get("failure_fingerprint"):
110
+ raise AttemptJournalError("failure fingerprint mismatch")
111
+ if set(event) - set(checked):
112
+ # Forward-compatible fields are allowed, but are included in the hash and
113
+ # therefore cannot be changed during replay.
114
+ checked.update({key: event[key] for key in set(event) - set(checked)})
115
+ return checked
116
+
117
+
118
+ class AttemptJournal:
119
+ """Hash-chained JSONL journal with idempotent append and deterministic import."""
120
+
121
+ def __init__(self, path: str | os.PathLike[str]):
122
+ self.path = Path(path)
123
+ self.lock_path = Path(str(self.path) + ".lock")
124
+
125
+ def _lock(self):
126
+ if _locks is None:
127
+ raise AttemptJournalError("cross-process locking helper is unavailable")
128
+ _locks._ensure_lock_file(str(self.lock_path))
129
+ if _locks.fcntl is not None:
130
+ handle = self.lock_path.open("a+b")
131
+ if not _locks._acquire_posix(handle, _locks.DEFAULT_TIMEOUT_MS):
132
+ handle.close()
133
+ raise AttemptJournalError("journal lock acquisition timed out")
134
+ return handle, _locks._release_posix
135
+ if _locks.msvcrt is not None: # pragma: no cover - exercised on Windows CI
136
+ handle = self.lock_path.open("r+b")
137
+ if not _locks._acquire_windows(handle, _locks.DEFAULT_TIMEOUT_MS):
138
+ handle.close()
139
+ raise AttemptJournalError("journal lock acquisition timed out")
140
+ return handle, _locks._release_windows
141
+ raise AttemptJournalError("no cross-process locking primitive is available")
142
+
143
+ def _read(self) -> List[Dict[str, Any]]:
144
+ if not self.path.exists():
145
+ return []
146
+ rows: List[Dict[str, Any]] = []
147
+ for n, line in enumerate(self.path.read_text(encoding="utf-8").splitlines(), 1):
148
+ if not line.strip():
149
+ continue
150
+ try:
151
+ row = json.loads(line)
152
+ except ValueError as exc:
153
+ raise AttemptJournalError("invalid JSON at journal line %d" % n) from exc
154
+ if not isinstance(row, dict):
155
+ raise AttemptJournalError("journal line %d is not an object" % n)
156
+ rows.append(row)
157
+ return rows
158
+
159
+ @staticmethod
160
+ def _verify(rows: List[Dict[str, Any]]) -> None:
161
+ previous = ""
162
+ for expected, row in enumerate(rows, 1):
163
+ validate_observation(row)
164
+ if row.get("sequence") != expected or row.get("prev_hash", "") != previous:
165
+ raise AttemptJournalError("journal sequence/hash gap at %s" % row.get("event_id", "?"))
166
+ body = dict(row)
167
+ recorded = body.pop("hash", None)
168
+ if recorded != _hash(body):
169
+ raise AttemptJournalError("journal hash mismatch at %s" % row.get("event_id", "?"))
170
+ previous = recorded
171
+
172
+ def replay(self) -> List[Dict[str, Any]]:
173
+ handle, release = self._lock()
174
+ try:
175
+ rows = self._read()
176
+ self._verify(rows)
177
+ return rows
178
+ finally:
179
+ release(handle)
180
+ handle.close()
181
+
182
+ def append(self, event: Mapping[str, Any]) -> Dict[str, Any]:
183
+ normalized = validate_observation(event)
184
+ handle, release = self._lock()
185
+ try:
186
+ rows = self._read()
187
+ self._verify(rows)
188
+ for prior in rows:
189
+ if prior.get("event_id") == normalized["event_id"]:
190
+ prior_core = {key: value for key, value in prior.items()
191
+ if key not in ("sequence", "prev_hash", "hash")}
192
+ normalized_core = {key: value for key, value in normalized.items()
193
+ if key not in ("sequence", "prev_hash", "hash")}
194
+ if prior_core == normalized_core:
195
+ return prior
196
+ raise AttemptJournalError("event_id already exists with a different envelope")
197
+ body = dict(normalized)
198
+ # Imported envelopes carry their source chain metadata. Recompute
199
+ # local sequence/link/hash instead of hashing the old hash field.
200
+ body.pop("hash", None)
201
+ body.pop("prev_hash", None)
202
+ body["sequence"] = len(rows) + 1
203
+ body["prev_hash"] = rows[-1].get("hash", "") if rows else ""
204
+ body["hash"] = _hash(body)
205
+ self.path.parent.mkdir(parents=True, exist_ok=True)
206
+ with self.path.open("a", encoding="utf-8", newline="\n") as stream:
207
+ stream.write(json.dumps(body, ensure_ascii=False, sort_keys=True) + "\n")
208
+ stream.flush()
209
+ os.fsync(stream.fileno())
210
+ return body
211
+ finally:
212
+ release(handle)
213
+ handle.close()
214
+
215
+ def import_legacy(self, path: str | os.PathLike[str], *, run_id: str,
216
+ work_item_id: str, attempt_id: str, actor: str) -> List[Dict[str, Any]]:
217
+ """Migrate human journal rows deterministically; source is never modified."""
218
+ source = Path(path)
219
+ if not source.exists():
220
+ raise AttemptJournalError("legacy journal does not exist")
221
+ imported: List[Dict[str, Any]] = []
222
+ for index, raw in enumerate(source.read_text(encoding="utf-8").splitlines(), 1):
223
+ if not raw.strip():
224
+ continue
225
+ try:
226
+ old = json.loads(raw)
227
+ except ValueError as exc:
228
+ raise AttemptJournalError("invalid legacy JSON at line %d" % index) from exc
229
+ gate = str(old.get("gate", "blocked")).lower()
230
+ kind = "validation" if gate == "pass" else ("failure" if gate == "fail" else "observation")
231
+ event = build_observation(
232
+ run_id=run_id, work_item_id=work_item_id, attempt_id=attempt_id,
233
+ actor=actor, kind=kind, sequence=index, event_id="legacy-%d" % index,
234
+ causation_id="legacy-%d" % index,
235
+ claim_type="MEASURED" if gate == "pass" else "UNVERIFIED",
236
+ payload={"legacy": old},
237
+ )
238
+ imported.append(self.append(event))
239
+ return imported
240
+
241
+ def export(self) -> List[Dict[str, Any]]:
242
+ """Return the verified canonical envelopes for Runtime ingestion."""
243
+ return self.replay()
244
+
245
+ def import_events(self, events: Iterable[Mapping[str, Any]]) -> List[Dict[str, Any]]:
246
+ """Validate and idempotently ingest envelopes from Runtime or another host."""
247
+ return [self.append(event) for event in events]
248
+
249
+
250
+ __all__ = ["AttemptJournal", "AttemptJournalError", "CLAIM_TYPES", "EVENT_KINDS",
251
+ "SCHEMA", "build_observation", "validate_observation"]
@@ -42,6 +42,8 @@ from .ops_ledger import (
42
42
  LedgerError,
43
43
  validate_handshake,
44
44
  )
45
+ from .progress import stream as stream_progress
46
+ from .oracle import evaluate_matrix, persist_completion_receipt
45
47
 
46
48
  BUNDLE = Path(__file__).resolve().parent / "_bundle"
47
49
  DASHBOARD = BUNDLE / "hooks" / "simplicio_dashboard.py"
@@ -208,7 +210,8 @@ def tick(repo: str, run_id: str, task_index: int) -> int:
208
210
  return 0
209
211
 
210
212
 
211
- def batch(repo: str, run_id: str, task_indices: str, max_workers: int, retry_budget: int) -> int:
213
+ def batch(repo: str, run_id: str, task_indices: str, max_workers: int, retry_budget: int,
214
+ serial: bool = False) -> int:
212
215
  """Run selected ready tasks through the durable real-operator pool."""
213
216
  indices = None
214
217
  if task_indices.strip():
@@ -222,6 +225,7 @@ def batch(repo: str, run_id: str, task_indices: str, max_workers: int, retry_bud
222
225
  indices,
223
226
  max_workers=max_workers or None,
224
227
  retry_budget=retry_budget,
228
+ auto_fan_out=not serial,
225
229
  )
226
230
  print(json.dumps(payload, ensure_ascii=False, indent=2))
227
231
  return 0
@@ -233,6 +237,38 @@ def cancel(repo: str, run_id: str) -> int:
233
237
  return 0
234
238
 
235
239
 
240
+ def oracle(loop_dir: str, run_dir: str, response_text: str, flow_gap: str,
241
+ write_receipt: bool) -> int:
242
+ """Evaluate the shared completion oracle and its cross-runtime parity."""
243
+ payload = evaluate_matrix(loop_dir, run_dir, response_text=response_text, flow_gap=flow_gap)
244
+ if write_receipt:
245
+ # Persist the canonical verdict once; the matrix is only the parity proof.
246
+ from .oracle import evaluate_completion
247
+ verdict = evaluate_completion(loop_dir, run_dir, response_text=response_text,
248
+ flow_gap=flow_gap)
249
+ payload["receipt_path"] = persist_completion_receipt(verdict, loop_dir, run_dir)
250
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
251
+ return 0 if payload.get("parity") and payload.get("signature", [False])[0] else 1
252
+
253
+
254
+ def progress(repo: str, run_id: str, fmt: str, once: bool, interval: float,
255
+ no_animation: bool = False, ascii_only: bool = False) -> int:
256
+ """Render a run's portable progress event for an LLM, terminal, or dashboard."""
257
+ try:
258
+ status = read_status(repo, run_id)
259
+ except (FileNotFoundError, OSError, ValueError, KeyError) as exc:
260
+ print(json.dumps({"schema": "simplicio.progress/v1", "status": "UNVERIFIED",
261
+ "reason_code": "run_missing", "error": str(exc)}, ensure_ascii=False))
262
+ return 2
263
+ if not status or not status.get("run_dir"):
264
+ print(json.dumps({"schema": "simplicio.progress/v1", "status": "UNVERIFIED",
265
+ "reason_code": "run_missing"}, ensure_ascii=False))
266
+ return 2
267
+ stream_progress(status["run_dir"], fmt=fmt, once=once, interval=interval,
268
+ no_animation=no_animation, ascii_only=ascii_only)
269
+ return 0
270
+
271
+
236
272
  def maintenance_deferred(repo: str, run_id: str, mode: str, disposition: str,
237
273
  correction_summary: str, deferral_reason: str,
238
274
  resume_instructions: list[str], evidence_status: str) -> int:
@@ -484,10 +520,30 @@ def main(argv=None) -> int:
484
520
  p_run.add_argument("--delivery", default="verified", help="requested delivery target")
485
521
  p_run.add_argument("--max-iterations", type=int, default=12, help="safety cap")
486
522
 
523
+ p_oracle = sub.add_parser("oracle", help="evaluate completion and cross-runtime parity")
524
+ p_oracle.add_argument("--loop-dir", default=os.path.join(".orchestrator", "loop"))
525
+ p_oracle.add_argument("--run-dir", default=os.environ.get("SIMPLICIO_RUN_DIR", ""))
526
+ p_oracle.add_argument("--response-text", default="")
527
+ p_oracle.add_argument("--flow-gap", default="")
528
+ p_oracle.add_argument("--write-receipt", action="store_true")
529
+
487
530
  p_status = sub.add_parser("status", help="show the latest run state or a specific run")
488
531
  p_status.add_argument("--repo", default=".", help="repository root")
489
532
  p_status.add_argument("--run-id", default="", help="run id to inspect")
490
533
 
534
+ p_progress = sub.add_parser("progress", help="render visual progress for a run")
535
+ p_progress.add_argument("--repo", default=".", help="repository root")
536
+ p_progress.add_argument("run_id", help="run id to render")
537
+ p_progress.add_argument("--format", choices=("text", "json", "markdown", "ansi"),
538
+ default="text", dest="fmt", help="output format")
539
+ p_progress.add_argument("--once", action="store_true", help="render one snapshot")
540
+ p_progress.add_argument("--no-animation", action="store_true",
541
+ help="emit one static snapshot without spinner/ANSI control codes")
542
+ p_progress.add_argument("--ascii", action="store_true", dest="ascii_only",
543
+ help="use ASCII icons/bar for terminals without Unicode support")
544
+ p_progress.add_argument("--interval", type=float, default=0.25,
545
+ help="animation polling interval in seconds")
546
+
491
547
  p_resume = sub.add_parser("resume", help="resume a non-terminal run")
492
548
  p_resume.add_argument("--repo", default=".", help="repository root")
493
549
  p_resume.add_argument("run_id", help="run id to resume")
@@ -512,6 +568,10 @@ def main(argv=None) -> int:
512
568
  help="maximum live operator workers (default: SIMPLICIO_LOOP_OPERATOR_WORKERS/6)",
513
569
  )
514
570
  p_batch.add_argument("--retry-budget", type=int, default=3, help="retries after the first attempt")
571
+ p_batch.add_argument(
572
+ "--serial", action="store_true",
573
+ help="disable the default isolated fan-out and force the shared-run serial lane",
574
+ )
515
575
 
516
576
  p_cancel = sub.add_parser("cancel", help="cancel a non-terminal run")
517
577
  p_cancel.add_argument("--repo", default=".", help="repository root")
@@ -611,14 +671,20 @@ def main(argv=None) -> int:
611
671
  return plan(args.task, args.out)
612
672
  if command == "run":
613
673
  return run(args.repo, args.task, args.delivery, args.max_iterations)
674
+ if command == "oracle":
675
+ return oracle(args.loop_dir, args.run_dir, args.response_text, args.flow_gap,
676
+ args.write_receipt)
614
677
  if command == "status":
615
678
  return status(args.repo, args.run_id)
679
+ if command == "progress":
680
+ return progress(args.repo, args.run_id, args.fmt, args.once, args.interval,
681
+ args.no_animation, args.ascii_only)
616
682
  if command == "resume":
617
683
  return resume(args.repo, args.run_id)
618
684
  if command == "tick":
619
685
  return tick(args.repo, args.run_id, args.task_index)
620
686
  if command == "batch":
621
- return batch(args.repo, args.run_id, args.task_indices, args.max_workers, args.retry_budget)
687
+ return batch(args.repo, args.run_id, args.task_indices, args.max_workers, args.retry_budget, args.serial)
622
688
  if command == "cancel":
623
689
  return cancel(args.repo, args.run_id)
624
690
  if command in {"maintenance-deferred", "defer-maintenance"}:
@@ -0,0 +1,101 @@
1
+ """Host-neutral loop driver contract.
2
+
3
+ Hook callbacks and scheduler ticks are transports, not separate state machines. This
4
+ module normalizes either transport into one deterministic decision and canonical event
5
+ stream. It is intentionally stdlib-only so runtimes can import it without the CLI.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from typing import Any, Dict, Iterable, List, Mapping
12
+
13
+ SCHEMA = "simplicio.loop-driver/v1"
14
+
15
+
16
+ class DriverContractError(ValueError):
17
+ pass
18
+
19
+
20
+ def _canon(value: Any) -> str:
21
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
22
+
23
+
24
+ def event_id(event: Mapping[str, Any]) -> str:
25
+ """Return a stable id when a transport did not provide one."""
26
+ explicit = str(event.get("event_id") or "").strip()
27
+ if explicit:
28
+ return explicit
29
+ seed = {k: event.get(k) for k in ("run_id", "iteration", "phase", "decision", "reason_code")}
30
+ return "evt-" + hashlib.sha256(_canon(seed).encode("utf-8")).hexdigest()[:20]
31
+
32
+
33
+ def normalize_event(raw: Mapping[str, Any], source: str) -> Dict[str, Any]:
34
+ """Normalize hook/self-paced observations into the same envelope."""
35
+ if not isinstance(raw, Mapping):
36
+ raise DriverContractError("driver event must be an object")
37
+ source = str(source or raw.get("source") or "").strip().lower()
38
+ if source not in {"hook", "self-paced"}:
39
+ raise DriverContractError("source must be hook or self-paced")
40
+ event = {
41
+ "schema": SCHEMA,
42
+ "event_id": event_id(raw),
43
+ "source": source,
44
+ "run_id": str(raw.get("run_id") or "").strip(),
45
+ "iteration": int(raw.get("iteration") or 0),
46
+ "phase": str(raw.get("phase") or "tick").strip(),
47
+ "decision": str(raw.get("decision") or "continue").strip().lower(),
48
+ "reason_code": str(raw.get("reason_code") or "").strip(),
49
+ "gates": dict(raw.get("gates") or {}),
50
+ }
51
+ if event["iteration"] < 0:
52
+ raise DriverContractError("iteration must be non-negative")
53
+ if event["decision"] not in {"continue", "stop", "block"}:
54
+ raise DriverContractError("invalid driver decision")
55
+ return event
56
+
57
+
58
+ def reconcile_events(events: Iterable[Mapping[str, Any]]) -> List[Dict[str, Any]]:
59
+ """Deduplicate identical callbacks and reject conflicting duplicate ids."""
60
+ seen: Dict[str, Dict[str, Any]] = {}
61
+ for raw in events:
62
+ source = str(raw.get("source") or "").strip().lower()
63
+ event = normalize_event(raw, source)
64
+ key = event["event_id"]
65
+ prior = seen.get(key)
66
+ if prior is not None:
67
+ # Hook and scheduler may deliver the same logical tick. Transport
68
+ # identity is deliberately excluded from the conflict comparison.
69
+ left = dict(prior); right = dict(event)
70
+ left.pop("source", None); right.pop("source", None)
71
+ if _canon(left) != _canon(right):
72
+ raise DriverContractError("conflicting duplicate event: %s" % key)
73
+ continue
74
+ seen[key] = event
75
+ return sorted(seen.values(), key=lambda item: (item["iteration"], item["event_id"]))
76
+
77
+
78
+ def evaluate_tick(snapshot: Mapping[str, Any]) -> Dict[str, Any]:
79
+ """Apply shared cap/STOP/evidence gates; absent hook delivery never completes."""
80
+ if not isinstance(snapshot, Mapping):
81
+ raise DriverContractError("snapshot must be an object")
82
+ mode = str(snapshot.get("mode") or "self-paced").strip().lower()
83
+ if mode not in {"hook", "self-paced"}:
84
+ raise DriverContractError("mode must be hook or self-paced")
85
+ iteration = int(snapshot.get("iteration") or 0)
86
+ maximum = int(snapshot.get("max_iterations") or 1)
87
+ if iteration < 0 or maximum < 1:
88
+ raise DriverContractError("invalid iteration cap")
89
+ gates = dict(snapshot.get("gates") or {})
90
+ if snapshot.get("hook_delivered") is False:
91
+ return {"schema": SCHEMA, "decision": "block", "reason_code": "hook_missing", "tag": "UNVERIFIED", "mode": mode}
92
+ if snapshot.get("stop_requested") is True:
93
+ return {"schema": SCHEMA, "decision": "block", "reason_code": "stop_requested", "tag": "UNVERIFIED", "mode": mode}
94
+ if iteration >= maximum:
95
+ return {"schema": SCHEMA, "decision": "block", "reason_code": "iteration_cap", "tag": "UNVERIFIED", "mode": mode}
96
+ if snapshot.get("promise_exact") is True and all(bool(gates.get(name)) for name in ("watcher", "evidence", "oracle")):
97
+ return {"schema": SCHEMA, "decision": "stop", "reason_code": "completion_verified", "tag": "MEASURED", "mode": mode}
98
+ return {"schema": SCHEMA, "decision": "continue", "reason_code": "gates_pending", "tag": "UNVERIFIED", "mode": mode}
99
+
100
+
101
+ __all__ = ["SCHEMA", "DriverContractError", "event_id", "normalize_event", "reconcile_events", "evaluate_tick"]
@@ -79,8 +79,12 @@ def _command_policy(check: Dict[str, Any]) -> tuple[List[str] | None, str]:
79
79
  return None, "no argv resolved"
80
80
  exe = Path(normalized[0]).name.lower()
81
81
  if exe.endswith(".exe"):
82
- exe = exe
83
- if exe not in SAFE_EXECUTABLES and not normalized[0].lower().endswith(".py"):
82
+ exe = exe[:-4]
83
+ # Test runners and receipts often pass an absolute interpreter path
84
+ # (python3.11, python3.14, ...). Treat versioned Python executables as
85
+ # equivalent to the explicitly allowlisted python/python3 commands.
86
+ python_executable = exe == "python" or exe.startswith("python3.")
87
+ if exe not in SAFE_EXECUTABLES and not python_executable and not normalized[0].lower().endswith(".py"):
84
88
  return None, f"command executable not allowlisted: {normalized[0]}"
85
89
  return normalized, "allowed"
86
90