ctrlrelay 0.12.4__tar.gz → 0.14.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/CHANGELOG.md +53 -1
  2. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/PKG-INFO +1 -1
  3. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/config/orchestrator.yaml.example +17 -0
  4. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/bridge.md +32 -0
  5. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/configuration.md +34 -0
  6. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/operations.md +72 -0
  7. ctrlrelay-0.14.0/docs/superpowers/plans/2026-10-30-status-page.md +304 -0
  8. ctrlrelay-0.14.0/docs/superpowers/specs/2026-10-30-status-page-design.md +222 -0
  9. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/pyproject.toml +1 -1
  10. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/server.py +208 -3
  11. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/cli.py +673 -84
  12. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/config.py +35 -0
  13. ctrlrelay-0.14.0/src/ctrlrelay/core/events.py +173 -0
  14. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/github.py +39 -0
  15. ctrlrelay-0.14.0/src/ctrlrelay/core/question_expiry.py +498 -0
  16. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/state.py +421 -7
  17. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/install.py +5 -4
  18. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/dev.py +178 -16
  19. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/post_merge.py +18 -0
  20. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/secops.py +200 -10
  21. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/task.py +75 -6
  22. ctrlrelay-0.14.0/src/ctrlrelay/templates/systemd/web.service.template +22 -0
  23. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/base.py +11 -0
  24. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/socket_client.py +8 -0
  25. ctrlrelay-0.14.0/src/ctrlrelay/web/__init__.py +1 -0
  26. ctrlrelay-0.14.0/src/ctrlrelay/web/server.py +339 -0
  27. ctrlrelay-0.14.0/src/ctrlrelay/web/static/index.html +477 -0
  28. ctrlrelay-0.14.0/src/ctrlrelay/web/status.py +636 -0
  29. ctrlrelay-0.14.0/src/ctrlrelay/web/transcript.py +480 -0
  30. ctrlrelay-0.14.0/src/ctrlrelay/web/waiting.py +430 -0
  31. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/conftest.py +18 -0
  32. ctrlrelay-0.14.0/tests/fixtures/gh_search_issues.json +162 -0
  33. ctrlrelay-0.14.0/tests/fixtures/transcript_sample.jsonl +105 -0
  34. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_binary_resolution.py +3 -2
  35. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_bridge_server.py +559 -0
  36. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_dispatch_outcome_hook.py +71 -12
  37. ctrlrelay-0.14.0/tests/test_event_emission.py +1047 -0
  38. ctrlrelay-0.14.0/tests/test_events.py +425 -0
  39. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_install.py +22 -1
  40. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_pipeline_base.py +1 -0
  41. ctrlrelay-0.14.0/tests/test_question_backoff.py +763 -0
  42. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_question_expiry.py +335 -0
  43. ctrlrelay-0.14.0/tests/test_relay_alert_off_lock.py +638 -0
  44. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_resume_prompt_contract.py +109 -1
  45. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_secops_integration.py +1 -0
  46. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_secops_pipeline.py +21 -0
  47. ctrlrelay-0.14.0/tests/test_web_server.py +887 -0
  48. ctrlrelay-0.14.0/tests/test_web_status.py +1065 -0
  49. ctrlrelay-0.14.0/tests/test_web_transcript.py +549 -0
  50. ctrlrelay-0.14.0/tests/test_web_waiting.py +905 -0
  51. ctrlrelay-0.12.4/src/ctrlrelay/core/question_expiry.py +0 -193
  52. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  53. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  54. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  55. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/dependabot.yml +0 -0
  56. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/workflows/build.yml +0 -0
  57. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/workflows/cla.yml +0 -0
  58. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/workflows/pages.yml +0 -0
  59. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/workflows/publish.yml +0 -0
  60. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.github/workflows/test.yml +0 -0
  61. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/.gitignore +0 -0
  62. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/CODE_OF_CONDUCT.md +0 -0
  63. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/CONTRIBUTING.md +0 -0
  64. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/LICENSE +0 -0
  65. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/README.md +0 -0
  66. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/SECURITY.md +0 -0
  67. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/Gemfile +0 -0
  68. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/_config.yml +0 -0
  69. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/architecture.md +0 -0
  70. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/cli.md +0 -0
  71. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/development.md +0 -0
  72. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/feedback-loop.md +0 -0
  73. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/getting-started.md +0 -0
  74. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/index.md +0 -0
  75. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/docs/personalization.md +0 -0
  76. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/__init__.py +0 -0
  77. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/__init__.py +0 -0
  78. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/__main__.py +0 -0
  79. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/factory.py +0 -0
  80. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/handler.py +0 -0
  81. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
  82. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/protocol.py +0 -0
  83. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
  84. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/__init__.py +0 -0
  85. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/archived.py +0 -0
  86. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/audit.py +0 -0
  87. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/binaries.py +0 -0
  88. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/checkpoint.py +0 -0
  89. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/code_review.py +0 -0
  90. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/dispatch_health.py +0 -0
  91. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/dispatcher.py +0 -0
  92. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/network.py +0 -0
  93. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/obs.py +0 -0
  94. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/poller.py +0 -0
  95. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/pr_verifier.py +0 -0
  96. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/pr_watcher.py +0 -0
  97. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/redact.py +0 -0
  98. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/scheduler.py +0 -0
  99. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/core/worktree.py +0 -0
  100. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/dashboard/__init__.py +0 -0
  101. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/dashboard/client.py +0 -0
  102. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/gh_protocol.py +0 -0
  103. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/personalization/__init__.py +0 -0
  104. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/personalization/manager.py +0 -0
  105. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/personalization/paths.py +0 -0
  106. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/__init__.py +0 -0
  107. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/pipelines/base.py +0 -0
  108. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/setup.py +0 -0
  109. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/__init__.py +0 -0
  110. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
  111. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
  112. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
  113. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
  114. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
  115. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
  116. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
  117. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/__init__.py +0 -0
  118. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/src/ctrlrelay/transports/file_mock.py +0 -0
  119. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/__init__.py +0 -0
  120. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_archived.py +0 -0
  121. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_audit.py +0 -0
  122. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_bridge_protocol.py +0 -0
  123. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_checkpoint.py +0 -0
  124. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_claim_reconciliation.py +0 -0
  125. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_cli_ci_wait.py +0 -0
  126. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_cli_dev.py +0 -0
  127. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_cli_repos.py +0 -0
  128. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_cli_secops.py +0 -0
  129. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_cli_start.py +0 -0
  130. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_cli_version.py +0 -0
  131. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_code_review.py +0 -0
  132. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_config.py +0 -0
  133. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_dashboard_client.py +0 -0
  134. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_dev_integration.py +0 -0
  135. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_dev_pipeline.py +0 -0
  136. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_dispatch_health.py +0 -0
  137. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_dispatcher.py +0 -0
  138. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_docs_site.py +0 -0
  139. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_gh_protocol.py +0 -0
  140. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_github.py +0 -0
  141. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_issue_outcome_announcement.py +0 -0
  142. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_mattermost_handler.py +0 -0
  143. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_network.py +0 -0
  144. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_notify_transport.py +0 -0
  145. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_obs.py +0 -0
  146. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_personalization.py +0 -0
  147. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_poller.py +0 -0
  148. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_post_merge.py +0 -0
  149. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_pr_verifier.py +0 -0
  150. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_pr_watcher.py +0 -0
  151. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_redact.py +0 -0
  152. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_scheduler.py +0 -0
  153. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_setup.py +0 -0
  154. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_state.py +0 -0
  155. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_task_pipeline.py +0 -0
  156. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_telegram_handler.py +0 -0
  157. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_transport.py +0 -0
  158. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_transport_resolution.py +0 -0
  159. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_unreviewed_pr_announcement.py +0 -0
  160. {ctrlrelay-0.12.4 → ctrlrelay-0.14.0}/tests/test_worktree.py +0 -0
@@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.14.0] - 2026-10-31
11
+
12
+ A read-only status page. `state.db` switches to WAL mode and gains an
13
+ `events` table; both happen automatically. The page is off until you start
14
+ it. **No config edit is required** to upgrade.
15
+
16
+ ### Added
17
+
18
+ - `ctrlrelay web start|stop|status` and a `ctrlrelay-web` systemd unit: a
19
+ read-only page plus `/api/status` and `/api/events` JSON showing what each
20
+ agent is doing (phase and last command), the waiting line, questions that
21
+ need you, today's counts, and a feed of dispatches, PRs, CI, reviews,
22
+ merges, messages and alerts. New `web:` config block (defaults
23
+ `127.0.0.1:8787`, 7 days). Requests for other host names are refused; add
24
+ LAN names to `web.allowed_hosts` (#217, closes #209)
25
+ - An `events` table, written best-effort by the daemon on a separate
26
+ connection with a 0.25s lock wait, so a slow or failed write never affects
27
+ the work it records (#217)
28
+
29
+ ### Changed
30
+
31
+ - `state.db` uses WAL mode so the page can read while the daemon writes. If
32
+ the switch cannot get the lock at startup it is deferred to the next start
33
+ instead of failing (#217)
34
+
35
+ ## [0.13.0] - 2026-10-30
36
+
37
+ New database columns and one new config value. All of them are added
38
+ automatically and have defaults, so **no config edit is required**.
39
+
40
+ ### Added
41
+
42
+ - A re-asked question says so: "Repeat: asked N times before, last on
43
+ <date> UTC, no answer yet" (#213, closes #200)
44
+ - Finished questions older than 90 days are pruned from `pending_resumes`.
45
+ Open ones are always kept. New config value `question_retention_days`,
46
+ default 90 (#213, closes #200)
47
+
48
+ ### Changed
49
+
50
+ - The scheduled secops sweep holds back a question nobody answered, posting it again after 1, 2, 4, then 7 days (#215, closes #214)
51
+ - A reply to an earlier post of a question whose session expired unanswered goes to the newest open session asking the same thing in that repo, instead of being refused (#215, closes #214)
52
+ - The relay-health alert is sent in the background instead of while the per-repository lock is held, so a stuck chat bridge no longer slows down every repo in a sweep (#212, closes #208)
53
+
54
+ ### Fixed
55
+
56
+ - A state file path with an apostrophe, a trailing newline in a folder name, or a leading dash broke every checkpoint snippet, so the agent looked crashed (#211, closes #191)
57
+ - A secops repo that failed after its result was recorded no longer adds a second result, which paired every later repo's notification with its neighbour's result (#215)
58
+
10
59
  ## [0.12.4] - 2026-10-18
11
60
 
12
61
  Carries 0.12.3's entries too: v0.12.3 was never tagged, so nothing has
@@ -1747,7 +1796,10 @@ pipeline).
1747
1796
  per-phase implementation plans (Phase 0 through Phase 4).
1748
1797
  - `docs/Claude_Code_Project_Guide.md` — project development guide.
1749
1798
 
1750
- [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.3...HEAD
1799
+ [Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.14.0...HEAD
1800
+ [0.14.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.13.0...v0.14.0
1801
+ [0.13.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.4...v0.13.0
1802
+ [0.12.4]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.4
1751
1803
  [0.12.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.2...v0.12.3
1752
1804
  [0.12.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...v0.12.2
1753
1805
  [0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrlrelay
3
- Version: 0.12.4
3
+ Version: 0.14.0
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/
@@ -76,6 +76,9 @@ transport:
76
76
  # Read from whichever transport is active, so a Mattermost deployment
77
77
  # takes this value and not the telegram block's.
78
78
  question_ttl_seconds: 172800
79
+ # How long a finished (expired or resumed) question is kept before it
80
+ # is deleted. Open questions are never deleted.
81
+ question_retention_days: 90
79
82
  file_mock:
80
83
  inbox: "~/.ctrlrelay/inbox.txt"
81
84
  outbox: "~/.ctrlrelay/outbox.txt"
@@ -85,6 +88,20 @@ dashboard:
85
88
  url: "https://ctrlrelay-dashboard.example.com"
86
89
  auth_token_env: "CTRLRELAY_DASHBOARD_TOKEN"
87
90
 
91
+ # Read-only status page: `ctrlrelay web start`. Loopback by default; set
92
+ # host to this machine's LAN address to open it from the home network.
93
+ # It has no login: never expose it to the internet.
94
+ web:
95
+ host: "127.0.0.1"
96
+ port: 8787
97
+ history_days: 7
98
+ refresh_seconds: 5
99
+ github_check_seconds: 60
100
+ stale_after_minutes: 15
101
+ # Extra names you open the page by from another machine (the Host
102
+ # header); loopback, web.host and this machine's hostname are allowed.
103
+ allowed_hosts: []
104
+
88
105
  # Scheduled jobs run in-process by the poller daemon. Cron expressions are
89
106
  # standard 5-field (minute hour dom month dow) and evaluate in the `timezone`
90
107
  # set above. Change `secops_cron` to e.g. "0 6 * * 1" for weekly (Mondays).
@@ -245,6 +245,38 @@ exists. Messages it sends:
245
245
  - `✅ PR ready: ...` — pipeline finished green.
246
246
  - `❌ Failed on #123: ...` — pipeline failed.
247
247
 
248
+ **A question asked again is marked.** A scheduled sweep starts a fresh
249
+ session each day and the agent often reaches the same blocker and asks
250
+ again, in new words. When that happens the post carries a line such as
251
+ `Repeat: asked 3 times before, last on Oct 28 UTC, no answer yet.` Two
252
+ questions count as the same if they cite at least one PR or issue number
253
+ in common (or, when neither cites a number, have the same text). The
254
+ count runs from the last answer to that question, including an answer
255
+ on a row a later re-block rewrote (rows rewritten before this was kept
256
+ can still count too high). The history this reads is kept for
257
+ `question_retention_days`.
258
+
259
+ **A question nobody answers is asked less often.** On the scheduled
260
+ secops sweep, each earlier post of the same question that got no answer
261
+ counts as a miss: one that expired on `question_ttl_seconds`, or one
262
+ still open and more than 6 hours old (one closed as resolved elsewhere
263
+ does not count, and neither does one that never reached the chat
264
+ because the bridge was down, the post failed, or the bridge never
265
+ confirmed it). After one miss the next post waits 1 day from the last
266
+ post, after two 2 days, after three 4 days, then once a week, so a daily
267
+ sweep posts it on days 0, 1, 3, 7, 14, 21. An answer to any post of it
268
+ resets the count. A held question is not posted and
269
+ gets no `blocked on <repo>` message; the sweep summary lists it with its
270
+ session id and the time it is next due, and the poller logs
271
+ `secops.question.held`. The session still ends blocked with its row
272
+ saved, so it stays answerable and expires on the TTL like any unanswered
273
+ question. To answer it, name its session in a message, or reply to the
274
+ earlier post: while that post's own session is still open the reply
275
+ answers that session; once it has expired without an answer, the reply
276
+ goes to the newest open session asking the same thing in that repo, and
277
+ the confirmation says so. A manual
278
+ `ctrlrelay run secops` never holds a question back.
279
+
248
280
  For the full BLOCKED → answer → resume mechanics, see
249
281
  [Feedback loop]({{ '/feedback-loop/' | relative_url }}).
250
282
 
@@ -26,6 +26,7 @@ paths: { ... }
26
26
  claude: { ... }
27
27
  transport: { ... }
28
28
  dashboard: { ... }
29
+ web: { ... }
29
30
  repos: [ ... ]
30
31
  ```
31
32
 
@@ -38,6 +39,7 @@ repos: [ ... ]
38
39
  | `claude` | object | no | (defaults) | See [claude](#claude). |
39
40
  | `transport` | object | **yes** | — | See [transport](#transport). |
40
41
  | `dashboard` | object | no | (defaults) | See [dashboard](#dashboard). |
42
+ | `web` | object | no | (defaults) | The read-only status page. See [web](#web). |
41
43
  | `repos` | list | no | `[]` | See [repos](#repos). |
42
44
  | `schedules` | object | no | (defaults) | See [schedules](#schedules). |
43
45
  | `personalization` | object | no | unset | Cross-machine sync of Claude state. See [personalization](#personalization) and [Personalization sync]({{ '/personalization/' | relative_url }}). |
@@ -147,6 +149,7 @@ reach anybody.
147
149
  | `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
148
150
  | `ask_timeout_seconds` | int | `900` | How long a pipeline blocks waiting on your reply in-session. Minimum `60`. A reply arriving later still works: the question is persisted to `pending_resumes` and the every-minute sweeper drives the resume. |
149
151
  | `question_ttl_seconds` | int | `172800` (48h) | How long an **unanswered** question stays routable before it is retired. Minimum `3600`. Without a ceiling those rows never die — a question whose PR was merged by hand weeks ago stays a live target for an orphan reply. The hourly `question_expiry_sweeper` also retires a question early once every issue/PR it cites is closed or merged. |
152
+ | `question_retention_days` | int | `90` | How long a **finished** question (expired, or answered and resumed) is kept before the hourly sweeper deletes it. Minimum `1`. Open questions are never deleted. The kept history is what lets a re-asked question be marked as a repeat. |
150
153
 
151
154
  See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkthrough.
152
155
 
@@ -160,6 +163,7 @@ See [Telegram bridge]({{ '/bridge/' | relative_url }}) for the full setup walkth
160
163
  | `socket_path` | path | `"~/.ctrlrelay/ctrlrelay.sock"` | Unix socket path the bridge listens on. Pipelines connect to this socket as clients. |
161
164
  | `ask_timeout_seconds` | int | `900` | Same meaning as the Telegram key above: it bounds only the in-session wait, and a later reply still resolves through `pending_resumes`. |
162
165
  | `question_ttl_seconds` | int | `172800` (48h) | Same meaning as the Telegram key above. |
166
+ | `question_retention_days` | int | `90` | Same meaning as the Telegram key above. |
163
167
 
164
168
  **The bot must be a member of that channel.** A bot without `post:all` cannot
165
169
  post where it is not a member — and, the part that is easy to miss, it does not
@@ -203,6 +207,36 @@ dashboard:
203
207
  The dashboard is optional. Leaving `url` empty (the default) is the supported
204
208
  no-op configuration.
205
209
 
210
+ ## web
211
+
212
+ The read-only status page served by `ctrlrelay web start` (see
213
+ [Operations: status page]({{ '/operations/' | relative_url }}#status-page)). Every key is optional;
214
+ without a `web:` block the defaults below apply.
215
+
216
+ ```yaml
217
+ web:
218
+ host: "127.0.0.1"
219
+ port: 8787
220
+ history_days: 7
221
+ refresh_seconds: 5
222
+ github_check_seconds: 60
223
+ stale_after_minutes: 15
224
+ allowed_hosts: [] # e.g. ["mybox", "mybox.lan"]
225
+ ```
226
+
227
+ | Key | Type | Default | Description |
228
+ |---|---|---|---|
229
+ | `host` | string | `"127.0.0.1"` | Address to listen on, exactly. Loopback by default so a fresh install is never reachable from other machines. Set it to this machine's LAN address to open the page from the home network. `0.0.0.0` listens on every interface and is warned about at startup. |
230
+ | `port` | int (1-65535) | `8787` | Port to listen on. |
231
+ | `history_days` | int (>= 1) | `7` | Days of feed the page shows, and what `/api/events` returns without `?since=`. |
232
+ | `refresh_seconds` | int (>= 1) | `5` | How often the page asks `/api/status` again. |
233
+ | `github_check_seconds` | int (>= 15) | `60` | How often the waiting line asks GitHub (one search, plus one query per `include_labels` label). |
234
+ | `stale_after_minutes` | int (>= 1) | `15` | A running card is marked "looks stuck" after this long with no new agent step, unless ctrlrelay itself has a CI wait or review open for it. |
235
+ | `allowed_hosts` | list of strings | `[]` | Extra names the page may be opened by. A request is served only when its `Host` header is `localhost`, `127.0.0.1`, `[::1]`, `host` (unless it is `0.0.0.0`/`::`), this machine's hostname, the hostname plus `.local`, or one of these, alone or followed by `:port`. Anything else, or no `Host`, gets `421`. Matching ignores case and a trailing dot. This stops a web page on another site from pointing its own name at this server (DNS rebinding) and reading it. |
236
+
237
+ The page has no login and shows repository names, issue titles and the
238
+ questions agents asked. **Never expose it to the internet.**
239
+
206
240
  ## repos
207
241
 
208
242
  A list of repositories the orchestrator manages.
@@ -21,6 +21,7 @@ ctrlrelay generates platform-appropriate service unit files for you with
21
21
  |---|---|---|
22
22
  | Bridge | `ctrlrelay bridge start` | Delivers BLOCKED questions to Telegram and routes answers back. |
23
23
  | Poller | `ctrlrelay poller start --interval 300` | Watches GitHub for newly assigned issues and runs the dev pipeline. |
24
+ | Status page (optional) | `ctrlrelay web start` | Read-only page and JSON API of what ctrlrelay is doing. See [Status page](#status-page). |
24
25
 
25
26
  Both should restart on failure and on login.
26
27
 
@@ -234,6 +235,76 @@ systemctl --user status ctrlrelay-poller
234
235
  journalctl --user -u ctrlrelay-poller -f
235
236
  ```
236
237
 
238
+ ## Status page
239
+
240
+ `ctrlrelay web start` serves a read-only page and JSON API showing what each
241
+ agent is doing (phase, last command), what is waiting and why, the questions
242
+ that need an answer, and a feed of what ctrlrelay did and said. It is a third
243
+ process next to the bridge and the poller, so a slow or broken page can never
244
+ stall them.
245
+
246
+ It is **read-only**: it opens `state.db` read-only, reads the tail of each
247
+ agent's transcript, and asks GitHub (through the `gh` login ctrlrelay already
248
+ uses) for the waiting line once a minute. It writes nothing but its PID file
249
+ (`web.pid`, next to `state.db`), and serves `GET` only; any other method is
250
+ `405`. It has **no login** and shows repository names, issue titles and the
251
+ questions agents asked, so **never expose it to the internet**. Keep it on
252
+ loopback or on the home network.
253
+
254
+ | Path | What |
255
+ |---|---|
256
+ | `/` | The page. Refreshes itself every `web.refresh_seconds`. |
257
+ | `/api/status` | Everything on the page, as JSON (the contract is in `src/ctrlrelay/web/status.py`). A section that could not be read is `null` and `errors` says why; `[]` means "read, and empty". The feed holds at most the newest `feed_limit` (200) events: `feed_total` is how many the history window holds (`null` if unreadable) and `feed_truncated` is `true` when some were left out, in which case the page says "showing newest N of M". |
258
+ | `/api/events?since=<epoch seconds>` | The feed, newest first, at most `limit` (200) events. Without `since`, the last `web.history_days` days. Carries the same `feed_total` and `feed_truncated`, plus the `limit` used. |
259
+
260
+ Try it on loopback first (the default `web.host` is `127.0.0.1`, port `8787`):
261
+
262
+ ```bash
263
+ ctrlrelay web start
264
+ ctrlrelay web status
265
+ curl -s http://127.0.0.1:8787/api/status | python3 -m json.tool | head -40
266
+ curl -s "http://127.0.0.1:8787/api/events?since=$(( $(date +%s) - 3600 ))"
267
+ ctrlrelay web stop
268
+ ```
269
+
270
+ To open it from another machine on the home network, set `web.host` in
271
+ `orchestrator.yaml` to this machine's LAN address (see
272
+ [web]({{ '/configuration/' | relative_url }}#web)), then run it under
273
+ systemd. `ctrlrelay install systemd` renders `ctrlrelay-web.service` with the
274
+ other two units. It refuses to overwrite units that already exist, so on a
275
+ host where the bridge and poller are installed, render into a scratch
276
+ directory and copy only the web unit:
277
+
278
+ ```bash
279
+ ctrlrelay install systemd --workdir ~/Projects/ctrlrelay --target-dir ~/ctrlrelay-units
280
+ cp ~/ctrlrelay-units/ctrlrelay-web.service ~/.config/systemd/user/
281
+ rm -r ~/ctrlrelay-units # the other two carry the chat token
282
+ systemctl --user daemon-reload
283
+ systemctl --user enable --now ctrlrelay-web.service
284
+ journalctl --user -u ctrlrelay-web -n 20
285
+ tail -n 20 ~/.ctrlrelay/logs/web.log
286
+ ```
287
+
288
+ The first line in `web.log` is a `web.started` event naming the address it
289
+ bound. If `web.host` is `0.0.0.0` it also logs a warning: that listens on
290
+ every interface, which is rarely what you want.
291
+
292
+ The page answers only to names it knows: `localhost`, `127.0.0.1`, `[::1]`,
293
+ `web.host`, and this machine's own hostname (plus `.local`). A request whose
294
+ `Host` header carries any other name gets `421 misdirected request`, and the
295
+ refused name is logged once as `web.misdirected_host`. So if you open it from
296
+ another machine by a name that is none of those, for example a DNS name your
297
+ router hands out, add that name to `web.allowed_hosts` and restart the unit:
298
+
299
+ ```yaml
300
+ web:
301
+ host: "192.168.1.20"
302
+ allowed_hosts: ["mybox.lan"]
303
+ ```
304
+
305
+ `grep misdirected_host ~/.ctrlrelay/logs/web.log` shows any name that was
306
+ refused, which is the quickest way to find the one to add.
307
+
237
308
  ## Scheduled jobs
238
309
 
239
310
  The poller daemon also hosts an in-process cron (APScheduler). The jobs run
@@ -300,6 +371,7 @@ Tail them:
300
371
  tail -f ~/.ctrlrelay/logs/poller.log
301
372
  tail -f ~/.ctrlrelay/logs/bridge.log
302
373
  tail -f ~/.ctrlrelay/logs/poller.error.log
374
+ tail -f ~/.ctrlrelay/logs/web.log # if the status page is installed
303
375
  ```
304
376
 
305
377
  The poller prints one line per detected issue and one line per pipeline outcome.
@@ -0,0 +1,304 @@
1
+ ---
2
+ title: Status page plan
3
+ layout: default
4
+ nav_order: 101
5
+ nav_exclude: true
6
+ ---
7
+
8
+ # Status Page Implementation Plan
9
+
10
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
11
+
12
+ **Goal:** A read-only web page and JSON API, served by a new `ctrlrelay web` process, that show what each agent is doing (phase, last command), what is waiting, what needs an answer, and a feed of what ctrlrelay did and said.
13
+
14
+ **Architecture:** The daemon gains one thing: best-effort rows in a new `events` table (and WAL mode on `state.db`). A separate `ctrlrelay web` process reads `state.db` read-only, tails agent transcripts, asks GitHub once a minute for the waiting line, and serves `/`, `/api/status`, `/api/events` from the standard-library HTTP server.
15
+
16
+ **Tech Stack:** Python 3.12, sqlite3, `http.server.ThreadingHTTPServer`, existing `GitHubCLI` (`gh`), typer, pydantic, pytest + pytest-asyncio (explicit `@pytest.mark.asyncio`, not auto mode).
17
+
18
+ **Spec:** `docs/superpowers/specs/2026-10-30-status-page-design.md`
19
+
20
+ ## Global Constraints
21
+
22
+ - Read-only: the web process never writes to `state.db`, the filesystem (except its pid file), or GitHub. No endpoint changes state; only `GET` is served, anything else is `405`.
23
+ - No new runtime dependency. Standard library HTTP server; no external CDN in the page.
24
+ - Daemon changes are limited to: WAL mode, and best-effort event writes that log and swallow every exception and never change, delay or fail the work they describe.
25
+ - Unknown is shown as unknown: missing transcript, busy database, failed GitHub call each produce an explicit `"unknown"`/`null` plus a reason, never an empty list that reads as "nothing".
26
+ - Config defaults: `host: 127.0.0.1`, `port: 8787`, `history_days: 7`, refresh every 5 s, GitHub checked every 60 s, "looks stuck" after 15 minutes.
27
+ - Every timestamp in JSON is a pair: `<name>_ts` (epoch seconds, int) and `<name>` (ISO 8601 UTC string).
28
+ - No secrets in output: no environment, no tokens, no full prompts. Commands are truncated to 200 chars.
29
+ - Run the suite on Python 3.12: `uv run -p 3.12 --extra dev pytest` and `uv run -p 3.12 --extra dev ruff check src tests`.
30
+
31
+ ## Review Focus
32
+
33
+ 1. An issue title or a shell command containing `<script>` or `"` renders as text on the page, never as HTML (titles come from GitHub, where anyone can file on a public repo). Test in Task 6.
34
+ 2. A transcript whose last line is half-written, a transcript dir with no `.jsonl`, or a multi-MB transcript gives the last complete step or `unknown`, never an exception, and reads at most the tail. Test in Task 3.
35
+ 3. `state.db` missing, locked, or older than the `events` table returns `200` with `"error"` fields and `unknown` sections, not a `500`. Test in Task 5.
36
+ 4. `gh` failing (not logged in, rate limit, timeout) leaves the waiting line `unknown` with the time of the last good answer, not empty. Test in Task 4.
37
+ 5. A secops session (no issue number, no title) and a `task` pipeline session render without errors. Test in Task 5.
38
+
39
+ ---
40
+
41
+ ## File map
42
+
43
+ | File | Responsibility |
44
+ |---|---|
45
+ | `src/ctrlrelay/core/state.py` (modify) | WAL, `events` table, `record_event`, `list_events`, `prune_events` |
46
+ | `src/ctrlrelay/core/events.py` (create) | `EventKind`, `emit_event()` best-effort wrapper |
47
+ | `src/ctrlrelay/pipelines/dev.py`, `task.py`, `secops.py`, `post_merge.py`, `bridge/server.py`, `cli.py` (modify) | call `emit_event` at existing points |
48
+ | `src/ctrlrelay/web/__init__.py` (create) | package marker |
49
+ | `src/ctrlrelay/web/transcript.py` (create) | find and tail an agent transcript, classify phase |
50
+ | `src/ctrlrelay/web/waiting.py` (create) | compute the waiting line from GitHub + poller rules |
51
+ | `src/ctrlrelay/web/status.py` (create) | read-only DB snapshot → JSON contract |
52
+ | `src/ctrlrelay/web/server.py` (create) | HTTP server, routes, background GitHub refresh |
53
+ | `src/ctrlrelay/web/static/index.html` (create) | the page (layout B) |
54
+ | `src/ctrlrelay/core/config.py` (modify) | `WebConfig` |
55
+ | `src/ctrlrelay/core/github.py` (modify) | `search_assigned_issues` |
56
+ | `src/ctrlrelay/templates/systemd/web.service.template`, `install.py` (create/modify) | third unit |
57
+ | `docs/configuration.md`, `docs/operations.md` (modify) | `web:` block, how to enable |
58
+
59
+ ---
60
+
61
+ ### Task 1: WAL mode and the events table
62
+
63
+ **Files:**
64
+ - Modify: `src/ctrlrelay/core/state.py` (`SCHEMA` :15-179, `__init__` :189-202, new methods near `list_pr_watches` :1160)
65
+ - Create: `src/ctrlrelay/core/events.py`
66
+ - Test: `tests/test_events.py`
67
+
68
+ **Interfaces:**
69
+ - Produces:
70
+ - `StateDB.record_event(kind: str, *, repo: str | None = None, issue_number: int | None = None, session_id: str | None = None, detail: dict | None = None, ts: int | None = None) -> None` (commits)
71
+ - `StateDB.list_events(*, since_ts: int = 0, limit: int = 200) -> list[dict]` newest first; `detail` decoded from JSON
72
+ - `StateDB.prune_events(older_than_ts: int) -> int`
73
+ - `events.EventKind` (`StrEnum`): `DISPATCHED="dispatched"`, `DONE="done"`, `BLOCKED="blocked"`, `FAILED="failed"`, `PR_OPENED="pr_opened"`, `MERGED="merged"`, `CI_STARTED="ci_started"`, `CI_FINISHED="ci_finished"`, `REVIEW_STARTED="review_started"`, `REVIEW_FINISHED="review_finished"`, `MESSAGE_SENT="message_sent"`, `QUESTION_POSTED="question_posted"`, `QUESTION_HELD="question_held"`, `ALERT_SENT="alert_sent"`, `RESTART="restart"`
74
+ - `events.emit_event(state_db: StateDB | None, kind: EventKind, **fields) -> None`: no-op when `state_db` is None; any exception is logged via `log_event(_logger, "events.write_failed", kind=..., reason=type(e).__name__)` and swallowed.
75
+
76
+ - [ ] **Step 1: Write the failing tests** in `tests/test_events.py`:
77
+ - `test_state_db_uses_wal`: `StateDB(tmp_path/"s.db")`; `db.execute("PRAGMA journal_mode").fetchone()[0] == "wal"`.
78
+ - `test_record_and_list_events_newest_first`: record `dispatched` at ts=100 and `merged` at ts=200 with `detail={"pr": 5}`; `list_events()` returns kinds `["merged","dispatched"]` and `[0]["detail"] == {"pr": 5}`; `list_events(since_ts=150)` returns only `merged`.
79
+ - `test_prune_events`: rows at 100 and 200, `prune_events(150) == 1`, one row left.
80
+ - `test_existing_db_gains_events_table`: create a DB with the old schema by running `SCHEMA` minus the events DDL on a raw `sqlite3` connection, then open with `StateDB`; `record_event` works.
81
+ - `test_emit_event_swallows_failure`: a `StateDB` whose `record_event` raises `sqlite3.OperationalError("database is locked")` (monkeypatch); `emit_event(db, EventKind.DONE, repo="a/b")` returns `None`, and `caplog` holds `events.write_failed`.
82
+ - `test_emit_event_none_db_is_noop`.
83
+
84
+ - [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_events.py -v`. Expected: FAIL (no `record_event`, no `ctrlrelay.core.events`).
85
+
86
+ - [ ] **Step 3: Implement.** In `__init__`, right after connect: `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`. Add to `SCHEMA`:
87
+
88
+ ```sql
89
+ CREATE TABLE IF NOT EXISTS events (
90
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
91
+ ts INTEGER NOT NULL,
92
+ kind TEXT NOT NULL,
93
+ repo TEXT,
94
+ issue_number INTEGER,
95
+ session_id TEXT,
96
+ detail TEXT
97
+ );
98
+ CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
99
+ CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, ts);
100
+ ```
101
+
102
+ `detail` is `json.dumps(detail)` or NULL. `emit_event` lives in `core/events.py`.
103
+
104
+ - [ ] **Step 4: Run** the same command. Expected: PASS. Then the full suite; expected: same count as main plus the new tests, 0 failed.
105
+
106
+ - [ ] **Step 5: Commit** `git add src/ctrlrelay/core/state.py src/ctrlrelay/core/events.py tests/test_events.py && git commit -m "feat(state): events table and WAL mode for the status page (#209)"`
107
+
108
+ ---
109
+
110
+ ### Task 2: Emit events from the daemon
111
+
112
+ **Files:**
113
+ - Modify: `src/ctrlrelay/pipelines/dev.py` (running INSERT :1282-1291, final UPDATE :1503-1508, failed :1605, `add_pending_resume` :1517, PR number read :1362, `_run_verify` :1045-1105, `_review_and_comment` :904-933), `src/ctrlrelay/pipelines/task.py` (:375, :431, :471), `src/ctrlrelay/pipelines/secops.py` (:746, :895, :945, :992, `_question_hold` :122), `src/ctrlrelay/pipelines/post_merge.py` (:355-359), `src/ctrlrelay/bridge/server.py` (SEND :467-478, ASK :545-550), `src/ctrlrelay/cli.py` (`deliver_relay_alert` after `mark_dispatch_alert_delivered` :1140; `poller_start` after `scheduler.started`; `_run_question_expiry_sweeper` :3020)
114
+ - Test: `tests/test_event_emission.py`
115
+
116
+ **Interfaces:**
117
+ - Consumes: `emit_event`, `EventKind`, `StateDB.list_events`, `StateDB.prune_events` (Task 1).
118
+ - Produces: event rows with these `detail` keys, which Task 5 reads:
119
+ - `dispatched`: `{"pipeline": str}`
120
+ - `done`/`failed`/`blocked`: `{"pipeline": str, "exit_code": int|None, "question": str|None}` (question truncated to 300 chars)
121
+ - `pr_opened`, `merged`: `{"pr_number": int, "pr_url": str|None}`
122
+ - `ci_started`/`ci_finished`, `review_started`/`review_finished`: `{"pr_number": int}`, finished adds `{"ok": bool}`
123
+ - `message_sent`: `{"request_id": str|None, "chars": int}`; `question_posted`: `{"request_id": str|None}`
124
+ - `question_held`: `{"held_until": int}`; `alert_sent`: `{"consecutive_failures": int|None}`; `restart`: `{"process": "poller"|"bridge", "version": str}`
125
+
126
+ - [ ] **Step 1: Write the failing tests** in `tests/test_event_emission.py`, each driving the real function with the repo's existing fakes (follow the fixtures already used by `tests/test_dev_pipeline*.py`, `tests/test_secops*.py`, `tests/test_post_merge*.py`, `tests/test_bridge_server.py`) and asserting on `state_db.list_events()`:
127
+ - `test_dev_run_records_dispatched_pr_ci_review_done`: a dev run that opens PR 7 and passes CI and review yields kinds in time order `dispatched, pr_opened, ci_started, ci_finished, review_started, review_finished, done`, all with the session's `session_id`, `pr_opened.detail["pr_number"] == 7`.
128
+ - `test_dev_blocked_records_blocked_with_question`.
129
+ - `test_task_and_secops_record_dispatched_and_outcome`: secops rows have `issue_number is None`.
130
+ - `test_secops_hold_records_question_held` with `detail["held_until"]` equal to the hold time.
131
+ - `test_pr_watch_merge_records_merged`.
132
+ - `test_bridge_send_and_ask_record_events`.
133
+ - `test_event_write_failure_does_not_change_the_run`: patch `StateDB.record_event` to raise; the dev run result and its `sessions` row equal those of the same run without the patch.
134
+ - `test_hourly_sweep_prunes_events_past_history`: events at now-8d and now-1d; after the sweep only the 1-day one remains (window: `config.web.history_days`, default 7 → use 7 here since Task 6 adds the config; read it with `getattr(getattr(config, "web", None), "history_days", 7)`).
135
+
136
+ - [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_event_emission.py -v`. Expected: FAIL (no rows).
137
+
138
+ - [ ] **Step 3: Implement.** Add one `emit_event(...)` call at each point listed under Files, after the work it describes has happened. The prune goes in `_run_question_expiry_sweeper` before its early return on missing TTL, in its own `try` that logs and continues, so it runs even with no chat transport.
139
+
140
+ - [ ] **Step 4: Run** the new file, then the full suite. Expected: PASS, no existing test changed.
141
+
142
+ - [ ] **Step 5: Commit** `git commit -m "feat(events): record dispatch, PR, CI, review, merge and message events (#209)"` with the files listed above staged by name.
143
+
144
+ ---
145
+
146
+ ### Task 3: Agent transcript reader
147
+
148
+ **Files:**
149
+ - Create: `src/ctrlrelay/web/__init__.py`, `src/ctrlrelay/web/transcript.py`
150
+ - Create: `tests/fixtures/transcript_sample.jsonl` (real rows, see Step 1)
151
+ - Test: `tests/test_web_transcript.py`
152
+
153
+ **Interfaces:**
154
+ - Produces:
155
+ - `Phase` (`StrEnum`): `READING="reading"`, `CODING="coding"`, `TESTING="testing"`, `CI="ci"`, `REVIEW="review"`, `PR="pr"`, `UNKNOWN="unknown"`
156
+ - `@dataclass(frozen=True) AgentActivity: phase: Phase; last_command: str | None; last_step_ts: int | None; transcript: str | None; reason: str | None`
157
+ - `transcript_dir_for(worktree_path: str, home: Path) -> Path`: `home/".claude"/"projects"/re.sub(r"[/._]", "-", worktree_path)`
158
+ - `classify(tool_name: str, tool_input: dict) -> Phase | None`
159
+ - `read_activity(worktree_path: str | None, *, home: Path | None = None, tail_bytes: int = 262_144) -> AgentActivity`
160
+
161
+ - [ ] **Step 1: Capture a real fixture.** On alkaturbo, copy ~30 consecutive lines from a finished dev transcript under `~/.claude/projects/-home-ovalenzuela--ctrlrelay-worktrees-*/` that include `Read`, `Edit`, a `Bash` running `pytest`, and a `Bash` running `git push`. Replace repo-private text in `content` strings with `"..."`; keep keys, `type`, `timestamp`, tool names and inputs. Write failing tests:
162
+ - `test_transcript_dir_matches_measured_layout`: `transcript_dir_for("/home/u/.ctrlrelay/worktrees/AInvirion-yt-dev-215-49a5", Path("/home/u"))` == `Path("/home/u/.claude/projects/-home-u--ctrlrelay-worktrees-AInvirion-yt-dev-215-49a5")`.
163
+ - `test_classify`: `("Read",{})→READING`, `("Edit",{})→CODING`, `("Bash",{"command":"uv run pytest -q"})→TESTING`, `("Bash",{"command":"npx playwright test"})→TESTING`, `("Bash",{"command":"git push -u origin x"})→PR`, `("Bash",{"command":"gh pr create --fill"})→PR`, `("Bash",{"command":"sed -n '1,60p' f.py"})→READING`, `("TodoWrite",{})→None`.
164
+ - `test_read_activity_from_real_fixture`: last step's phase, `last_command`, `last_step_ts` match the fixture's last `tool_use`.
165
+ - `test_half_written_last_line_is_ignored`: fixture + a truncated JSON line → same result as without it.
166
+ - `test_no_transcript_is_unknown`: empty dir → `phase=UNKNOWN`, `reason="no transcript"`; `worktree_path=None` → `UNKNOWN`, `reason="no worktree"`.
167
+ - `test_reads_only_the_tail`: a 5 MB file whose last line is a `Bash pytest` step returns `TESTING`, and monkeypatched `open().read` is never asked for more than `tail_bytes`.
168
+ - `test_last_command_truncated_to_200`.
169
+
170
+ - [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_transcript.py -v`. Expected: FAIL (module missing).
171
+
172
+ - [ ] **Step 3: Implement.** Newest `*.jsonl` by mtime in the dir. Seek to `max(0, size - tail_bytes)`, drop the first (partial) line and any line that fails `json.loads`. Walk `assistant` rows' `message.content` lists for `type == "tool_use"`. `last_command` is `input.command`, else `input.file_path`, else `input.pattern`, newline-collapsed, max 200 chars. Phase is `classify` of the last tool use that classifies to non-None. Test-runner patterns: `pytest`, `npm test`, `npm run test`, `npx playwright`, `go test`, `cargo test`, `jest`, `vitest`. PR patterns: `git push`, `gh pr create`. Read-only shell (everything else under `Bash`) → `READING`.
173
+
174
+ - [ ] **Step 4: Run** the test file. Expected: PASS.
175
+
176
+ - [ ] **Step 5: Commit** `git commit -m "feat(web): read an agent's phase from its live transcript (#209)"`
177
+
178
+ ---
179
+
180
+ ### Task 4: Waiting line
181
+
182
+ **Files:**
183
+ - Modify: `src/ctrlrelay/core/github.py` (new method after `list_assigned_issues` :273)
184
+ - Create: `src/ctrlrelay/web/waiting.py`
185
+ - Test: `tests/test_web_waiting.py`
186
+
187
+ **Interfaces:**
188
+ - Consumes: `IssuePoller._matched_exclude_label`, `_matched_include_label`, `_matched_require_label`, `_issue_is_assigned_to` (static, `core/poller.py:602-669`); `Config.repos[*].automation` (`exclude_labels`, `include_labels`, `require_labels`, `accept_foreign_assignments`).
189
+ - Produces:
190
+ - `GitHubCLI.search_assigned_issues(assignee: str, *, limit: int = 200) -> list[dict]`: `gh search issues --assignee <a> --state open --limit <n> --json repository,number,title,labels,assignees,updatedAt`; each dict gets `"repo": repository["nameWithOwner"]`.
191
+ - `@dataclass(frozen=True) WaitingItem: repo: str; issue_number: int; title: str; reason: str; updated_ts: int`
192
+ - `compute_waiting(config: Config, issues: list[dict], *, seen: set[str], running: list[dict], locks: dict[str, str]) -> list[WaitingItem]` (pure)
193
+ - `load_seen(poller_state_file: Path) -> set[str]`: the poller's `seen_issues` from `poller_state.json` (empty set if the file is missing; raises nothing).
194
+ - `class WaitingTracker` with `async def refresh(self) -> None` and `snapshot(self) -> dict` returning `{"items": [...], "status": "ok"|"unknown", "checked": iso, "checked_ts": int, "last_good": iso|None, "last_good_ts": int|None, "error": str|None}`. Username from `gh api user --jq .login` once, retried on next refresh if it failed.
195
+
196
+ - [ ] **Step 1: Write failing tests** in `tests/test_web_waiting.py`, with issue dicts in the exact shape `gh search issues --json ...` returns (capture one real response on alkaturbo into `tests/fixtures/gh_search_issues.json`):
197
+ - `test_only_configured_repos`.
198
+ - `test_label_rules_match_the_poller`: an issue with an exclude label is dropped; a repo with `require_labels=["ready"]` drops an issue without it; behaviour is asserted against calling the poller's own static methods on the same input.
199
+ - `test_unlabeled_foreign_assignment_needs_accept`: in a repo not in `accept_foreign_assignments`, an issue admitted by assignment only has reason `"waits for the poller to confirm the assignment"`; in an accepting repo it gets the normal reason.
200
+ - `test_seen_and_running_issues_are_dropped`.
201
+ - `test_reasons`: repo locked by session of issue 215 → `"same repo as #215"`; any session running elsewhere → `"poller busy with <repo>#<n>"`; nothing running → `"next poll"`.
202
+ - `test_order_is_config_repo_order_then_github_order`.
203
+ - `test_gh_failure_keeps_last_good` (asyncio): first refresh ok with 2 items, second refresh `GitHubError` → `status == "unknown"`, `items` are the last good 2, `last_good_ts` is the first refresh's, `error` set.
204
+ - `test_never_succeeded_is_unknown_not_empty`: first refresh fails → `status "unknown"`, `items == []`, `last_good is None`.
205
+
206
+ - [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_waiting.py -v`. Expected: FAIL.
207
+
208
+ - [ ] **Step 3: Implement** as specified. `seen` keys use the poller's own key format (read it from `IssuePoller.mark_seen` :688). No other GitHub calls.
209
+
210
+ - [ ] **Step 4: Run.** Expected: PASS.
211
+
212
+ - [ ] **Step 5: Commit** `git commit -m "feat(web): compute the waiting line from GitHub with the poller's own rules (#209)"`
213
+
214
+ ---
215
+
216
+ ### Task 5: Status snapshot (the JSON contract)
217
+
218
+ **Files:**
219
+ - Create: `src/ctrlrelay/web/status.py`
220
+ - Test: `tests/test_web_status.py`
221
+
222
+ **Interfaces:**
223
+ - Consumes: `read_activity` (Task 3), `WaitingTracker.snapshot` (Task 4), event kinds and detail keys (Task 2).
224
+ - Produces:
225
+ - `open_readonly(db_path: Path) -> sqlite3.Connection`: `sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=1.0)`, `row_factory = sqlite3.Row`.
226
+ - `build_status(*, db_path: Path, waiting: dict, version: str, host: str, now: int | None = None, stale_after_s: int = 900, history_days: int = 7, home: Path | None = None) -> dict`
227
+ - `list_feed(*, db_path: Path, since_ts: int, limit: int = 200) -> dict` → `{"events": [...], "error": str|None}`
228
+
229
+ The contract (keys exactly):
230
+
231
+ ```
232
+ {
233
+ "version": str, "host": str, "generated": iso, "generated_ts": int,
234
+ "running": [ {"session_id", "pipeline", "repo", "issue_number": int|null,
235
+ "title": str|null, "started", "started_ts", "elapsed_s": int,
236
+ "phase": "reading|coding|testing|ci|review|pr|unknown",
237
+ "phase_source": "transcript|pipeline|none",
238
+ "last_command": str|null, "last_step", "last_step_ts",
239
+ "looks_stuck": bool, "reason": str|null } ],
240
+ "needs_you": [ {"session_id", "pipeline", "repo", "question", "asked", "asked_ts",
241
+ "held_until", "held_until_ts"} ],
242
+ "waiting": <WaitingTracker.snapshot()>,
243
+ "today": {"dispatched", "pr_opened", "merged", "failed", "blocked", "messages_sent"},
244
+ "feed": [ {"ts", "time", "kind", "repo", "issue_number", "session_id", "detail"} ],
245
+ "errors": [str]
246
+ }
247
+ ```
248
+
249
+ - [ ] **Step 1: Write failing tests**, building real DBs with `StateDB(tmp_path/"state.db")` and the real write methods:
250
+ - `test_running_dev_session_with_transcript_phase`: a running dev session whose worktree has the Task 3 fixture under a temp `home` → `phase == "pr"` (or the fixture's last phase), `phase_source == "transcript"`.
251
+ - `test_open_ci_event_overrides_transcript`: `ci_started` with no matching `ci_finished` for that session → `phase == "ci"`, `phase_source == "pipeline"`, and `looks_stuck is False` even when the transcript's last step is 2 hours old.
252
+ - `test_looks_stuck_after_threshold`: last step 16 min old, no open pipeline phase → `True`; 14 min → `False`.
253
+ - `test_secops_session_has_no_issue` and `test_task_session_renders`.
254
+ - `test_needs_you_lists_open_unanswered_questions` including `held_until`.
255
+ - `test_today_counts_from_events` using the local day of `now`.
256
+ - `test_missing_db_returns_unknown_sections`: nonexistent path → `running == []`, `errors` names the DB, no exception.
257
+ - `test_locked_db_reports_error`: hold an `EXCLUSIVE` transaction on a second connection → `errors` non-empty, no exception, returns within 3 s.
258
+ - `test_never_writes`: file mtime and size of `state.db` (and `-wal`) unchanged after `build_status`.
259
+ - `test_feed_since`.
260
+
261
+ - [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_status.py -v`. Expected: FAIL.
262
+
263
+ - [ ] **Step 3: Implement.** Pipeline phase = latest of `ci_started`/`review_started` for the session without a later matching `*_finished`. Title: from the waiting tracker's last GitHub answer when the issue is there, else `null` (no extra GitHub calls in this task).
264
+
265
+ - [ ] **Step 4: Run.** Expected: PASS.
266
+
267
+ - [ ] **Step 5: Commit** `git commit -m "feat(web): build the status JSON from a read-only snapshot (#209)"`
268
+
269
+ ---
270
+
271
+ ### Task 6: Server, page, config, command and systemd unit
272
+
273
+ **Files:**
274
+ - Create: `src/ctrlrelay/web/server.py`, `src/ctrlrelay/web/static/index.html`, `src/ctrlrelay/templates/systemd/web.service.template`
275
+ - Modify: `src/ctrlrelay/core/config.py` (new `WebConfig`, field on `Config` :694-700), `src/ctrlrelay/cli.py` (new `web_app` typer group like `poller_app` :2212-2305), `src/ctrlrelay/install.py` (`render_systemd` :154 loop), `tests/test_install.py:68`, `docs/configuration.md`, `docs/operations.md`, `config/orchestrator.yaml.example`, `pyproject.toml` (only if `static/*.html` is not already shipped as package data; check with `uv build` and `unzip -l`)
276
+ - Test: `tests/test_web_server.py`, `tests/test_install.py`
277
+
278
+ **Interfaces:**
279
+ - Consumes: `build_status`, `list_feed` (Task 5), `WaitingTracker` (Task 4).
280
+ - Produces:
281
+ - `WebConfig(BaseModel)`: `host: str = "127.0.0.1"`, `port: int = Field(8787, ge=1, le=65535)`, `history_days: int = Field(7, ge=1)`, `refresh_seconds: int = Field(5, ge=1)`, `github_check_seconds: int = Field(60, ge=15)`, `stale_after_minutes: int = Field(15, ge=1)`; `Config.web: WebConfig = Field(default_factory=WebConfig)`.
282
+ - `make_server(config: Config, *, waiting: WaitingTracker) -> ThreadingHTTPServer`
283
+ - CLI: `ctrlrelay web start [--config/-c] [--foreground/-F]`, pid file `state_db.parent/"web.pid"`, same daemonize pattern as `poller start`.
284
+ - Unit `ctrlrelay-web.service`: `ExecStart=${CTRLRELAY_BIN} web start --foreground`, logs `append:%h/.ctrlrelay/logs/web.log`.
285
+
286
+ - [ ] **Step 1: Write failing tests** in `tests/test_web_server.py` (server on port 0 in a thread, real DB from Task 5's helpers):
287
+ - `test_index_served_with_no_external_urls`: `GET /` 200, `text/html`, body contains no `http://` or `https://`.
288
+ - `test_api_status_contract`: `GET /api/status` 200, `application/json`, top-level keys equal the Task 5 contract.
289
+ - `test_api_events_since`; `test_api_events_bad_since_is_400` (`?since=abc`).
290
+ - `test_unknown_path_404`; `test_post_put_delete_are_405` and the DB is unchanged after them.
291
+ - `test_page_escapes_titles_and_commands`: a session titled `<img src=x onerror=alert(1)>` and a last command `"><script>` — load `index.html` and assert the page builds DOM with `textContent` (no `innerHTML` assignment of data anywhere in the file: `assert "innerHTML" not in html` or every `innerHTML` use is a static literal, checked by regex).
292
+ - `test_status_error_is_200_with_errors` when `state_db` is missing.
293
+ - `test_web_config_defaults`: `Config` without a `web` key → `host "127.0.0.1"`, `port 8787`, `history_days 7`.
294
+ - In `tests/test_install.py`: systemd render now writes `ctrlrelay-web.service` with the ExecStart above; update the `{"bridge","poller"}` expectation at :68 only for systemd if launchd is out of scope (keep launchd unchanged).
295
+
296
+ - [ ] **Step 2: Run** `uv run -p 3.12 --extra dev pytest tests/test_web_server.py tests/test_install.py -v`. Expected: FAIL.
297
+
298
+ - [ ] **Step 3: Implement.** The waiting tracker refreshes on its own `asyncio` loop in a background thread every `github_check_seconds`; request handlers only read its last snapshot. The page is layout B from the spec: left column running cards (phase bar `reading → coding → testing → CI → review → PR`, last command, how long ago, "looks stuck" mark), "Needs you", "Waiting", "Today"; right column the feed. It polls `/api/status` every `refresh_seconds` with `fetch`, builds every data node with `textContent`, and shows the `errors` and any `unknown` section as visible text. No external URLs.
299
+
300
+ - [ ] **Step 4: Run** the two test files, then the full suite and ruff. Expected: all pass. Then run it for real: `uv run -p 3.12 ctrlrelay web start -F -c <a test config pointing at a copy of state.db>` and `curl -s localhost:8787/api/status | python -m json.tool | head -40`; expected: the contract with real sessions.
301
+
302
+ - [ ] **Step 5: Docs.** `docs/configuration.md`: the `web:` block with every field and default. `docs/operations.md`: enabling on a host (set `web.host` to the LAN address, `ctrlrelay install systemd`, `systemctl --user enable --now ctrlrelay-web`), and that it is read-only and must not be exposed to the internet. Run every command written there once.
303
+
304
+ - [ ] **Step 6: Commit** `git commit -m "feat(web): serve the read-only status page and JSON API (#209)"`