ahub 3.0.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 (119) hide show
  1. ahub-3.0.0/LICENSE +21 -0
  2. ahub-3.0.0/PKG-INFO +147 -0
  3. ahub-3.0.0/README.md +120 -0
  4. ahub-3.0.0/ahub/__init__.py +3 -0
  5. ahub-3.0.0/ahub/__main__.py +3 -0
  6. ahub-3.0.0/ahub/accept.py +257 -0
  7. ahub-3.0.0/ahub/archive.py +129 -0
  8. ahub-3.0.0/ahub/claude/SKILL.md +53 -0
  9. ahub-3.0.0/ahub/cli.py +77 -0
  10. ahub-3.0.0/ahub/cliutil.py +56 -0
  11. ahub-3.0.0/ahub/commands/__init__.py +1 -0
  12. ahub-3.0.0/ahub/commands/bot.py +26 -0
  13. ahub-3.0.0/ahub/commands/comms.py +161 -0
  14. ahub-3.0.0/ahub/commands/config_.py +41 -0
  15. ahub-3.0.0/ahub/commands/doctor.py +36 -0
  16. ahub-3.0.0/ahub/commands/draft.py +65 -0
  17. ahub-3.0.0/ahub/commands/mcp_.py +19 -0
  18. ahub-3.0.0/ahub/commands/models.py +125 -0
  19. ahub-3.0.0/ahub/commands/observer.py +47 -0
  20. ahub-3.0.0/ahub/commands/projects.py +34 -0
  21. ahub-3.0.0/ahub/commands/service.py +328 -0
  22. ahub-3.0.0/ahub/commands/setup.py +507 -0
  23. ahub-3.0.0/ahub/commands/task.py +328 -0
  24. ahub-3.0.0/ahub/commands/top.py +17 -0
  25. ahub-3.0.0/ahub/commands/version.py +18 -0
  26. ahub-3.0.0/ahub/comms.py +127 -0
  27. ahub-3.0.0/ahub/config.py +502 -0
  28. ahub-3.0.0/ahub/doctor.py +388 -0
  29. ahub-3.0.0/ahub/drafts.py +211 -0
  30. ahub-3.0.0/ahub/engine.py +631 -0
  31. ahub-3.0.0/ahub/events.py +223 -0
  32. ahub-3.0.0/ahub/gates.py +192 -0
  33. ahub-3.0.0/ahub/i18n/__init__.py +94 -0
  34. ahub-3.0.0/ahub/i18n/en.py +667 -0
  35. ahub-3.0.0/ahub/i18n/ru.py +667 -0
  36. ahub-3.0.0/ahub/log.py +203 -0
  37. ahub-3.0.0/ahub/mcp.py +173 -0
  38. ahub-3.0.0/ahub/migrations/001_init.sql +193 -0
  39. ahub-3.0.0/ahub/migrations/002_task_request.sql +2 -0
  40. ahub-3.0.0/ahub/migrations/003_event_deliveries.sql +2 -0
  41. ahub-3.0.0/ahub/model.py +151 -0
  42. ahub-3.0.0/ahub/observer.py +352 -0
  43. ahub-3.0.0/ahub/paths.py +57 -0
  44. ahub-3.0.0/ahub/prepare.py +110 -0
  45. ahub-3.0.0/ahub/procs.py +143 -0
  46. ahub-3.0.0/ahub/prompts.py +150 -0
  47. ahub-3.0.0/ahub/providers/__init__.py +34 -0
  48. ahub-3.0.0/ahub/providers/agy.py +342 -0
  49. ahub-3.0.0/ahub/providers/base.py +246 -0
  50. ahub-3.0.0/ahub/providers/fake.py +114 -0
  51. ahub-3.0.0/ahub/providers/fake_agent.py +73 -0
  52. ahub-3.0.0/ahub/providers/opencode.py +337 -0
  53. ahub-3.0.0/ahub/providers/opencode_db.py +482 -0
  54. ahub-3.0.0/ahub/providers/runner.py +318 -0
  55. ahub-3.0.0/ahub/pulse.py +136 -0
  56. ahub-3.0.0/ahub/registry.py +193 -0
  57. ahub-3.0.0/ahub/review.py +170 -0
  58. ahub-3.0.0/ahub/service.py +349 -0
  59. ahub-3.0.0/ahub/store.py +447 -0
  60. ahub-3.0.0/ahub/tasks.py +272 -0
  61. ahub-3.0.0/ahub/tg/__init__.py +0 -0
  62. ahub-3.0.0/ahub/tg/core.py +175 -0
  63. ahub-3.0.0/ahub/tg/launcher.py +227 -0
  64. ahub-3.0.0/ahub/tg/proxy.py +76 -0
  65. ahub-3.0.0/ahub/tg/run.py +183 -0
  66. ahub-3.0.0/ahub/time.py +133 -0
  67. ahub-3.0.0/ahub/transitions.py +193 -0
  68. ahub-3.0.0/ahub/tui/__init__.py +0 -0
  69. ahub-3.0.0/ahub/tui/app.py +300 -0
  70. ahub-3.0.0/ahub/tui/data.py +155 -0
  71. ahub-3.0.0/ahub/views.py +167 -0
  72. ahub-3.0.0/ahub/worker.py +56 -0
  73. ahub-3.0.0/ahub/workspace.py +115 -0
  74. ahub-3.0.0/ahub.egg-info/PKG-INFO +147 -0
  75. ahub-3.0.0/ahub.egg-info/SOURCES.txt +117 -0
  76. ahub-3.0.0/ahub.egg-info/dependency_links.txt +1 -0
  77. ahub-3.0.0/ahub.egg-info/entry_points.txt +2 -0
  78. ahub-3.0.0/ahub.egg-info/requires.txt +13 -0
  79. ahub-3.0.0/ahub.egg-info/top_level.txt +1 -0
  80. ahub-3.0.0/pyproject.toml +42 -0
  81. ahub-3.0.0/setup.cfg +4 -0
  82. ahub-3.0.0/tests/test_accept.py +204 -0
  83. ahub-3.0.0/tests/test_agy_provider.py +251 -0
  84. ahub-3.0.0/tests/test_cli.py +53 -0
  85. ahub-3.0.0/tests/test_config.py +308 -0
  86. ahub-3.0.0/tests/test_doctor.py +291 -0
  87. ahub-3.0.0/tests/test_drafts.py +62 -0
  88. ahub-3.0.0/tests/test_engine_code.py +215 -0
  89. ahub-3.0.0/tests/test_engine_scout.py +179 -0
  90. ahub-3.0.0/tests/test_events.py +111 -0
  91. ahub-3.0.0/tests/test_handles.py +128 -0
  92. ahub-3.0.0/tests/test_history.py +47 -0
  93. ahub-3.0.0/tests/test_i18n.py +361 -0
  94. ahub-3.0.0/tests/test_log.py +88 -0
  95. ahub-3.0.0/tests/test_mcp.py +51 -0
  96. ahub-3.0.0/tests/test_no_cyrillic.py +100 -0
  97. ahub-3.0.0/tests/test_observer.py +228 -0
  98. ahub-3.0.0/tests/test_opencode_db.py +312 -0
  99. ahub-3.0.0/tests/test_opencode_paths.py +93 -0
  100. ahub-3.0.0/tests/test_opencode_provider.py +164 -0
  101. ahub-3.0.0/tests/test_packaging.py +60 -0
  102. ahub-3.0.0/tests/test_procs.py +110 -0
  103. ahub-3.0.0/tests/test_prompts.py +149 -0
  104. ahub-3.0.0/tests/test_pulse.py +151 -0
  105. ahub-3.0.0/tests/test_registry.py +95 -0
  106. ahub-3.0.0/tests/test_review.py +32 -0
  107. ahub-3.0.0/tests/test_runner.py +212 -0
  108. ahub-3.0.0/tests/test_service.py +282 -0
  109. ahub-3.0.0/tests/test_service_cmd.py +123 -0
  110. ahub-3.0.0/tests/test_setup.py +52 -0
  111. ahub-3.0.0/tests/test_setup_global.py +62 -0
  112. ahub-3.0.0/tests/test_setup_wizard.py +152 -0
  113. ahub-3.0.0/tests/test_store.py +145 -0
  114. ahub-3.0.0/tests/test_tasks.py +128 -0
  115. ahub-3.0.0/tests/test_tg.py +278 -0
  116. ahub-3.0.0/tests/test_time.py +273 -0
  117. ahub-3.0.0/tests/test_transitions.py +178 -0
  118. ahub-3.0.0/tests/test_tui.py +146 -0
  119. ahub-3.0.0/tests/test_views.py +39 -0
ahub-3.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Takeh1ko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ahub-3.0.0/PKG-INFO ADDED
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: ahub
3
+ Version: 3.0.0
4
+ Summary: Service through which Claude Code and a human hand tasks to cheap worker models and accept the results
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/Takeh1ko/agent-hub
7
+ Project-URL: Issues, https://github.com/Takeh1ko/agent-hub/issues
8
+ Keywords: agents,orchestrator,llm,telegram
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Operating System :: POSIX :: Linux
13
+ Classifier: Operating System :: MacOS
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: textual>=0.80
18
+ Requires-Dist: psutil>=5.9; sys_platform != "linux"
19
+ Provides-Extra: telegram
20
+ Requires-Dist: aiogram>=3.13; extra == "telegram"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8; extra == "dev"
23
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
24
+ Requires-Dist: psutil>=5.9; extra == "dev"
25
+ Requires-Dist: aiogram>=3.13; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ <p align="center"><img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/assets/banner.png" alt="agent-hub" width="100%"></p>
29
+
30
+ <p align="center"><a href="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml"><img src="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://pypi.org/project/ahub/"><img src="https://img.shields.io/pypi/v/ahub" alt="PyPI"></a> <img src="https://img.shields.io/badge/python-3.11%E2%80%933.13-blue" alt="Python 3.11–3.13"> <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></p>
31
+
32
+ <p align="center"><a href="https://github.com/Takeh1ko/agent-hub/blob/main/README.ru.md">Русская версия</a></p>
33
+
34
+ Let Claude Code hand work to cheap models — and only spend its own tokens on decisions.
35
+
36
+ agent-hub is a local service between an orchestrator (Claude Code, or any CLI agent) and worker models
37
+ (opencode: Muse Spark, free models; Gemini via Antigravity). Claude files a task, goes quiet, and is woken by
38
+ one line when there is something to decide: accept, send back with notes, or reject. Everything in between —
39
+ running the worker in its own copy of the repo, checking its work, review by other models, retries, budgets —
40
+ the hub does without Claude.
41
+
42
+
43
+ ## Why
44
+
45
+ - **Claude's context is the expensive part.** A delegated task costs Claude about 2 KB of text for the whole
46
+ cycle: one event line, a summary capped at 4 KB, a one-word decision. Reports, diffs and logs are there on
47
+ request, never pushed.
48
+ - **Cheap models are good enough when the work is checked.** A task is not "done" because the model says so:
49
+ the commit exists, the diff stays inside the allowed files, the acceptance tests pass under a project lock,
50
+ and one or more reviewer models (in fresh sessions) agree. Typical costs on Muse Spark: recon $0.004–0.06,
51
+ code with review $0.02–0.13.
52
+ - **Nothing gets lost.** Events are stored until acknowledged, so a restarted Claude session, a restarted hub or a
53
+ dead terminal does not drop a "done". Worker processes outlive service restarts; orphaned tasks are picked up
54
+ again; an observer watches the hub itself.
55
+
56
+ ## What it does
57
+
58
+ | | |
59
+ |---|---|
60
+ | Task kinds | `scout` (report only), `code` (branch + gates + review), `routine` (light changes), `review` (of a branch/commit/range) |
61
+ | Isolation | a git worktree per task, secrets hidden from the copy, clean environment for the worker |
62
+ | Gates | commit present, diff ⊆ allowed paths, structured result, acceptance tests under a shared lock |
63
+ | Review | panel of reviewer models in new sessions; disputes count only with file, line and reason; N rounds |
64
+ | Merge | `ahub accept` merges `--no-ff`, re-runs acceptance, rolls back if red |
65
+ | Failures | network error → retry; silence → one nudge; quota/timeout/budget → "needs decision"; never a silent hang |
66
+ | Budgets | per task (including review); at 100 % the worker is asked to save and stop, extension is one command |
67
+ | Waking Claude | `ahub watch` for Claude Code's Monitor, `ahub wait`; stable codes `DONE` `DECISION` `ERROR` `OWNER` `ANSWER` `ALARM` |
68
+ | Pulse | 🟢 working · 🟡 waiting for a reason · 🔴 silent · ⚫ dead · ⚪ no data — from the provider, processes and locks |
69
+ | Observer | code checks every 5 min, a model review every 30 min, escalation to Claude, then to you |
70
+ | Human | `ahub top` terminal UI; optional Telegram bot that talks to Claude (and starts Claude if no session is live) |
71
+ | Other agents | the same handles over MCP (`ahub mcp`) |
72
+ | Languages | English and Russian (`AHUB_LANG`, `lang` in config, or the locale) |
73
+
74
+ ## How it compares
75
+
76
+ Several projects connect Claude Code to other agents. Most are thin bridges (start a session, poll its status).
77
+ agent-hub is narrower in scope and deeper in the task lifecycle:
78
+
79
+ | | agent-hub | thin MCP bridges (opencode-mcp, agent-delegation-mcp, agy bridges) | fleet orchestrators (claw-orchestrator, Composio agent-orchestrator) |
80
+ |---|---|---|---|
81
+ | Focus | Claude decides, cheap models work | pass a prompt, return output | many agents in parallel, dashboards, PR loops |
82
+ | Gates and acceptance tests before "done" | yes | no | partly |
83
+ | Review by other models | yes | no | yes |
84
+ | Orchestrator token budget as a design rule | yes (hard size limits) | no | no |
85
+ | Events survive restarts (ack) | yes | no | partly |
86
+ | Observer of the hub itself | yes | no | no |
87
+ | Web dashboard | no (terminal UI + Telegram) | no | yes |
88
+ | Number of supported agents | opencode, agy | one | many |
89
+
90
+ If you want a dashboard for twenty parallel agents with PR automation, use a fleet orchestrator. If you want
91
+ Claude Code to stop burning its context on work a cheaper model can do — and to trust the result — this is it.
92
+
93
+ <details>
94
+ <summary>See it work: a real task on Muse Spark ($0.007)</summary>
95
+
96
+ <img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/demo.gif" alt="demo">
97
+ </details>
98
+
99
+ ## Install
100
+
101
+ Requires Python 3.11+, git, and at least one provider: [opencode](https://opencode.ai) (free models work) or
102
+ Google Antigravity CLI (`agy`).
103
+
104
+ ```
105
+ pipx install ahub
106
+ ahub setup # language, project, providers, models, service, Claude skill, Telegram (optional)
107
+ ahub doctor # what is wrong and how to fix it
108
+ ```
109
+
110
+ `ahub setup --yes` takes all defaults without questions. Telegram is optional: `pipx install 'ahub[telegram]'`.
111
+
112
+ Platforms: Linux (systemd), macOS (launchd; tested in CI), Windows via WSL2 only.
113
+
114
+ ## Use
115
+
116
+ In Claude Code (after `ahub setup` installs the skill):
117
+
118
+ ```
119
+ ahub task new --kind code --title "add retry to the payment client" \
120
+ --spec-file spec.md --paths "app/payments/**,tests/**" --accept "tests/test_payments.py"
121
+ ```
122
+
123
+ Claude keeps a Monitor on `ahub watch`; when a line like `DONE T12 …` arrives it runs `ahub status T12` and decides:
124
+ `ahub accept T12`, `ahub rework T12 --notes "…"`, or `ahub reject T12`.
125
+
126
+ You: `ahub top` to watch (press `c` for control mode, `?` for help), `ahub status`, `ahub history`.
127
+ Plain-language tasks: `ahub draft new "what you want, in your words"` → preview → `ahub draft start N`.
128
+
129
+ Everything else: `ahub --help`.
130
+
131
+ ## Documentation
132
+
133
+ - [docs/ARCHITECTURE.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/ARCHITECTURE.md) — code map, start here
134
+ - [docs/architecture.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/architecture.md) — design
135
+ - [docs/contracts.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/contracts.md) — interfaces between the parts
136
+
137
+ ## Development
138
+
139
+ ```
140
+ python -m venv .venv && .venv/bin/pip install -e '.[dev]'
141
+ .venv/bin/python -m pytest -q # ~2.5 min, no network, HOME is faked
142
+ AHUB_LIVE=1 .venv/bin/python -m pytest -m live # real providers, costs money
143
+ ```
144
+
145
+ ## License
146
+
147
+ MIT
ahub-3.0.0/README.md ADDED
@@ -0,0 +1,120 @@
1
+ <p align="center"><img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/assets/banner.png" alt="agent-hub" width="100%"></p>
2
+
3
+ <p align="center"><a href="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml"><img src="https://github.com/Takeh1ko/agent-hub/actions/workflows/ci.yml/badge.svg" alt="CI"></a> <a href="https://pypi.org/project/ahub/"><img src="https://img.shields.io/pypi/v/ahub" alt="PyPI"></a> <img src="https://img.shields.io/badge/python-3.11%E2%80%933.13-blue" alt="Python 3.11–3.13"> <img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></p>
4
+
5
+ <p align="center"><a href="https://github.com/Takeh1ko/agent-hub/blob/main/README.ru.md">Русская версия</a></p>
6
+
7
+ Let Claude Code hand work to cheap models — and only spend its own tokens on decisions.
8
+
9
+ agent-hub is a local service between an orchestrator (Claude Code, or any CLI agent) and worker models
10
+ (opencode: Muse Spark, free models; Gemini via Antigravity). Claude files a task, goes quiet, and is woken by
11
+ one line when there is something to decide: accept, send back with notes, or reject. Everything in between —
12
+ running the worker in its own copy of the repo, checking its work, review by other models, retries, budgets —
13
+ the hub does without Claude.
14
+
15
+
16
+ ## Why
17
+
18
+ - **Claude's context is the expensive part.** A delegated task costs Claude about 2 KB of text for the whole
19
+ cycle: one event line, a summary capped at 4 KB, a one-word decision. Reports, diffs and logs are there on
20
+ request, never pushed.
21
+ - **Cheap models are good enough when the work is checked.** A task is not "done" because the model says so:
22
+ the commit exists, the diff stays inside the allowed files, the acceptance tests pass under a project lock,
23
+ and one or more reviewer models (in fresh sessions) agree. Typical costs on Muse Spark: recon $0.004–0.06,
24
+ code with review $0.02–0.13.
25
+ - **Nothing gets lost.** Events are stored until acknowledged, so a restarted Claude session, a restarted hub or a
26
+ dead terminal does not drop a "done". Worker processes outlive service restarts; orphaned tasks are picked up
27
+ again; an observer watches the hub itself.
28
+
29
+ ## What it does
30
+
31
+ | | |
32
+ |---|---|
33
+ | Task kinds | `scout` (report only), `code` (branch + gates + review), `routine` (light changes), `review` (of a branch/commit/range) |
34
+ | Isolation | a git worktree per task, secrets hidden from the copy, clean environment for the worker |
35
+ | Gates | commit present, diff ⊆ allowed paths, structured result, acceptance tests under a shared lock |
36
+ | Review | panel of reviewer models in new sessions; disputes count only with file, line and reason; N rounds |
37
+ | Merge | `ahub accept` merges `--no-ff`, re-runs acceptance, rolls back if red |
38
+ | Failures | network error → retry; silence → one nudge; quota/timeout/budget → "needs decision"; never a silent hang |
39
+ | Budgets | per task (including review); at 100 % the worker is asked to save and stop, extension is one command |
40
+ | Waking Claude | `ahub watch` for Claude Code's Monitor, `ahub wait`; stable codes `DONE` `DECISION` `ERROR` `OWNER` `ANSWER` `ALARM` |
41
+ | Pulse | 🟢 working · 🟡 waiting for a reason · 🔴 silent · ⚫ dead · ⚪ no data — from the provider, processes and locks |
42
+ | Observer | code checks every 5 min, a model review every 30 min, escalation to Claude, then to you |
43
+ | Human | `ahub top` terminal UI; optional Telegram bot that talks to Claude (and starts Claude if no session is live) |
44
+ | Other agents | the same handles over MCP (`ahub mcp`) |
45
+ | Languages | English and Russian (`AHUB_LANG`, `lang` in config, or the locale) |
46
+
47
+ ## How it compares
48
+
49
+ Several projects connect Claude Code to other agents. Most are thin bridges (start a session, poll its status).
50
+ agent-hub is narrower in scope and deeper in the task lifecycle:
51
+
52
+ | | agent-hub | thin MCP bridges (opencode-mcp, agent-delegation-mcp, agy bridges) | fleet orchestrators (claw-orchestrator, Composio agent-orchestrator) |
53
+ |---|---|---|---|
54
+ | Focus | Claude decides, cheap models work | pass a prompt, return output | many agents in parallel, dashboards, PR loops |
55
+ | Gates and acceptance tests before "done" | yes | no | partly |
56
+ | Review by other models | yes | no | yes |
57
+ | Orchestrator token budget as a design rule | yes (hard size limits) | no | no |
58
+ | Events survive restarts (ack) | yes | no | partly |
59
+ | Observer of the hub itself | yes | no | no |
60
+ | Web dashboard | no (terminal UI + Telegram) | no | yes |
61
+ | Number of supported agents | opencode, agy | one | many |
62
+
63
+ If you want a dashboard for twenty parallel agents with PR automation, use a fleet orchestrator. If you want
64
+ Claude Code to stop burning its context on work a cheaper model can do — and to trust the result — this is it.
65
+
66
+ <details>
67
+ <summary>See it work: a real task on Muse Spark ($0.007)</summary>
68
+
69
+ <img src="https://raw.githubusercontent.com/Takeh1ko/agent-hub/main/docs/demo.gif" alt="demo">
70
+ </details>
71
+
72
+ ## Install
73
+
74
+ Requires Python 3.11+, git, and at least one provider: [opencode](https://opencode.ai) (free models work) or
75
+ Google Antigravity CLI (`agy`).
76
+
77
+ ```
78
+ pipx install ahub
79
+ ahub setup # language, project, providers, models, service, Claude skill, Telegram (optional)
80
+ ahub doctor # what is wrong and how to fix it
81
+ ```
82
+
83
+ `ahub setup --yes` takes all defaults without questions. Telegram is optional: `pipx install 'ahub[telegram]'`.
84
+
85
+ Platforms: Linux (systemd), macOS (launchd; tested in CI), Windows via WSL2 only.
86
+
87
+ ## Use
88
+
89
+ In Claude Code (after `ahub setup` installs the skill):
90
+
91
+ ```
92
+ ahub task new --kind code --title "add retry to the payment client" \
93
+ --spec-file spec.md --paths "app/payments/**,tests/**" --accept "tests/test_payments.py"
94
+ ```
95
+
96
+ Claude keeps a Monitor on `ahub watch`; when a line like `DONE T12 …` arrives it runs `ahub status T12` and decides:
97
+ `ahub accept T12`, `ahub rework T12 --notes "…"`, or `ahub reject T12`.
98
+
99
+ You: `ahub top` to watch (press `c` for control mode, `?` for help), `ahub status`, `ahub history`.
100
+ Plain-language tasks: `ahub draft new "what you want, in your words"` → preview → `ahub draft start N`.
101
+
102
+ Everything else: `ahub --help`.
103
+
104
+ ## Documentation
105
+
106
+ - [docs/ARCHITECTURE.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/ARCHITECTURE.md) — code map, start here
107
+ - [docs/architecture.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/architecture.md) — design
108
+ - [docs/contracts.md](https://github.com/Takeh1ko/agent-hub/blob/main/docs/contracts.md) — interfaces between the parts
109
+
110
+ ## Development
111
+
112
+ ```
113
+ python -m venv .venv && .venv/bin/pip install -e '.[dev]'
114
+ .venv/bin/python -m pytest -q # ~2.5 min, no network, HOME is faked
115
+ AHUB_LIVE=1 .venv/bin/python -m pytest -m live # real providers, costs money
116
+ ```
117
+
118
+ ## License
119
+
120
+ MIT
@@ -0,0 +1,3 @@
1
+ """agent-hub v2: the service through which the orchestrator and humans hand work to worker models."""
2
+
3
+ __version__ = "3.0.0"
@@ -0,0 +1,3 @@
1
+ from ahub.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,257 @@
1
+ """Decisions on results (V15–V18, architecture §6.7–§6.8): accept/merge, rework, reject, orchestrator edit,
2
+ path extension, budget top-up, model change, resume with a new brief.
3
+
4
+ Merge: task → "accepting" (lease held by whoever accepts; a second "accept" is refused) → gates at the copy's
5
+ current HEAD (orchestrator edit — if HEAD ≠ worker result commit: legitimate, with an event) → in the project root
6
+ `git merge --no-ff` into the work branch → acceptance under the test resource → red — roll the merge back →
7
+ push per config → "accepted", task_cleanup hook, copy and branch removed, archived.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ from ahub import archive, events, gates, prepare, registry, tasks, transitions, workspace
15
+ from ahub import log as hublog
16
+ from ahub.config import ProjectConfig
17
+ from ahub.engine import owner_token
18
+ from ahub.i18n import t as _t
19
+ from ahub.model import CHANGES_FILES, Ev, Kind, State
20
+ from ahub.store import Store, Task
21
+
22
+ _log = hublog.get("accept")
23
+
24
+
25
+ class DecisionError(RuntimeError):
26
+ """Action impossible (one line for the orchestrator)."""
27
+
28
+
29
+ def _get(store: Store, task_id: int) -> Task:
30
+ t = store.get_task(task_id)
31
+ if t is None:
32
+ raise DecisionError(_t("trans.no_task", id=task_id))
33
+ return t
34
+
35
+
36
+ def _root_ready(project: ProjectConfig) -> None:
37
+ cur = workspace.git(project.root, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
38
+ if cur != project.work_branch:
39
+ raise DecisionError(_t("accept.root_branch", cur=cur, branch=project.work_branch))
40
+ dirty = [ln for ln in workspace.git(project.root, "status", "--porcelain", "--untracked-files=no").stdout
41
+ .splitlines() if ln.strip()]
42
+ if dirty:
43
+ raise DecisionError(_t("accept.root_dirty", files=", ".join(x[3:] for x in dirty[:5])))
44
+
45
+
46
+ def accept(store: Store, project: ProjectConfig, task_id: int, *, by: str = "orchestrator") -> str:
47
+ t = _get(store, task_id)
48
+ if t.kind not in CHANGES_FILES:
49
+ if t.state not in (State.DONE, State.NEEDS_DECISION):
50
+ raise DecisionError(_t("accept.can_accept", label=t.label, state=t.state.value))
51
+ transitions.move(store, t.id, State.ACCEPTED, reason=_t("accept.accepted"), by=by)
52
+ events.ack_task(store, t.id)
53
+ archive.write_task(store, project, t.id)
54
+ workspace.remove(project, t.id, delete_branch=True)
55
+ return _t("accept.accepted_msg", label=t.label)
56
+ if t.state not in (State.DONE, State.NEEDS_DECISION, State.ACCEPTING):
57
+ raise DecisionError(_t("accept.can_accept", label=t.label, state=t.state.value))
58
+ if not t.worktree or not Path(t.worktree).is_dir():
59
+ raise DecisionError(_t("accept.no_worktree", label=t.label, wt=t.worktree or "—"))
60
+ _root_ready(project)
61
+ owner = owner_token()
62
+ if t.state is not State.ACCEPTING:
63
+ transitions.move(store, t.id, State.ACCEPTING, reason=_t("accept.accepting"), by=by,
64
+ expect_from={State.DONE, State.NEEDS_DECISION})
65
+ if not transitions.acquire(store, t.id, owner, pid=None):
66
+ raise DecisionError(_t("accept.busy", label=t.label))
67
+ try:
68
+ return _merge(store, project, _get(store, t.id), owner, by)
69
+ except DecisionError as e:
70
+ _back(store, t.id, owner, str(e))
71
+ raise
72
+ except Exception as e:
73
+ _log.exception("accept T%d failed", t.id, extra={"task": t.id})
74
+ _back(store, t.id, owner, _t("accept.fail", err=f"{type(e).__name__}: {e}"))
75
+ raise DecisionError(_t("accept.fail", err=e)) from e
76
+ finally:
77
+ transitions.release(store, t.id, owner)
78
+
79
+
80
+ def _back(store: Store, task_id: int, owner: str, reason: str) -> None:
81
+ t = store.get_task(task_id)
82
+ if t is not None and t.state is State.ACCEPTING:
83
+ transitions.move(store, task_id, State.NEEDS_DECISION, reason=reason[:500], by="accept", owner=owner)
84
+ events.ack_task(store, task_id) # the orchestrator saw the refusal in the command output
85
+
86
+
87
+ def _merge(store: Store, project: ProjectConfig, t: Task, owner: str, by: str) -> str:
88
+ res = archive.read_json(Path(t.worktree) / workspace.AHUB_DIR / "result.json")
89
+ head = workspace.head(t.worktree)
90
+ orch_edit = not res.get("commit") or not head.startswith(str(res.get("commit"))[:7])
91
+ if orch_edit and not t.limits.get("orch_edit"):
92
+ store.add_event(Ev.ORCH_EDIT, task_id=t.id, project=t.project,
93
+ payload={"head": head[:12], "worker_commit": str(res.get("commit", ""))[:12], "by": by})
94
+ g = gates.check(project, t, run_tests=False, orch_edit=orch_edit)
95
+ problems = g.fatal + [p for p in g.repairable if not (orch_edit and p.startswith("result.json"))]
96
+ if problems:
97
+ raise DecisionError(_t("accept.gates_head", problems="; ".join(problems)))
98
+ title = t.title.replace('"', "'")[:100]
99
+ r = workspace.git(project.root, "merge", "--no-ff", "-m", f"merge {t.label}: {title}", t.branch, check=False)
100
+ if r.returncode != 0:
101
+ conflicts = workspace.git(project.root, "diff", "--name-only", "--diff-filter=U", check=False).stdout.split()
102
+ workspace.git(project.root, "merge", "--abort", check=False)
103
+ raise DecisionError(_t("accept.conflict", info=", ".join(conflicts[:10]) or (r.stderr or r.stdout)[-300:]))
104
+ merged = workspace.git(project.root, "rev-parse", "HEAD").stdout.strip()
105
+ nodes = list(t.limits.get("accept") or [])
106
+ if t.kind is Kind.CODE and nodes:
107
+ ok, tail, cmd = gates.run_acceptance(project, project.root, nodes, task_label=t.label)
108
+ if not ok:
109
+ head_now = workspace.git(project.root, "rev-parse", "HEAD").stdout.strip()
110
+ if head_now != merged: # someone committed into the work branch meanwhile — leave foreign commits alone
111
+ raise DecisionError(_t("accept.root_moved", now=head_now[:10], merged=merged[:10]))
112
+ workspace.git(project.root, "reset", "--keep", "HEAD~1", check=False) # --keep leaves foreign dirt alone
113
+ raise DecisionError(_t("accept.red_rolled_back", cmd=cmd, tail=tail[-600:]))
114
+ note = ""
115
+ if project.push.strip():
116
+ parts = project.push.split()
117
+ pr = workspace.git(project.root, "push", *parts, check=False, timeout=300)
118
+ if pr.returncode != 0:
119
+ note = _t("accept.push_fail", err=(pr.stderr or pr.stdout).strip()[-200:])
120
+ _log.warning("push T%d: %s", t.id, note, extra={"task": t.id})
121
+ transitions.move(store, t.id, State.ACCEPTED, reason=_t("accept.merged_reason", branch=project.work_branch,
122
+ note=note), by=by, owner=owner,
123
+ fields={"accepted_sha": merged})
124
+ events.ack_task(store, t.id)
125
+ try:
126
+ prepare.run_hook(project, "task_cleanup", t, t.worktree)
127
+ except prepare.PrepareError as e:
128
+ _log.warning("hook task_cleanup T%d: %s", t.id, e, extra={"task": t.id})
129
+ archive.write_task(store, project, t.id)
130
+ workspace.remove(project, t.id, delete_branch=True)
131
+ return _t("accept.merged_msg", label=t.label, branch=project.work_branch, sha=merged[:10], note=note)
132
+
133
+
134
+ def reject(store: Store, project: ProjectConfig, task_id: int, *, reason: str = "", by: str = "orchestrator",
135
+ keep: bool = False) -> str:
136
+ t = _get(store, task_id)
137
+ try:
138
+ transitions.move(store, t.id, State.REJECTED, reason=reason or _t("accept.rejected_default"), by=by)
139
+ except (transitions.TransitionError, transitions.ConflictError) as e:
140
+ raise DecisionError(f"{e}{_t('accept.active_first')}") from e
141
+ events.ack_task(store, t.id)
142
+ archive.write_task(store, project, t.id)
143
+ if not keep:
144
+ workspace.remove(project, t.id, delete_branch=True)
145
+ return _t("accept.rejected_msg", label=t.label)
146
+
147
+
148
+ def rework(store: Store, task_id: int, notes: str, *, by: str = "orchestrator") -> str:
149
+ """Send back for rework with notes: same executor session, new round."""
150
+ t = _get(store, task_id)
151
+ if t.state not in (State.DONE, State.NEEDS_DECISION, State.ERROR, State.STOPPED):
152
+ raise DecisionError(_t("accept.rework_state", label=t.label, state=t.state.value))
153
+ if not notes.strip():
154
+ raise DecisionError(_t("accept.need_notes"))
155
+ lim = dict(t.limits)
156
+ lim["rework_notes"] = notes.strip()
157
+ store.update_task(t.id, limits=lim)
158
+ transitions.move(store, t.id, State.QUEUED, reason=_t("accept.rework"), by=by, fields={"round": t.round + 1},
159
+ payload={"notes": notes[:500]})
160
+ events.ack_task(store, t.id)
161
+ return _t("accept.rework_msg", label=t.label, round=t.round + 1)
162
+
163
+
164
+ def continue_task(store: Store, task_id: int, *, by: str = "orchestrator") -> str:
165
+ t = _get(store, task_id)
166
+ if t.state not in (State.STOPPED, State.ERROR, State.NEEDS_DECISION):
167
+ raise DecisionError(_t("accept.continue_state", label=t.label, state=t.state.value))
168
+ transitions.move(store, t.id, State.QUEUED, reason=_t("accept.continue"), by=by)
169
+ events.ack_task(store, t.id)
170
+ return _t("accept.continued_msg", label=t.label)
171
+
172
+
173
+ def edit(store: Store, project: ProjectConfig, task_id: int, *, spec: str | None = None,
174
+ title: str | None = None, by: str = "orchestrator") -> str:
175
+ """New brief: on resume — a fresh executor session (different fingerprint)."""
176
+ t = _get(store, task_id)
177
+ if t.state in (State.ACCEPTED, State.REJECTED) or t.state in transitions.ACTIVE:
178
+ raise DecisionError(_t("accept.edit_state", label=t.label))
179
+ new = tasks.TaskSpec(project=t.project, kind=t.kind, title=title if title is not None else t.title,
180
+ spec=spec if spec is not None else t.spec, result_format=t.result_format,
181
+ paths=list(t.limits.get("paths") or []), accept=list(t.limits.get("accept") or []),
182
+ review_input=str(t.limits.get("input") or ""))
183
+ h = tasks.spec_hash(new)
184
+ if h == t.spec_hash:
185
+ return _t("accept.edit_same", label=t.label)
186
+ lim = dict(t.limits)
187
+ lim["fresh_session"] = True
188
+ store.update_task(t.id, title=new.title, spec=new.spec, spec_hash=h, limits=lim)
189
+ store.add_event(Ev.STATE, task_id=t.id, project=t.project, payload={"edit": _t("accept.edit_text"), "by": by})
190
+ return _t("accept.edit_msg", label=t.label)
191
+
192
+
193
+ def extend_paths(store: Store, project: ProjectConfig, task_id: int, paths: list[str], *,
194
+ by: str = "orchestrator") -> str:
195
+ t = _get(store, task_id)
196
+ bad = [p for p in paths if tasks._path_escapes(p) or not tasks._glob_allowed(p, project.allowed_paths)]
197
+ if bad:
198
+ raise DecisionError(_t("accept.paths_outside", items=", ".join(bad)))
199
+ cur = list(t.limits.get("paths") or [])
200
+ added = [p for p in paths if p not in cur]
201
+ if not added:
202
+ return _t("accept.paths_same", label=t.label)
203
+ lim = dict(t.limits)
204
+ lim["paths"] = cur + added
205
+ store.update_task(t.id, limits=lim)
206
+ store.add_event(Ev.PATHS_EXTENDED, task_id=t.id, project=t.project, payload={"added": added, "by": by})
207
+ return _t("accept.paths_added", label=t.label, items=", ".join(added))
208
+
209
+
210
+ def _stopped_by_budget(store: Store, task_id: int) -> bool:
211
+ """Task parked on budget: after a hard budget stop (budget_hard) it never left "needs decision"."""
212
+ stopped = False
213
+ for e in store.events(task_id=task_id):
214
+ if e.kind == Ev.BUDGET_HARD.value:
215
+ stopped = True
216
+ elif e.kind == Ev.STATE.value and (e.payload or {}).get("to") != State.NEEDS_DECISION.value:
217
+ stopped = False
218
+ return stopped
219
+
220
+
221
+ def extend_budget(store: Store, task_id: int, *, add: float | None = None, set_to: float | None = None,
222
+ add_usd: float | None = None, by: str = "orchestrator") -> str:
223
+ """Top up the budget in one move: raised + resumed (if the task was parked on budget).
224
+
225
+ add/set_to — Go counter (subscription); add_usd — real money (default 0 = no spending).
226
+ """
227
+ t = _get(store, task_id)
228
+ new = set_to if set_to is not None else t.budget_go + (add or 0.0)
229
+ new_usd = t.budget_usd + (add_usd or 0.0)
230
+ if new <= t.budget_go and set_to is None and new_usd <= t.budget_usd:
231
+ raise DecisionError(_t("accept.budget_need"))
232
+ store.update_task(t.id, budget_go=float(new), budget_usd=float(new_usd))
233
+ store.add_event(Ev.BUDGET_EXTENDED, task_id=t.id, project=t.project,
234
+ payload={"from": t.budget_go, "to": new, "usd_from": t.budget_usd, "usd_to": new_usd, "by": by})
235
+ msg = _t("accept.budget_msg", label=t.label, old=f"{t.budget_go:g}", new=f"{new:g}") + (
236
+ _t("accept.budget_usd", old=f"{t.budget_usd:g}", new=f"{new_usd:g}")
237
+ if new_usd != t.budget_usd else "")
238
+ if t.state is State.NEEDS_DECISION and _stopped_by_budget(store, t.id):
239
+ transitions.move(store, t.id, State.QUEUED, reason=_t("accept.budget_long"), by=by)
240
+ events.ack_task(store, t.id)
241
+ msg += _t("accept.budget_resumed")
242
+ return msg
243
+
244
+
245
+ def change_model(store: Store, project: ProjectConfig, task_id: int, alias: str, *, by: str = "orchestrator") -> str:
246
+ t = _get(store, task_id)
247
+ if t.state in transitions.ACTIVE:
248
+ raise DecisionError(_t("accept.model_active", label=t.label))
249
+ try:
250
+ registry.check(store, alias, project)
251
+ except registry.RegistryError as e:
252
+ raise DecisionError(str(e)) from e
253
+ lim = dict(t.limits)
254
+ lim["fresh_session"] = True # never resume another model's session
255
+ store.update_task(t.id, executor=alias, limits=lim)
256
+ store.add_event(Ev.MODEL_CHANGED, task_id=t.id, project=t.project, payload={"from": t.executor, "to": alias, "by": by})
257
+ return _t("accept.model_msg", label=t.label, old=t.executor, new=alias)