cli-agent-runner 0.1.7__tar.gz → 0.1.9__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 (142) hide show
  1. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/CHANGELOG.md +86 -3
  2. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/PKG-INFO +1 -1
  3. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/_version.py +2 -2
  4. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/api.py +14 -0
  5. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/api_types.py +2 -1
  6. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/common.py +6 -2
  7. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/config.py +48 -5
  8. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/events.py +6 -1
  9. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/runner.py +18 -6
  10. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/service_unit.py +9 -2
  11. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/vcs_state.py +59 -1
  12. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/configuration.md +26 -0
  13. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/plugins.md +125 -17
  14. cli_agent_runner-0.1.9/tests/_test_helpers.py +44 -0
  15. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/contract/test_public_api_surface.py +17 -0
  16. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_context_enricher_namespacing.py +2 -10
  17. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_plugin_detector_loaded.py +2 -10
  18. cli_agent_runner-0.1.9/tests/integration/test_plugin_owned_paths.py +88 -0
  19. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_run_one_round_with_fake_agent.py +47 -0
  20. cli_agent_runner-0.1.9/tests/invariants/test_module_sizes.py +22 -0
  21. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_peek_schema_version.py +2 -2
  22. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_api_observation.py +59 -0
  23. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_api_types.py +33 -0
  24. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_config.py +244 -0
  25. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_detector_protocol.py +3 -10
  26. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_events.py +3 -10
  27. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_hook_failure_isolation.py +2 -10
  28. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_hooks.py +6 -17
  29. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_runner.py +59 -1
  30. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_service_unit.py +34 -3
  31. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_vcs_state.py +79 -0
  32. cli_agent_runner-0.1.7/tests/invariants/test_module_sizes.py +0 -49
  33. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.codecov.yml +0 -0
  34. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.githooks/commit-msg +0 -0
  35. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  36. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  37. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  38. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  39. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.github/workflows/ci.yml +0 -0
  40. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.github/workflows/release.yml +0 -0
  41. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.gitignore +0 -0
  42. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/.vulture-whitelist.py +0 -0
  43. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/CODE_OF_CONDUCT.md +0 -0
  44. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/CONTRIBUTING.md +0 -0
  45. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/LICENSE +0 -0
  46. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/README.md +0 -0
  47. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/README.zh.md +0 -0
  48. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/SECURITY.md +0 -0
  49. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/__init__.py +0 -0
  50. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/_docgen.py +0 -0
  51. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/_registry.py +0 -0
  52. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/agent_runtime.py +0 -0
  53. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/__init__.py +0 -0
  54. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/__main__.py +0 -0
  55. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/init_cmd.py +0 -0
  56. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/install_cmd.py +0 -0
  57. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/monitor_cmd.py +0 -0
  58. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/peek_cmd.py +0 -0
  59. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/round_cmd.py +0 -0
  60. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/serve_cmd.py +0 -0
  61. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/cli/service_cmd.py +0 -0
  62. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/context_store.py +0 -0
  63. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/defenses.py +0 -0
  64. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/detector_helpers.py +0 -0
  65. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/hooks.py +0 -0
  66. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/lifecycle.py +0 -0
  67. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/metrics.py +0 -0
  68. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/monitor.py +0 -0
  69. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/presets/__init__.py +0 -0
  70. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/presets/aider.toml +0 -0
  71. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/presets/claude.toml +0 -0
  72. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/prompt_loader.py +0 -0
  73. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/round_view.py +0 -0
  74. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/scaffold.py +0 -0
  75. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/agent_runner/startup_check.py +0 -0
  76. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/build.sh +0 -0
  77. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/deploy/example-agent-runner.toml +0 -0
  78. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/deploy/launchd.plist.tmpl +0 -0
  79. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/deploy/run-loop.sh +0 -0
  80. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/deploy/systemd.service.tmpl +0 -0
  81. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/README.md +0 -0
  82. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/architecture.md +0 -0
  83. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/commands.md +0 -0
  84. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/quickstart.md +0 -0
  85. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/recipes/aider.md +0 -0
  86. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/docs/runbook.md +0 -0
  87. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/pyproject.toml +0 -0
  88. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/__init__.py +0 -0
  89. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/conftest.py +0 -0
  90. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/contract/__init__.py +0 -0
  91. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/e2e/__init__.py +0 -0
  92. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/e2e/conftest.py +0 -0
  93. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  94. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/e2e/test_e2e_install_systemd.py +0 -0
  95. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  96. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  97. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/__init__.py +0 -0
  98. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_install_dry_run.py +0 -0
  99. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_monitor_seeded.py +0 -0
  100. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_run_loop_backoff.py +0 -0
  101. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_scaffold_presets.py +0 -0
  102. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/integration/test_serve_loop.py +0 -0
  103. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/__init__.py +0 -0
  104. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_architecture.py +0 -0
  105. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_atomic_write_enforced.py +0 -0
  106. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_catalogs.py +0 -0
  107. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_docs_generated.py +0 -0
  108. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_event_kind_registry.py +0 -0
  109. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_module_boundaries.py +0 -0
  110. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_no_ai_signatures.py +0 -0
  111. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  112. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  113. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_round_result_stable.py +0 -0
  114. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  115. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/literate/__init__.py +0 -0
  116. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/literate/parser.py +0 -0
  117. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/literate/test_parser.py +0 -0
  118. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/literate/test_quickstart.py +0 -0
  119. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/__init__.py +0 -0
  120. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_agent_runtime.py +0 -0
  121. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_api_service.py +0 -0
  122. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_auto_stop_gating.py +0 -0
  123. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_cli.py +0 -0
  124. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_cli_common.py +0 -0
  125. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_cli_init_install.py +0 -0
  126. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  127. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_context_store.py +0 -0
  128. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_defenses.py +0 -0
  129. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_detector_helpers.py +0 -0
  130. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_docgen.py +0 -0
  131. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_init_entry_points.py +0 -0
  132. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_lifecycle.py +0 -0
  133. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_metrics.py +0 -0
  134. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_monitor_assembly.py +0 -0
  135. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_monitor_detectors.py +0 -0
  136. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_monitor_remote.py +0 -0
  137. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_peek_argparse.py +0 -0
  138. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_presets.py +0 -0
  139. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_prompt_loader.py +0 -0
  140. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_round_view.py +0 -0
  141. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_scaffold.py +0 -0
  142. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.9}/tests/unit/test_startup_check.py +0 -0
@@ -7,6 +7,85 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.9] - 2026-05-13
11
+
12
+ ### Acknowledgements
13
+
14
+ Thanks to the argus-gateway team for the dev/qa/product wall-time data
15
+ (Phase 4 feedback §3.1) that drove this API shape. Their three-role
16
+ distribution made the case for per-phase overrides concrete.
17
+
18
+ ### Added
19
+
20
+ - `[runtime.round_timeout_per_phase]` TOML block — per-phase overrides for
21
+ `round_timeout_s`. Unconfigured phases fall back to global. Keys validated
22
+ against `[phases] list` at config-load (typo catcher); non-positive values
23
+ rejected; bool / float values rejected (would otherwise silently coerce
24
+ to int).
25
+ - `agent_runner.runner._round_timeout_for(cfg, phase)` helper — single
26
+ lookup point for phase-aware timeout resolution.
27
+
28
+ ### Migration
29
+
30
+ No breaking changes. Existing configs without the new block keep using a
31
+ single global timeout — identical to 0.1.8 behavior.
32
+
33
+ Plugin authors: no public API change. `RuntimeConfig` is not in the
34
+ documented plugin-author public surface.
35
+
36
+ ## [0.1.8] - 2026-05-13
37
+
38
+ ### Acknowledgements
39
+
40
+ Thanks to the argus-gateway team for Phase 4 dogfooding feedback that drove
41
+ every item in this release. 3 audit memos (~90KB) silently swept into an
42
+ orphan stash is a real-world failure mode; this release closes that loop.
43
+
44
+ ### Added
45
+
46
+ - `agent_runner.vcs_state.register_plugin_owned_paths()` — plugins opt-out
47
+ files/dirs from orphan-stash defense. Matching: trailing-slash prefix or
48
+ `pathlib.PurePath.match` glob (recognizes `**` for recursive segments via
49
+ `fnmatch` fallback on Python 3.11). Call at module import (entry_point
50
+ side-effect).
51
+ - `agent_runner.vcs_state.plugin_owned_paths()` — snapshot accessor for peek.
52
+ - `ProjectState.recent_hook_failures: list[dict]` — last 10 `hook_failed`
53
+ events filtered from `recent_events` for debugging hook integration.
54
+ - peek schema bumped 1.4 → 1.5. `plugins` block now includes
55
+ `pre_round_hooks`, `post_round_hooks`, `owned_paths` lists.
56
+
57
+ ### Changed
58
+
59
+ - `docs/plugins.md` register-pattern examples corrected: registration must
60
+ happen as module-top side effect; entry_point loaders only import, they
61
+ do not invoke. Old `_register()` wrapper pattern silently didn't fire.
62
+ - `docs/plugins.md` gained "Declaring plugin-owned paths" and "Plugin tests
63
+ + consumer pytest collision" sections.
64
+
65
+ ### Fixed
66
+
67
+ - Plugin outputs in plugin-declared paths (e.g. `proposals/`,
68
+ `logs/plugins/my_plugin/`) no longer silently swept into orphan stashes
69
+ by `process_orphan_wip`. Previously: 90KB Argus audit memos invisible
70
+ after Phase 4 round; required stash archaeology to recover.
71
+
72
+ ### Migration
73
+
74
+ No breaking changes. Plugin authors:
75
+
76
+ - If your plugin writes files to `work_dir` and they keep getting stashed
77
+ between rounds, opt them out:
78
+ ```python
79
+ from agent_runner.vcs_state import register_plugin_owned_paths
80
+ register_plugin_owned_paths(["your-output-dir/", "logs/your-plugin/**/*"])
81
+ ```
82
+ - If you followed the old `_register()` pattern from docs and noticed
83
+ registrations not firing: move the call to module top:
84
+ ```python
85
+ # was: def _register(): register_pre_round_hook(MyHook())
86
+ # now: register_pre_round_hook(MyHook()) # module-top side-effect
87
+ ```
88
+
10
89
  ## [0.1.7] - 2026-05-13
11
90
 
12
91
  ### Migration for existing 0.1.6 users (DOWNSTREAM CONSUMERS READ THIS)
@@ -288,6 +367,10 @@ Initial public release on PyPI as `cli-agent-runner`.
288
367
  - Tag-triggered release publishing to PyPI via Trusted Publishing OIDC,
289
368
  gated by a manual approval on the `pypi` GitHub environment.
290
369
 
291
- [Unreleased]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.1...HEAD
292
- [0.1.1]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.1
293
- [0.1.0]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.0
370
+ [Unreleased]: https://github.com/wan9yu/cli-agent-runner/compare/v0.1.9...HEAD
371
+ [0.1.9]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.9
372
+ [0.1.8]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.8
373
+ [0.1.7]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.7
374
+ [0.1.6]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.6
375
+ [0.1.5]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.5
376
+ [0.1.4]: https://github.com/wan9yu/cli-agent-runner/releases/tag/v0.1.4
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cli-agent-runner
3
- Version: 0.1.7
3
+ Version: 0.1.9
4
4
  Summary: Restart-on-exit supervisor for autonomous CLI agents
5
5
  Project-URL: Homepage, https://github.com/wan9yu/cli-agent-runner
6
6
  Project-URL: Documentation, https://github.com/wan9yu/cli-agent-runner#readme
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.7'
22
- __version_tuple__ = version_tuple = (0, 1, 7)
21
+ __version__ = version = '0.1.9'
22
+ __version_tuple__ = version_tuple = (0, 1, 9)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -240,6 +240,9 @@ def _log_dir_for_project(project: str | Path) -> Path:
240
240
  # for callers that only use lifecycle verbs.
241
241
 
242
242
  from agent_runner import defenses, monitor # noqa: E402
243
+ from agent_runner.events import HOOK_FAILED # noqa: E402
244
+
245
+ _RECENT_HOOK_FAILURES_LIMIT = 10
243
246
 
244
247
 
245
248
  def peek(
@@ -266,6 +269,16 @@ def peek(
266
269
  if current is None:
267
270
  raise KeyError(f"round {round_num} not found under {log_dir}/rounds/")
268
271
  recent = parsed_events[-events:] if events else []
272
+ # Walk the tail in reverse so we stop as soon as the limit is filled.
273
+ # parsed_events grows unboundedly over a project's lifetime; a full-scan
274
+ # comprehension here would dominate watch-loop peek cost.
275
+ recent_hook_failures: list[dict[str, Any]] = []
276
+ for e in reversed(parsed_events):
277
+ if e.get("event") == HOOK_FAILED:
278
+ recent_hook_failures.append(e)
279
+ if len(recent_hook_failures) == _RECENT_HOOK_FAILURES_LIMIT:
280
+ break
281
+ recent_hook_failures.reverse()
269
282
 
270
283
  state = ProjectState(
271
284
  project=base_state.project,
@@ -286,6 +299,7 @@ def peek(
286
299
  system=base_state.system,
287
300
  service=status(project if project is not None else work_dir),
288
301
  recent_events=recent,
302
+ recent_hook_failures=recent_hook_failures,
289
303
  )
290
304
  return state if select is None else select_path(state, select)
291
305
 
@@ -72,6 +72,7 @@ class ProjectState:
72
72
  system: SystemMetrics
73
73
  service: ServiceStatus
74
74
  recent_events: list[dict[str, Any]] = field(default_factory=list)
75
+ recent_hook_failures: list[dict[str, Any]] = field(default_factory=list)
75
76
 
76
77
 
77
78
  @dataclass(frozen=True)
@@ -130,7 +131,7 @@ class InitResult:
130
131
  work_dir: Path
131
132
  files_created: list[Path]
132
133
  committed: bool
133
- preset: str = "claude" # 0.1.7+; default for backward compat with synthesised InitResults
134
+ preset: str = "claude" # default keeps synthesised InitResults working
134
135
 
135
136
 
136
137
  @dataclass(frozen=True)
@@ -12,10 +12,11 @@ from typing import Any
12
12
  from agent_runner.api_types import ProjectState
13
13
  from agent_runner.config import Config, load_config
14
14
  from agent_runner.events import plugin_event_kinds
15
- from agent_runner.hooks import plugin_context_enrichers
15
+ from agent_runner.hooks import plugin_context_enrichers, post_round_hooks, pre_round_hooks
16
16
  from agent_runner.monitor import plugin_detectors
17
+ from agent_runner.vcs_state import plugin_owned_paths
17
18
 
18
- PEEK_SCHEMA_VERSION = "1.4"
19
+ PEEK_SCHEMA_VERSION = "1.5"
19
20
 
20
21
 
21
22
  def cfg_from_args(args) -> Config:
@@ -48,7 +49,10 @@ def emit(value: Any, *, json_mode: bool) -> None:
48
49
  "plugins": {
49
50
  "event_kinds": plugin_event_kinds(),
50
51
  "context_enrichers": plugin_context_enrichers(),
52
+ "pre_round_hooks": [h.name for h in pre_round_hooks()],
53
+ "post_round_hooks": [h.name for h in post_round_hooks()],
51
54
  "detectors": plugin_detectors(),
55
+ "owned_paths": plugin_owned_paths(),
52
56
  },
53
57
  **_to_jsonable(value),
54
58
  }
@@ -24,6 +24,7 @@ class RuntimeConfig:
24
24
  log_dir: Path
25
25
  round_timeout_s: int = 1800
26
26
  restart_delay_s: int = 3
27
+ round_timeout_per_phase: dict[str, int] = field(default_factory=dict)
27
28
 
28
29
 
29
30
  @dataclass(frozen=True)
@@ -86,6 +87,32 @@ def _expand_path(s: str, project_name: str) -> Path:
86
87
  return Path(s.replace("{project}", project_name)).expanduser()
87
88
 
88
89
 
90
+ def _require_positive_int(value: Any, *, field: str) -> int:
91
+ """Validate a TOML value is a positive int. Rejects bool (subclass of int
92
+ in Python, would silently coerce e.g. ``true`` → 1) and any non-int."""
93
+ if isinstance(value, bool) or not isinstance(value, int):
94
+ raise ValueError(f"{field}: must be an integer, got {type(value).__name__} ({value!r})")
95
+ if value <= 0:
96
+ raise ValueError(f"{field}: must be positive, got {value}")
97
+ return value
98
+
99
+
100
+ def _validate_round_timeout_per_phase_keys(
101
+ per_phase: dict[str, int], phases: list[str] | None
102
+ ) -> None:
103
+ """All keys must appear in [phases] list (typo catcher)."""
104
+ if not per_phase:
105
+ return
106
+ if phases is None:
107
+ raise ValueError("runtime.round_timeout_per_phase requires [phases] list to be defined")
108
+ unknown = set(per_phase) - set(phases)
109
+ if unknown:
110
+ raise ValueError(
111
+ f"runtime.round_timeout_per_phase keys not in phases list: "
112
+ f"{sorted(unknown)}; available phases: {phases}"
113
+ )
114
+
115
+
89
116
  def load_config(toml_path: Path) -> Config:
90
117
  if not toml_path.exists():
91
118
  raise FileNotFoundError(f"config not found: {toml_path}")
@@ -103,12 +130,28 @@ def load_config(toml_path: Path) -> Config:
103
130
  work_dir = _expand_path(raw_work_dir, "").resolve()
104
131
  project_name = work_dir.name or "default"
105
132
 
133
+ # Phases first — needed for per-phase round_timeout validation below.
134
+ phases_d = raw.get("phases", {})
135
+ phases = list(phases_d["list"]) if "list" in phases_d else None
136
+
106
137
  runtime_d = raw.get("runtime", {})
138
+ per_phase_raw = runtime_d.get("round_timeout_per_phase", {})
139
+ per_phase: dict[str, int] = {
140
+ str(k): _require_positive_int(v, field=f"runtime.round_timeout_per_phase[{str(k)!r}]")
141
+ for k, v in per_phase_raw.items()
142
+ }
143
+ _validate_round_timeout_per_phase_keys(per_phase, phases)
144
+
107
145
  runtime = RuntimeConfig(
108
146
  work_dir=work_dir,
109
147
  log_dir=_expand_path(str(_require(raw, "runtime", "log_dir")), project_name),
110
- round_timeout_s=int(runtime_d.get("round_timeout_s", 1800)),
111
- restart_delay_s=int(runtime_d.get("restart_delay_s", 3)),
148
+ round_timeout_s=_require_positive_int(
149
+ runtime_d.get("round_timeout_s", 1800), field="runtime.round_timeout_s"
150
+ ),
151
+ restart_delay_s=_require_positive_int(
152
+ runtime_d.get("restart_delay_s", 3), field="runtime.restart_delay_s"
153
+ ),
154
+ round_timeout_per_phase=per_phase,
112
155
  )
113
156
  prompt_d = raw.get("prompt", {})
114
157
  mode = prompt_d.get("context_injection_mode", "prepend")
@@ -125,7 +168,9 @@ def load_config(toml_path: Path) -> Config:
125
168
  vcs_d = raw.get("vcs", {})
126
169
  vcs = VcsConfig(
127
170
  orphan_action=str(vcs_d.get("orphan_action", "stash")),
128
- stash_idempotency_s=int(vcs_d.get("stash_idempotency_s", 5)),
171
+ stash_idempotency_s=_require_positive_int(
172
+ vcs_d.get("stash_idempotency_s", 5), field="vcs.stash_idempotency_s"
173
+ ),
129
174
  )
130
175
  monitor_d = raw.get("monitor", {})
131
176
  monitor = MonitorConfig(
@@ -133,8 +178,6 @@ def load_config(toml_path: Path) -> Config:
133
178
  auth_fail_hint=str(monitor_d.get("auth_fail_hint", _DEFAULT_AUTH_HINT)),
134
179
  auto_stop_on=list(monitor_d.get("auto_stop_on", _DEFAULT_AUTO_STOP_ON)),
135
180
  )
136
- phases_d = raw.get("phases", {})
137
- phases = list(phases_d["list"]) if "list" in phases_d else None
138
181
  plugins_d = raw.get("plugins")
139
182
 
140
183
  return Config(
@@ -22,6 +22,11 @@ from datetime import UTC, datetime
22
22
  from pathlib import Path
23
23
  from typing import Any
24
24
 
25
+ # Cross-module event-kind constants. Most kinds are emitted in only one place
26
+ # (runner.py), but kinds that are also CONSUMED elsewhere (filtered, surfaced
27
+ # in peek, asserted in tests) earn a constant to keep the spelling honest.
28
+ HOOK_FAILED = "hook_failed"
29
+
25
30
  _BUILTIN_KINDS: frozenset[str] = frozenset(
26
31
  {
27
32
  "round_start",
@@ -38,7 +43,7 @@ _BUILTIN_KINDS: frozenset[str] = frozenset(
38
43
  "round_end",
39
44
  "monitor_alert_emitted",
40
45
  "monitor_auto_stop_triggered",
41
- "hook_failed",
46
+ HOOK_FAILED,
42
47
  }
43
48
  )
44
49
 
@@ -49,6 +49,17 @@ def _phase_for(round_num: int, phases: list[str] | None) -> tuple[str | None, in
49
49
  return phases[idx], idx
50
50
 
51
51
 
52
+ def _round_timeout_for(cfg: Config, phase: str | None) -> int:
53
+ """Per-phase override of round_timeout_s; falls back to global default.
54
+
55
+ Phase=None (no phases configured) → global. Phase not in override dict →
56
+ global. Phase in override dict → that phase's configured timeout.
57
+ """
58
+ if phase is None:
59
+ return cfg.runtime.round_timeout_s
60
+ return cfg.runtime.round_timeout_per_phase.get(phase, cfg.runtime.round_timeout_s)
61
+
62
+
52
63
  def _previous_block(prev: context_store.Status | None, dirty_last: bool) -> dict[str, Any] | None:
53
64
  if prev is None:
54
65
  return None
@@ -95,7 +106,7 @@ def _stitch_enricher_slices(
95
106
  payload = hooks._summarize_error(exc, tb=tb_mod.format_exc())
96
107
  events.emit(
97
108
  log_dir,
98
- "hook_failed",
109
+ events.HOOK_FAILED,
99
110
  hook_name=enricher.name,
100
111
  hook_kind="context_enricher",
101
112
  **payload,
@@ -114,7 +125,7 @@ def _run_pre_round_hooks(hook_ctx: hooks.HookContext, log_dir: Path) -> None:
114
125
  payload = hooks._summarize_error(exc, tb=tb_mod.format_exc())
115
126
  events.emit(
116
127
  log_dir,
117
- "hook_failed",
128
+ events.HOOK_FAILED,
118
129
  hook_name=hook.name,
119
130
  hook_kind="pre_round",
120
131
  **payload,
@@ -136,7 +147,7 @@ def _run_post_round_hooks(
136
147
  payload = hooks._summarize_error(exc, tb=tb_mod.format_exc())
137
148
  events.emit(
138
149
  log_dir,
139
- "hook_failed",
150
+ events.HOOK_FAILED,
140
151
  hook_name=hook.name,
141
152
  hook_kind="post_round",
142
153
  **payload,
@@ -175,6 +186,7 @@ def _run_one_round_inner(cfg: Config) -> RoundResult:
175
186
 
176
187
  round_num = (prev_status.round_num if prev_status else 0) + 1
177
188
  phase, phase_idx = _phase_for(round_num, cfg.phases)
189
+ timeout_s = _round_timeout_for(cfg, phase)
178
190
  started_at = now_iso_ms()
179
191
 
180
192
  orphan = context_store.read_orphan_state(log_dir)
@@ -223,12 +235,12 @@ def _run_one_round_inner(cfg: Config) -> RoundResult:
223
235
  mode=cfg.prompt.context_injection_mode,
224
236
  )
225
237
 
226
- events.emit(log_dir, "agent_spawn", round_num=round_num, timeout_s=cfg.runtime.round_timeout_s)
238
+ events.emit(log_dir, "agent_spawn", round_num=round_num, timeout_s=timeout_s)
227
239
  result = agent_runtime.run(
228
240
  command=cfg.agent.command,
229
241
  prompt_arg_template=cfg.agent.prompt_arg_template,
230
242
  prompt=prompt,
231
- timeout_s=cfg.runtime.round_timeout_s,
243
+ timeout_s=timeout_s,
232
244
  log_path=log_path,
233
245
  env_extra=dict(cfg.agent.env),
234
246
  )
@@ -281,7 +293,7 @@ def _run_one_round_inner(cfg: Config) -> RoundResult:
281
293
  log_dir,
282
294
  "round_timeout_kill",
283
295
  round_num=round_num,
284
- reason=f"exceeded round_timeout_s={cfg.runtime.round_timeout_s}",
296
+ reason=f"exceeded round_timeout_s={timeout_s}",
285
297
  )
286
298
 
287
299
  completed_at = now_iso_ms()
@@ -5,7 +5,9 @@ Two units per project:
5
5
  agent-runner-monitor@<project>.service - runs `agent-runner monitor`
6
6
 
7
7
  Install command writes these to ~/.config/systemd/user/. The graceful-stop
8
- contract relies on KillSignal=SIGTERM + TimeoutStopSec=round_timeout_s+60.
8
+ contract relies on KillSignal=SIGTERM + TimeoutStopSec=max(round_timeout_s,
9
+ round_timeout_per_phase.values())+60 — the LARGEST possible round budget
10
+ plus grace.
9
11
  """
10
12
 
11
13
  from __future__ import annotations
@@ -32,7 +34,12 @@ def _config_path(cfg: Config) -> Path:
32
34
 
33
35
  def render_serve_unit(cfg: Config, *, venv_bin: Path) -> str:
34
36
  """Generate the serve systemd unit body."""
35
- timeout_total = cfg.runtime.round_timeout_s + _GRACE_S
37
+ # TimeoutStopSec covers the largest possible round so `systemctl stop`
38
+ # doesn't SIGKILL a long per-phase round mid-flight.
39
+ max_round_timeout = max(
40
+ [cfg.runtime.round_timeout_s, *cfg.runtime.round_timeout_per_phase.values()]
41
+ )
42
+ timeout_total = max_round_timeout + _GRACE_S
36
43
  return (
37
44
  f"[Unit]\n"
38
45
  f"Description=Agent Runner Supervisor ({cfg.runtime.work_dir.name})\n"
@@ -7,14 +7,69 @@ Stash safety rules (R820 + §9 IMMUTABLE):
7
7
  design means external concurrent ``git stash push`` is not a defended scenario.
8
8
  - "Auto-tool change vs human change" detection uses set-based diff vs HEAD,
9
9
  not unified-diff +/-line parsing (R2110 lesson).
10
+ - Also hosts the plugin-owned-paths registry consumed by
11
+ ``detect_dirty_files()`` so plugins can opt files/dirs out of the
12
+ orphan-stash defense.
10
13
  """
11
14
 
12
15
  from __future__ import annotations
13
16
 
17
+ import fnmatch
14
18
  import subprocess # noqa: TID251 — vcs_state.py is the only sanctioned git CLI caller
15
19
  import time
16
20
  from dataclasses import dataclass
17
- from pathlib import Path
21
+ from pathlib import Path, PurePath
22
+
23
+ # Plugin-owned paths registry — set via register_plugin_owned_paths().
24
+ # detect_dirty_files() filters its return through this list, so plugin-declared
25
+ # paths are not flagged as orphan WIP and not stashed by the supervisor.
26
+ _PLUGIN_OWNED_PATHS: list[str] = []
27
+
28
+
29
+ def register_plugin_owned_paths(paths: list[str]) -> None:
30
+ """Register paths the plugin considers its own deliverables.
31
+
32
+ Paths are relative to the work_dir. Matching:
33
+
34
+ - Trailing ``/`` → prefix match (e.g. ``"proposals/"`` matches
35
+ ``"proposals/dev-round1.md"`` and the bare directory name).
36
+ - Anything else without ``**`` → ``pathlib.PurePath.match`` glob
37
+ (e.g. ``"reports/*.md"``). Single ``*`` does not cross slashes.
38
+ - Patterns containing ``**`` → ``fnmatch.fnmatch`` (e.g.
39
+ ``"logs/plugins/**/*"``). ``**`` matches recursive directory
40
+ segments. (``PurePath.full_match`` would handle this natively
41
+ but requires Python 3.13+; this project's minimum is 3.11.)
42
+
43
+ Plugins call this at module import time (entry_point side-effect) so the
44
+ paths are known before the first round runs.
45
+
46
+ Raises ValueError on non-string entries.
47
+ """
48
+ for p in paths:
49
+ if not isinstance(p, str):
50
+ raise ValueError(f"register_plugin_owned_paths: non-string entry {p!r}")
51
+ _PLUGIN_OWNED_PATHS.extend(paths)
52
+
53
+
54
+ def plugin_owned_paths() -> list[str]:
55
+ """Snapshot of registered plugin-owned paths (for peek visibility)."""
56
+ return list(_PLUGIN_OWNED_PATHS)
57
+
58
+
59
+ def _matches_owned_path(path: str) -> bool:
60
+ """True if `path` matches any registered plugin-owned pattern."""
61
+ for pattern in _PLUGIN_OWNED_PATHS:
62
+ if pattern.endswith("/"):
63
+ stripped = pattern.rstrip("/")
64
+ if path == stripped or path.startswith(pattern):
65
+ return True
66
+ elif "**" in pattern:
67
+ # fnmatch handles ** recursively; PurePath.match (3.11) does not.
68
+ if fnmatch.fnmatch(path, pattern):
69
+ return True
70
+ elif PurePath(path).match(pattern):
71
+ return True
72
+ return False
18
73
 
19
74
 
20
75
  @dataclass(frozen=True)
@@ -74,6 +129,9 @@ def detect_dirty_files(repo: Path) -> list[str]:
74
129
  else:
75
130
  out.append(path)
76
131
  i += 1
132
+ # Early-out preserves zero behavior change when no plugin has registered.
133
+ if _PLUGIN_OWNED_PATHS:
134
+ out = [p for p in out if not _matches_owned_path(p)]
77
135
  return out
78
136
 
79
137
 
@@ -23,6 +23,7 @@ writes a templated copy you can edit.
23
23
  | `log_dir` | `Path` | — |
24
24
  | `round_timeout_s` | `int` | 1800 |
25
25
  | `restart_delay_s` | `int` | 3 |
26
+ | `round_timeout_per_phase` | `dict[str, int]` | {} |
26
27
 
27
28
  ### `[prompt]`
28
29
 
@@ -76,6 +77,31 @@ Override in your `agent-runner.toml` if you ship a custom CLI.
76
77
  |---|---|---|---|
77
78
  | `list` | list[str] | (none → no phase rotation) | round N gets `phases[(N-1) % len(phases)]` |
78
79
 
80
+ ## Per-phase timeouts (0.1.9+)
81
+
82
+ If your `[phases]` rotation has phases with different wall-clock budgets,
83
+ override the global timeout per phase:
84
+
85
+ ```toml
86
+ [runtime]
87
+ round_timeout_s = 1800 # fallback for unconfigured phases
88
+
89
+ [runtime.round_timeout_per_phase]
90
+ dev = 3600 # implementation work, longer budget
91
+ qa = 1200 # test review, tighter budget
92
+ product = 1200 # docs writing, tighter budget
93
+
94
+ [phases]
95
+ list = ["dev", "qa", "product"]
96
+ ```
97
+
98
+ Validation: typos in phase names (keys not in `[phases] list`) and
99
+ non-positive / non-integer values are caught at config-load time with
100
+ `ValueError`.
101
+
102
+ Unconfigured phases (and configs without `[phases]`) keep using the
103
+ global `round_timeout_s`.
104
+
79
105
  ## `[monitor]` (optional, defaults shown)
80
106
 
81
107
  ```toml