repomoot 0.2.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.
- repomoot/__init__.py +4 -0
- repomoot/adapters/__init__.py +1 -0
- repomoot/adapters/claude_code/__init__.py +1 -0
- repomoot/adapters/claude_code/install.py +211 -0
- repomoot/cli/__init__.py +1 -0
- repomoot/cli/fmt.py +202 -0
- repomoot/cli/main.py +1073 -0
- repomoot/client/__init__.py +1 -0
- repomoot/client/http.py +250 -0
- repomoot/config.py +73 -0
- repomoot/core/__init__.py +1 -0
- repomoot/core/cards.py +91 -0
- repomoot/core/db.py +116 -0
- repomoot/core/lifecycle.py +109 -0
- repomoot/core/service.py +1007 -0
- repomoot/server/__init__.py +1 -0
- repomoot/server/app.py +367 -0
- repomoot/server/run.py +24 -0
- repomoot/server/ui.html +182 -0
- repomoot-0.2.0.dist-info/METADATA +189 -0
- repomoot-0.2.0.dist-info/RECORD +26 -0
- repomoot-0.2.0.dist-info/WHEEL +5 -0
- repomoot-0.2.0.dist-info/entry_points.txt +2 -0
- repomoot-0.2.0.dist-info/licenses/LICENSE +202 -0
- repomoot-0.2.0.dist-info/licenses/NOTICE +5 -0
- repomoot-0.2.0.dist-info/top_level.txt +1 -0
repomoot/cli/main.py
ADDED
|
@@ -0,0 +1,1073 @@
|
|
|
1
|
+
"""`repomoot` command line: the primary client of the workspace for agents, humans and hooks.
|
|
2
|
+
|
|
3
|
+
Layout: verbs an agent uses most are top-level (connect, inbox, reply, propose, deliver, evaluate,
|
|
4
|
+
ask-po, decide, handoff, wait); `repomoot req ...` holds request management; `repomoot po ...` only PO operations.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import functools
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .. import PROTOCOL_VERSION, __version__, config
|
|
18
|
+
from ..adapters.claude_code import install as inst
|
|
19
|
+
from ..client.http import Client, ClientError, daemon_alive, drop_session, find_session, human_token_if_human, runtime_session_id, save_session, update_session
|
|
20
|
+
from ..core import cards
|
|
21
|
+
from . import fmt
|
|
22
|
+
|
|
23
|
+
app = typer.Typer(help="Repomoot CLI. Rules for agents: `repomoot protocol`.", no_args_is_help=True, add_completion=False,
|
|
24
|
+
context_settings={"help_option_names": ["-h", "--help"]})
|
|
25
|
+
req_app = typer.Typer(help="Request management: new, list, show, claim, decline, withdraw, close, reassign, accept, escalate, ack.", no_args_is_help=True)
|
|
26
|
+
art_app = typer.Typer(help="Artifacts (investigation, note; proposal/delivery/evaluation have top-level verbs).", no_args_is_help=True)
|
|
27
|
+
agr_app = typer.Typer(help="Cross-project agreements.", no_args_is_help=True)
|
|
28
|
+
po_app = typer.Typer(help="Product Owner operations: dashboard, policy, task, delegate.", no_args_is_help=True)
|
|
29
|
+
perm_app = typer.Typer(help="Permissions (advisory; the runtime enforces).", no_args_is_help=True)
|
|
30
|
+
events_app = typer.Typer(help="Event log of this session.", invoke_without_command=True)
|
|
31
|
+
hook_app = typer.Typer(help="Runtime hooks (Claude Code). Print to stdout; never fail.", no_args_is_help=True)
|
|
32
|
+
agents_app = typer.Typer(help="Registered agents.", invoke_without_command=True)
|
|
33
|
+
for name, sub in [("req", req_app), ("artifact", art_app), ("agreement", agr_app), ("po", po_app), ("perm", perm_app),
|
|
34
|
+
("events", events_app), ("hook", hook_app), ("agents", agents_app)]:
|
|
35
|
+
app.add_typer(sub, name=name)
|
|
36
|
+
|
|
37
|
+
STATE = {"json": False}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _version_cb(value: bool):
|
|
41
|
+
if value:
|
|
42
|
+
typer.echo(f"repomoot {__version__} protocol {PROTOCOL_VERSION}")
|
|
43
|
+
raise typer.Exit()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def _root(json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
48
|
+
version: bool = typer.Option(False, "--version", help="Print version and protocol.", callback=_version_cb, is_eager=True)):
|
|
49
|
+
STATE["json"] = json_out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------- plumbing
|
|
53
|
+
def fail(e: ClientError) -> None:
|
|
54
|
+
if STATE["json"]:
|
|
55
|
+
typer.echo(json.dumps({"error": str(e), "code": e.code, "hint": e.hint, "protocol": PROTOCOL_VERSION}))
|
|
56
|
+
else:
|
|
57
|
+
typer.echo(f"error: {e} [{e.code}]" + (f"\nhint: {e.hint}" if e.hint else ""), err=True)
|
|
58
|
+
raise typer.Exit(e.exit_code)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def emit(data: dict, text: str | None) -> None:
|
|
62
|
+
if STATE["json"]:
|
|
63
|
+
typer.echo(json.dumps({**data, "protocol": PROTOCOL_VERSION}, ensure_ascii=False, indent=1))
|
|
64
|
+
elif text:
|
|
65
|
+
typer.echo(text)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def auto_reconnect() -> dict | None:
|
|
69
|
+
"""No session file, but this folder belongs to a registered agent: reconnect silently (own repo, no human needed)."""
|
|
70
|
+
try:
|
|
71
|
+
c = Client(autostart=True)
|
|
72
|
+
sug = c.get("/agent-for-cwd", cwd=str(Path.cwd())).get("agent")
|
|
73
|
+
if not sug:
|
|
74
|
+
return None
|
|
75
|
+
rsid = runtime_session_id()
|
|
76
|
+
runtime = os.environ.get("REPOMOOT_RUNTIME") or ("claude-code" if os.environ.get("CLAUDECODE") else "cli")
|
|
77
|
+
res = c.post("/sessions/connect", agent=sug, kind="project", cwd=str(Path.cwd()), name=rsid, runtime=runtime,
|
|
78
|
+
runtime_ref=claude_session_name(), pid=os.getppid(), depends_on=[], description=None)
|
|
79
|
+
s_ = res["session"]
|
|
80
|
+
save_session(Path.cwd(), rsid, {"token": res["token"], "agent": s_["agent_id"], "session_id": s_["id"], "hook_cursor": res["cursor"]})
|
|
81
|
+
typer.echo(f"[repomoot] reconnected as {s_['agent_id']} (session {s_['id']}); run `repomoot address \"NAME [ref]\"` (from ListAgents) if you have not yet.", err=True)
|
|
82
|
+
return find_session()
|
|
83
|
+
except ClientError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def client(need_session: bool = True, human: bool = False) -> tuple[Client, dict | None]:
|
|
88
|
+
ses = find_session()
|
|
89
|
+
if need_session and not ses:
|
|
90
|
+
ses = auto_reconnect()
|
|
91
|
+
if need_session and not ses:
|
|
92
|
+
fail(ClientError("this directory is not registered in Repomoot", "not_connected",
|
|
93
|
+
"the user registers it by typing `/repomoot connect` (or `/repomoot po`) in the chat", 401))
|
|
94
|
+
return Client(session_token=ses["token"] if ses else None, human=human_token_if_human() if human else None), ses
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def me_of(ses: dict | None) -> str | None:
|
|
98
|
+
return ses.get("agent") if ses else None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read_body(body: Optional[str], body_file: Optional[Path]) -> str:
|
|
102
|
+
if body_file:
|
|
103
|
+
return body_file.read_text(encoding="utf-8")
|
|
104
|
+
if body == "-":
|
|
105
|
+
return sys.stdin.read()
|
|
106
|
+
return body or ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def csv(s: Optional[str]) -> list[str]:
|
|
110
|
+
return [x.strip() for x in s.split(",") if x.strip()] if s else []
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def opts_of(options: Optional[List[str]]) -> list[dict]:
|
|
114
|
+
out = []
|
|
115
|
+
for part in options or []:
|
|
116
|
+
if ":" in part:
|
|
117
|
+
i, s = part.split(":", 1)
|
|
118
|
+
out.append({"id": i.strip(), "summary": s.strip()})
|
|
119
|
+
else:
|
|
120
|
+
out.append({"id": part.strip(), "summary": part.strip()})
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def notify_line(res: dict) -> str:
|
|
125
|
+
n = res.get("notify") or []
|
|
126
|
+
if not n:
|
|
127
|
+
return "notify: none online (they will see it on their next `repomoot inbox`)"
|
|
128
|
+
return "notify: " + ", ".join(dict.fromkeys(f"{t['agent']}[{t.get('runtime') or '?'}:{t['ref']}]" for t in n))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def guard(fn):
|
|
132
|
+
@functools.wraps(fn)
|
|
133
|
+
def wrapper(*a, **k):
|
|
134
|
+
try:
|
|
135
|
+
return fn(*a, **k)
|
|
136
|
+
except ClientError as e:
|
|
137
|
+
fail(e)
|
|
138
|
+
return wrapper
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------- daemon
|
|
142
|
+
@app.command()
|
|
143
|
+
def serve():
|
|
144
|
+
"""Start the daemon in the foreground (idempotent). Clients also start it on demand."""
|
|
145
|
+
url = config.base_url()
|
|
146
|
+
if daemon_alive(url):
|
|
147
|
+
typer.echo(f"daemon already running at {url}")
|
|
148
|
+
raise typer.Exit()
|
|
149
|
+
from ..server.run import main
|
|
150
|
+
typer.echo(f"repomoot daemon {__version__} at {url} (db: {config.db_path()})")
|
|
151
|
+
main()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
@guard
|
|
156
|
+
def status():
|
|
157
|
+
"""Daemon health, agents and online sessions."""
|
|
158
|
+
c = Client(autostart=False)
|
|
159
|
+
if not daemon_alive(c.base_url):
|
|
160
|
+
fail(ClientError(f"daemon not running at {c.base_url}", "daemon_unavailable", "repomoot serve", 503))
|
|
161
|
+
h = c.get("/health")
|
|
162
|
+
ag = c.get("/agents")
|
|
163
|
+
lines = [f"daemon ok v{h['version']} protocol {h['protocol']} po: {'present' if h['po_present'] else 'absent'}"]
|
|
164
|
+
for a in ag["agents"]:
|
|
165
|
+
on = ", ".join(s.get("name") or s["id"] for s in a["sessions"]) or "offline"
|
|
166
|
+
lines.append(f" {a['id']:<14} {a['kind']:<14} open→{a['open_requests_to']:<3} {on}")
|
|
167
|
+
emit({"health": h, **ag}, "\n".join(lines))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.command()
|
|
171
|
+
def ui(no_browser: bool = typer.Option(False, "--no-browser", help="Only print the URL.")):
|
|
172
|
+
"""Open the live request tree in the browser."""
|
|
173
|
+
import webbrowser
|
|
174
|
+
c = Client()
|
|
175
|
+
c.get("/health")
|
|
176
|
+
url = f"{c.base_url}/ui?token={config.token()}"
|
|
177
|
+
typer.echo(url)
|
|
178
|
+
if not no_browser:
|
|
179
|
+
webbrowser.open(url)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@app.command()
|
|
183
|
+
@guard
|
|
184
|
+
def tick():
|
|
185
|
+
"""Run the timers now (session expiry, local→PO promotion)."""
|
|
186
|
+
res = Client().post("/admin/tick")
|
|
187
|
+
emit(res, f"sessions expired: {res['sessions_expired']}, decisions promoted: {res['decisions_promoted']}")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------- identity
|
|
191
|
+
@app.command()
|
|
192
|
+
@guard
|
|
193
|
+
def connect(agent: Optional[str] = typer.Argument(None, help="Agent id. Omit to use the agent registered for this directory."),
|
|
194
|
+
kind: str = typer.Option("project", help="project | po (registration only; needs the human: TTY or REPOMOOT_HUMAN_TOKEN)"),
|
|
195
|
+
name: Optional[str] = typer.Option(None, "--session", "-s", help="Session name (several sessions in one directory)."),
|
|
196
|
+
depends_on: Optional[str] = typer.Option(None, help="Comma-separated agents this project depends on (human only)."),
|
|
197
|
+
description: Optional[str] = typer.Option(None, help="(human only)"),
|
|
198
|
+
no_card: bool = typer.Option(False, "--no-card", help="Do not print the protocol card.")):
|
|
199
|
+
"""Connect this directory's session to an agent. First-time registration (repo = cwd) is done by the human."""
|
|
200
|
+
cwd = Path.cwd()
|
|
201
|
+
name = name or os.environ.get("REPOMOOT_SESSION") or runtime_session_id() or None
|
|
202
|
+
runtime = os.environ.get("REPOMOOT_RUNTIME") or ("claude-code" if os.environ.get("CLAUDECODE") else "cli")
|
|
203
|
+
existing = find_session(cwd, name, exact=True)
|
|
204
|
+
c = Client(human=human_token_if_human())
|
|
205
|
+
res = c.post("/sessions/connect", agent=agent, kind=kind, cwd=str(cwd), name=name, runtime=runtime,
|
|
206
|
+
runtime_ref=os.environ.get("REPOMOOT_RUNTIME_REF") or claude_session_name() or name, pid=os.getppid(), depends_on=csv(depends_on), description=description)
|
|
207
|
+
ses = res["session"]
|
|
208
|
+
save_session(cwd, name, {"token": res["token"], "agent": ses["agent_id"], "session_id": ses["id"], "hook_cursor": res["cursor"]})
|
|
209
|
+
who = res["who"]
|
|
210
|
+
head = f"connected: {who['agent']} ({who['kind']}) session {ses['id']}" + (f" '{name}'" if name else "") + (" (replaced previous session)" if existing else "")
|
|
211
|
+
if who["kind"] != "product_owner":
|
|
212
|
+
head += f"\nrepos: {', '.join(who['repos']) or '-'} depends_on: {', '.join(who['depends_on']) or '-'} consumers: {', '.join(who['consumers']) or '-'}"
|
|
213
|
+
parts = [head, fmt.inbox_view(res["inbox"], who["agent"])]
|
|
214
|
+
if not no_card:
|
|
215
|
+
parts += ["", "=== protocol card (rules for this session) ===", res["card"].rstrip()]
|
|
216
|
+
emit(res, "\n".join(parts))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@app.command()
|
|
220
|
+
@guard
|
|
221
|
+
def address(text: str = typer.Argument(..., help="Your cross-session address, e.g. 'gridlib-44 [e07008]' — the 'This session is …' line of ListAgents.")):
|
|
222
|
+
"""Record this session's address so other agents can wake it with a cross-session message."""
|
|
223
|
+
import re as _re
|
|
224
|
+
m = _re.search(r"([^\s\[]+(?:\s[^\s\[]+)*)\s*(\[[0-9a-f]{4,}\])?", text.replace("This session is", "").strip())
|
|
225
|
+
ref = (m.group(1).strip() + (" " + m.group(2) if m.group(2) else "")) if m else text.strip()
|
|
226
|
+
c, _ = client()
|
|
227
|
+
res = c.post("/sessions/me/address", runtime_ref=ref)
|
|
228
|
+
emit(res, f"address recorded: {res['runtime_ref']} (others will SendMessage you at this name)")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@app.command()
|
|
232
|
+
@guard
|
|
233
|
+
def disconnect():
|
|
234
|
+
"""End this directory's session."""
|
|
235
|
+
c, ses = client()
|
|
236
|
+
try:
|
|
237
|
+
c.post("/sessions/disconnect")
|
|
238
|
+
finally:
|
|
239
|
+
drop_session(ses)
|
|
240
|
+
emit({"ok": True}, "disconnected")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command()
|
|
244
|
+
@guard
|
|
245
|
+
def whoami():
|
|
246
|
+
"""Which agent this session is (exit 3 if not connected)."""
|
|
247
|
+
c, ses = client()
|
|
248
|
+
w = c.get("/whoami")
|
|
249
|
+
emit(w, f"{w['agent']} ({w['kind']}) session {w['session']} po: {'present' if w['po_present'] else 'absent'}\n"
|
|
250
|
+
f"repos: {', '.join(w['repos']) or '-'} depends_on: {', '.join(w['depends_on']) or '-'} consumers: {', '.join(w['consumers']) or '-'}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@agents_app.callback()
|
|
254
|
+
@guard
|
|
255
|
+
def agents_list(ctx: typer.Context):
|
|
256
|
+
"""Registered agents and who is online."""
|
|
257
|
+
if ctx.invoked_subcommand:
|
|
258
|
+
return
|
|
259
|
+
res = Client().get("/agents")
|
|
260
|
+
lines = []
|
|
261
|
+
for a in res["agents"]:
|
|
262
|
+
on = ", ".join(s.get("name") or s["id"] for s in a["sessions"]) or "offline"
|
|
263
|
+
deps = f" depends_on={','.join(a['depends_on'])}" if a["depends_on"] else ""
|
|
264
|
+
lines.append(f"{a['id']:<14} {a['kind']:<14} {on:<20} open→{a['open_requests_to']}{deps}")
|
|
265
|
+
emit(res, "\n".join(lines) or "no agents; the human registers one with `repomoot connect <name>` inside its repository")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@agents_app.command("set")
|
|
269
|
+
@guard
|
|
270
|
+
def agents_set(agent: str, depends_on: Optional[str] = typer.Option(None), description: Optional[str] = None,
|
|
271
|
+
add_repo: Optional[Path] = typer.Option(None, help="Additional repository directory.")):
|
|
272
|
+
"""Update an agent (human only)."""
|
|
273
|
+
c = Client(human=human_token_if_human())
|
|
274
|
+
res = c.post(f"/agents/{agent}", depends_on=csv(depends_on), description=description, add_repo=str(add_repo) if add_repo else None)
|
|
275
|
+
emit(res, f"{agent}: repos={', '.join(res['repos'])} depends_on={', '.join(res['depends_on']) or '-'}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------- inbox / events
|
|
279
|
+
@app.command()
|
|
280
|
+
@guard
|
|
281
|
+
def inbox():
|
|
282
|
+
"""What needs your attention: blocking / awaiting you / in progress / waiting / fyi."""
|
|
283
|
+
c, ses = client()
|
|
284
|
+
ib = c.get("/inbox")
|
|
285
|
+
emit(ib, fmt.inbox_view(ib, me_of(ses)))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@app.command()
|
|
289
|
+
@guard
|
|
290
|
+
def wait(req: Optional[str] = typer.Option(None, "--req", help="Wait for events on this request only."),
|
|
291
|
+
timeout: int = typer.Option(600, help="Seconds (max 600).")):
|
|
292
|
+
"""Block until an event arrives for you (long-poll). Exit 7 on timeout. Nothing wakes you afterwards — tell the user."""
|
|
293
|
+
c, _ = client()
|
|
294
|
+
res = c.get("/events", request_id=req, wait=min(timeout, 600))
|
|
295
|
+
if res["timeout"]:
|
|
296
|
+
emit(res, f"timeout after {timeout}s; nothing new" + (f" on {req}" if req else "") + ". Tell the user what you are waiting for.")
|
|
297
|
+
raise typer.Exit(7)
|
|
298
|
+
emit(res, "\n".join(fmt.event_line(e) for e in res["events"]))
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@events_app.callback()
|
|
302
|
+
@guard
|
|
303
|
+
def events_list(ctx: typer.Context, since: Optional[int] = typer.Option(None, help="Cursor; default: this session's cursor."),
|
|
304
|
+
req: Optional[str] = typer.Option(None, "--req"), limit: int = 50, own: bool = typer.Option(False, help="Include your own actions.")):
|
|
305
|
+
"""Events since the session cursor (non-blocking); advances the cursor."""
|
|
306
|
+
if ctx.invoked_subcommand:
|
|
307
|
+
return
|
|
308
|
+
c, _ = client()
|
|
309
|
+
res = c.get("/events", since=since, request_id=req, limit=limit, own=own)
|
|
310
|
+
emit(res, "\n".join(fmt.event_line(e) for e in res["events"]) or "no new events")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@events_app.command("ack")
|
|
314
|
+
@guard
|
|
315
|
+
def events_ack(req: Optional[str] = typer.Option(None, "--req", help="Acknowledge all events of a request."),
|
|
316
|
+
all_: bool = typer.Option(False, "--all"), event_ids: Optional[str] = typer.Argument(None, help="Comma-separated event ids.")):
|
|
317
|
+
"""Mark events as read."""
|
|
318
|
+
c, _ = client()
|
|
319
|
+
res = c.post("/ack", event_ids=[int(x) for x in csv(event_ids)], request_id=req, all=all_)
|
|
320
|
+
emit(res, f"acked {res['acked']}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# ---------------------------------------------------------------- requests
|
|
324
|
+
@req_app.command("new")
|
|
325
|
+
@guard
|
|
326
|
+
def req_new(to: str = typer.Option(..., help="Recipient agent."),
|
|
327
|
+
type_: str = typer.Option("question", "--type", help="question|capability|bug|change|review|task"),
|
|
328
|
+
title: str = typer.Option(..., help="One line."),
|
|
329
|
+
body: Optional[str] = typer.Option(None, help="Text, or '-' for stdin."), body_file: Optional[Path] = typer.Option(None),
|
|
330
|
+
goal: Optional[str] = typer.Option(None, help="Measurable goal."),
|
|
331
|
+
constraint: Optional[List[str]] = typer.Option(None, help="Repeatable."), acceptance: Optional[List[str]] = typer.Option(None, help="Repeatable."),
|
|
332
|
+
ref: Optional[List[str]] = typer.Option(None, help="@refs (repeatable; also parsed from body)."),
|
|
333
|
+
priority: str = typer.Option("normal", help="low|normal|high"), blocking: bool = typer.Option(False, help="You cannot continue without the answer."),
|
|
334
|
+
cc: Optional[List[str]] = typer.Option(None), parent: Optional[str] = typer.Option(None, help="Parent request id (sub-request)."),
|
|
335
|
+
label: Optional[List[str]] = typer.Option(None), due: Optional[str] = typer.Option(None, help="ISO date."),
|
|
336
|
+
idem: Optional[str] = typer.Option(None, help="Idempotency key: repeating the command will not create a duplicate."),
|
|
337
|
+
as_agent: Optional[str] = typer.Option(None, "--as", help="Human only: speak as this agent.")):
|
|
338
|
+
"""Create a request to another agent."""
|
|
339
|
+
c, ses = client(human=bool(as_agent))
|
|
340
|
+
g = {k: v for k, v in (("description", goal), ("constraints", constraint), ("acceptance", acceptance)) if v}
|
|
341
|
+
text = read_body(body, body_file)
|
|
342
|
+
if ref:
|
|
343
|
+
text = text.rstrip() + "\n\nrefs: " + " ".join(ref) + "\n"
|
|
344
|
+
res = c.post("/requests", to=to, type=type_, title=title, body=text, goal=g, priority=priority, blocking=blocking,
|
|
345
|
+
cc=cc or [], parent_id=parent, labels=label or [], due_at=due, idem=idem, as_agent=as_agent)
|
|
346
|
+
r = res["request"]
|
|
347
|
+
dup = " (duplicate of an earlier call; nothing created)" if res.get("duplicate") else ""
|
|
348
|
+
emit(res, f"{r['id']} created ({r['type']}{', blocking' if r['blocking'] else ''}) → {r['to_agent']}{dup}\n{notify_line(res)}"
|
|
349
|
+
+ (f"\nnext: repomoot wait --req {r['id']}" if r["blocking"] else ""))
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@req_app.command("list")
|
|
353
|
+
@guard
|
|
354
|
+
def req_list(box: str = typer.Option("all", help="inbox|outbox|all"), status: Optional[str] = None, type_: Optional[str] = typer.Option(None, "--type"),
|
|
355
|
+
agent: Optional[str] = typer.Option(None, help="Only requests involving this agent."), all_: bool = typer.Option(False, "--all", help="Include closed."),
|
|
356
|
+
limit: int = 50):
|
|
357
|
+
"""List requests you are involved in."""
|
|
358
|
+
c, ses = client()
|
|
359
|
+
res = c.get("/requests", box=box, status=status, type=type_, other=agent, limit=limit, all=all_)
|
|
360
|
+
emit(res, "\n".join(fmt.req_line(r, me_of(ses)) for r in res["requests"]) or "none")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@req_app.command("show")
|
|
364
|
+
@guard
|
|
365
|
+
def req_show(rid: str, since: Optional[int] = typer.Option(None, help="Only messages after this message rowid."),
|
|
366
|
+
events_: bool = typer.Option(False, "--events", help="Include the event log.")):
|
|
367
|
+
"""Full thread: request, messages, artifacts, next actions."""
|
|
368
|
+
c, ses = client()
|
|
369
|
+
res = c.get(f"/requests/{rid}", include="messages,artifacts" + (",events" if events_ else ""), since=since)
|
|
370
|
+
emit(res, fmt.request_view(res, me_of(ses)))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@req_app.command("claim")
|
|
374
|
+
@guard
|
|
375
|
+
def req_claim(rid: str):
|
|
376
|
+
"""Take a request addressed to you into work (a reply does this implicitly)."""
|
|
377
|
+
c, _ = client()
|
|
378
|
+
res = c.post(f"/requests/{rid}/claim")
|
|
379
|
+
emit(res, f"{rid} → {res['request']['status']} (claimed)")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _action(rid: str, action: str, reason: Optional[str] = None, to: Optional[str] = None):
|
|
383
|
+
c, _ = client()
|
|
384
|
+
res = c.post(f"/requests/{rid}/action", action=action, reason=reason, to=to)
|
|
385
|
+
emit(res, f"{rid} → {res['request']['status']}\n{notify_line(res)}")
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
@req_app.command("decline")
|
|
389
|
+
@guard
|
|
390
|
+
def req_decline(rid: str, reason: str = typer.Option(..., help="Why. The initiator may escalate to the PO.")):
|
|
391
|
+
"""Decline a request addressed to you."""
|
|
392
|
+
_action(rid, "decline", reason)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
@req_app.command("withdraw")
|
|
396
|
+
@guard
|
|
397
|
+
def req_withdraw(rid: str, reason: Optional[str] = None):
|
|
398
|
+
"""Withdraw your own request (open sub-requests are closed as wont_do)."""
|
|
399
|
+
_action(rid, "withdraw", reason)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@req_app.command("close")
|
|
403
|
+
@guard
|
|
404
|
+
def req_close(rid: str, reason: Optional[str] = None):
|
|
405
|
+
"""Close your own request (e.g. after an answer)."""
|
|
406
|
+
_action(rid, "close", reason)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
@req_app.command("ack")
|
|
410
|
+
@guard
|
|
411
|
+
def req_ack(rid: str):
|
|
412
|
+
"""Acknowledge a notice addressed to you."""
|
|
413
|
+
_action(rid, "ack")
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
@req_app.command("reassign")
|
|
417
|
+
@guard
|
|
418
|
+
def req_reassign(rid: str, to: str = typer.Option(...), reason: Optional[str] = None):
|
|
419
|
+
"""Hand a request addressed to you to the right owner (thread is kept)."""
|
|
420
|
+
_action(rid, "reassign", reason, to)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
@req_app.command("accept")
|
|
424
|
+
@guard
|
|
425
|
+
def req_accept(rid: str, option: Optional[str] = typer.Option(None, help="Option id from the proposal."), notes: Optional[str] = None):
|
|
426
|
+
"""Initiator accepts the pending proposal; the recipient may implement."""
|
|
427
|
+
c, _ = client()
|
|
428
|
+
res = c.post(f"/requests/{rid}/accept_proposal", option=option, notes=notes)
|
|
429
|
+
emit(res, f"{rid} → implementation (option {res['request'].get('accepted_option') or '-'})\n{notify_line(res)}")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@req_app.command("escalate")
|
|
433
|
+
@guard
|
|
434
|
+
def req_escalate(rid: str, question: str = typer.Option(..., help="What the PO must decide."),
|
|
435
|
+
kind: str = typer.Option("conflict", help="priority|scope_change|breaking_change|conflict|budget|access|other"),
|
|
436
|
+
option: Optional[List[str]] = typer.Option(None, help="'A: text' (repeatable)")):
|
|
437
|
+
"""Escalate a request to the PO (creates a child decision; parent becomes blocked)."""
|
|
438
|
+
c, _ = client()
|
|
439
|
+
res = c.post(f"/requests/{rid}/escalate", question=question, kind=kind, options=opts_of(option))
|
|
440
|
+
emit(res, _po_created_text(res))
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
@app.command()
|
|
444
|
+
@guard
|
|
445
|
+
def reply(rid: str, body: Optional[str] = typer.Option(None, help="Text, or '-' for stdin."), body_file: Optional[Path] = None,
|
|
446
|
+
type_: str = typer.Option("comment", "--type", help="comment|clarification|answer"),
|
|
447
|
+
as_agent: Optional[str] = typer.Option(None, "--as", help="Human only: speak as this agent.")):
|
|
448
|
+
"""Post a message into a request thread (claims it if you are the recipient)."""
|
|
449
|
+
c, _ = client(human=bool(as_agent))
|
|
450
|
+
res = c.post(f"/requests/{rid}/messages", body=read_body(body, body_file), type=type_, as_agent=as_agent)
|
|
451
|
+
extra = f" ({rid} → {res['status']})" if res.get("status") else ""
|
|
452
|
+
emit(res, f"{res['message']['id']} posted{extra}\n{notify_line(res)}")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@app.command()
|
|
456
|
+
@guard
|
|
457
|
+
def notice(title: str = typer.Option(...), body: Optional[str] = typer.Option(None), body_file: Optional[Path] = None,
|
|
458
|
+
to: Optional[List[str]] = typer.Option(None, help="Recipients (repeatable); default: all consumers of your repo."),
|
|
459
|
+
label: Optional[List[str]] = typer.Option(None, help="e.g. breaking (also alerts the PO)")):
|
|
460
|
+
"""Inform dependent agents (release, breaking change). One notice per recipient."""
|
|
461
|
+
c, ses = client()
|
|
462
|
+
targets = to or c.get("/whoami")["consumers"]
|
|
463
|
+
if not targets:
|
|
464
|
+
fail(ClientError("no consumers registered and no --to given", "bad_request"))
|
|
465
|
+
text = read_body(body, body_file)
|
|
466
|
+
out = [c.post("/requests", to=t, type="notice", title=title, body=text, labels=label or []) for t in targets]
|
|
467
|
+
emit({"notices": out}, "\n".join(f"{r['request']['id']} → {r['request']['to_agent']} {notify_line(r)}" for r in out))
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
# ---------------------------------------------------------------- top-level verbs over artifacts
|
|
471
|
+
@app.command()
|
|
472
|
+
@guard
|
|
473
|
+
def propose(rid: str, title: str = typer.Option(...), option: Optional[List[str]] = typer.Option(None, help="'A: summary' (repeatable)"),
|
|
474
|
+
recommend: Optional[str] = typer.Option(None, help="Recommended option id."),
|
|
475
|
+
compat: Optional[List[str]] = typer.Option(None, help="'A: full|breaking' (repeatable)"),
|
|
476
|
+
effort: Optional[List[str]] = typer.Option(None, help="'A: 1h' (repeatable)"),
|
|
477
|
+
body: Optional[str] = typer.Option(None, help="Markdown, or '-' for stdin."), body_file: Optional[Path] = None,
|
|
478
|
+
requires_po: bool = typer.Option(False, help="Needs a PO decision before acceptance (breaking change / scope)."),
|
|
479
|
+
data: Optional[str] = typer.Option(None, help="Raw JSON instead of --option/--compat/--effort.")):
|
|
480
|
+
"""Publish a proposal on a request you received: options + recommendation."""
|
|
481
|
+
c, _ = client()
|
|
482
|
+
if data:
|
|
483
|
+
d = json.loads(data)
|
|
484
|
+
else:
|
|
485
|
+
opts = opts_of(option)
|
|
486
|
+
extra = {k: dict(o.split(":", 1) for o in (v or [])) for k, v in (("compat", compat), ("effort", effort))}
|
|
487
|
+
for o in opts:
|
|
488
|
+
for k in ("compat", "effort"):
|
|
489
|
+
val = extra[k].get(o["id"])
|
|
490
|
+
if val:
|
|
491
|
+
o[k] = val.strip()
|
|
492
|
+
d = {"options": opts, "recommended": recommend}
|
|
493
|
+
res = c.post("/artifacts", type="proposal", title=title, content=read_body(body, body_file), data=d, request_id=rid, requires_po=requires_po)
|
|
494
|
+
lines = [f"{res['artifact']['id']} proposal published; {rid} → {res['request']['status']}"]
|
|
495
|
+
if res.get("decision"):
|
|
496
|
+
lines.append(_po_created_text(res | {"request": res["decision"]}))
|
|
497
|
+
lines.append(notify_line(res))
|
|
498
|
+
emit(res, "\n".join(lines))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
@app.command()
|
|
502
|
+
@guard
|
|
503
|
+
def deliver(rid: str, repo: str = typer.Option(..., help="Your agent id or repo path."), commit: Optional[str] = typer.Option(None),
|
|
504
|
+
branch: Optional[str] = typer.Option(None), title: Optional[str] = typer.Option(None),
|
|
505
|
+
tests_passed: bool = typer.Option(False, "--tests-passed"), tests_failed: bool = typer.Option(False, "--tests-failed"),
|
|
506
|
+
tests_cmd: Optional[str] = typer.Option(None, help="Command you ran."), version: Optional[str] = None,
|
|
507
|
+
body: Optional[str] = typer.Option(None, help="How to integrate; '-' for stdin."), body_file: Optional[Path] = None,
|
|
508
|
+
data: Optional[str] = typer.Option(None, help="Extra JSON merged into data.")):
|
|
509
|
+
"""Publish a delivery (repo + commit + tests you ran) on a request you received."""
|
|
510
|
+
c, _ = client()
|
|
511
|
+
if not (tests_passed or tests_failed):
|
|
512
|
+
fail(ClientError("say what happened to the tests: --tests-passed or --tests-failed", "bad_request"))
|
|
513
|
+
d = {"repo": repo, "commit": commit, "branch": branch, "version": version,
|
|
514
|
+
"tests": {"cmd": tests_cmd, "result": "passed" if tests_passed else "failed"}}
|
|
515
|
+
if data:
|
|
516
|
+
d.update(json.loads(data))
|
|
517
|
+
res = c.post("/artifacts", type="delivery", title=title or f"Delivery {commit or branch}", content=read_body(body, body_file), data=d, request_id=rid)
|
|
518
|
+
emit(res, f"{res['artifact']['id']} delivery published; {rid} → {res['request']['status']}\n{notify_line(res)}")
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@app.command()
|
|
522
|
+
@guard
|
|
523
|
+
def evaluate(rid: str, verdict: str = typer.Option(..., help="accepted|rejected"), title: Optional[str] = typer.Option(None),
|
|
524
|
+
body: Optional[str] = typer.Option(None, help="Measured results and reason; '-' for stdin."), body_file: Optional[Path] = None,
|
|
525
|
+
data: Optional[str] = typer.Option(None, help="JSON: bench, tests, consumer_commit ...")):
|
|
526
|
+
"""Publish your evaluation of a delivery (you are the initiator)."""
|
|
527
|
+
c, _ = client()
|
|
528
|
+
res = c.post("/artifacts", type="evaluation", title=title or f"Evaluation: {verdict}", content=read_body(body, body_file),
|
|
529
|
+
data=json.loads(data) if data else {}, request_id=rid, verdict=verdict)
|
|
530
|
+
emit(res, f"{res['artifact']['id']} evaluation ({verdict}); {rid} → {res['request']['status']}\n{notify_line(res)}")
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
# ---------------------------------------------------------------- PO interaction (any agent)
|
|
534
|
+
def _po_created_text(res: dict) -> str:
|
|
535
|
+
r = res["request"]
|
|
536
|
+
if res.get("po_present"):
|
|
537
|
+
return f"{r['id']} decision → PO (present)\n{notify_line(res)}"
|
|
538
|
+
routed = res.get("routed_to") or r.get("routed_to")
|
|
539
|
+
holder = "Show" if routed in (None, r["from_agent"]) else f"{routed} shows"
|
|
540
|
+
return (f"{r['id']} decision created; po_present: false → mode: local (with {routed})\n"
|
|
541
|
+
f"{holder} the question to the user in the chat, in their language, with the options and a recommendation; then either\n"
|
|
542
|
+
f" repomoot decide {r['id']} --author human --option <id> --reason \"<user's words>\" or repomoot handoff {r['id']}")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@app.command("ask-po")
|
|
546
|
+
@guard
|
|
547
|
+
def ask_po(title: str = typer.Option(...), body: Optional[str] = typer.Option(None, help="Context, or '-' for stdin."), body_file: Optional[Path] = None,
|
|
548
|
+
kind: str = typer.Option("other", help="priority|scope_change|breaking_change|conflict|budget|access|clarification|other"),
|
|
549
|
+
option: Optional[List[str]] = typer.Option(None, help="'A: text' (repeatable)"), parent: Optional[str] = typer.Option(None, help="Request this blocks."),
|
|
550
|
+
blocking: bool = False):
|
|
551
|
+
"""Ask the Product Owner. If the PO is absent the question stays in your chat (mode: local)."""
|
|
552
|
+
c, _ = client()
|
|
553
|
+
res = c.post("/po/ask", title=title, body=read_body(body, body_file), kind=kind, options=opts_of(option), parent_id=parent, blocking=blocking)
|
|
554
|
+
emit(res, _po_created_text(res))
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
@app.command()
|
|
558
|
+
@guard
|
|
559
|
+
def handoff(rid: str):
|
|
560
|
+
"""Hand a local decision over to the PO queue (the user said "hand it to the PO")."""
|
|
561
|
+
c, _ = client()
|
|
562
|
+
res = c.post(f"/requests/{rid}/handoff")
|
|
563
|
+
emit(res, f"{rid} handed over to PO ({'present' if res['po_present'] else 'absent — it waits in the PO inbox'})\n{notify_line(res)}")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
@app.command()
|
|
567
|
+
@guard
|
|
568
|
+
def decide(rid: str, option: Optional[str] = typer.Option(None), decision: Optional[str] = typer.Option(None, help="Free-text decision if no option."),
|
|
569
|
+
reason: str = typer.Option(..., help="For --author human: the user's own words."), affected: Optional[str] = typer.Option(None, help="Comma-separated agents."),
|
|
570
|
+
author: str = typer.Option("po", help="po | human (human: the user answered in the chat)")):
|
|
571
|
+
"""Record a decision. PO session: --author po. Local question answered by the user in your chat: --author human."""
|
|
572
|
+
c, _ = client(human=(author == "human"))
|
|
573
|
+
if not option and not decision:
|
|
574
|
+
fail(ClientError("--option or --decision is required", "bad_request"))
|
|
575
|
+
res = c.post(f"/requests/{rid}/decide", decision=decision or f"option {option}", option=option, reason=reason, affected=csv(affected), author=author)
|
|
576
|
+
att = "" if res.get("attested") else " — UNATTESTED: recorded from an agent session; the PO dashboard lists it"
|
|
577
|
+
emit(res, f"{res['artifact']['id']} decision recorded ({author}{att}); {rid} closed\n{notify_line(res)}")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ---------------------------------------------------------------- PO-only
|
|
581
|
+
@po_app.command("dashboard")
|
|
582
|
+
@guard
|
|
583
|
+
def po_dashboard():
|
|
584
|
+
"""Everything open across projects, ordered by urgency."""
|
|
585
|
+
c, _ = client()
|
|
586
|
+
res = c.get("/po/dashboard")
|
|
587
|
+
emit(res, fmt.dashboard_view(res))
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
@po_app.command("policy")
|
|
591
|
+
@guard
|
|
592
|
+
def po_policy(set_json: Optional[str] = typer.Option(None, "--set", help="JSON policy (human only)."), set_file: Optional[Path] = None):
|
|
593
|
+
"""Show or set the delegation policy."""
|
|
594
|
+
c, _ = client(human=bool(set_json or set_file))
|
|
595
|
+
if set_json or set_file:
|
|
596
|
+
res = c.put("/po/policy", json.loads(set_file.read_text(encoding="utf-8")) if set_file else json.loads(set_json))
|
|
597
|
+
else:
|
|
598
|
+
res = c.get("/po/policy")
|
|
599
|
+
emit(res, f"mode: {res.get('mode', 'hybrid')}\ndecide alone: {', '.join(res.get('auto_decide', [])) or '-'}\n"
|
|
600
|
+
f"ask the user first: {', '.join(res.get('always_ask_human', [])) or '-'}\notherwise: {res.get('default', 'ask_human')}")
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@po_app.command("task")
|
|
604
|
+
@guard
|
|
605
|
+
def po_task(to: str = typer.Argument(...), title: str = typer.Option(...), body: Optional[str] = typer.Option(None), body_file: Optional[Path] = None,
|
|
606
|
+
depends_on: Optional[str] = typer.Option(None, help="Comma-separated request ids."), due: Optional[str] = None):
|
|
607
|
+
"""Assign work to an agent (PO only)."""
|
|
608
|
+
c, _ = client()
|
|
609
|
+
res = c.post("/tasks", to=to, title=title, body=read_body(body, body_file), depends_on=csv(depends_on), due_at=due)
|
|
610
|
+
emit(res, f"{res['request']['id']} task → {to}\n{notify_line(res)}")
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
@po_app.command("delegate")
|
|
614
|
+
@guard
|
|
615
|
+
def po_delegate(rid: str, reason: str = typer.Option(..., help="Why this is the owner's call.")):
|
|
616
|
+
"""PO sends a decision back: it is an engineering question for the owner."""
|
|
617
|
+
c, _ = client()
|
|
618
|
+
res = c.post(f"/requests/{rid}/delegate", reason=reason)
|
|
619
|
+
emit(res, f"{rid} delegated back\n{notify_line(res)}")
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
# ---------------------------------------------------------------- artifacts & agreements
|
|
623
|
+
@art_app.command("publish")
|
|
624
|
+
@guard
|
|
625
|
+
def art_publish(type_: str = typer.Option(..., "--type", help="investigation|note|proposal|delivery|evaluation"),
|
|
626
|
+
title: str = typer.Option(...), request: Optional[str] = typer.Option(None),
|
|
627
|
+
body: Optional[str] = typer.Option(None), body_file: Optional[Path] = None,
|
|
628
|
+
data: Optional[str] = typer.Option(None, help="JSON"), verdict: Optional[str] = None, requires_po: bool = False, supersedes: Optional[str] = None):
|
|
629
|
+
"""Publish any artifact (low-level; prefer propose/deliver/evaluate)."""
|
|
630
|
+
c, _ = client()
|
|
631
|
+
res = c.post("/artifacts", type=type_, title=title, content=read_body(body, body_file), data=json.loads(data) if data else {}, request_id=request,
|
|
632
|
+
supersedes=supersedes, verdict=verdict, requires_po=requires_po)
|
|
633
|
+
emit(res, f"{res['artifact']['id']} {type_} published" + (f"; {request} → {res['request']['status']}" if res.get("request") else "") + f"\n{notify_line(res)}")
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@art_app.command("show")
|
|
637
|
+
@guard
|
|
638
|
+
def art_show(aid: str):
|
|
639
|
+
"""Show an artifact."""
|
|
640
|
+
c, _ = client()
|
|
641
|
+
a = c.get(f"/artifacts/{aid}")
|
|
642
|
+
emit(a, f"{a['id']} {a['type']} {fmt.q(a['title'])} by {a['author_agent']} v{a['version']} {a['created_at'][:19]}\n"
|
|
643
|
+
f"request: {a.get('request_id') or '-'}\ndata: {json.dumps(a.get('data') or {}, ensure_ascii=False)}\n--- content (quoted) ---\n{a['content']}")
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@art_app.command("list")
|
|
647
|
+
@guard
|
|
648
|
+
def art_list(type_: Optional[str] = typer.Option(None, "--type"), request: Optional[str] = None, agent: Optional[str] = None, limit: int = 50):
|
|
649
|
+
"""List artifacts."""
|
|
650
|
+
c, _ = client()
|
|
651
|
+
res = c.get("/artifacts", type=type_, request_id=request, agent=agent, limit=limit)
|
|
652
|
+
emit(res, "\n".join(f"{a['id']} {a['type']:<13} {a.get('request_id') or '-':<9} {a['author_agent']:<12} {fmt.q(a['title'], 60)}" for a in res["artifacts"]) or "none")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
@agr_app.command("publish")
|
|
656
|
+
@guard
|
|
657
|
+
def agr_publish(title: str = typer.Option(...), parties: str = typer.Option(..., help="Comma-separated agents."),
|
|
658
|
+
body: Optional[str] = typer.Option(None), body_file: Optional[Path] = None, supersedes: Optional[str] = None):
|
|
659
|
+
"""Record a cross-project rule. Parties acknowledge with `repomoot agreement ack`."""
|
|
660
|
+
c, _ = client()
|
|
661
|
+
res = c.post("/agreements", title=title, content=read_body(body, body_file), parties=csv(parties), supersedes=supersedes)
|
|
662
|
+
g = res["agreement"]
|
|
663
|
+
emit(res, f"{g['id']} published; pending ack: {', '.join(set(g['parties']) - set(g['acknowledged'])) or 'none'}\n{notify_line(res)}")
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
@agr_app.command("ack")
|
|
667
|
+
@guard
|
|
668
|
+
def agr_ack(gid: str):
|
|
669
|
+
"""Acknowledge an agreement you are a party of."""
|
|
670
|
+
c, _ = client()
|
|
671
|
+
g = c.post(f"/agreements/{gid}/ack")
|
|
672
|
+
emit(g, f"{gid} acknowledged by {', '.join(g['acknowledged'])} / {', '.join(g['parties'])}")
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
@agr_app.command("list")
|
|
676
|
+
@guard
|
|
677
|
+
def agr_list(all_: bool = typer.Option(False, "--all", help="Include superseded.")):
|
|
678
|
+
"""Active agreements (constraints for proposals)."""
|
|
679
|
+
c, _ = client()
|
|
680
|
+
res = c.get("/agreements", all=all_)
|
|
681
|
+
lines = []
|
|
682
|
+
for g in res["agreements"]:
|
|
683
|
+
lines.append(f"{g['id']} [{g['status']}] {fmt.q(g['title'])} parties: {', '.join(g['parties'])} acked: {', '.join(g['acknowledged']) or '-'}")
|
|
684
|
+
lines.extend(" " + x for x in g["content"].strip().splitlines()[:6])
|
|
685
|
+
emit(res, "\n".join(lines) or "no agreements")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
# ---------------------------------------------------------------- misc
|
|
689
|
+
@perm_app.command("check")
|
|
690
|
+
@guard
|
|
691
|
+
def perm_check(repo: str, op: str = typer.Option("write", help="read|write")):
|
|
692
|
+
"""May this agent read/write that repo (agent id or path)? Advisory — the runtime enforces."""
|
|
693
|
+
c, _ = client()
|
|
694
|
+
res = c.get("/perm", repo=repo, op=op)
|
|
695
|
+
emit(res, f"{op} {repo}: {'allowed' if res['allowed'] else 'DENIED'} ({res['source']})")
|
|
696
|
+
if not res["allowed"]:
|
|
697
|
+
raise typer.Exit(4)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@app.command()
|
|
701
|
+
@guard
|
|
702
|
+
def resolve(ref: str):
|
|
703
|
+
"""Resolve an @ref to an absolute path."""
|
|
704
|
+
res = Client().get("/resolve", ref=ref)
|
|
705
|
+
if not res.get("known") or res.get("error"):
|
|
706
|
+
emit(res, res.get("error") or f"unknown repo in {ref}")
|
|
707
|
+
raise typer.Exit(5)
|
|
708
|
+
loc = res.get("abs_path") or res.get("root")
|
|
709
|
+
if res.get("line_start"):
|
|
710
|
+
loc += f":{res['line_start']}" + (f"-{res['line_end']}" if res.get("line_end") else "")
|
|
711
|
+
emit(res, loc + ("" if res.get("exists", True) else " (missing)"))
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
@app.command()
|
|
715
|
+
@guard
|
|
716
|
+
def search(query: str, scope: str = typer.Option("all", help="all|requests|artifacts|messages|agreements"), limit: int = 20):
|
|
717
|
+
"""Full-text search over requests, messages, artifacts, agreements."""
|
|
718
|
+
res = Client().get("/search", q=query, scope=scope, limit=limit)
|
|
719
|
+
emit(res, "\n".join(f"{r['entity_id']:<9} {r['entity_type']:<10} {fmt.q(r['title'], 50)} {fmt.one_line(r['snippet'], 80)}" for r in res["results"]) or "no matches")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
@app.command()
|
|
723
|
+
@guard
|
|
724
|
+
def export(handover: bool = typer.Option(False, help="Block for docs/HANDOVER.md."), req: Optional[str] = typer.Option(None, help="Export one thread as markdown.")):
|
|
725
|
+
"""Export for handover documents or archives."""
|
|
726
|
+
c, ses = client()
|
|
727
|
+
if req:
|
|
728
|
+
d = c.get(f"/requests/{req}", include="messages,artifacts")
|
|
729
|
+
typer.echo("# " + fmt.one_line(d["request"]["title"]) + "\n\n```\n" + fmt.request_view(d, None) + "\n```")
|
|
730
|
+
return
|
|
731
|
+
typer.echo(inst.handover_block(me_of(ses), c.get("/requests", box="all", limit=100)["requests"]))
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
@app.command()
|
|
735
|
+
def protocol(runtime: Optional[str] = typer.Option(None, help="generic|claude-code|codex|gemini|cursor"),
|
|
736
|
+
fmt_: str = typer.Option("text", "--format", help="text|json")):
|
|
737
|
+
"""Print the protocol card (the rules handed to agents at connect)."""
|
|
738
|
+
ses = find_session()
|
|
739
|
+
who = None
|
|
740
|
+
if ses:
|
|
741
|
+
try:
|
|
742
|
+
who = Client(session_token=ses["token"], autostart=False).get("/whoami")
|
|
743
|
+
except ClientError:
|
|
744
|
+
who = None
|
|
745
|
+
if fmt_ == "json":
|
|
746
|
+
cmds = []
|
|
747
|
+
|
|
748
|
+
def walk(t, prefix=""):
|
|
749
|
+
for cmd in t.registered_commands:
|
|
750
|
+
cmds.append({"command": (prefix + (cmd.name or cmd.callback.__name__)).strip(), "help": (cmd.callback.__doc__ or "").strip()})
|
|
751
|
+
for g in t.registered_groups:
|
|
752
|
+
walk(g.typer_instance, prefix + g.name + " ")
|
|
753
|
+
walk(app)
|
|
754
|
+
typer.echo(json.dumps({"protocol": PROTOCOL_VERSION, "core": cards.CORE, "commands": cmds}, ensure_ascii=False, indent=1))
|
|
755
|
+
else:
|
|
756
|
+
typer.echo(cards.card(who, runtime))
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
@app.command()
|
|
760
|
+
def install(runtime: str = typer.Option("claude-code", help="claude-code|codex|gemini|cursor|generic"),
|
|
761
|
+
dir_: Optional[Path] = typer.Option(None, "--dir", help="codex/gemini/cursor: project directory for the instruction file."),
|
|
762
|
+
hooks: bool = typer.Option(False, help="claude-code: merge hooks into ~/.claude/settings.json (backup is made)."),
|
|
763
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Do not ask for confirmation.")):
|
|
764
|
+
"""Install the runtime adapter: 3-line pointer, thin skill, hooks. Shows what it writes first."""
|
|
765
|
+
plan = inst.plan(runtime, dir_, hooks)
|
|
766
|
+
if plan:
|
|
767
|
+
typer.echo("repomoot install will write:")
|
|
768
|
+
for action, path, desc in plan:
|
|
769
|
+
typer.echo(f" {action:<12} {path} — {desc}")
|
|
770
|
+
if not yes and not typer.confirm("Proceed?", default=False):
|
|
771
|
+
raise typer.Exit(1)
|
|
772
|
+
who = None
|
|
773
|
+
ses = find_session()
|
|
774
|
+
if ses:
|
|
775
|
+
try:
|
|
776
|
+
who = Client(session_token=ses["token"], autostart=False).get("/whoami")
|
|
777
|
+
except ClientError:
|
|
778
|
+
pass
|
|
779
|
+
for line in inst.install(runtime, dir_, hooks, who):
|
|
780
|
+
typer.echo(line)
|
|
781
|
+
typer.echo("undo: repomoot uninstall --runtime " + runtime)
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@app.command()
|
|
785
|
+
def uninstall(runtime: str = typer.Option("claude-code"), dir_: Optional[Path] = typer.Option(None, "--dir")):
|
|
786
|
+
"""Remove what `repomoot install` wrote (pointer block, skill, hooks)."""
|
|
787
|
+
for line in inst.uninstall(runtime, dir_):
|
|
788
|
+
typer.echo(line)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
# ---------------------------------------------------------------- hooks (never fail, print little)
|
|
792
|
+
def _quiet(fn):
|
|
793
|
+
@functools.wraps(fn)
|
|
794
|
+
def w(*a, **k):
|
|
795
|
+
try:
|
|
796
|
+
try:
|
|
797
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
|
|
798
|
+
except Exception: # noqa: BLE001
|
|
799
|
+
pass
|
|
800
|
+
fn(*a, **k)
|
|
801
|
+
except Exception: # noqa: BLE001
|
|
802
|
+
pass
|
|
803
|
+
return w
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@hook_app.command("session-start")
|
|
807
|
+
@_quiet
|
|
808
|
+
def hook_session_start():
|
|
809
|
+
"""SessionStart: identity, protocol card, inbox summary — only in a connected directory."""
|
|
810
|
+
data = _hook_input()
|
|
811
|
+
if data.get("cwd"):
|
|
812
|
+
try:
|
|
813
|
+
os.chdir(data["cwd"])
|
|
814
|
+
except OSError:
|
|
815
|
+
pass
|
|
816
|
+
ses = find_session(exact=True)
|
|
817
|
+
if not ses:
|
|
818
|
+
# Auto-connect: a directory that was registered before reconnects on its own (new session, /clear, resume).
|
|
819
|
+
if not config.db_path().exists():
|
|
820
|
+
return
|
|
821
|
+
c = Client(autostart=True)
|
|
822
|
+
sug = c.get("/agent-for-cwd", cwd=str(Path.cwd())).get("agent")
|
|
823
|
+
if not sug:
|
|
824
|
+
typer.echo("[repomoot] This repository is not registered in Repomoot. The user can type `/repomoot connect` to register it (or `/repomoot po` for the product owner).")
|
|
825
|
+
return
|
|
826
|
+
rsid = runtime_session_id()
|
|
827
|
+
res = c.post("/sessions/connect", agent=sug, kind="project", cwd=str(Path.cwd()), name=rsid, runtime="claude-code",
|
|
828
|
+
runtime_ref=claude_session_name(), pid=os.getppid(), depends_on=[], description=None)
|
|
829
|
+
s_ = res["session"]
|
|
830
|
+
save_session(Path.cwd(), rsid, {"token": res["token"], "agent": s_["agent_id"], "session_id": s_["id"], "hook_cursor": res["cursor"]})
|
|
831
|
+
ses = find_session(exact=True)
|
|
832
|
+
typer.echo(f"[repomoot] Reconnected automatically as agent {s_['agent_id']} (session {s_['id']}). "
|
|
833
|
+
f"Call ListAgents and run `repomoot address \"NAME [ref]\"` with its `This session is …` line so others can wake you.")
|
|
834
|
+
c = Client(session_token=ses["token"], autostart=False)
|
|
835
|
+
who = c.get("/whoami")
|
|
836
|
+
ib = c.get("/inbox")
|
|
837
|
+
card = c.get("/protocol", runtime=who.get("runtime"))["card"]
|
|
838
|
+
typer.echo(f"[repomoot] Agent: {who['agent']} ({who['kind']}), session {who['session']}. PO: {'present' if who['po_present'] else 'absent'}.")
|
|
839
|
+
hint = _exe_hint()
|
|
840
|
+
if hint:
|
|
841
|
+
typer.echo(hint)
|
|
842
|
+
typer.echo(agents_block(c))
|
|
843
|
+
typer.echo(card.rstrip())
|
|
844
|
+
typer.echo(cards.UNTRUSTED_BANNER)
|
|
845
|
+
typer.echo("\n".join(fmt.inbox_view(ib, who["agent"]).splitlines()[:15]))
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def agents_block(c: Client) -> str:
|
|
849
|
+
"""Who is in the workspace: agent, repositories, online/offline — so the agent knows whom it can ask."""
|
|
850
|
+
try:
|
|
851
|
+
ag = c.get("/agents")["agents"]
|
|
852
|
+
except ClientError:
|
|
853
|
+
return ""
|
|
854
|
+
if not ag:
|
|
855
|
+
return "[repomoot] no other agents registered yet."
|
|
856
|
+
lines = ["[repomoot] Agents in this workspace (you can ask any of them or order work from them):"]
|
|
857
|
+
for a in ag:
|
|
858
|
+
repos = ", ".join(Path(r).name for r in a.get("repos") or []) or "-"
|
|
859
|
+
state = "online" if a["sessions"] else "offline (will read its inbox when it connects)"
|
|
860
|
+
kind = " — Product Owner" if a["kind"] == "product_owner" else ""
|
|
861
|
+
lines.append(f" {a['id']:<14} repo: {repos:<24} {state}{kind}")
|
|
862
|
+
return "\n".join(lines)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _exe_hint() -> str:
|
|
866
|
+
import shutil
|
|
867
|
+
if shutil.which("repomoot"):
|
|
868
|
+
return ""
|
|
869
|
+
return f'[repomoot] `repomoot` is not on PATH in this shell; call it as "{inst.aw_executable()}" (same arguments).'
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def claude_session_name() -> str | None:
|
|
873
|
+
"""Name of the current Claude Code session (the address for cross-session messages), if any."""
|
|
874
|
+
import shutil
|
|
875
|
+
import subprocess
|
|
876
|
+
sid = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
877
|
+
if not sid:
|
|
878
|
+
return None
|
|
879
|
+
exe = os.environ.get("CLAUDE_CODE_EXECPATH") or shutil.which("claude")
|
|
880
|
+
if not exe:
|
|
881
|
+
return None
|
|
882
|
+
try:
|
|
883
|
+
from ..client.http import no_window
|
|
884
|
+
out = subprocess.run([exe, "agents", "--json"], capture_output=True, text=True, timeout=8, stdin=subprocess.DEVNULL, **no_window()).stdout
|
|
885
|
+
for a in json.loads(out or "[]"):
|
|
886
|
+
if a.get("sessionId") == sid:
|
|
887
|
+
return a.get("name")
|
|
888
|
+
except Exception: # noqa: BLE001
|
|
889
|
+
return None
|
|
890
|
+
return None
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _slug(name: str) -> str:
|
|
894
|
+
import re as _re
|
|
895
|
+
s = _re.sub(r"[^a-z0-9_.-]+", "-", name.lower()).strip("-.")
|
|
896
|
+
return s[:40] or "agent"
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def _hook_input() -> dict:
|
|
900
|
+
"""Claude Code passes hook input as JSON on stdin (session_id, cwd, prompt, ...)."""
|
|
901
|
+
try:
|
|
902
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
903
|
+
return {}
|
|
904
|
+
raw = sys.stdin.read()
|
|
905
|
+
return json.loads(raw) if raw.strip() else {}
|
|
906
|
+
except Exception: # noqa: BLE001
|
|
907
|
+
return {}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _human_command(argv: list[str]) -> bool:
|
|
911
|
+
"""`/repomoot start|connect|po|depends|status|disconnect` typed by the user in chat.
|
|
912
|
+
|
|
913
|
+
The prompt hook fires only on human input, so this is the human principal: the hook performs
|
|
914
|
+
the action with the human token and prints the result for the agent to relay."""
|
|
915
|
+
if not argv:
|
|
916
|
+
return False
|
|
917
|
+
cmd, args = argv[0], argv[1:]
|
|
918
|
+
cwd = Path.cwd()
|
|
919
|
+
human = config.human_token()
|
|
920
|
+
out: list[str] = []
|
|
921
|
+
try:
|
|
922
|
+
if cmd == "start":
|
|
923
|
+
c = Client(human=human)
|
|
924
|
+
h = c.get("/health")
|
|
925
|
+
ag = c.get("/agents")["agents"]
|
|
926
|
+
out.append(f"[repomoot] workspace running at {c.base_url} (v{h['version']}); agents: " + (", ".join(a['id'] + ('*' if a['sessions'] else '') for a in ag) or "none yet") + ".")
|
|
927
|
+
out.append("Next: `/repomoot connect` in each project's session, `/repomoot po` in the product owner's session.")
|
|
928
|
+
elif cmd in ("connect", "po"):
|
|
929
|
+
name = None
|
|
930
|
+
deps: list[str] = []
|
|
931
|
+
kind = "po" if cmd == "po" else "project"
|
|
932
|
+
it = iter(args)
|
|
933
|
+
for a in it:
|
|
934
|
+
if a in ("--depends-on", "--depends", "-d"):
|
|
935
|
+
deps = csv(next(it, ""))
|
|
936
|
+
elif a == "--po":
|
|
937
|
+
kind = "po"
|
|
938
|
+
elif not a.startswith("-") and name is None:
|
|
939
|
+
name = a
|
|
940
|
+
name = _slug(name or ("po" if kind == "po" else cwd.name))
|
|
941
|
+
runtime = "claude-code"
|
|
942
|
+
c = Client(human=human)
|
|
943
|
+
rsid = runtime_session_id()
|
|
944
|
+
res = c.post("/sessions/connect", agent=name, kind=kind, cwd=str(cwd), name=rsid, runtime=runtime, runtime_ref=claude_session_name(),
|
|
945
|
+
pid=os.getppid(), depends_on=deps, description=None)
|
|
946
|
+
ses = res["session"]
|
|
947
|
+
save_session(cwd, rsid, {"token": res["token"], "agent": ses["agent_id"], "session_id": ses["id"], "hook_cursor": res["cursor"]})
|
|
948
|
+
who = res["who"]
|
|
949
|
+
out.append(f"[repomoot] connected by the user: this session is agent **{who['agent']}** ({who['kind']}), session {ses['id']}"
|
|
950
|
+
f"{(' (Claude session ' + ses['runtime_ref'] + ')') if ses.get('runtime_ref') else ''}. PO: {'present' if res['po_present'] else 'absent'}.")
|
|
951
|
+
if who["kind"] != "product_owner":
|
|
952
|
+
out.append(f"repos: {', '.join(who['repos'])} depends_on: {', '.join(who['depends_on']) or '-'} consumers: {', '.join(who['consumers']) or '-'}")
|
|
953
|
+
out.append("Do NOT run `repomoot connect` yourself — it is done. Tell the user in one line, then follow the card below.")
|
|
954
|
+
out.append("FIRST: call your ListAgents tool, take its line `This session is NAME [ref]` and run "
|
|
955
|
+
"`repomoot address \"NAME [ref]\"` — that is how other agents will wake you.")
|
|
956
|
+
out.append(agents_block(c))
|
|
957
|
+
out.append("")
|
|
958
|
+
out.append(res["card"].rstrip())
|
|
959
|
+
out.append(cards.UNTRUSTED_BANNER)
|
|
960
|
+
out.append(fmt.inbox_view(res["inbox"], who["agent"]))
|
|
961
|
+
elif cmd == "depends":
|
|
962
|
+
ses = find_session()
|
|
963
|
+
if not ses:
|
|
964
|
+
out.append("[repomoot] not connected here; `/repomoot connect` first.")
|
|
965
|
+
else:
|
|
966
|
+
res = Client(human=human).post(f"/agents/{ses['agent']}", depends_on=csv(" ".join(args).replace(" ", ",")))
|
|
967
|
+
out.append(f"[repomoot] {ses['agent']} now depends on: {', '.join(res['depends_on']) or '-'}. Tell the user; nothing else to do.")
|
|
968
|
+
elif cmd == "ui":
|
|
969
|
+
import webbrowser
|
|
970
|
+
c = Client(human=human)
|
|
971
|
+
c.get("/health")
|
|
972
|
+
url = f"{c.base_url}/ui?token={config.token()}"
|
|
973
|
+
webbrowser.open(url)
|
|
974
|
+
out.append(f"[repomoot] UI opened in the browser: {url} . Tell the user; nothing else to do.")
|
|
975
|
+
elif cmd == "status":
|
|
976
|
+
c = Client(autostart=False)
|
|
977
|
+
if not daemon_alive(c.base_url):
|
|
978
|
+
out.append("[repomoot] workspace is not running; `/repomoot start`.")
|
|
979
|
+
else:
|
|
980
|
+
ag = c.get("/agents")["agents"]
|
|
981
|
+
out.append("[repomoot] agents: " + (", ".join(f"{a['id']} ({', '.join(s.get('name') or s['id'] for s in a['sessions']) or 'offline'}, open→{a['open_requests_to']})" for a in ag) or "none") + ". Relay this to the user.")
|
|
982
|
+
elif cmd in ("disconnect", "stop"):
|
|
983
|
+
ses = find_session(exact=True)
|
|
984
|
+
if ses:
|
|
985
|
+
Client(session_token=ses["token"], autostart=False).post("/sessions/disconnect")
|
|
986
|
+
drop_session(ses)
|
|
987
|
+
out.append(f"[repomoot] session of {ses['agent']} disconnected. Tell the user; nothing else to do.")
|
|
988
|
+
else:
|
|
989
|
+
out.append("[repomoot] nothing connected in this directory.")
|
|
990
|
+
else:
|
|
991
|
+
return False
|
|
992
|
+
except ClientError as e:
|
|
993
|
+
out.append(f"[repomoot] {cmd} failed: {e} [{e.code}]" + (f" — {e.hint}" if e.hint else "") + ". Tell the user; do not retry yourself.")
|
|
994
|
+
hint = _exe_hint()
|
|
995
|
+
if hint:
|
|
996
|
+
out.append(hint)
|
|
997
|
+
typer.echo("\n".join(out))
|
|
998
|
+
return True
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
@hook_app.command("prompt")
|
|
1002
|
+
@_quiet
|
|
1003
|
+
def hook_prompt():
|
|
1004
|
+
"""UserPromptSubmit: handle `/repomoot start|connect|po|...` typed by the user; else one line per new event."""
|
|
1005
|
+
data = _hook_input()
|
|
1006
|
+
if data.get("cwd"):
|
|
1007
|
+
try:
|
|
1008
|
+
os.chdir(data["cwd"])
|
|
1009
|
+
except OSError:
|
|
1010
|
+
pass
|
|
1011
|
+
prompt = (data.get("prompt") or "").strip()
|
|
1012
|
+
if prompt.startswith("/repomoot"):
|
|
1013
|
+
argv = prompt.split()[1:]
|
|
1014
|
+
if argv and argv[0] in ("start", "connect", "po", "depends", "status", "disconnect", "stop", "ui") and _human_command(argv):
|
|
1015
|
+
return
|
|
1016
|
+
ses = find_session()
|
|
1017
|
+
if not ses:
|
|
1018
|
+
return
|
|
1019
|
+
c = Client(session_token=ses["token"], autostart=False)
|
|
1020
|
+
res = c.get("/events", since=int(ses.get("hook_cursor") or 0), limit=20)
|
|
1021
|
+
if res["events"]:
|
|
1022
|
+
update_session(ses, hook_cursor=res["cursor"])
|
|
1023
|
+
typer.echo(f"[repomoot] {len(res['events'])} new — run `repomoot inbox`. " + cards.UNTRUSTED_BANNER)
|
|
1024
|
+
for e in res["events"][:5]:
|
|
1025
|
+
typer.echo(" " + fmt.event_line(e))
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
@hook_app.command("stop")
|
|
1029
|
+
@_quiet
|
|
1030
|
+
def hook_stop():
|
|
1031
|
+
"""Stop: heartbeat + one line for the human about what this agent is waiting on / must handle."""
|
|
1032
|
+
ses = find_session()
|
|
1033
|
+
if not ses:
|
|
1034
|
+
return
|
|
1035
|
+
ib = Client(session_token=ses["token"], autostart=False).get("/inbox")
|
|
1036
|
+
if ib.get("blocking"):
|
|
1037
|
+
typer.echo(f"[repomoot] {len(ib['blocking'])} blocking request(s) await you: " + ", ".join(r["id"] for r in ib["blocking"]))
|
|
1038
|
+
waiting = [r for r in ib.get("waiting_on_others", []) if r.get("blocking")]
|
|
1039
|
+
if waiting:
|
|
1040
|
+
typer.echo("[repomoot] waiting on: " + ", ".join(f"{r['id']} ({r['to_agent']})" for r in waiting))
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
@hook_app.command("session-end")
|
|
1044
|
+
@_quiet
|
|
1045
|
+
def hook_session_end():
|
|
1046
|
+
"""SessionEnd: mark the session gone so PO presence and claims are accurate."""
|
|
1047
|
+
ses = find_session(exact=True)
|
|
1048
|
+
if not ses:
|
|
1049
|
+
return
|
|
1050
|
+
Client(session_token=ses["token"], autostart=False).post("/sessions/disconnect")
|
|
1051
|
+
drop_session(ses)
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
@hook_app.command("pre-compact")
|
|
1055
|
+
@_quiet
|
|
1056
|
+
def hook_pre_compact():
|
|
1057
|
+
"""PreCompact: refresh the Repomoot block in docs/HANDOVER.md (inside your repo only)."""
|
|
1058
|
+
ses = find_session()
|
|
1059
|
+
if not ses:
|
|
1060
|
+
return
|
|
1061
|
+
c = Client(session_token=ses["token"], autostart=False)
|
|
1062
|
+
who = c.get("/whoami")
|
|
1063
|
+
block = inst.handover_block(ses["agent"], c.get("/requests", box="all", limit=100)["requests"])
|
|
1064
|
+
f = inst.find_handover(Path.cwd(), who.get("repos") or [])
|
|
1065
|
+
if f:
|
|
1066
|
+
inst.write_handover(f, block)
|
|
1067
|
+
typer.echo(f"[repomoot] open requests written to {f}")
|
|
1068
|
+
else:
|
|
1069
|
+
typer.echo(block)
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
if __name__ == "__main__":
|
|
1073
|
+
app()
|