ctrlrelay 0.12.0__tar.gz → 0.12.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 (129) hide show
  1. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/test.yml +1 -6
  2. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/CHANGELOG.md +14 -1
  3. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/PKG-INFO +4 -4
  4. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/README.md +3 -3
  5. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/config/orchestrator.yaml.example +1 -1
  6. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/bridge.md +5 -3
  7. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/configuration.md +5 -4
  8. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/pyproject.toml +1 -1
  9. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/dev.py +42 -2
  10. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/secops.py +42 -2
  11. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/task.py +44 -2
  12. ctrlrelay-0.12.1/tests/test_docs_site.py +244 -0
  13. ctrlrelay-0.12.1/tests/test_resume_prompt_contract.py +255 -0
  14. ctrlrelay-0.12.0/tests/test_docs_site.py +0 -112
  15. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  16. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  17. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  18. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/dependabot.yml +0 -0
  19. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/build.yml +0 -0
  20. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/cla.yml +0 -0
  21. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/pages.yml +0 -0
  22. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/publish.yml +0 -0
  23. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.gitignore +0 -0
  24. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/CODE_OF_CONDUCT.md +0 -0
  25. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/CONTRIBUTING.md +0 -0
  26. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/LICENSE +0 -0
  27. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/SECURITY.md +0 -0
  28. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/Gemfile +0 -0
  29. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/_config.yml +0 -0
  30. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/architecture.md +0 -0
  31. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/cli.md +0 -0
  32. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/development.md +0 -0
  33. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/feedback-loop.md +0 -0
  34. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/getting-started.md +0 -0
  35. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/index.md +0 -0
  36. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/operations.md +0 -0
  37. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/personalization.md +0 -0
  38. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/__init__.py +0 -0
  39. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/__init__.py +0 -0
  40. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/__main__.py +0 -0
  41. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/factory.py +0 -0
  42. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/handler.py +0 -0
  43. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  44. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/protocol.py +0 -0
  45. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/server.py +0 -0
  46. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  47. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/cli.py +0 -0
  48. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/__init__.py +0 -0
  49. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/archived.py +0 -0
  50. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/audit.py +0 -0
  51. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/checkpoint.py +0 -0
  52. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/code_review.py +0 -0
  53. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/config.py +0 -0
  54. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/dispatcher.py +0 -0
  55. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/github.py +0 -0
  56. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/network.py +0 -0
  57. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/obs.py +0 -0
  58. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/poller.py +0 -0
  59. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
  60. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
  61. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/question_expiry.py +0 -0
  62. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/scheduler.py +0 -0
  63. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/state.py +0 -0
  64. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/worktree.py +0 -0
  65. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
  66. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/dashboard/client.py +0 -0
  67. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/gh_protocol.py +0 -0
  68. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/install.py +0 -0
  69. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/__init__.py +0 -0
  70. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/manager.py +0 -0
  71. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/paths.py +0 -0
  72. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
  73. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/base.py +0 -0
  74. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
  75. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/setup.py +0 -0
  76. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/__init__.py +0 -0
  77. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  78. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  79. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  80. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  81. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  82. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  83. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  84. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/__init__.py +0 -0
  85. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/base.py +0 -0
  86. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/file_mock.py +0 -0
  87. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/socket_client.py +0 -0
  88. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/__init__.py +0 -0
  89. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/conftest.py +0 -0
  90. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_archived.py +0 -0
  91. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_audit.py +0 -0
  92. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_bridge_protocol.py +0 -0
  93. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_bridge_server.py +0 -0
  94. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_checkpoint.py +0 -0
  95. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_ci_wait.py +0 -0
  96. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_dev.py +0 -0
  97. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_repos.py +0 -0
  98. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_secops.py +0 -0
  99. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_start.py +0 -0
  100. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_version.py +0 -0
  101. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_code_review.py +0 -0
  102. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_config.py +0 -0
  103. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dashboard_client.py +0 -0
  104. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dev_integration.py +0 -0
  105. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dev_pipeline.py +0 -0
  106. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dispatcher.py +0 -0
  107. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_gh_protocol.py +0 -0
  108. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_github.py +0 -0
  109. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_install.py +0 -0
  110. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_mattermost_handler.py +0 -0
  111. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_network.py +0 -0
  112. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_obs.py +0 -0
  113. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_personalization.py +0 -0
  114. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_pipeline_base.py +0 -0
  115. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_poller.py +0 -0
  116. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_post_merge.py +0 -0
  117. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_pr_verifier.py +0 -0
  118. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_pr_watcher.py +0 -0
  119. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_question_expiry.py +0 -0
  120. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_scheduler.py +0 -0
  121. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_secops_integration.py +0 -0
  122. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_secops_pipeline.py +0 -0
  123. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_setup.py +0 -0
  124. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_state.py +0 -0
  125. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_task_pipeline.py +0 -0
  126. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_telegram_handler.py +0 -0
  127. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_transport.py +0 -0
  128. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_transport_resolution.py +0 -0
  129. {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_worktree.py +0 -0
@@ -37,9 +37,4 @@ jobs:
37
37
  run: uv run ruff check src/ tests/
38
38
 
39
39
  - name: Pytest
40
- # Deselect the pre-existing unrelated docs-site test that asserts
41
- # nav_order uniqueness across docs/ — tracked separately and
42
- # unchanged by this PR.
43
- run: |
44
- uv run pytest tests/ -v \
45
- --deselect tests/test_docs_site.py::test_nav_order_unique_per_sibling_group
40
+ run: uv run pytest tests/ -v
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.12.1] - 2026-10-06
11
+
12
+ ### Fixed
13
+
14
+ - Every checkpoint snippet creates its own directory (#190)
15
+ - Restate the checkpoint contract when resuming (#188)
16
+ - Run the whole suite, and make the next exclusion defendable (#186)
17
+
18
+ ### Changed
19
+
20
+ - Both-transports-at-once is not planned, not pending (#187)
21
+
10
22
  ## [0.12.0] - 2026-10-06
11
23
 
12
24
  ### Added
@@ -1671,7 +1683,8 @@ pipeline).
1671
1683
  per-phase implementation plans (Phase 0 through Phase 4).
1672
1684
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1673
1685
 
1674
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...HEAD
1686
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...HEAD
1687
+ [0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
1675
1688
  [0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
1676
1689
  [0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
1677
1690
  [0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.12.0
3
+ Version: 0.12.1
4
4
  Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
5
5
  Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
6
6
  Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
@@ -97,9 +97,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
97
97
  relays it to you and resumes the session once you reply. Choose
98
98
  **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
99
99
  server) with one `transport.type` setting. Exactly one is active at a
100
- time; running both at once is not supported yet
101
- ([#176](https://github.com/AInvirion/ctrlrelay/issues/176)). See
102
- [configuration][docs-config].
100
+ time; running both at once is not supported and not planned
101
+ ([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
102
+ won't-do). See [configuration][docs-config].
103
103
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
104
104
  with a notification on whichever chat transport is configured.
105
105
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
@@ -56,9 +56,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
56
56
  relays it to you and resumes the session once you reply. Choose
57
57
  **Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
58
58
  server) with one `transport.type` setting. Exactly one is active at a
59
- time; running both at once is not supported yet
60
- ([#176](https://github.com/AInvirion/ctrlrelay/issues/176)). See
61
- [configuration][docs-config].
59
+ time; running both at once is not supported and not planned
60
+ ([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
61
+ won't-do). See [configuration][docs-config].
62
62
  - **PR watcher.** Tracks the opened PR to merge and closes the loop
63
63
  with a notification on whichever chat transport is configured.
64
64
  - **In-process scheduler** (APScheduler). Runs periodic jobs inside
@@ -34,7 +34,7 @@ agent:
34
34
  # EXACTLY ONE transport is active, and it is the one named by `type`.
35
35
  # The other blocks below are inert: they may stay in the file, but nothing
36
36
  # reads them. Running Telegram and Mattermost at the same time is NOT
37
- # supported - that is tracked as issue #176.
37
+ # supported and NOT planned - designed in issue #176 and closed won't-do.
38
38
  #
39
39
  # Choose:
40
40
  # type: "telegram" production, 1:1 chat with a bot
@@ -22,9 +22,11 @@ one-line config change and no pipeline code moves.
22
22
  **One at a time.** `transport.type` names the transport that is active, and the
23
23
  other block is inert — it may stay in the file, but nothing reads it, including
24
24
  its `question_ttl_seconds`. There is no fallback: a question goes to the one
25
- you named or nowhere. Running both at once is
26
- [issue #176](https://github.com/AInvirion/ctrlrelay/issues/176) and is not
27
- built.
25
+ you named or nowhere. Running both at once is **not supported and not
26
+ planned** — it was designed in
27
+ [#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed
28
+ won't-do. Reopen that issue rather than re-deriving the design if you need
29
+ it.
28
30
 
29
31
  | | Telegram | Mattermost |
30
32
  |---|---|---|
@@ -104,10 +104,11 @@ block in the file is inert — it may stay there, but nothing reads it.
104
104
  | A channel on your own server | `"mattermost"` | A server URL, a bot token, and the channel's 26-character ID. |
105
105
  | No chat at all | `"file_mock"` | Nothing. Questions go to a file; **nobody is asked anything.** |
106
106
 
107
- **Running Telegram and Mattermost at the same time is not supported.** Setting
108
- `type` to one does not fall back to the other, and a question is delivered to
109
- one place or not at all. That is tracked as
110
- [issue #176](https://github.com/AInvirion/ctrlrelay/issues/176).
107
+ **Running Telegram and Mattermost at the same time is not supported, and is
108
+ not planned.** Setting `type` to one does not fall back to the other, and a
109
+ question is delivered to one place or not at all. It was designed in
110
+ [#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed won't-do;
111
+ the design is still in that issue if anyone wants it.
111
112
 
112
113
  Switching is a two-line change — set `type`, add that transport's block — plus
113
114
  a restart of **both** daemons, which share `state.db`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrlrelay"
3
- version = "0.12.0"
3
+ version = "0.12.1"
4
4
  description = "Local-first orchestrator for headless coding agents across multiple GitHub repos"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -318,7 +318,20 @@ class DevPipeline:
318
318
 
319
319
  async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
320
320
  """Resume blocked dev session with user answer."""
321
- prompt = f"User answered: {answer}\n\nContinue from where you left off."
321
+ # The checkpoint contract is restated here, not just in the
322
+ # initial prompt (#173). A resumed agent that is told "continue"
323
+ # and nothing else can do its work, exit 0, write no state, and
324
+ # be indistinguishable from one that crashed - which is what
325
+ # happened on 2026-09-16, after three of four pull requests had
326
+ # already been merged.
327
+ contract = self._checkpoint_contract(
328
+ str(ctx.state_file), ctx.session_id
329
+ )
330
+ prompt = (
331
+ f"User answered: {answer}\n\n"
332
+ "Continue from where you left off.\n\n"
333
+ f"{contract}"
334
+ )
322
335
 
323
336
  # Resume uses Claude's own session UUID (captured on the first spawn
324
337
  # and persisted to state_db). Passing our composite id here makes
@@ -444,7 +457,34 @@ Do NOT merge the PR - wait for human review.
444
457
  The orchestrator re-verifies CI and mergeability after you hand off; if it
445
458
  finds the PR broken it will resume this session asking you to fix it.
446
459
 
447
- ## Signaling Completion
460
+ {self._checkpoint_contract(state_file_path, session_id)}"""
461
+
462
+ def _checkpoint_contract(
463
+ self, state_file_path: str, session_id: str
464
+ ) -> str:
465
+ """How the agent signals how it stopped.
466
+
467
+ Extracted so the RESUME prompt can carry it too (#173). The
468
+ initial prompt spent ~30 lines establishing this; the resume
469
+ prompt was 58 characters and said only "continue", so a resumed
470
+ agent could do its work, exit 0, write no state, and be
471
+ indistinguishable from one that crashed.
472
+
473
+ Measured case, 2026-09-16:
474
+ secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
475
+ "Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
476
+ in fact merged three of four pull requests. The operator was told
477
+ the resume failed, which was wrong in both directions: most of
478
+ the work had landed, and the one thing outstanding was never
479
+ named.
480
+
481
+ Per-pipeline rather than shared, because the three contracts
482
+ differ in substance and not only wording: the dev DONE block
483
+ carries pr_url and pr_number outputs, and each names different
484
+ conditions for DONE. One shared block would have quietly changed
485
+ what two of them promise.
486
+ """
487
+ return f"""## Signaling Completion
448
488
 
449
489
  **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
450
490
 
@@ -126,7 +126,20 @@ class SecopsPipeline:
126
126
 
127
127
  async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
128
128
  """Resume blocked secops with user answer."""
129
- prompt = f"User answered: {answer}\n\nContinue from where you left off."
129
+ # The checkpoint contract is restated here, not just in the
130
+ # initial prompt (#173). A resumed agent that is told "continue"
131
+ # and nothing else can do its work, exit 0, write no state, and
132
+ # be indistinguishable from one that crashed - which is what
133
+ # happened on 2026-09-16, after three of four pull requests had
134
+ # already been merged.
135
+ contract = self._checkpoint_contract(
136
+ str(ctx.state_file), ctx.session_id
137
+ )
138
+ prompt = (
139
+ f"User answered: {answer}\n\n"
140
+ "Continue from where you left off.\n\n"
141
+ f"{contract}"
142
+ )
130
143
 
131
144
  # Resume uses Claude's own session UUID (captured on the first spawn
132
145
  # and persisted to state_db). Our composite id is not a UUID and will
@@ -425,7 +438,34 @@ yourself about to write a phrase like "no open PRs at all" or "N open
425
438
  PRs total", you have already stepped outside the fence — you can only
426
439
  speak to the author-filtered results.
427
440
 
428
- ## Signaling Completion
441
+ {self._checkpoint_contract(state_file_path, session_id)}"""
442
+
443
+ def _checkpoint_contract(
444
+ self, state_file_path: str, session_id: str
445
+ ) -> str:
446
+ """How the agent signals how it stopped.
447
+
448
+ Extracted so the RESUME prompt can carry it too (#173). The
449
+ initial prompt spent ~30 lines establishing this; the resume
450
+ prompt was 58 characters and said only "continue", so a resumed
451
+ agent could do its work, exit 0, write no state, and be
452
+ indistinguishable from one that crashed.
453
+
454
+ Measured case, 2026-09-16:
455
+ secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
456
+ "Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
457
+ in fact merged three of four pull requests. The operator was told
458
+ the resume failed, which was wrong in both directions: most of
459
+ the work had landed, and the one thing outstanding was never
460
+ named.
461
+
462
+ Per-pipeline rather than shared, because the three contracts
463
+ differ in substance and not only wording: the dev DONE block
464
+ carries pr_url and pr_number outputs, and each names different
465
+ conditions for DONE. One shared block would have quietly changed
466
+ what two of them promise.
467
+ """
468
+ return f"""## Signaling Completion
429
469
 
430
470
  **CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
431
471
 
@@ -80,7 +80,20 @@ class TaskPipeline:
80
80
  return self._session_to_result(result)
81
81
 
82
82
  async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
83
- prompt = f"User answered: {answer}\n\nContinue from where you left off."
83
+ # The checkpoint contract is restated here, not just in the
84
+ # initial prompt (#173). A resumed agent that is told "continue"
85
+ # and nothing else can do its work, exit 0, write no state, and
86
+ # be indistinguishable from one that crashed - which is what
87
+ # happened on 2026-09-16, after three of four pull requests had
88
+ # already been merged.
89
+ contract = self._checkpoint_contract(
90
+ str(ctx.state_file), ctx.session_id
91
+ )
92
+ prompt = (
93
+ f"User answered: {answer}\n\n"
94
+ "Continue from where you left off.\n\n"
95
+ f"{contract}"
96
+ )
84
97
  resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
85
98
  log_event(
86
99
  _logger,
@@ -181,7 +194,34 @@ pipeline. Don't BLOCK on things you can figure out yourself.
181
194
 
182
195
  ## Signaling completion
183
196
 
184
- **CRITICAL**: Before exiting, write a checkpoint file.
197
+ {self._checkpoint_contract(state_file_path, session_id)}"""
198
+
199
+ def _checkpoint_contract(
200
+ self, state_file_path: str, session_id: str
201
+ ) -> str:
202
+ """How the agent signals how it stopped.
203
+
204
+ Extracted so the RESUME prompt can carry it too (#173). The
205
+ initial prompt spent ~30 lines establishing this; the resume
206
+ prompt was 58 characters and said only "continue", so a resumed
207
+ agent could do its work, exit 0, write no state, and be
208
+ indistinguishable from one that crashed.
209
+
210
+ Measured case, 2026-09-16:
211
+ secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
212
+ "Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
213
+ in fact merged three of four pull requests. The operator was told
214
+ the resume failed, which was wrong in both directions: most of
215
+ the work had landed, and the one thing outstanding was never
216
+ named.
217
+
218
+ Per-pipeline rather than shared, because the three contracts
219
+ differ in substance and not only wording: the dev DONE block
220
+ carries pr_url and pr_number outputs, and each names different
221
+ conditions for DONE. One shared block would have quietly changed
222
+ what two of them promise.
223
+ """
224
+ return f"""**CRITICAL**: Before exiting, write a checkpoint file.
185
225
 
186
226
  STATE_FILE: {state_file_path}
187
227
  SESSION_ID: {session_id}
@@ -196,6 +236,7 @@ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\\
196
236
 
197
237
  **BLOCKED** (need operator input):
198
238
  ```bash
239
+ mkdir -p "$(dirname '{state_file_path}')"
199
240
  printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
200
241
  '"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
201
242
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
@@ -203,6 +244,7 @@ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
203
244
 
204
245
  **FAILED** (genuine failure, no way forward):
205
246
  ```bash
247
+ mkdir -p "$(dirname '{state_file_path}')"
206
248
  printf '{{"version":"1","status":"FAILED",'\\
207
249
  '"session_id":"{session_id}","timestamp":"%s","error":"%s"}}' \\
208
250
  "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
@@ -0,0 +1,244 @@
1
+ """Validation tests for the Jekyll documentation site under ``docs/``.
2
+
3
+ These tests do not invoke Jekyll. They assert the structural invariants the
4
+ site relies on so a misconfigured page is caught before GitHub Pages builds.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import defaultdict
10
+ from pathlib import Path
11
+
12
+ import pytest
13
+ import yaml
14
+
15
+ REPO_ROOT = Path(__file__).resolve().parent.parent
16
+ DOCS = REPO_ROOT / "docs"
17
+
18
+
19
+ def _split_front_matter(text: str) -> tuple[dict, str]:
20
+ if not text.startswith("---\n"):
21
+ return {}, text
22
+ end = text.find("\n---", 4)
23
+ if end == -1:
24
+ return {}, text
25
+ raw = text[4:end]
26
+ body = text[end + 4 :].lstrip("\n")
27
+ data = yaml.safe_load(raw) or {}
28
+ if not isinstance(data, dict):
29
+ raise ValueError("front matter must be a mapping")
30
+ return data, body
31
+
32
+
33
+ def _markdown_files() -> list[Path]:
34
+ return sorted(p for p in DOCS.rglob("*.md") if "_site" not in p.parts)
35
+
36
+
37
+ def test_config_is_valid_yaml():
38
+ config_path = DOCS / "_config.yml"
39
+ assert config_path.exists(), "docs/_config.yml must exist"
40
+ data = yaml.safe_load(config_path.read_text())
41
+ assert isinstance(data, dict)
42
+ assert data.get("remote_theme"), "remote_theme must be set for GitHub Pages"
43
+ assert "jekyll-remote-theme" in (data.get("plugins") or []), (
44
+ "jekyll-remote-theme plugin required"
45
+ )
46
+
47
+
48
+ def test_landing_page_exists():
49
+ assert (DOCS / "index.md").exists(), "docs/index.md landing page required"
50
+
51
+
52
+ def test_all_markdown_pages_have_title_front_matter():
53
+ missing: list[str] = []
54
+ for md in _markdown_files():
55
+ front, _ = _split_front_matter(md.read_text())
56
+ if not front.get("title"):
57
+ missing.append(str(md.relative_to(REPO_ROOT)))
58
+ assert not missing, f"pages missing title front matter: {missing}"
59
+
60
+
61
+ def test_parent_pages_declare_has_children():
62
+ """Any page referenced as ``parent`` must itself set ``has_children: true``."""
63
+ titles_with_children: set[str] = set()
64
+ referenced_parents: set[str] = set()
65
+ for md in _markdown_files():
66
+ front, _ = _split_front_matter(md.read_text())
67
+ if front.get("has_children"):
68
+ titles_with_children.add(front["title"])
69
+ parent = front.get("parent")
70
+ if parent:
71
+ referenced_parents.add(parent)
72
+ missing = referenced_parents - titles_with_children
73
+ assert not missing, f"parents referenced but not declared with has_children: {missing}"
74
+
75
+
76
+ def test_nav_order_unique_per_sibling_group():
77
+ """Pages sharing a parent (or all top-level) must have distinct nav_order."""
78
+ groups: dict[str, dict[int, list[str]]] = defaultdict(lambda: defaultdict(list))
79
+ for md in _markdown_files():
80
+ front, _ = _split_front_matter(md.read_text())
81
+ parent = front.get("parent", "__root__")
82
+ order = front.get("nav_order")
83
+ if order is None:
84
+ continue
85
+ groups[parent][order].append(str(md.relative_to(REPO_ROOT)))
86
+ for parent, by_order in groups.items():
87
+ collisions = {o: files for o, files in by_order.items() if len(files) > 1}
88
+ assert not collisions, (
89
+ f"duplicate nav_order under parent {parent!r}: {collisions}"
90
+ )
91
+
92
+
93
+ def test_no_stray_bare_markdown_outside_structure():
94
+ """Every page must either be the index, a top-level nav page, or have a parent."""
95
+ offenders: list[str] = []
96
+ for md in _markdown_files():
97
+ rel = md.relative_to(DOCS)
98
+ if rel.name == "index.md":
99
+ continue
100
+ front, _ = _split_front_matter(md.read_text())
101
+ if front.get("parent") or front.get("nav_order") is not None:
102
+ continue
103
+ offenders.append(str(md.relative_to(REPO_ROOT)))
104
+ assert not offenders, (
105
+ f"pages without parent or top-level nav_order: {offenders}"
106
+ )
107
+
108
+
109
+ @pytest.mark.parametrize("path", _markdown_files())
110
+ def test_front_matter_parses(path: Path):
111
+ front, _ = _split_front_matter(path.read_text())
112
+ assert isinstance(front, dict)
113
+
114
+
115
+ def test_ci_runs_the_whole_suite():
116
+ """No workflow may exclude tests from the run (#179).
117
+
118
+ `test.yml` carried `--deselect
119
+ tests/test_docs_site.py::test_nav_order_unique_per_sibling_group` under
120
+ the comment "tracked separately and unchanged by this PR". The test
121
+ passed the whole time - verified by making two pages collide on
122
+ `nav_order`, which it catches and names both files for - so CI had
123
+ been skipping a working guard for months and under-counting itself.
124
+
125
+ The transferable part is the justification, not the flag. "Unchanged
126
+ by this PR" is a pull-request-scoped reason frozen into a file that
127
+ runs for every change. It was true the day it was written and stays
128
+ plausible forever, because it is true of almost every pull request -
129
+ so every reader nods and moves on. **Such a reason cannot expire on
130
+ its own.**
131
+
132
+ This makes the next exclusion something that has to be defended
133
+ rather than inherited: adding one fails here, and the person adding
134
+ it has to come and say why in this file, where a reviewer will see
135
+ it next to this paragraph.
136
+
137
+ If an exclusion is ever genuinely needed, prefer deleting or fixing
138
+ the test. Do not leave the third state - a test that exists, is not
139
+ run, and is believed to be covering something.
140
+
141
+ **Reads the parsed `run:` values, not lines of the file.** A review
142
+ round found three faults in the line-grep version, and parsing fixes
143
+ all of them: a step `name:` mentioning a flag was reported as an
144
+ offender (a false positive invites relaxing the guard, and a relaxed
145
+ guard stops guarding); `--ignore tests/x` with a space slipped past a
146
+ pattern that required `--ignore=`; and the guard reported clean when
147
+ it had inspected no pytest command at all.
148
+
149
+ **What this does NOT stop, stated so the paragraph above is not the
150
+ false sentence.** It reads text, so an author actively evading it
151
+ wins: `--ig"nore"` reaches pytest as `--ignore` and no pattern here
152
+ sees it. That is not worth chasing - every fix is defeated by the
153
+ next quoting trick, and anyone editing the workflow to dodge this
154
+ guard can edit this file just as easily. The fault in #179 was
155
+ **inherited, not evasive**: a reason that was true once, left in
156
+ place, nodded past by every later reader. That is what this catches.
157
+
158
+ One known false positive: a run script that *prints* an example
159
+ containing an exclusion flag - `echo "e.g. pytest --ignore tests/x"` -
160
+ is reported. Deliberately not fixed, because telling the quoted from
161
+ the executed needs a shell parser, which is more machinery than the
162
+ defect. Do not write that line; if you must, name it here.
163
+ """
164
+ import re
165
+
166
+ import yaml
167
+
168
+ wf_dir = REPO_ROOT / ".github" / "workflows"
169
+ workflows = sorted(
170
+ [*wf_dir.glob("*.yml"), *wf_dir.glob("*.yaml")]
171
+ )
172
+ assert len(workflows) >= 3, f"only found {len(workflows)} workflows"
173
+
174
+ # Every `run:` script in every step, with where it came from.
175
+ runs: list[tuple[str, str]] = []
176
+ for wf in workflows:
177
+ doc = yaml.safe_load(wf.read_text()) or {}
178
+ for job_name, job in (doc.get("jobs") or {}).items():
179
+ for step in (job or {}).get("steps") or []:
180
+ script = (step or {}).get("run")
181
+ if isinstance(script, str):
182
+ runs.append((f"{wf.name}:{job_name}", script))
183
+
184
+ def _code_lines(script: str) -> list[str]:
185
+ """The lines of a run script that are not comments."""
186
+ return [
187
+ line
188
+ for line in script.splitlines()
189
+ if not line.lstrip().startswith("#")
190
+ ]
191
+
192
+ invocations = [
193
+ (where, script)
194
+ for where, script in runs
195
+ if any(
196
+ re.search(r"(?<![\w-])pytest(?![\w-])", line)
197
+ for line in _code_lines(script)
198
+ )
199
+ ]
200
+
201
+ # Invariant D, as a POSITIVE assertion. `offenders == []` alone passes
202
+ # when no pytest command exists at all - a workflow replaced with
203
+ # `run: true` would have reported clean.
204
+ assert invocations, (
205
+ "no workflow step runs pytest. Either CI stopped running the "
206
+ "suite, or this guard can no longer see it - both are faults, "
207
+ f"and neither is a clean result. Scanned: {[w for w, _ in runs]}"
208
+ )
209
+
210
+ # Flags that remove tests from a run, accepting `=` or a space.
211
+ # `--maxfail` is not one: it stops early on failure, it does not hide
212
+ # a passing test. `-m` selects by marker, which this suite does not
213
+ # use for exclusion.
214
+ excluders = re.compile(
215
+ r"--deselect(?:[=\s]|$)"
216
+ r"|--ignore(?:-glob)?(?:[=\s]|$)"
217
+ r"|(?<!\w)-k(?:[=\s]|$)"
218
+ )
219
+
220
+ offenders: list[str] = []
221
+ for where, script in invocations:
222
+ for lineno, line in enumerate(script.splitlines(), start=1):
223
+ if line.lstrip().startswith("#"):
224
+ continue
225
+ if excluders.search(line):
226
+ offenders.append(f"{where} run line {lineno}: {line.strip()}")
227
+
228
+ # Narrowing the target paths excludes tests without any flag at
229
+ # all, which is the bypass a flag list cannot see.
230
+ targets = [
231
+ tok
232
+ for tok in script.split()
233
+ if tok.startswith("tests") and not tok.startswith("-")
234
+ ]
235
+ if targets and not any(t.rstrip("/") == "tests" for t in targets):
236
+ offenders.append(
237
+ f"{where}: runs only {targets}, not the whole tests/ tree"
238
+ )
239
+
240
+ assert offenders == [], (
241
+ "a workflow excludes tests from the run. Delete the test, fix it, "
242
+ "or change this guard and say why here - do not leave a test that "
243
+ f"exists, is not run, and is assumed to pass (#179): {offenders}"
244
+ )