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.
- ahub/__init__.py +3 -0
- ahub/__main__.py +3 -0
- ahub/accept.py +257 -0
- ahub/archive.py +129 -0
- ahub/claude/SKILL.md +53 -0
- ahub/cli.py +77 -0
- ahub/cliutil.py +56 -0
- ahub/commands/__init__.py +1 -0
- ahub/commands/bot.py +26 -0
- ahub/commands/comms.py +161 -0
- ahub/commands/config_.py +41 -0
- ahub/commands/doctor.py +36 -0
- ahub/commands/draft.py +65 -0
- ahub/commands/mcp_.py +19 -0
- ahub/commands/models.py +125 -0
- ahub/commands/observer.py +47 -0
- ahub/commands/projects.py +34 -0
- ahub/commands/service.py +328 -0
- ahub/commands/setup.py +507 -0
- ahub/commands/task.py +328 -0
- ahub/commands/top.py +17 -0
- ahub/commands/version.py +18 -0
- ahub/comms.py +127 -0
- ahub/config.py +502 -0
- ahub/doctor.py +388 -0
- ahub/drafts.py +211 -0
- ahub/engine.py +631 -0
- ahub/events.py +223 -0
- ahub/gates.py +192 -0
- ahub/i18n/__init__.py +94 -0
- ahub/i18n/en.py +667 -0
- ahub/i18n/ru.py +667 -0
- ahub/log.py +203 -0
- ahub/mcp.py +173 -0
- ahub/migrations/001_init.sql +193 -0
- ahub/migrations/002_task_request.sql +2 -0
- ahub/migrations/003_event_deliveries.sql +2 -0
- ahub/model.py +151 -0
- ahub/observer.py +352 -0
- ahub/paths.py +57 -0
- ahub/prepare.py +110 -0
- ahub/procs.py +143 -0
- ahub/prompts.py +150 -0
- ahub/providers/__init__.py +34 -0
- ahub/providers/agy.py +342 -0
- ahub/providers/base.py +246 -0
- ahub/providers/fake.py +114 -0
- ahub/providers/fake_agent.py +73 -0
- ahub/providers/opencode.py +337 -0
- ahub/providers/opencode_db.py +482 -0
- ahub/providers/runner.py +318 -0
- ahub/pulse.py +136 -0
- ahub/registry.py +193 -0
- ahub/review.py +170 -0
- ahub/service.py +349 -0
- ahub/store.py +447 -0
- ahub/tasks.py +272 -0
- ahub/tg/__init__.py +0 -0
- ahub/tg/core.py +175 -0
- ahub/tg/launcher.py +227 -0
- ahub/tg/proxy.py +76 -0
- ahub/tg/run.py +183 -0
- ahub/time.py +133 -0
- ahub/transitions.py +193 -0
- ahub/tui/__init__.py +0 -0
- ahub/tui/app.py +300 -0
- ahub/tui/data.py +155 -0
- ahub/views.py +167 -0
- ahub/worker.py +56 -0
- ahub/workspace.py +115 -0
- ahub-3.0.0.dist-info/METADATA +147 -0
- ahub-3.0.0.dist-info/RECORD +76 -0
- ahub-3.0.0.dist-info/WHEEL +5 -0
- ahub-3.0.0.dist-info/entry_points.txt +2 -0
- ahub-3.0.0.dist-info/licenses/LICENSE +21 -0
- ahub-3.0.0.dist-info/top_level.txt +1 -0
ahub/__init__.py
ADDED
ahub/__main__.py
ADDED
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)
|