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/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()