ahub 3.0.0__py3-none-any.whl

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 (76) hide show
  1. ahub/__init__.py +3 -0
  2. ahub/__main__.py +3 -0
  3. ahub/accept.py +257 -0
  4. ahub/archive.py +129 -0
  5. ahub/claude/SKILL.md +53 -0
  6. ahub/cli.py +77 -0
  7. ahub/cliutil.py +56 -0
  8. ahub/commands/__init__.py +1 -0
  9. ahub/commands/bot.py +26 -0
  10. ahub/commands/comms.py +161 -0
  11. ahub/commands/config_.py +41 -0
  12. ahub/commands/doctor.py +36 -0
  13. ahub/commands/draft.py +65 -0
  14. ahub/commands/mcp_.py +19 -0
  15. ahub/commands/models.py +125 -0
  16. ahub/commands/observer.py +47 -0
  17. ahub/commands/projects.py +34 -0
  18. ahub/commands/service.py +328 -0
  19. ahub/commands/setup.py +507 -0
  20. ahub/commands/task.py +328 -0
  21. ahub/commands/top.py +17 -0
  22. ahub/commands/version.py +18 -0
  23. ahub/comms.py +127 -0
  24. ahub/config.py +502 -0
  25. ahub/doctor.py +388 -0
  26. ahub/drafts.py +211 -0
  27. ahub/engine.py +631 -0
  28. ahub/events.py +223 -0
  29. ahub/gates.py +192 -0
  30. ahub/i18n/__init__.py +94 -0
  31. ahub/i18n/en.py +667 -0
  32. ahub/i18n/ru.py +667 -0
  33. ahub/log.py +203 -0
  34. ahub/mcp.py +173 -0
  35. ahub/migrations/001_init.sql +193 -0
  36. ahub/migrations/002_task_request.sql +2 -0
  37. ahub/migrations/003_event_deliveries.sql +2 -0
  38. ahub/model.py +151 -0
  39. ahub/observer.py +352 -0
  40. ahub/paths.py +57 -0
  41. ahub/prepare.py +110 -0
  42. ahub/procs.py +143 -0
  43. ahub/prompts.py +150 -0
  44. ahub/providers/__init__.py +34 -0
  45. ahub/providers/agy.py +342 -0
  46. ahub/providers/base.py +246 -0
  47. ahub/providers/fake.py +114 -0
  48. ahub/providers/fake_agent.py +73 -0
  49. ahub/providers/opencode.py +337 -0
  50. ahub/providers/opencode_db.py +482 -0
  51. ahub/providers/runner.py +318 -0
  52. ahub/pulse.py +136 -0
  53. ahub/registry.py +193 -0
  54. ahub/review.py +170 -0
  55. ahub/service.py +349 -0
  56. ahub/store.py +447 -0
  57. ahub/tasks.py +272 -0
  58. ahub/tg/__init__.py +0 -0
  59. ahub/tg/core.py +175 -0
  60. ahub/tg/launcher.py +227 -0
  61. ahub/tg/proxy.py +76 -0
  62. ahub/tg/run.py +183 -0
  63. ahub/time.py +133 -0
  64. ahub/transitions.py +193 -0
  65. ahub/tui/__init__.py +0 -0
  66. ahub/tui/app.py +300 -0
  67. ahub/tui/data.py +155 -0
  68. ahub/views.py +167 -0
  69. ahub/worker.py +56 -0
  70. ahub/workspace.py +115 -0
  71. ahub-3.0.0.dist-info/METADATA +147 -0
  72. ahub-3.0.0.dist-info/RECORD +76 -0
  73. ahub-3.0.0.dist-info/WHEEL +5 -0
  74. ahub-3.0.0.dist-info/entry_points.txt +2 -0
  75. ahub-3.0.0.dist-info/licenses/LICENSE +21 -0
  76. ahub-3.0.0.dist-info/top_level.txt +1 -0
ahub/__init__.py ADDED
@@ -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"
ahub/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from ahub.cli import main
2
+
3
+ raise SystemExit(main())
ahub/accept.py ADDED
@@ -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)
ahub/archive.py ADDED
@@ -0,0 +1,129 @@
1
+ """Task archive inside the project (architecture §12): `<project>/.agent-hub/` — local, not in project git.
2
+
3
+ tasks.md — shared list; tasks/T<id>/ — brief, worker result, report, diff (for code), cost, outcome.
4
+ The hub only writes; a human cleans up. Archive errors never break work — just a log line.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from ahub import log as hublog
14
+ from ahub import workspace
15
+ from ahub.config import ProjectConfig
16
+ from ahub.i18n import Words
17
+ from ahub.i18n import t as _t
18
+ from ahub.model import CHANGES_FILES
19
+ from ahub.store import Store, Task
20
+ from ahub.time import fmt_local
21
+
22
+ DIR = ".agent-hub"
23
+ STATE_WORDS: Words = Words("archive.state_", ("draft", "queued", "preparing", "working", "checking",
24
+ "reviewing", "fixing", "done", "needs_decision", "error",
25
+ "stopped", "accepting", "accepted", "rejected"))
26
+ _log = hublog.get("archive")
27
+
28
+
29
+ def root(project: ProjectConfig) -> Path:
30
+ return Path(project.root) / DIR
31
+
32
+
33
+ def _exclude(project: ProjectConfig) -> None:
34
+ try:
35
+ r = workspace.git(project.root, "rev-parse", "--git-path", "info/exclude", check=False)
36
+ if r.returncode != 0:
37
+ return
38
+ excl = Path(r.stdout.strip())
39
+ if not excl.is_absolute():
40
+ excl = Path(project.root) / excl
41
+ excl.parent.mkdir(parents=True, exist_ok=True)
42
+ lines = excl.read_text(encoding="utf-8").splitlines() if excl.exists() else []
43
+ if f"{DIR}/" not in lines:
44
+ with excl.open("a", encoding="utf-8") as f:
45
+ f.write(f"\n{DIR}/\n")
46
+ except (OSError, workspace.WorkspaceError):
47
+ pass
48
+
49
+
50
+ def task_cost(store: Store, task_id: int) -> tuple[float, float]:
51
+ go = usd = 0.0
52
+ for s in store.list_sessions(task_id):
53
+ go += s.cost_go or 0.0
54
+ usd += s.cost_usd or 0.0
55
+ return go, usd
56
+
57
+
58
+ def _task_md(store: Store, t: Task) -> str:
59
+ go, usd = task_cost(store, t.id)
60
+ sessions = store.list_sessions(t.id)
61
+ lines = [f"# {t.label} — {t.title}", "",
62
+ _t("archive.type", kind=t.kind.value, state=STATE_WORDS.get(t.state.value, t.state.value))
63
+ + (f" — {t.state_reason}" if t.state_reason else ""),
64
+ _t("archive.model", executor=t.executor,
65
+ review=', '.join(t.review.get('models', [])) or _t("archive.no_review"))
66
+ + (f" × {t.review.get('rounds')}" if t.review else ""),
67
+ _t("archive.rounds", round=t.round, sessions=len(sessions)),
68
+ _t("archive.cost", go=f"{go:.3f}") + (_t("archive.cost_real", usd=f"{usd:.3f}") if usd else ""),
69
+ _t("archive.created", when=fmt_local(t.created_at), by=t.created_by or '—')
70
+ + (_t("archive.finished", when=fmt_local(t.finished_at)) if t.finished_at else ""),
71
+ ""]
72
+ if t.after:
73
+ lines.insert(-1, _t("archive.after", items=', '.join(f'T{a}' for a in t.after)))
74
+ if t.limits.get("paths"):
75
+ lines.insert(-1, _t("archive.paths", items=', '.join(t.limits['paths'])))
76
+ if t.limits.get("accept"):
77
+ lines.insert(-1, _t("archive.accept", items=', '.join(t.limits['accept'])))
78
+ if t.spec.strip():
79
+ lines += [_t("archive.spec"), "", t.spec.strip(), ""]
80
+ return "\n".join(lines)
81
+
82
+
83
+ def write_task(store: Store, project: ProjectConfig, task_id: int) -> Path | None:
84
+ try:
85
+ t = store.get_task(task_id)
86
+ if t is None:
87
+ return None
88
+ base = root(project)
89
+ d = base / "tasks" / t.label
90
+ d.mkdir(parents=True, exist_ok=True)
91
+ _exclude(project)
92
+ (d / "task.md").write_text(_task_md(store, t), encoding="utf-8")
93
+ if t.worktree and Path(t.worktree).is_dir():
94
+ src = Path(t.worktree) / workspace.AHUB_DIR
95
+ for name in ("result.json", "report.md"):
96
+ if (src / name).is_file():
97
+ shutil.copyfile(src / name, d / name)
98
+ for rv in src.glob("review_r*.json"):
99
+ shutil.copyfile(rv, d / rv.name)
100
+ if t.kind in CHANGES_FILES and t.base_sha:
101
+ r = workspace.git(t.worktree, "diff", f"{t.base_sha}..HEAD", check=False)
102
+ if r.returncode == 0:
103
+ (d / "diff.patch").write_text(r.stdout, encoding="utf-8")
104
+ write_index(store, project)
105
+ return d
106
+ except Exception:
107
+ _log.exception("archive T%s not written", task_id, extra={"task": task_id})
108
+ return None
109
+
110
+
111
+ def write_index(store: Store, project: ProjectConfig) -> None:
112
+ tasks = store.list_tasks(project=project.name, newest_first=True)
113
+ rows = [_t("archive.index_title"), "", _t("archive.index_head"), "|---|---|---|---|---|---|"]
114
+ for t in tasks:
115
+ go, usd = task_cost(store, t.id)
116
+ title = t.title.replace("|", "/")[:80]
117
+ rows.append(f"| [{t.label}](tasks/{t.label}/task.md) | {fmt_local(t.created_at)} | {t.kind.value} | {title} |"
118
+ f" {STATE_WORDS.get(t.state.value, t.state.value)} | {go + usd:.3f} |")
119
+ base = root(project)
120
+ base.mkdir(parents=True, exist_ok=True)
121
+ (base / "tasks.md").write_text("\n".join(rows) + "\n", encoding="utf-8")
122
+
123
+
124
+ def read_json(path: Path) -> dict:
125
+ try:
126
+ data = json.loads(path.read_text(encoding="utf-8"))
127
+ except (OSError, json.JSONDecodeError):
128
+ return {}
129
+ return data if isinstance(data, dict) else {}
ahub/claude/SKILL.md ADDED
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: ahub
3
+ description: Work with agent-hub (ahub CLI) — delegate scout, code, routine or review to cheap worker models (Spark etc.), wait without polling, read the result briefly, accept and merge. Use when a task is cheaper to delegate, or when an ahub event line arrives (DONE/DECISION/ERROR/OWNER/ANSWER/ALARM).
4
+ ---
5
+
6
+ # agent-hub: how to work cheaply
7
+
8
+ The hub runs tasks itself (project copy, worker, gates, review, retries on failures). Your job — file a task,
9
+ wake on an event, read the result and decide. Don't poll the hub: it wakes you.
10
+
11
+ ## 1. Session start
12
+ - Start a Monitor on `ahub watch` (description: "ahub: events for Claude"). Each line is work for you.
13
+ Monitor lives ≤ 30 min: restart on expiry (events are not lost — position is in the hub DB).
14
+ - What's going on: `ahub status` (≤ 1.5 KB).
15
+
16
+ ## 2. File a task
17
+ ```
18
+ ahub task new --kind scout --title "goal" --spec "what to find, where to look, what goes in the report"
19
+ ahub task new --kind code --title "goal" --spec-file spec.md --paths "core/**,tests/**" \
20
+ --accept "tests/test_x.py::test_y" [--level 0..4] [--after T3] [--budget 1.5]
21
+ ahub task new --kind routine --title "tidy up docs/" --paths "docs/**"
22
+ ahub task new --kind review --title "check the branch" --input "main..feature"
23
+ ```
24
+ - Review level: 0 — none; 1 — docs/routine; 2 — regular code (default for code); 3–4 — near money.
25
+ - Spec is self-contained: what to do, which files, how to verify, what not to touch. External facts (API, prices)
26
+ verify with a live call BEFORE filing. A task that fails validation — error comes in one line, no model is called.
27
+ - Models: `ahub models` (role menu; project bans cannot be bypassed).
28
+
29
+ ## 3. Woke on an event
30
+ - `DONE T12 …` → `ahub status T12` (result, report essence, checks, cost — ≤ 4 KB; confirms the event).
31
+ Full report — `ahub result T12 --full`, diff — `ahub diff T12`, raw logs — `ahub log T12` (only if needed).
32
+ - Decide: `ahub accept T12` (scout — accept; code/routine — merge into the working branch, acceptance reruns,
33
+ on red — rollback) · `ahub rework T12 --notes "what to fix"` (same session, new round) · `ahub reject T12`.
34
+ - Your own small fix on top of the result: commit in the task copy (`ahub status T12` shows the path), then
35
+ `ahub accept T12` — this is legal ("orchestrator edit"). Need more files — `ahub extend T12 --paths "…"`.
36
+ - `DECISION T12 …` → read the reason (`ahub status T12`): rounds over, budget (`ahub budget T12 --add 1` —
37
+ the task resumes itself), files outside allowed paths, failure. Resume — `ahub continue T12`, change model —
38
+ `ahub model T12 mimo-flash`, new spec — `ahub task edit T12 --spec-file …`.
39
+ - `ERROR T12 …` → reason is in the line; usually `ahub continue` after fixing the environment or `ahub reject`.
40
+
41
+ Old events may start with Russian words ГОТОВО/РЕШЕНИЕ/ОШИБКА/ВЛАДЕЛЕЦ/ОТВЕТ/ТРЕВОГА instead of codes — same events.
42
+
43
+ ## 4. Owner
44
+ - `OWNER «…»` → `ahub inbox` (read), reply — `ahub say "short, in the owner's language"`.
45
+ - Need approval (merge, extend, spend) — `ahub ask "merge T12?" --options "yes,no"`; the answer comes as
46
+ `ANSWER #N … → yes`. Merge code only with owner approval if they asked for it.
47
+ - `ALARM …` → the observer found a problem with the hub itself: `ahub alarms`, `ahub service status`,
48
+ `ahub observer reports`.
49
+
50
+ ## 5. Economy
51
+ - Don't read L3 (full report, diff, logs) without need — L2 is usually enough to decide.
52
+ - Recon is cheaper than your code reading: give search and survey to the worker, read the essence.
53
+ - Parallel tasks — in one batch; dependent — `--after T3` (starts after T3 is accepted, for code with it).
ahub/cli.py ADDED
@@ -0,0 +1,77 @@
1
+ """ahub entry point: subcommands are ahub/commands/*.py modules with register(subparsers).
2
+
3
+ Shared `--json` flag — machine output; default compact text (orchestrator token savings).
4
+ Config errors and expected refusals — one stderr line and exit 2, no traceback.
5
+
6
+ Imports live inside functions: main() refuses on Windows before command imports
7
+ (some modules pull fcntl, which is missing there).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+
14
+
15
+ def _discover(subparsers) -> list[str]:
16
+ import importlib
17
+ import pkgutil
18
+
19
+ import ahub.commands as _cmds
20
+
21
+ found = []
22
+ for mod in sorted(pkgutil.iter_modules(_cmds.__path__), key=lambda m: m.name):
23
+ if mod.name.startswith("_"):
24
+ continue
25
+ module = importlib.import_module(f"ahub.commands.{mod.name}")
26
+ if hasattr(module, "register"):
27
+ module.register(subparsers)
28
+ found.append(mod.name)
29
+ return found
30
+
31
+
32
+ def build_parser():
33
+ import argparse
34
+
35
+ from ahub.i18n import t
36
+
37
+ ap = argparse.ArgumentParser(prog="ahub", description=t("cli.desc"))
38
+ ap.add_argument("--json", action="store_true", help=t("cli.help_json"))
39
+ ap.add_argument("--lang", choices=("en", "ru"), default=None, help=t("cli.help_lang"))
40
+ sub = ap.add_subparsers(dest="cmd", required=True)
41
+ _discover(sub)
42
+ return ap
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ if sys.platform == "win32":
47
+ from ahub.i18n import t as _t
48
+
49
+ print(_t("cli.err_win"), file=sys.stderr)
50
+ return 2
51
+ from ahub import log
52
+ from ahub.cliutil import CliError
53
+ from ahub.config import ConfigError
54
+ from ahub.i18n import set_lang, t
55
+
56
+ ap = build_parser()
57
+ args = ap.parse_args(argv)
58
+ if getattr(args, "lang", None):
59
+ set_lang(args.lang)
60
+ func = getattr(args, "func", None)
61
+ if func is None:
62
+ ap.print_help()
63
+ return 2
64
+ try:
65
+ return int(func(args) or 0)
66
+ except (ConfigError, CliError) as e:
67
+ print(t("cli.error", msg=e), file=sys.stderr)
68
+ return 2
69
+ except KeyboardInterrupt:
70
+ return 130
71
+ except Exception:
72
+ log.get("cli").exception("command %s failed", args.cmd)
73
+ raise
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
ahub/cliutil.py ADDED
@@ -0,0 +1,56 @@
1
+ """Shared by subcommands: text/JSON output, project pick."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ahub import config
11
+
12
+
13
+ class CliError(RuntimeError):
14
+ """Expected command refusal: printed on one line, exit 2."""
15
+
16
+
17
+ def emit(args, data: Any, text: str) -> None:
18
+ """--json → data as JSON, else text."""
19
+ if getattr(args, "json", False):
20
+ json.dump(data, sys.stdout, ensure_ascii=False, separators=(",", ":"), default=str)
21
+ sys.stdout.write("\n")
22
+ else:
23
+ sys.stdout.write(text if text.endswith("\n") or not text else text + "\n")
24
+
25
+
26
+ def add_project_arg(parser) -> None:
27
+ from ahub.i18n import t
28
+
29
+ parser.add_argument("--project", "-P", default=None, help=t("cli.help_project"))
30
+
31
+
32
+ def resolve_project(args, cwd: str | Path | None = None) -> config.ProjectConfig:
33
+ """Project by --project (hub config name or path) or by current dir."""
34
+ want = getattr(args, "project", None)
35
+ cwd = Path(cwd) if cwd is not None else Path.cwd()
36
+ if want:
37
+ p = Path(config.expand(want))
38
+ if p.exists():
39
+ return config.load_project(p)
40
+ projects, _errors = config.load_projects()
41
+ for cfg in projects:
42
+ if cfg.name == want:
43
+ return cfg
44
+ from ahub.i18n import t
45
+
46
+ raise CliError(t("err.no_project", want=want))
47
+ try:
48
+ return config.load_project(cwd)
49
+ except FileNotFoundError:
50
+ projects, _errors = config.load_projects()
51
+ cfg = config.project_for(cwd, projects)
52
+ if cfg is None:
53
+ from ahub.i18n import t
54
+
55
+ raise CliError(t("err.no_project_cwd", cwd=cwd, file=config.PROJECT_FILE))
56
+ return cfg
@@ -0,0 +1 @@
1
+ """ahub subcommands: each module exposes register(subparsers)."""
ahub/commands/bot.py ADDED
@@ -0,0 +1,26 @@
1
+ """ahub bot run — TG bot v2 (link to Claude, task view, alarms, Claude launch)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.util
6
+
7
+ from ahub.cliutil import CliError
8
+
9
+
10
+ def cmd_run(args) -> int:
11
+ if importlib.util.find_spec("aiogram") is None:
12
+ from ahub.i18n import t
13
+
14
+ raise CliError(t("err.no_telegram"))
15
+ from ahub.tg.run import main
16
+
17
+ return main()
18
+
19
+
20
+ def register(subparsers) -> None:
21
+ from ahub.i18n import t
22
+
23
+ p = subparsers.add_parser("bot", help=t("help.bot"))
24
+ sub = p.add_subparsers(dest="bot_cmd", required=True)
25
+ r = sub.add_parser("run", help=t("help.bot_run"))
26
+ r.set_defaults(func=cmd_run)