cli-agent-runner 0.1.7__tar.gz → 0.1.8__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.8}/CHANGELOG.md +53 -0
  2. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/PKG-INFO +1 -1
  3. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/_version.py +2 -2
  4. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/api.py +14 -0
  5. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/api_types.py +1 -0
  6. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/common.py +6 -2
  7. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/events.py +6 -1
  8. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/runner.py +3 -3
  9. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/vcs_state.py +60 -1
  10. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/plugins.md +107 -10
  11. cli_agent_runner-0.1.8/tests/_test_helpers.py +44 -0
  12. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/contract/test_public_api_surface.py +17 -0
  13. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_context_enricher_namespacing.py +2 -10
  14. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_plugin_detector_loaded.py +2 -10
  15. cli_agent_runner-0.1.8/tests/integration/test_plugin_owned_paths.py +88 -0
  16. cli_agent_runner-0.1.8/tests/invariants/test_module_sizes.py +22 -0
  17. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_peek_schema_version.py +2 -2
  18. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_api_observation.py +59 -0
  19. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_api_types.py +33 -0
  20. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_detector_protocol.py +3 -10
  21. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_events.py +3 -10
  22. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_hook_failure_isolation.py +2 -10
  23. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_hooks.py +6 -17
  24. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_vcs_state.py +79 -0
  25. cli_agent_runner-0.1.7/tests/invariants/test_module_sizes.py +0 -49
  26. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.codecov.yml +0 -0
  27. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.githooks/commit-msg +0 -0
  28. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  29. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  30. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  31. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.github/workflows/ci.yml +0 -0
  33. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.github/workflows/release.yml +0 -0
  34. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.gitignore +0 -0
  35. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/.vulture-whitelist.py +0 -0
  36. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/CODE_OF_CONDUCT.md +0 -0
  37. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/CONTRIBUTING.md +0 -0
  38. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/LICENSE +0 -0
  39. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/README.md +0 -0
  40. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/README.zh.md +0 -0
  41. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/SECURITY.md +0 -0
  42. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/__init__.py +0 -0
  43. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/_docgen.py +0 -0
  44. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/_registry.py +0 -0
  45. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/agent_runtime.py +0 -0
  46. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/__init__.py +0 -0
  47. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/__main__.py +0 -0
  48. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/init_cmd.py +0 -0
  49. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/install_cmd.py +0 -0
  50. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/monitor_cmd.py +0 -0
  51. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/peek_cmd.py +0 -0
  52. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/round_cmd.py +0 -0
  53. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/serve_cmd.py +0 -0
  54. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/cli/service_cmd.py +0 -0
  55. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/config.py +0 -0
  56. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/context_store.py +0 -0
  57. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/defenses.py +0 -0
  58. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/detector_helpers.py +0 -0
  59. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/hooks.py +0 -0
  60. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/lifecycle.py +0 -0
  61. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/metrics.py +0 -0
  62. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/monitor.py +0 -0
  63. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/presets/__init__.py +0 -0
  64. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/presets/aider.toml +0 -0
  65. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/presets/claude.toml +0 -0
  66. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/prompt_loader.py +0 -0
  67. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/round_view.py +0 -0
  68. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/scaffold.py +0 -0
  69. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/service_unit.py +0 -0
  70. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/agent_runner/startup_check.py +0 -0
  71. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/build.sh +0 -0
  72. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/deploy/example-agent-runner.toml +0 -0
  73. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/deploy/launchd.plist.tmpl +0 -0
  74. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/deploy/run-loop.sh +0 -0
  75. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/deploy/systemd.service.tmpl +0 -0
  76. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/README.md +0 -0
  77. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/architecture.md +0 -0
  78. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/commands.md +0 -0
  79. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/configuration.md +0 -0
  80. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/quickstart.md +0 -0
  81. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/recipes/aider.md +0 -0
  82. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/docs/runbook.md +0 -0
  83. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/pyproject.toml +0 -0
  84. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/__init__.py +0 -0
  85. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/conftest.py +0 -0
  86. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/contract/__init__.py +0 -0
  87. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/e2e/__init__.py +0 -0
  88. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/e2e/conftest.py +0 -0
  89. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/e2e/test_e2e_graceful_stop.py +0 -0
  90. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/e2e/test_e2e_install_systemd.py +0 -0
  91. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/e2e/test_e2e_monitor_remote.py +0 -0
  92. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/e2e/test_e2e_round_lifecycle.py +0 -0
  93. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/__init__.py +0 -0
  94. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_install_dry_run.py +0 -0
  95. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_monitor_seeded.py +0 -0
  96. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_run_loop_backoff.py +0 -0
  97. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_run_one_round_with_fake_agent.py +0 -0
  98. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_scaffold_presets.py +0 -0
  99. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/integration/test_serve_loop.py +0 -0
  100. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/__init__.py +0 -0
  101. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_architecture.py +0 -0
  102. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_atomic_write_enforced.py +0 -0
  103. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_catalogs.py +0 -0
  104. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_docs_generated.py +0 -0
  105. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_event_kind_registry.py +0 -0
  106. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_module_boundaries.py +0 -0
  107. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_no_ai_signatures.py +0 -0
  108. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_no_pytest_skip_on_parse_fail.py +0 -0
  109. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_repo_constants_patched_in_tests.py +0 -0
  110. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_round_result_stable.py +0 -0
  111. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/invariants/test_stash_uses_sha_not_index.py +0 -0
  112. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/literate/__init__.py +0 -0
  113. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/literate/parser.py +0 -0
  114. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/literate/test_parser.py +0 -0
  115. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/literate/test_quickstart.py +0 -0
  116. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/__init__.py +0 -0
  117. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_agent_runtime.py +0 -0
  118. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_api_service.py +0 -0
  119. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_auto_stop_gating.py +0 -0
  120. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_cli.py +0 -0
  121. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_cli_common.py +0 -0
  122. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_cli_init_install.py +0 -0
  123. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_cli_service_peek_monitor.py +0 -0
  124. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_config.py +0 -0
  125. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_context_store.py +0 -0
  126. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_defenses.py +0 -0
  127. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_detector_helpers.py +0 -0
  128. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_docgen.py +0 -0
  129. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_init_entry_points.py +0 -0
  130. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_lifecycle.py +0 -0
  131. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_metrics.py +0 -0
  132. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_monitor_assembly.py +0 -0
  133. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_monitor_detectors.py +0 -0
  134. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_monitor_remote.py +0 -0
  135. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_peek_argparse.py +0 -0
  136. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_presets.py +0 -0
  137. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_prompt_loader.py +0 -0
  138. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_round_view.py +0 -0
  139. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_runner.py +0 -0
  140. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_scaffold.py +0 -0
  141. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_service_unit.py +0 -0
  142. {cli_agent_runner-0.1.7 → cli_agent_runner-0.1.8}/tests/unit/test_startup_check.py +0 -0
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.8] - 2026-05-13
11
+
12
+ ### Acknowledgements
13
+
14
+ Thanks to the argus-gateway team for Phase 4 dogfooding feedback that drove
15
+ every item in this release. 3 audit memos (~90KB) silently swept into an
16
+ orphan stash is a real-world failure mode; this release closes that loop.
17
+
18
+ ### Added
19
+
20
+ - `agent_runner.vcs_state.register_plugin_owned_paths()` — plugins opt-out
21
+ files/dirs from orphan-stash defense. Matching: trailing-slash prefix or
22
+ `pathlib.PurePath.match` glob (recognizes `**` for recursive segments via
23
+ `fnmatch` fallback on Python 3.11). Call at module import (entry_point
24
+ side-effect).
25
+ - `agent_runner.vcs_state.plugin_owned_paths()` — snapshot accessor for peek.
26
+ - `ProjectState.recent_hook_failures: list[dict]` — last 10 `hook_failed`
27
+ events filtered from `recent_events` for debugging hook integration.
28
+ - peek schema bumped 1.4 → 1.5. `plugins` block now includes
29
+ `pre_round_hooks`, `post_round_hooks`, `owned_paths` lists.
30
+
31
+ ### Changed
32
+
33
+ - `docs/plugins.md` register-pattern examples corrected: registration must
34
+ happen as module-top side effect; entry_point loaders only import, they
35
+ do not invoke. Old `_register()` wrapper pattern silently didn't fire.
36
+ - `docs/plugins.md` gained "Declaring plugin-owned paths" and "Plugin tests
37
+ + consumer pytest collision" sections.
38
+
39
+ ### Fixed
40
+
41
+ - Plugin outputs in plugin-declared paths (e.g. `proposals/`,
42
+ `logs/plugins/my_plugin/`) no longer silently swept into orphan stashes
43
+ by `process_orphan_wip`. Previously: 90KB Argus audit memos invisible
44
+ after Phase 4 round; required stash archaeology to recover.
45
+
46
+ ### Migration
47
+
48
+ No breaking changes. Plugin authors:
49
+
50
+ - If your plugin writes files to `work_dir` and they keep getting stashed
51
+ between rounds, opt them out:
52
+ ```python
53
+ from agent_runner.vcs_state import register_plugin_owned_paths
54
+ register_plugin_owned_paths(["your-output-dir/", "logs/your-plugin/**/*"])
55
+ ```
56
+ - If you followed the old `_register()` pattern from docs and noticed
57
+ registrations not firing: move the call to module top:
58
+ ```python
59
+ # was: def _register(): register_pre_round_hook(MyHook())
60
+ # now: register_pre_round_hook(MyHook()) # module-top side-effect
61
+ ```
62
+
10
63
  ## [0.1.7] - 2026-05-13
11
64
 
12
65
  ### Migration for existing 0.1.6 users (DOWNSTREAM CONSUMERS READ THIS)
@@ -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.8
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.8'
22
+ __version_tuple__ = version_tuple = (0, 1, 8)
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)
@@ -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
  }
@@ -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
 
@@ -95,7 +95,7 @@ def _stitch_enricher_slices(
95
95
  payload = hooks._summarize_error(exc, tb=tb_mod.format_exc())
96
96
  events.emit(
97
97
  log_dir,
98
- "hook_failed",
98
+ events.HOOK_FAILED,
99
99
  hook_name=enricher.name,
100
100
  hook_kind="context_enricher",
101
101
  **payload,
@@ -114,7 +114,7 @@ def _run_pre_round_hooks(hook_ctx: hooks.HookContext, log_dir: Path) -> None:
114
114
  payload = hooks._summarize_error(exc, tb=tb_mod.format_exc())
115
115
  events.emit(
116
116
  log_dir,
117
- "hook_failed",
117
+ events.HOOK_FAILED,
118
118
  hook_name=hook.name,
119
119
  hook_kind="pre_round",
120
120
  **payload,
@@ -136,7 +136,7 @@ def _run_post_round_hooks(
136
136
  payload = hooks._summarize_error(exc, tb=tb_mod.format_exc())
137
137
  events.emit(
138
138
  log_dir,
139
- "hook_failed",
139
+ events.HOOK_FAILED,
140
140
  hook_name=hook.name,
141
141
  hook_kind="post_round",
142
142
  **payload,
@@ -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 (0.1.8+).
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,10 @@ def detect_dirty_files(repo: Path) -> list[str]:
74
129
  else:
75
130
  out.append(path)
76
131
  i += 1
132
+ # Filter out plugin-declared paths (0.1.8+). Early-out preserves zero
133
+ # behavior change when no plugin has registered anything.
134
+ if _PLUGIN_OWNED_PATHS:
135
+ out = [p for p in out if not _matches_owned_path(p)]
77
136
  return out
78
137
 
79
138
 
@@ -10,6 +10,12 @@ plugin code is observability/coordination glue, not workflow logic.
10
10
 
11
11
  ## Entry-points groups (0.1.3)
12
12
 
13
+ > **Entry-point semantics:** agent-runner imports the target module when it
14
+ > loads a plugin. It does **not** call the target as a function — registration
15
+ > must happen as a module-top side effect (the `register_*` call at module
16
+ > level). A `def _register():` wrapper around the call will NOT fire; the
17
+ > loader only imports.
18
+
13
19
  | Group | Purpose | Available in |
14
20
  |---|---|---|
15
21
  | `agent_runner.event_kinds` | Register custom event kind names | 0.1.3+ |
@@ -22,7 +28,7 @@ detectors are reserved for 0.1.4 and 0.1.5.
22
28
  ```toml
23
29
  # my_plugin/pyproject.toml
24
30
  [project.entry-points."agent_runner.event_kinds"]
25
- my_workflow_stage_advanced = "my_plugin.events:_register"
31
+ my_workflow_stage_advanced = "my_plugin.events"
26
32
  ```
27
33
 
28
34
  ```python
@@ -31,9 +37,9 @@ from agent_runner.events import register_event_kind
31
37
 
32
38
  STAGE_ADVANCED = "my_workflow_stage_advanced"
33
39
 
34
-
35
- def _register() -> None:
36
- register_event_kind(STAGE_ADVANCED, source="my-plugin@1.0")
40
+ # Module-top side effect: entry_point load imports this module, which
41
+ # triggers the registration.
42
+ register_event_kind(STAGE_ADVANCED, source="my-plugin@1.0")
37
43
  ```
38
44
 
39
45
  After installation, the registered kind:
@@ -84,7 +90,7 @@ Its field set is stable across 0.1.x (additions only).
84
90
  ```toml
85
91
  # my_plugin/pyproject.toml
86
92
  [project.entry-points."agent_runner.context_enrichers"]
87
- current_branch = "my_plugin.enrichers:_register"
93
+ current_branch = "my_plugin.enrichers"
88
94
  ```
89
95
 
90
96
  ```python
@@ -104,8 +110,9 @@ class CurrentBranchEnricher:
104
110
  return {"branch": out.stdout.strip() or "(detached)"}
105
111
 
106
112
 
107
- def _register() -> None:
108
- register_context_enricher(CurrentBranchEnricher())
113
+ # Module-top side effect: entry_point load imports this module, which
114
+ # triggers the registration.
115
+ register_context_enricher(CurrentBranchEnricher())
109
116
  ```
110
117
 
111
118
  After installation, each round's `round-context.json` gains a `current_branch` key:
@@ -164,7 +171,7 @@ detectors that run alongside the 9 builtins on every monitor poll.
164
171
 
165
172
  ```toml
166
173
  [project.entry-points."agent_runner.detectors"]
167
- my_detector = "my_plugin.detectors:_register"
174
+ my_detector = "my_plugin.detectors"
168
175
  ```
169
176
 
170
177
  ```python
@@ -191,8 +198,9 @@ class MyDetector:
191
198
  )
192
199
 
193
200
 
194
- def _register() -> None:
195
- register_detector(MyDetector())
201
+ # Module-top side effect: entry_point load imports this module, which
202
+ # triggers the registration.
203
+ register_detector(MyDetector())
196
204
  ```
197
205
 
198
206
  `Detector` is a `@runtime_checkable` Protocol — `isinstance(obj, Detector)` returns
@@ -309,3 +317,92 @@ class NoCommitsDetector:
309
317
  **Codifies the lesson:** "0 commits in N rounds" detectors mis-fire on
310
318
  retrospective/reflection phases that intentionally produce zero commits.
311
319
  Pass the set of phase names where the detector should NOT run.
320
+
321
+ ## Declaring plugin-owned paths (0.1.8+)
322
+
323
+ If your plugin writes files inside the supervisor's `work_dir`
324
+ (audit memos, generated reports, plugin-local state, etc.), declare them
325
+ so the orphan-stash defense doesn't silently sweep them into a stash
326
+ between rounds.
327
+
328
+ ```python
329
+ # my_plugin/__init__.py
330
+ from agent_runner.vcs_state import register_plugin_owned_paths
331
+
332
+ # Module-top side effect — must register before the first round runs.
333
+ register_plugin_owned_paths([
334
+ "proposals/", # trailing slash → prefix match
335
+ "logs/plugins/my_plugin/**/*", # recursive glob (fnmatch)
336
+ "reports/*.md", # single-segment glob (PurePath.match)
337
+ ])
338
+ ```
339
+
340
+ ### Matching semantics
341
+
342
+ | Pattern | Matches | Notes |
343
+ |---|---|---|
344
+ | `"proposals/"` | `proposals`, `proposals/foo.md`, `proposals/sub/bar.md` | Trailing `/` → prefix match. |
345
+ | `"proposals"` (no slash) | `proposals` exactly | Single-segment literal. |
346
+ | `"reports/*.md"` | `reports/dev.md` | `*` does not cross slashes. |
347
+ | `"reports/**/*.md"` | `reports/dev.md`, `reports/sub/qa.md` | `**` matches across directory separators. |
348
+ | `"logs/plugins/**/*"` | `logs/plugins/argus/state.json` | Same — `**` covers intermediate dirs. |
349
+
350
+ ### Caveat — this is NOT a "make work_dir messy" license
351
+
352
+ Plugin-owned paths express *"these are the plugin's expected deliverable
353
+ files; do not stash them"*. They are not permission to scatter scratch
354
+ files. Operator owns cleanup of these paths.
355
+
356
+ If your plugin writes ephemeral state that should be cleaned up between
357
+ rounds, do the cleanup yourself in a `PostRoundHook` — don't rely on
358
+ the orphan-stash defense to sweep it.
359
+
360
+ ### Visibility
361
+
362
+ `agent-runner peek --select plugins.owned_paths` shows the currently
363
+ registered list (peek schema v1.5+).
364
+
365
+ ## Plugin tests + consumer pytest collision
366
+
367
+ Consumer projects often have their own `tests/` directory. If your plugin
368
+ also has tests (e.g. `tools/my_agent_plugin/tests/`), pytest's testpaths
369
+ walk can find both and fail with `ModuleNotFoundError` when the same
370
+ package name lives in two locations.
371
+
372
+ Two recommended patterns:
373
+
374
+ ### Pattern A — plugin tests inside the plugin package
375
+
376
+ Plugin author owns this:
377
+
378
+ ```
379
+ my_agent_plugin/
380
+ ├── __init__.py
381
+ ├── core.py
382
+ └── tests/
383
+ ├── __init__.py
384
+ └── test_core.py
385
+ ```
386
+
387
+ In `my_agent_plugin/pyproject.toml`:
388
+
389
+ ```toml
390
+ [tool.pytest.ini_options]
391
+ testpaths = ["my_agent_plugin/tests"]
392
+ ```
393
+
394
+ This scopes pytest collection to your plugin's tests when running locally.
395
+
396
+ ### Pattern B — consumer ignores your plugin in their pytest config
397
+
398
+ Consumer owns this:
399
+
400
+ ```toml
401
+ # In the consumer project's pytest.ini or pyproject.toml:
402
+ [tool.pytest.ini_options]
403
+ addopts = ["--ignore=tools/my_agent_plugin"]
404
+ ```
405
+
406
+ Both work. Pattern A is preferable for plugin authors (no consumer
407
+ configuration needed); Pattern B is for cases where the consumer integrates
408
+ a plugin they don't own.
@@ -0,0 +1,44 @@
1
+ """Shared test helpers.
2
+
3
+ Centralises the snapshot+clear+restore fixture pattern used by every test
4
+ file that interacts with a plugin-extension registry (hooks, detectors,
5
+ event kinds, owned paths). Before: 8 near-identical autouse fixtures across
6
+ the test suite. After: one factory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ import pytest
14
+
15
+
16
+ def isolating(*registries: list[Any] | dict[Any, Any]) -> Any:
17
+ """Return an autouse fixture that snapshots, clears, and restores registries.
18
+
19
+ Usage in a test module:
20
+
21
+ from tests._test_helpers import isolating
22
+ from agent_runner import monitor
23
+
24
+ _reset = isolating(monitor._PLUGIN_DETECTORS)
25
+
26
+ Multiple registries can be passed; all are isolated around each test.
27
+ Supports list and dict registries.
28
+ """
29
+
30
+ @pytest.fixture(autouse=True)
31
+ def _reset():
32
+ saved: list[Any] = []
33
+ for reg in registries:
34
+ saved.append(reg.copy() if isinstance(reg, dict) else list(reg))
35
+ reg.clear()
36
+ yield
37
+ for reg, snap in zip(registries, saved, strict=True):
38
+ reg.clear()
39
+ if isinstance(reg, dict):
40
+ reg.update(snap)
41
+ else:
42
+ reg.extend(snap)
43
+
44
+ return _reset
@@ -80,6 +80,11 @@ EXPECTED_DETECTOR_HELPERS_API = {
80
80
  "phase_filter",
81
81
  }
82
82
 
83
+ EXPECTED_VCS_STATE_API = {
84
+ "register_plugin_owned_paths",
85
+ "plugin_owned_paths",
86
+ }
87
+
83
88
  # Doomed symbols (removed in 0.1.7) — verify ABSENCE so a future revert can't
84
89
  # silently restore them and re-couple core to Claude.
85
90
  FORBIDDEN_AGENT_RUNTIME = {
@@ -128,3 +133,15 @@ def test_given_agent_runtime_when_imported_then_claude_specific_symbols_absent()
128
133
  f"agent_runner.agent_runtime: forbidden Claude-specific symbols present: {present}. "
129
134
  f"These were intentionally removed in 0.1.7 — env injection lives in AgentConfig.env."
130
135
  )
136
+
137
+
138
+ def test_given_vcs_state_module_when_imported_then_plugin_owned_paths_api_present() -> None:
139
+ """0.1.8: register_plugin_owned_paths + plugin_owned_paths are the new
140
+ plugin-author public surface. Lock them in so a future refactor can't
141
+ silently rename or remove them."""
142
+ actual = _public_names("agent_runner.vcs_state")
143
+ missing = EXPECTED_VCS_STATE_API - actual
144
+ assert not missing, (
145
+ f"agent_runner.vcs_state: missing public names {missing}. "
146
+ f"Plugin authors registered against the 0.1.8 names — do not remove without major bump."
147
+ )
@@ -4,19 +4,11 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
-
9
7
  from agent_runner import hooks
10
8
  from agent_runner.runner import _stitch_enricher_slices
9
+ from tests._test_helpers import isolating
11
10
 
12
-
13
- @pytest.fixture(autouse=True)
14
- def _reset_enrichers():
15
- saved = list(hooks._CONTEXT_ENRICHERS)
16
- hooks._CONTEXT_ENRICHERS.clear()
17
- yield
18
- hooks._CONTEXT_ENRICHERS.clear()
19
- hooks._CONTEXT_ENRICHERS.extend(saved)
11
+ _reset = isolating(hooks._CONTEXT_ENRICHERS)
20
12
 
21
13
 
22
14
  class _Branch:
@@ -4,19 +4,11 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
-
9
7
  from agent_runner import monitor
10
8
  from agent_runner.api_types import Alert
9
+ from tests._test_helpers import isolating
11
10
 
12
-
13
- @pytest.fixture(autouse=True)
14
- def _reset_plugin_detectors():
15
- saved = list(monitor._PLUGIN_DETECTORS)
16
- monitor._PLUGIN_DETECTORS.clear()
17
- yield
18
- monitor._PLUGIN_DETECTORS.clear()
19
- monitor._PLUGIN_DETECTORS.extend(saved)
11
+ _reset = isolating(monitor._PLUGIN_DETECTORS)
20
12
 
21
13
 
22
14
  class _AlwaysFiresDetector:
@@ -0,0 +1,88 @@
1
+ """End-to-end: plugin-owned paths are filtered out of detect_dirty_files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ from agent_runner.vcs_state import _PLUGIN_OWNED_PATHS
9
+ from tests._test_helpers import isolating
10
+
11
+ _reset = isolating(_PLUGIN_OWNED_PATHS)
12
+
13
+
14
+ def _commit(repo: Path, msg: str) -> None:
15
+ subprocess.run(
16
+ [
17
+ "git",
18
+ "-c",
19
+ "commit.gpgsign=false",
20
+ "commit",
21
+ "-q",
22
+ "-m",
23
+ msg,
24
+ ],
25
+ cwd=repo,
26
+ check=True,
27
+ )
28
+
29
+
30
+ def _intent_to_add(repo: Path) -> None:
31
+ """Mark all untracked files with ``git add -N`` so ``status --porcelain``
32
+ lists them individually (rather than collapsing into the parent dir).
33
+
34
+ Real-world plugins write files into already-tracked directories, so
35
+ per-file porcelain entries are the production reality. This helper
36
+ reproduces that shape from a fresh tmp_git_repo.
37
+ """
38
+ subprocess.run(["git", "add", "-N", "."], cwd=repo, check=True)
39
+
40
+
41
+ def test_given_no_registration_when_dirty_files_then_all_returned(
42
+ tmp_git_repo: Path,
43
+ ) -> None:
44
+ """Baseline: with no registration, today's behavior is preserved."""
45
+ from agent_runner.vcs_state import detect_dirty_files
46
+
47
+ (tmp_git_repo / "proposals").mkdir()
48
+ (tmp_git_repo / "proposals" / "report.md").write_text("hello\n")
49
+ (tmp_git_repo / "other.txt").write_text("x\n")
50
+ _intent_to_add(tmp_git_repo)
51
+
52
+ dirty = detect_dirty_files(tmp_git_repo)
53
+ assert "other.txt" in dirty
54
+ assert "proposals/report.md" in dirty
55
+
56
+
57
+ def test_given_proposals_registered_when_dirty_files_then_proposals_filtered(
58
+ tmp_git_repo: Path,
59
+ ) -> None:
60
+ """Plugin-owned 'proposals/' files are excluded from dirty list."""
61
+ from agent_runner.vcs_state import detect_dirty_files, register_plugin_owned_paths
62
+
63
+ register_plugin_owned_paths(["proposals/"])
64
+ (tmp_git_repo / "proposals").mkdir()
65
+ (tmp_git_repo / "proposals" / "report.md").write_text("hello\n")
66
+ (tmp_git_repo / "other.txt").write_text("x\n")
67
+ _intent_to_add(tmp_git_repo)
68
+
69
+ dirty = detect_dirty_files(tmp_git_repo)
70
+ assert "other.txt" in dirty
71
+ assert "proposals/report.md" not in dirty
72
+
73
+
74
+ def test_given_recursive_glob_registered_when_dirty_files_then_deep_paths_filtered(
75
+ tmp_git_repo: Path,
76
+ ) -> None:
77
+ """Recursive ``**`` glob excludes nested paths too."""
78
+ from agent_runner.vcs_state import detect_dirty_files, register_plugin_owned_paths
79
+
80
+ register_plugin_owned_paths(["logs/plugins/**/*"])
81
+ (tmp_git_repo / "logs" / "plugins" / "argus").mkdir(parents=True)
82
+ (tmp_git_repo / "logs" / "plugins" / "argus" / "state.json").write_text("{}\n")
83
+ (tmp_git_repo / "logs" / "other.log").write_text("x\n")
84
+ _intent_to_add(tmp_git_repo)
85
+
86
+ dirty = detect_dirty_files(tmp_git_repo)
87
+ assert "logs/plugins/argus/state.json" not in dirty
88
+ assert "logs/other.log" in dirty
@@ -0,0 +1,22 @@
1
+ """Single ceiling on production module sizes.
2
+
3
+ Catches the actual signal worth catching — "this file got long enough that
4
+ a split is overdue" — without the per-release ratchet-bumping bookkeeping
5
+ the old parameterised version generated.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ PKG = Path(__file__).resolve().parent.parent.parent / "agent_runner"
13
+ LIMIT = 1000
14
+
15
+
16
+ def test_given_production_module_when_counted_then_under_thousand_lines() -> None:
17
+ offenders: list[tuple[str, int]] = []
18
+ for path in PKG.rglob("*.py"):
19
+ n = sum(1 for _ in path.read_text(encoding="utf-8").splitlines())
20
+ if n > LIMIT:
21
+ offenders.append((str(path.relative_to(PKG)), n))
22
+ assert offenders == [], f"modules exceed {LIMIT} LOC; split overdue: {offenders}"