stndp-cli 0.1.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.
stndp/main.py ADDED
@@ -0,0 +1,2969 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import importlib.metadata
5
+ import json
6
+ import os
7
+ import re
8
+ import secrets
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ import webbrowser
14
+ from datetime import date, timedelta
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.parse import quote
18
+
19
+ import httpx
20
+ import typer
21
+ from rich.console import Console
22
+ from rich.panel import Panel
23
+ from rich.table import Table
24
+
25
+ # v0.1.0
26
+ app = typer.Typer(
27
+ no_args_is_help=True,
28
+ help=(
29
+ "stndp — shared, durable memory for engineering teams and their AI agents.\n\n"
30
+ "New here? Run `stn guide` for the workflow playbook, `stn agent setup` to wire up "
31
+ "a coding agent, and `stn <command> --help` for any command."
32
+ ),
33
+ )
34
+ team_app = typer.Typer(no_args_is_help=True)
35
+ app.add_typer(team_app, name="team",
36
+ help="Manage teams: list, create, invite/remove members, accept invites.")
37
+ workspace_app = typer.Typer(no_args_is_help=True)
38
+ app.add_typer(workspace_app, name="workspace",
39
+ help="Switch between the workspaces you belong to (list, use, current).")
40
+ decision_app = typer.Typer(no_args_is_help=True)
41
+ app.add_typer(decision_app, name="decision",
42
+ help="Inspect and curate decisions: show, confirm pending, edit.")
43
+ episode_app = typer.Typer(no_args_is_help=True)
44
+ app.add_typer(episode_app, name="episode",
45
+ help="Episodic memory: open/close the arc of a unit of work (plan → done → result).")
46
+ bug_app = typer.Typer(no_args_is_help=True)
47
+ app.add_typer(bug_app, name="bug",
48
+ help="First-class bugs: record and curate symptom → cause → fix → where.")
49
+ ai_app = typer.Typer(no_args_is_help=True)
50
+ app.add_typer(ai_app, name="ai", help="Turn AI draft-assist on or off (opt-in, paid).")
51
+ hooks_app = typer.Typer(no_args_is_help=True)
52
+ app.add_typer(hooks_app, name="hooks",
53
+ help="Install/remove the git post-commit hook that captures context (stn glob).")
54
+ schedule_app = typer.Typer(no_args_is_help=True)
55
+ app.add_typer(schedule_app, name="schedule",
56
+ help="Manage standup / checkpoint / resume reminder schedules.")
57
+ graph_app = typer.Typer(no_args_is_help=True)
58
+ app.add_typer(graph_app, name="graph",
59
+ help="Query the context graph: a node's neighbors or a relation chain.")
60
+ agent_app = typer.Typer(no_args_is_help=True)
61
+ app.add_typer(agent_app, name="agent",
62
+ help="Set up coding agents (MCP server + rules + SessionStart hook).")
63
+
64
+ # Force UTF-8 on stdout/stderr so rich glyphs (e.g. the ⚙ agent-authored marker,
65
+ # em dashes, arrows) render instead of crashing with UnicodeEncodeError on a legacy
66
+ # Windows code page (cp1252) when output is piped or redirected. Must run before
67
+ # Console() so rich picks up the utf-8 stream encoding.
68
+ for _stream in (sys.stdout, sys.stderr):
69
+ try:
70
+ _stream.reconfigure(encoding="utf-8", errors="replace")
71
+ except (AttributeError, ValueError): # non-reconfigurable / already-wrapped stream
72
+ pass
73
+
74
+ # legacy_windows=False keeps rich off the Win32 console API (SetConsoleTextAttribute /
75
+ # cp1252 encode), which crashes on non-Latin-1 glyphs regardless of the stream
76
+ # encoding; it emits ANSI to the (now utf-8) stream instead. safe_box avoids a few
77
+ # box glyphs that some terminals can't encode. Together these make output robust on
78
+ # Windows whether piped or attached to a console.
79
+ console = Console(legacy_windows=False, safe_box=True)
80
+ CONFIG_PATH = Path.home() / ".config" / "stndp" / "config.json"
81
+ CACHE_ROOT = Path.home() / ".stndp"
82
+ # Endpoints resolve from the environment (see _resolve_urls): production by
83
+ # default, localhost when STNDP_DEV is set. The web app (Django) serves /auth/*;
84
+ # the API is mounted under /api on the same origin. Locally that single origin is
85
+ # the dev reverse proxy (docker-compose `proxy`, default :8080) — exactly the prod
86
+ # shape, so `STNDP_DEV=1` points at the same /api path the deployed CLI uses.
87
+ # `python dev.py up` writes these explicit URLs (honoring a custom proxy port) to
88
+ # ~/.config/stndp/.env, so STNDP_DEV is just the zero-config default-port shortcut.
89
+ PROD_API_URL = "https://stndp.io/api"
90
+ PROD_WEB_URL = "https://stndp.io"
91
+ # 127.0.0.1, not "localhost": on Windows "localhost" resolves to IPv6 ::1 first, and
92
+ # the dev proxy listens on IPv4 — so every request burns the ~10s connection timeout
93
+ # before falling back. Pinning IPv4 keeps local calls sub-second. (STNDP_API_URL /
94
+ # STNDP_WEB_URL still override for non-standard setups.)
95
+ DEV_API_URL = "http://127.0.0.1:8080/api"
96
+ DEV_WEB_URL = "http://127.0.0.1:8080"
97
+ DEFAULT_CONFIG = {
98
+ "token": None,
99
+ "email": None,
100
+ "user_id": None,
101
+ "team_id": None,
102
+ "handle": None,
103
+ # The workspace (org) the CLI acts in. Sent as the X-Stndp-Workspace header so the
104
+ # API scopes requests (e.g. which workspace a new team belongs to) to it. Unset →
105
+ # the server uses the user's current workspace. `stn workspace use` sets these.
106
+ "workspace_slug": None,
107
+ "workspace_id": None,
108
+ }
109
+
110
+
111
+ def _load_dotenv(path: Path) -> None:
112
+ """Load KEY=VALUE lines from a .env file into os.environ.
113
+
114
+ Mirrors the Django app's loader (standup/settings.py) so the CLI picks up the
115
+ same convention: a local .env points dev at localhost (STNDP_DEV=1 or explicit
116
+ STNDP_API_URL/STNDP_WEB_URL), while production relies on real env vars. Real
117
+ environment variables always win — these are only defaults (setdefault).
118
+ """
119
+ if not path.exists():
120
+ return
121
+ for raw in path.read_text(encoding="utf-8").splitlines():
122
+ line = raw.strip()
123
+ if not line or line.startswith("#") or "=" not in line:
124
+ continue
125
+ key, _, value = line.partition("=")
126
+ os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
127
+
128
+
129
+ # Load dev env files at startup from the per-user config dir ONLY. A project-local
130
+ # (CWD) .env is intentionally NOT auto-loaded: the CLI sends a bearer token on
131
+ # every API call (and runs from arbitrary repos, including a git post-commit hook),
132
+ # so a malicious repo could ship a .env pointing STNDP_API_URL at an attacker host
133
+ # and capture the token. Real environment variables still win (setdefault), so
134
+ # `STNDP_DEV=1 stn …` from your shell remains the way to target localhost.
135
+ _load_dotenv(CONFIG_PATH.parent / ".env")
136
+
137
+
138
+ def _resolve_urls() -> tuple[str, str]:
139
+ """API and web base URLs, resolved from the environment.
140
+
141
+ Production by default; localhost when STNDP_DEV is set. STNDP_API_URL and
142
+ STNDP_WEB_URL override either explicitly (e.g. non-standard dev ports).
143
+ """
144
+ dev = bool(os.environ.get("STNDP_DEV"))
145
+ api = os.environ.get("STNDP_API_URL") or (DEV_API_URL if dev else PROD_API_URL)
146
+ web = os.environ.get("STNDP_WEB_URL") or (DEV_WEB_URL if dev else PROD_WEB_URL)
147
+ return api, web
148
+
149
+
150
+ def _stdin_is_tty() -> bool:
151
+ """Whether stdin is an interactive terminal. Wrapped so command code can be
152
+ tested independently of however the test runner wires up stdin."""
153
+ try:
154
+ return sys.stdin.isatty()
155
+ except (AttributeError, ValueError):
156
+ return False
157
+
158
+
159
+ def _require(value: str | None, label: str, *, hint: str) -> str:
160
+ """Return `value`, or prompt for it interactively.
161
+
162
+ When there's no value AND no interactive input (a coding agent, a pipe, a git
163
+ hook), `typer.prompt` aborts on EOF with no explanation — an opaque failure for an
164
+ agent. Catch that and exit with an actionable message naming the argument to pass.
165
+ Interactive humans (and tests that pipe input) still get the normal prompt.
166
+ """
167
+ if value:
168
+ return value
169
+ try:
170
+ return typer.prompt(label)
171
+ except (EOFError, typer.Abort):
172
+ console.print(f"[red]{label} is required when not running interactively. {hint}[/red]")
173
+ raise typer.Exit(2) from None
174
+
175
+
176
+ VALID_SEVERITIES = ("low", "normal", "high", "critical")
177
+
178
+
179
+ def _validate_severity(severity: str | None) -> None:
180
+ """Reject an unknown severity client-side with a clear message, rather than
181
+ sending a typo (e.g. 'hgih') that silently loses the high+ immediate-relay
182
+ behavior the help text promises."""
183
+ if severity is not None and severity not in VALID_SEVERITIES:
184
+ console.print(
185
+ f"[red]Invalid severity {severity!r}. Choose one of: "
186
+ f"{', '.join(VALID_SEVERITIES)}.[/red]")
187
+ raise typer.Exit(2)
188
+
189
+
190
+ def _safe_filename(*parts: Any) -> str:
191
+ """A filesystem-safe name from server-supplied parts: strip anything but word
192
+ chars, dot and dash so a stray path separator (or drive prefix) in an API value
193
+ can't make an `export` write escape the output directory."""
194
+ raw = "-".join(str(p) for p in parts)
195
+ return re.sub(r"[^A-Za-z0-9._-]", "_", raw) or "item"
196
+
197
+
198
+ def _atomic_write_text(path: Path, text: str) -> None:
199
+ """Write text atomically (temp file in the same dir + os.replace) so a crash or
200
+ interrupt mid-write can't leave a truncated file that a later read chokes on."""
201
+ path.parent.mkdir(parents=True, exist_ok=True)
202
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
203
+ try:
204
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
205
+ handle.write(text)
206
+ os.replace(tmp, path)
207
+ except BaseException:
208
+ Path(tmp).unlink(missing_ok=True)
209
+ raise
210
+
211
+
212
+ def _version_callback(value: bool) -> None:
213
+ if value:
214
+ try:
215
+ ver = importlib.metadata.version("stndp-cli")
216
+ except importlib.metadata.PackageNotFoundError: # raw source run, not installed
217
+ ver = "unknown"
218
+ console.print(ver)
219
+ raise typer.Exit()
220
+
221
+
222
+ @app.callback()
223
+ def _root(
224
+ version: bool = typer.Option(
225
+ False, "--version", help="Show the stndp CLI version and exit.",
226
+ callback=_version_callback, is_eager=True,
227
+ ),
228
+ ) -> None:
229
+ # Root callback: hosts the global --version option. The app's help text is set
230
+ # on the Typer() constructor above, so this intentionally has no docstring.
231
+ ...
232
+
233
+
234
+ @app.command()
235
+ def init() -> None:
236
+ """Create a local CLI config file. Run `stn login` to authenticate."""
237
+ _save_config({})
238
+ console.print(f"config written to {CONFIG_PATH}")
239
+
240
+
241
+ def _poll_cli_login(config: dict[str, Any], session: str, verifier: str) -> dict[str, Any] | None:
242
+ """One quiet poll of cli-poll. Returns the JSON dict, or None on any transient
243
+ error — so a single network blip / 5xx during the polling window doesn't abort
244
+ the whole login; we keep trying until the overall timeout."""
245
+ url = f"{config['web_url'].rstrip('/')}/auth/cli-poll"
246
+ try:
247
+ resp = httpx.get(url, params={"session": session, "verifier": verifier}, timeout=10)
248
+ resp.raise_for_status()
249
+ return resp.json()
250
+ except (httpx.HTTPError, ValueError):
251
+ return None
252
+
253
+
254
+ @app.command()
255
+ def login() -> None:
256
+ """Authenticate by opening the browser and completing login on the website."""
257
+ config = _load_config()
258
+ # PKCE: send sha256(verifier) on start and present the verifier when polling, so
259
+ # possession of the browser-visible session id alone can never redeem the token.
260
+ # The server requires the challenge (a challenge-less start is rejected).
261
+ verifier = secrets.token_urlsafe(32)
262
+ challenge = hashlib.sha256(verifier.encode()).hexdigest()
263
+ response = _web_request("POST", "/auth/cli-start", config=config, json={"challenge": challenge})
264
+ session = response["session"]
265
+ # The browser authorize page shows this same code and asks the user to confirm it
266
+ # matches the one here — the check that stops a phished authorize link from minting
267
+ # a token for the wrong person. The server returns it; fall back to computing it
268
+ # locally (sha256(session)[:6], mirroring the server's _cli_user_code) so the code
269
+ # is always shown even against an older server.
270
+ user_code = response.get("user_code") or hashlib.sha256(session.encode()).hexdigest()[:6].upper()
271
+ next_path = quote(f"/auth/cli-complete?session={session}", safe='')
272
+ login_url = f"{config['web_url'].rstrip('/')}/auth/sign-in?next={next_path}"
273
+
274
+ # Show the match code in a box so it's impossible to miss: the browser authorize
275
+ # page shows the same code and asks the user to confirm it matches the one here —
276
+ # the check that stops a phished authorize link from approving the wrong person.
277
+ console.print()
278
+ console.print(Panel(f"[bold cyan]{user_code}[/bold cyan]",
279
+ title="authorize code",
280
+ subtitle="this must match the code on the browser page",
281
+ expand=False, padding=(0, 3)))
282
+ console.print("\nOpening your browser to approve this login…")
283
+ webbrowser.open(login_url)
284
+ console.print(f"[dim]If it doesn't open, visit:[/dim] {login_url}")
285
+
286
+ # A single in-place spinner instead of one "waiting..." line per poll — the
287
+ # status updates silently until the browser side completes (or we time out).
288
+ with console.status("Waiting for verification in your browser..."):
289
+ for _ in range(90):
290
+ time.sleep(2)
291
+ poll = _poll_cli_login(config, session, verifier)
292
+ if poll and poll.get("status") == "ready":
293
+ _save_auth_response(config, poll)
294
+ console.print(f"logged in as @{poll['user']['handle']}")
295
+ return
296
+
297
+ console.print("login timed out")
298
+ raise typer.Exit(1)
299
+
300
+
301
+ @app.command()
302
+ def profile(handle: str = typer.Option(..., "--handle")) -> None:
303
+ """Set display handle."""
304
+ config = _load_config()
305
+ response = _web_request("POST", "/auth/profile", config=config, auth=True, json={"handle": handle})
306
+ _save_auth_response(config, response)
307
+ console.print(f"handle set to @{response['user']['handle']}")
308
+
309
+
310
+ @app.command()
311
+ def whoami() -> None:
312
+ """Show local CLI identity."""
313
+ config = _load_config()
314
+ if not config.get("user_id"):
315
+ console.print("not logged in — run `stn login`")
316
+ console.print(f"api: {config['api_url']}")
317
+ console.print(f"web: {config['web_url']}")
318
+ return
319
+ console.print(f"@{config['handle']} user={config['user_id']} team={config['team_id']}")
320
+ if config.get("email"):
321
+ console.print(config["email"])
322
+ if config.get("workspace_slug"):
323
+ console.print(f"workspace: {config['workspace_slug']}")
324
+ console.print(f"api: {config['api_url']}")
325
+ console.print(f"web: {config['web_url']}")
326
+
327
+
328
+ @app.command()
329
+ def push(
330
+ team: str = typer.Argument(..., help="Team slug or name to post to (see `stn team list`)."),
331
+ content: str | None = typer.Argument(None),
332
+ blocked: bool | None = typer.Option(
333
+ None, "--blocked/--no-blocked",
334
+ help="Flag as a blocker; @mention who's blocking you inline in the text.",
335
+ ),
336
+ severity: str | None = typer.Option(
337
+ None, "--severity",
338
+ help="Blocker triage: low | normal | high | critical (high+ relays immediately).",
339
+ ),
340
+ auto: bool = typer.Option(
341
+ False, "--auto",
342
+ help="Draft from your local git activity; you edit & confirm before it posts.",
343
+ ),
344
+ from_checkpoint: int | None = typer.Option(
345
+ None, "--from", help="Promote a checkpoint id into this team update.",
346
+ ),
347
+ update: int | None = typer.Option(
348
+ None, "--update",
349
+ help="Edit an existing entry id (fix a typo or change its status).",
350
+ ),
351
+ replace: bool = typer.Option(
352
+ False, "--replace",
353
+ help="With --update: overwrite in place without saving a version (typos).",
354
+ ),
355
+ context: bool = typer.Option(
356
+ False, "--context",
357
+ help="Attach your current git snapshot (branch/commit) to the update.",
358
+ ),
359
+ ) -> None:
360
+ """Post today's update to a team — or edit one with --update.
361
+
362
+ @mention teammates inline (e.g. "blocked by @alice on x"); every @handle must
363
+ be a real user or the push is rejected. Add --blocked to mark it a blocker.
364
+ A normal --update keeps the prior text as a version; --replace discards it.
365
+ With --auto, an AI draft from your local git activity is offered to edit first.
366
+ """
367
+ _validate_severity(severity)
368
+ if from_checkpoint is not None and update is None and not content:
369
+ ck = _api_request("GET", f"/v1/checkpoints/{from_checkpoint}")
370
+ content = ck["focus"]
371
+ if auto and update is None and not content:
372
+ if not _stdin_is_tty():
373
+ # `typer.prompt(default=...)` returns the default WITHOUT confirmation
374
+ # when stdin isn't a TTY — which would auto-POST a machine-generated
375
+ # guess unreviewed. A human always approves: refuse to guess here.
376
+ console.print(
377
+ "[red]--auto needs an interactive terminal to edit & confirm the "
378
+ "AI draft. Pass explicit content (`stn push TEAM \"...\"`) when "
379
+ "running non-interactively.[/red]")
380
+ raise typer.Exit(2)
381
+ draft = _api_request("POST", "/v1/drafts",
382
+ json={"snapshot": capture_git(), "mode": "standup"})
383
+ content = typer.prompt("draft (edit & confirm)", default=draft["draft_text"])
384
+ text = _require(content, "Update", hint='Pass it as an argument: stn push TEAM "your update".')
385
+ if update is not None:
386
+ body: dict[str, Any] = {"content": text, "replace": replace}
387
+ if blocked is not None: # only touch status when explicitly set
388
+ body["blocked"] = blocked
389
+ entry = _api_request("PATCH", f"/v1/entries/{update}", json=body)
390
+ _write_cache(entry)
391
+ console.print(_pushed_line(entry, verb="updated"))
392
+ else:
393
+ # Stamp with the poster's *local* date — the server clock is UTC, so
394
+ # without this a late-night push would land on the previous day.
395
+ json_body: dict[str, Any] = {
396
+ "content": text, "blocked": bool(blocked), "date": date.today().isoformat(),
397
+ }
398
+ if severity:
399
+ json_body["severity"] = severity
400
+ if from_checkpoint is not None:
401
+ json_body["from_checkpoint"] = from_checkpoint
402
+ if context:
403
+ json_body["context"] = capture_git()
404
+ entry = _api_request("POST", "/v1/entries", params={"team": team}, json=json_body)
405
+ _write_cache(entry)
406
+ console.print(_pushed_line(entry, verb="posted"))
407
+
408
+
409
+ @app.command()
410
+ def block(
411
+ team: str = typer.Argument(..., help="Team slug or name."),
412
+ content: str | None = typer.Argument(None, help="What you're blocked on; @mention who can clear it."),
413
+ severity: str = typer.Option("normal", "--severity", help="low | normal | high | critical."),
414
+ ) -> None:
415
+ """Raise a blocker — an alias for `push --blocked`. high/critical relay immediately."""
416
+ _validate_severity(severity)
417
+ text = _require(content, "Blocked on", hint='Pass it as an argument: stn block TEAM "what blocks you".')
418
+ entry = _api_request(
419
+ "POST", "/v1/entries", params={"team": team},
420
+ json={"content": text, "blocked": True, "severity": severity,
421
+ "date": date.today().isoformat()},
422
+ )
423
+ _write_cache(entry)
424
+ console.print(_pushed_line(entry, verb="posted"))
425
+
426
+
427
+ @app.command()
428
+ def resolve(
429
+ entry_id: int,
430
+ note: str | None = typer.Argument(None, help="How the block was cleared (required)."),
431
+ blocker: str | None = typer.Option(
432
+ None, "--blocker", help="Admin: clear a specific blocker by @handle.",
433
+ ),
434
+ ) -> None:
435
+ """Clear a block with a note explaining how it was resolved.
436
+
437
+ You must be a named blocker (clears your own part) or a team admin (clears any
438
+ blocker via --blocker, or every remaining one at once). A block with several
439
+ blockers stays active until each has cleared their part.
440
+ """
441
+ text = _require(note, "Resolution note", hint="Pass it as an argument: stn resolve ID \"how it was cleared\".")
442
+ body: dict[str, Any] = {"content": text}
443
+ if blocker:
444
+ body["blocker"] = blocker.lstrip("@")
445
+ entry = _api_request("POST", f"/v1/entries/{entry_id}/resolve", json=body)
446
+ remaining = _unresolved_blockers(entry)
447
+ if remaining:
448
+ who = ", ".join(f"@{h}" for h in remaining)
449
+ console.print(f"resolved your part of entry {entry_id} — still blocked by {who}")
450
+ else:
451
+ console.print(f"resolved the block on entry {entry_id}")
452
+
453
+
454
+ @app.command()
455
+ def unresolve(
456
+ entry_id: int,
457
+ blocker: str | None = typer.Option(
458
+ None, "--blocker", help="Admin: re-open a specific blocker by @handle.",
459
+ ),
460
+ ) -> None:
461
+ """Re-open a block you previously resolved (drops its resolution note)."""
462
+ body: dict[str, Any] = {}
463
+ if blocker:
464
+ body["blocker"] = blocker.lstrip("@")
465
+ _api_request("POST", f"/v1/entries/{entry_id}/resolve",
466
+ params={"unresolve": True}, json=body)
467
+ console.print(f"re-opened the block on entry {entry_id}")
468
+
469
+
470
+ @app.command()
471
+ def versions(entry_id: int) -> None:
472
+ """Show an entry's edit history (its superseded versions, oldest first)."""
473
+ vs = _api_request("GET", f"/v1/entries/{entry_id}/versions")
474
+ if not vs:
475
+ console.print(f"entry {entry_id} has no prior versions")
476
+ return
477
+ table = Table(box=None)
478
+ table.add_column("v", justify="right")
479
+ table.add_column("saved")
480
+ table.add_column("content")
481
+ for v in vs:
482
+ table.add_row(str(v["version_num"]), v["created_at"][:19].replace("T", " "), v["content"])
483
+ console.print(table)
484
+
485
+
486
+ @app.command()
487
+ def log(
488
+ team: str = typer.Argument(..., help="Team slug or name."),
489
+ week: bool = False,
490
+ from_date: str | None = typer.Option(None, "--from"),
491
+ ) -> None:
492
+ """Show your entries in a team."""
493
+ start = date.today() - timedelta(days=6)
494
+ if week:
495
+ today = date.today()
496
+ start = today - timedelta(days=today.weekday())
497
+ if from_date:
498
+ start = _parse_date(from_date)
499
+ _print_entries(_api_request("GET", "/v1/entries", params={"team": team, "from": start.isoformat()}))
500
+
501
+
502
+ @app.command()
503
+ def status(team: str = typer.Argument(..., help="Team slug or name.")) -> None:
504
+ """Show today's entry in a team."""
505
+ today = date.today().isoformat()
506
+ entries = _api_request("GET", "/v1/entries", params={"team": team, "from": today, "to": today})
507
+ _print_entries(entries)
508
+
509
+
510
+ @app.command()
511
+ def edit(
512
+ entry_id: int,
513
+ content: str | None = typer.Argument(None),
514
+ replace: bool = typer.Option(
515
+ False, "--replace", help="Overwrite without saving a version (for typos).",
516
+ ),
517
+ ) -> None:
518
+ """Edit an entry. Keeps the prior text as a version unless --replace."""
519
+ text = _require(content, "Update", hint='Pass it as an argument: stn edit ID "new text".')
520
+ entry = _api_request("PATCH", f"/v1/entries/{entry_id}",
521
+ json={"content": text, "replace": replace})
522
+ _write_cache(entry)
523
+ console.print(f"updated entry {entry_id}")
524
+
525
+
526
+ @app.command()
527
+ def delete(entry_id: int) -> None:
528
+ """Delete an entry."""
529
+ _api_request("DELETE", f"/v1/entries/{entry_id}")
530
+ console.print(f"deleted entry {entry_id}")
531
+
532
+
533
+ @app.command()
534
+ def show(entry_id: int) -> None:
535
+ """Show a single entry by id, with its block resolutions if any."""
536
+ _print_feed([_api_request("GET", f"/v1/entries/{entry_id}")])
537
+
538
+
539
+ @app.command()
540
+ def inbox(
541
+ unresolved: bool = typer.Option(
542
+ False, "--unresolved", "-u",
543
+ help="Only blocks that name you and that you haven't cleared yet.",
544
+ ),
545
+ ) -> None:
546
+ """Show entries across your teams that @mention you (things put on you)."""
547
+ entries = _api_request("GET", "/v1/mentions", params={"unresolved": unresolved})
548
+ _print_feed(entries)
549
+
550
+
551
+ @team_app.command("feed")
552
+ def team_feed(
553
+ team: str = typer.Argument(..., help="Team slug or name."),
554
+ week: bool = False,
555
+ ) -> None:
556
+ """Show a team's feed, with each block's resolutions nested beneath it."""
557
+ entries = _api_request("GET", "/v1/team/feed", params={"team": team, "week": week})
558
+ _print_feed(entries)
559
+
560
+
561
+ @team_app.command("list")
562
+ def team_list() -> None:
563
+ """List all teams you're part of, with their members."""
564
+ teams = _api_request("GET", "/v1/teams")
565
+ if not teams:
566
+ console.print("no teams")
567
+ return
568
+ for team in teams:
569
+ owner = " (owner)" if team["is_owner"] else ""
570
+ console.print(f"[bold]~/{team['name']}[/bold]{owner} id={team['id']} slug={team['slug']}")
571
+ table = Table(box=None)
572
+ table.add_column("member")
573
+ table.add_column("email")
574
+ table.add_column("role")
575
+ table.add_column("status")
576
+ for member in team["members"]:
577
+ handle = f"@{member['handle']}" if member["handle"] else "—"
578
+ table.add_row(handle, member["email"], member["role"], member["status"])
579
+ console.print(table)
580
+ console.print("")
581
+
582
+
583
+ @team_app.command("use")
584
+ def team_use(team: str = typer.Argument(..., help="Team slug, name, or id to make current.")) -> None:
585
+ """Set your current team, so team-scoped commands no longer need --team.
586
+
587
+ Resolves a slug/name/id against the teams you're in and saves it locally; until
588
+ now the current team was only ever set at login. `stn whoami` shows the result.
589
+ """
590
+ teams = _api_request("GET", "/v1/teams") or []
591
+ key = team.strip().lstrip("~/").lower()
592
+ match = next(
593
+ (t for t in teams
594
+ if str(t["id"]) == key or str(t.get("slug", "")).lower() == key
595
+ or str(t.get("name", "")).lower() == key),
596
+ None,
597
+ )
598
+ if match is None:
599
+ names = ", ".join(t.get("slug") or str(t["id"]) for t in teams) or "(none)"
600
+ console.print(f"[red]not on a team matching {team!r}. Your teams: {names}[/red]")
601
+ raise typer.Exit(1)
602
+ config = _load_config()
603
+ config["team_id"] = match["id"]
604
+ config["team_slug"] = match.get("slug")
605
+ _save_config(config)
606
+ console.print(f"current team set to ~/{match['name']} (id={match['id']}, slug={match['slug']})")
607
+
608
+
609
+ @team_app.command("create")
610
+ def team_create(name: str = typer.Argument(...)) -> None:
611
+ """Create a team you own."""
612
+ team = _api_request("POST", "/v1/teams", json={"name": name})
613
+ console.print(f"created team ~/{team['name']} (id={team['id']}, slug={team['slug']})")
614
+
615
+
616
+ # ── workspaces (the org you act in) ───────────────────────────────────────────
617
+
618
+ @workspace_app.command("list")
619
+ def workspace_list() -> None:
620
+ """List the workspaces you belong to and your role in each."""
621
+ workspaces = _api_request("GET", "/v1/workspaces") or []
622
+ if not workspaces:
623
+ console.print("no workspaces")
624
+ return
625
+ table = Table(box=None)
626
+ table.add_column("")
627
+ table.add_column("workspace")
628
+ table.add_column("slug")
629
+ table.add_column("role")
630
+ for w in workspaces:
631
+ marker = "[green]●[/green]" if w["is_current"] else " "
632
+ table.add_row(marker, w["name"], w["slug"], w["role"])
633
+ console.print(table)
634
+ console.print("[dim]● = current. `stn workspace use <slug>` to switch.[/dim]")
635
+
636
+
637
+ @workspace_app.command("current")
638
+ def workspace_current() -> None:
639
+ """Show the workspace the CLI is acting in."""
640
+ config = _load_config()
641
+ slug = config.get("workspace_slug")
642
+ if slug:
643
+ console.print(f"workspace: {slug} (id={config.get('workspace_id')})")
644
+ else:
645
+ console.print("workspace: (server default — your current workspace)")
646
+
647
+
648
+ @workspace_app.command("use")
649
+ def workspace_use(
650
+ workspace: str = typer.Argument(..., help="Workspace slug, name, or id to switch to."),
651
+ ) -> None:
652
+ """Switch the workspace you act in (web + CLI follow). Resolves a slug/name/id
653
+ against the workspaces you belong to, sets it as your current workspace server-
654
+ side, and remembers it locally so requests carry it."""
655
+ workspaces = _api_request("GET", "/v1/workspaces") or []
656
+ key = workspace.strip().lower()
657
+ match = next(
658
+ (w for w in workspaces
659
+ if str(w["id"]) == key or str(w.get("slug", "")).lower() == key
660
+ or str(w.get("name", "")).lower() == key),
661
+ None,
662
+ )
663
+ if match is None:
664
+ names = ", ".join(w.get("slug") or str(w["id"]) for w in workspaces) or "(none)"
665
+ console.print(f"[red]no workspace matching {workspace!r}. Yours: {names}[/red]")
666
+ raise typer.Exit(1)
667
+ # Persist server-side (so every surface follows) and locally (for the header).
668
+ result = _api_request("POST", "/v1/workspaces/current", json={"workspace": match["slug"]})
669
+ config = _load_config()
670
+ config["workspace_slug"] = result["slug"]
671
+ config["workspace_id"] = result["id"]
672
+ _save_config(config)
673
+ console.print(f"current workspace set to {result['name']} "
674
+ f"(slug={result['slug']}, role={result['role']})")
675
+
676
+
677
+ @team_app.command("delete")
678
+ def team_delete(
679
+ team_id: int = typer.Argument(...),
680
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
681
+ ) -> None:
682
+ """Delete a team you own."""
683
+ if not yes and not typer.confirm(f"delete team {team_id}?"):
684
+ raise typer.Exit(0)
685
+ _api_request("DELETE", f"/v1/teams/{team_id}")
686
+ console.print(f"deleted team {team_id}")
687
+
688
+
689
+ @team_app.command("invite")
690
+ def team_invite(
691
+ email: str = typer.Argument(...),
692
+ team_id: int | None = typer.Option(None, "--team", help="Team id (defaults to current team)"),
693
+ ) -> None:
694
+ """Invite a user by email to a team you own."""
695
+ tid = team_id or _current_team()
696
+ result = _api_request("POST", f"/v1/teams/{tid}/invites", json={"email": email})
697
+ kind = "existing user" if result["registered"] else "new user"
698
+ note = "emailed" if result["notified"] else "pending in-app invite"
699
+ console.print(f"invited {result['email']} to team {tid} — {kind}, {note}")
700
+
701
+
702
+ @team_app.command("remove")
703
+ def team_remove(
704
+ email: str = typer.Argument(...),
705
+ team_id: int | None = typer.Option(None, "--team", help="Team id (defaults to current team)"),
706
+ ) -> None:
707
+ """Remove a user by email from a team you own."""
708
+ tid = team_id or _current_team()
709
+ _api_request("DELETE", f"/v1/teams/{tid}/members", params={"email": email})
710
+ console.print(f"removed {email} from team {tid}")
711
+
712
+
713
+ @team_app.command("invites")
714
+ def team_invites() -> None:
715
+ """List pending team invites you've received."""
716
+ invites = _api_request("GET", "/v1/invites")
717
+ if not invites:
718
+ console.print("no pending invites")
719
+ return
720
+ table = Table(box=None)
721
+ table.add_column("team")
722
+ table.add_column("slug")
723
+ table.add_column("id", justify="right")
724
+ table.add_column("role")
725
+ for invite in invites:
726
+ table.add_row(f"~/{invite['team_name']}", invite["team_slug"], str(invite["team_id"]), invite["role"])
727
+ console.print(table)
728
+ console.print("\naccept with `stn team accept <id>` or decline with `stn team decline <id>`")
729
+
730
+
731
+ @team_app.command("accept")
732
+ def team_accept(team_id: int = typer.Argument(..., help="Team id from `stn team invites`.")) -> None:
733
+ """Accept a pending invite and join the team."""
734
+ team = _api_request("POST", f"/v1/teams/{team_id}/accept")
735
+ console.print(f"joined team ~/{team['name']} (id={team['id']}, slug={team['slug']})")
736
+
737
+
738
+ @team_app.command("decline")
739
+ def team_decline(team_id: int = typer.Argument(..., help="Team id from `stn team invites`.")) -> None:
740
+ """Decline a pending invite."""
741
+ _api_request("POST", f"/v1/teams/{team_id}/decline")
742
+ console.print(f"declined the invite to team {team_id}")
743
+
744
+
745
+ @app.command()
746
+ def checkpoint(
747
+ text: str | None = typer.Argument(None, help="What you're working on."),
748
+ reason: str | None = typer.Option(None, "--reason", help="Why you're on this."),
749
+ next_: str | None = typer.Option(None, "--next", help="Next step (your resume hint)."),
750
+ team: str | None = typer.Option(None, "--team", help="Team this work is about (optional)."),
751
+ status: str = typer.Option("active", "--status", help="active | parked | done."),
752
+ no_context: bool = typer.Option(False, "--no-context", help="Skip local git capture."),
753
+ shell: bool = typer.Option(False, "--shell", help="Attach recent shell history (opt-in, redacted)."),
754
+ ai_session: str | None = typer.Option(None, "--ai-session", help="Path to an AI session log to attach."),
755
+ github: bool = typer.Option(False, "--github", help="Attach GitHub review/commit context via your local gh (network)."),
756
+ editor: bool = typer.Option(False, "--editor", help="Attach editor context (active/open files from a VSCode extension, plus recently-edited)."),
757
+ draft: bool = typer.Option(False, "--draft", help="AI-draft the focus from git; you edit & confirm."),
758
+ ) -> None:
759
+ """Save your private working state — with local git context — for `stn resume`.
760
+
761
+ Private to you: never posted to any team, no daily cap. Run it at a context
762
+ switch; reload it later with `stn resume`.
763
+ """
764
+ if draft and not text:
765
+ d = _api_request("POST", "/v1/drafts",
766
+ json={"snapshot": None if no_context else capture_git(), "mode": "checkpoint"})
767
+ text = typer.prompt("draft (edit & confirm)", default=d["draft_text"])
768
+ focus = _require(text, "Working on", hint='Pass it as an argument: stn checkpoint "what you are on".')
769
+ body: dict[str, Any] = {"focus": focus, "status": status, "kind": "state"}
770
+ if reason:
771
+ body["reason"] = reason
772
+ if next_:
773
+ body["next_step"] = next_
774
+ snap = _capture_context(no_context, shell, ai_session, github, editor)
775
+ if snap:
776
+ body["snapshot"] = snap
777
+ params: dict[str, Any] = {"team": team} if team else {}
778
+ ep_id = _open_episode_id()
779
+ if ep_id:
780
+ params["episode"] = ep_id
781
+ ckpt = _api_request("POST", "/v1/checkpoints", params=(params or None), json=body)
782
+ _mirror_checkpoint(ckpt)
783
+ snap = ckpt.get("snapshot")
784
+ where = f" on {snap['git_branch']}" if snap and snap.get("git_branch") else ""
785
+ console.print(f"checkpoint #{ckpt['id']} saved{where}")
786
+
787
+
788
+ @app.command()
789
+ def glob(
790
+ team: str | None = typer.Option(None, "--team", help="Team this work is about (optional)."),
791
+ no_github: bool = typer.Option(False, "--no-github", help="Skip the GitHub activity capture (no network)."),
792
+ force: bool = typer.Option(False, "--force", help="Capture even if nothing changed since the last glob."),
793
+ ) -> None:
794
+ """Capture this session's context once — git snapshot, repo, recent commits and your
795
+ GitHub activity — into the context graph.
796
+
797
+ Idempotent: re-running on the same commit/branch the same day is a no-op, so it's
798
+ safe to wire to session start / a post-login hook. Unlike `checkpoint` it carries no
799
+ human focus and never surfaces in `stn resume`.
800
+ """
801
+ res = _glob_capture(team=team, no_github=no_github, force=force)
802
+ if res["status"] == "not_a_repo":
803
+ console.print("not inside a git repo — nothing to glob")
804
+ elif res["status"] == "skipped":
805
+ console.print("context unchanged since the last glob — skipped (use --force to re-capture)")
806
+ else:
807
+ snid = res.get("snapshot_id")
808
+ console.print(f"globbed {res['repo']} @ {res.get('branch') or '—'}"
809
+ + (f" (snapshot #{snid})" if snid else ""))
810
+
811
+
812
+ def _glob_capture(team: str | None = None, no_github: bool = False,
813
+ force: bool = False) -> dict[str, Any]:
814
+ """Capture this session's git/GitHub context as a `kind='glob'` checkpoint, once.
815
+ Shared by the `glob` CLI command and the MCP `glob` tool. Idempotent per repo via
816
+ the local glob-state marker; returns a status dict (never prints)."""
817
+ snap = _capture_context(False, False, None, not no_github, False)
818
+ if not snap or not (snap.get("repo_name") or snap.get("git_remote_url")):
819
+ return {"status": "not_a_repo"}
820
+ key = snap.get("git_remote_url") or snap.get("repo_name") or "repo"
821
+ cur = {"sha": snap.get("git_commit_sha", ""), "branch": snap.get("git_branch", ""),
822
+ "dirty": bool(snap.get("git_dirty"))}
823
+ state = _glob_state_path(key)
824
+ if not force and _glob_seen(state, cur):
825
+ return {"status": "skipped", "reason": "unchanged"}
826
+ body = {
827
+ "focus": f"session context: {snap.get('repo_name') or key} @ {snap.get('git_branch') or '—'}",
828
+ "status": "active", "kind": "glob", "snapshot": snap,
829
+ }
830
+ params = {"team": team} if team else None
831
+ ckpt = _api_request("POST", "/v1/checkpoints", params=params, json=body)
832
+ _glob_record(state, cur)
833
+ return {"status": "captured", "repo": snap.get("repo_name") or key,
834
+ "branch": snap.get("git_branch"), "snapshot_id": (ckpt.get("snapshot") or {}).get("id")}
835
+
836
+
837
+ def _glob_state_path(key: str) -> Path:
838
+ """Per-repo local marker of the last globbed context — keyed by a hash of the repo
839
+ remote/name so the idempotency check never has to hit the network."""
840
+ return CACHE_ROOT / "glob" / f"{hashlib.sha256(key.encode()).hexdigest()[:16]}.json"
841
+
842
+
843
+ def _glob_seen(path: Path, cur: dict[str, Any]) -> bool:
844
+ """True when this exact (sha, branch, dirty) was already globbed today."""
845
+ try:
846
+ data = json.loads(path.read_text(encoding="utf-8"))
847
+ except (OSError, ValueError):
848
+ return False
849
+ return (data.get("sha") == cur["sha"] and data.get("branch") == cur["branch"]
850
+ and data.get("dirty") == cur["dirty"] and data.get("date") == date.today().isoformat())
851
+
852
+
853
+ def _glob_record(path: Path, cur: dict[str, Any]) -> None:
854
+ _atomic_write_text(path, json.dumps({**cur, "date": date.today().isoformat()}))
855
+
856
+
857
+ @app.command()
858
+ def resume(
859
+ team: str | None = typer.Option(None, "--team", help="Scope to one team."),
860
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
861
+ offline: bool = typer.Option(False, "--offline", help="Read the last local checkpoint mirror."),
862
+ github: bool = typer.Option(True, "--github/--no-github", help="Also show your GitHub review queue (gh)."),
863
+ ) -> None:
864
+ """Reload your working state: last checkpoint, the why, next step, open blockers.
865
+
866
+ If `gh` is installed, also shows PRs awaiting your review and resolves any
867
+ GitHub PR links — your live queue, surfaced where you reload context.
868
+ """
869
+ if offline:
870
+ data = _read_mirror()
871
+ else:
872
+ params = {"team": team} if team else None
873
+ data = _api_request("GET", "/v1/resume", params=params)
874
+ if github and gh_available():
875
+ data["review_queue"] = gh_review_queue()
876
+ _resolve_github_links(data.get("links") or [])
877
+ if json_out:
878
+ console.print(json.dumps(data, indent=2, default=str))
879
+ return
880
+ _render_resume(data, offline=offline)
881
+
882
+
883
+ @app.command()
884
+ def timeline(
885
+ team: str = typer.Argument(..., help="Team slug or name."),
886
+ days: int = typer.Option(14, "--days", help="How far back to look."),
887
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
888
+ ) -> None:
889
+ """A unified, time-ordered feed of your recent context in a team — your entries
890
+ and checkpoints plus the team's decisions, episodes and bugs, merged into one
891
+ timeline (the CLI view of the dashboard timeline). The merge happens server-side
892
+ (one request), so this stays fast no matter how much context a team accumulates."""
893
+ rows = _api_request("GET", "/v1/timeline", params={"team": team, "days": days}) or []
894
+ if json_out:
895
+ print(json.dumps(rows, indent=2))
896
+ return
897
+ if not rows:
898
+ console.print("(nothing in this window)")
899
+ return
900
+ for row in rows:
901
+ at, typ, text = row.get("at", ""), row.get("type", ""), row.get("text", "")
902
+ head = ((text or "").strip().splitlines() or [""])[0][:100]
903
+ console.print(f"[dim]{at[:10]}[/dim] [cyan]{typ:<14}[/cyan] {head}")
904
+
905
+
906
+ def _repo_from_url(url: str) -> str | None:
907
+ m = re.search(r"github\.com/([\w.-]+/[\w.-]+)/pull/", url or "")
908
+ return m.group(1) if m else None
909
+
910
+
911
+ def _resolve_github_links(links: list[dict[str, Any]]) -> None:
912
+ """Fill title/state on github PR links via the local gh (surface #4)."""
913
+ for lk in links:
914
+ if str(lk.get("provider", "")).startswith("github") and not lk.get("title"):
915
+ pr = gh_pr(lk.get("external_id", ""), _repo_from_url(lk.get("url", "")))
916
+ if pr:
917
+ lk["title"] = pr.get("title", "")
918
+ lk["state"] = pr.get("state", "")
919
+
920
+
921
+ @app.command()
922
+ def dump(
923
+ text: str | None = typer.Argument(None, help="A quick private note."),
924
+ show: bool = typer.Option(False, "--show", help="Show your recent notes."),
925
+ since: str | None = typer.Option(None, "--from", help="YYYY-MM-DD (with --show)."),
926
+ ) -> None:
927
+ """Jot a private note — a contextless checkpoint, visible only to you. Use it to
928
+ capture progress through the day, then frame your real `stn push` later."""
929
+ if show:
930
+ params: dict[str, Any] = {"kind": "note"}
931
+ if since:
932
+ params["since"] = _parse_date(since).isoformat()
933
+ items = _api_request("GET", "/v1/checkpoints", params=params)
934
+ if not items:
935
+ console.print("no notes")
936
+ return
937
+ for c in items:
938
+ when = str(c.get("created_at", ""))[:16].replace("T", " ")
939
+ console.print(f"[dim]{when}[/dim] {c['focus']}")
940
+ return
941
+ note = _require(text, "Note", hint='Pass it as an argument: stn dump "your note".')
942
+ c = _api_request("POST", "/v1/checkpoints", json={"focus": note, "kind": "note"})
943
+ console.print(f"noted (#{c['id']})")
944
+
945
+
946
+ @app.command()
947
+ def checkpoints(
948
+ team: str | None = typer.Option(None, "--team"),
949
+ since: str | None = typer.Option(None, "--since", help="YYYY-MM-DD"),
950
+ ) -> None:
951
+ """List your recent checkpoints (most recent first)."""
952
+ params: dict[str, Any] = {}
953
+ if team:
954
+ params["team"] = team
955
+ if since:
956
+ params["since"] = _parse_date(since).isoformat()
957
+ _print_checkpoints(_api_request("GET", "/v1/checkpoints", params=params or None))
958
+
959
+
960
+ @schedule_app.command("list")
961
+ def schedule_list(
962
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
963
+ ) -> None:
964
+ """Show the reminders that apply to you on a team: team defaults + your overrides."""
965
+ tid = team or str(_current_team())
966
+ rows = _api_request("GET", "/v1/schedules", params={"team": tid})
967
+ if not rows:
968
+ console.print("no reminders set")
969
+ return
970
+ table = Table(box=None, pad_edge=False)
971
+ for col in ("id", "kind", "at", "days", "tz", "scope", "on"):
972
+ table.add_column(col)
973
+ for r in rows:
974
+ table.add_row(str(r["id"]), r["kind"], r["at"], r["days"], r["tz"],
975
+ r["scope"], "yes" if r["enabled"] else "no")
976
+ console.print(table)
977
+
978
+
979
+ @schedule_app.command("set")
980
+ def schedule_set(
981
+ kind: str = typer.Argument(..., help="standup | blocker | checkpoint | resume"),
982
+ at: str = typer.Option(..., "--at", help="Local fire time, HH:MM (24h)."),
983
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
984
+ days: str = typer.Option("0,1,2,3,4", "--days", help="Weekday ints, Mon=0..Sun=6."),
985
+ tz: str = typer.Option("UTC", "--tz", help="IANA timezone for the fire time."),
986
+ team_wide: bool = typer.Option(False, "--team-wide", help="Set the team default (admin only)."),
987
+ ) -> None:
988
+ """Set (or update) a reminder. `checkpoint` nudges you at EOD to capture state;
989
+ `resume` nudges you in the morning to reload it."""
990
+ tid = team or str(_current_team())
991
+ body = {"kind": kind, "at": at, "days": days, "tz": tz,
992
+ "scope": "team" if team_wide else "user", "enabled": True}
993
+ s = _api_request("POST", "/v1/schedules", params={"team": tid}, json=body)
994
+ console.print(f"{s['kind']} reminder set for {s['at']} {s['tz']} ({s['scope']})")
995
+
996
+
997
+ def _toggle_schedule(team: str | None, kind: str, enabled: bool, team_wide: bool) -> None:
998
+ tid = team or str(_current_team())
999
+ scope = "team" if team_wide else "user"
1000
+ rows = _api_request("GET", "/v1/schedules", params={"team": tid})
1001
+ match = next((r for r in rows if r["kind"] == kind and r["scope"] == scope), None)
1002
+ if match is None:
1003
+ console.print(f"[red]no {scope} {kind} reminder to change[/red]")
1004
+ raise typer.Exit(1)
1005
+ _api_request("POST", "/v1/schedules", params={"team": tid},
1006
+ json={"kind": kind, "at": match["at"], "days": match["days"],
1007
+ "tz": match["tz"], "scope": scope, "enabled": enabled})
1008
+ console.print(f"{kind} reminder {'on' if enabled else 'off'}")
1009
+
1010
+
1011
+ @schedule_app.command("off")
1012
+ def schedule_off(
1013
+ kind: str = typer.Argument(..., help="standup | blocker | checkpoint | resume"),
1014
+ team: str | None = typer.Option(None, "--team"),
1015
+ team_wide: bool = typer.Option(False, "--team-wide"),
1016
+ ) -> None:
1017
+ """Pause a reminder without deleting it (keeps the time for later)."""
1018
+ _toggle_schedule(team, kind, False, team_wide)
1019
+
1020
+
1021
+ @schedule_app.command("on")
1022
+ def schedule_on(
1023
+ kind: str = typer.Argument(..., help="standup | blocker | checkpoint | resume"),
1024
+ team: str | None = typer.Option(None, "--team"),
1025
+ team_wide: bool = typer.Option(False, "--team-wide"),
1026
+ ) -> None:
1027
+ """Re-enable a paused reminder."""
1028
+ _toggle_schedule(team, kind, True, team_wide)
1029
+
1030
+
1031
+ @schedule_app.command("remove")
1032
+ def schedule_remove(
1033
+ schedule_id: int = typer.Argument(..., help="Reminder id (from `stn schedule list`)."),
1034
+ ) -> None:
1035
+ """Delete a reminder rule by id."""
1036
+ _api_request("DELETE", f"/v1/schedules/{schedule_id}")
1037
+ console.print("removed")
1038
+
1039
+
1040
+ @app.command()
1041
+ def decide(
1042
+ title: str = typer.Argument(..., help="The decision, in one line."),
1043
+ why: str | None = typer.Option(None, "--why", help="The rationale / body."),
1044
+ kind: str = typer.Option("decision", "--kind", help="decision | learning."),
1045
+ tag: list[str] = typer.Option([], "--tag", help="Tag (repeatable)."),
1046
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
1047
+ supersedes: int | None = typer.Option(None, "--supersedes", help="Decision id this revises."),
1048
+ ) -> None:
1049
+ """Log a durable, team-visible decision or learning — searchable later.
1050
+
1051
+ @mention teammates inline in the rationale; the title is the one-liner, the
1052
+ --why is the trade-offs. A --supersedes revises an earlier decision.
1053
+ """
1054
+ body = _require(why, "Why (rationale)", hint='Pass it with --why: stn decide "the choice" --why "the rationale".')
1055
+ tid = team or str(_current_team())
1056
+ payload: dict[str, Any] = {"title": title, "body": body, "kind": kind, "tags": list(tag)}
1057
+ if supersedes:
1058
+ payload["supersedes_id"] = supersedes
1059
+ params: dict[str, Any] = {"team": tid}
1060
+ ep_id = _open_episode_id()
1061
+ if ep_id:
1062
+ params["episode"] = ep_id
1063
+ d = _api_request("POST", "/v1/decisions", params=params, json=payload)
1064
+ console.print(f"logged decision #{d['id']}: {d['title']}")
1065
+ if d.get("nudge"):
1066
+ console.print(f"[yellow]nudge:[/yellow] {d['nudge']}")
1067
+
1068
+
1069
+ @app.command()
1070
+ def decisions(
1071
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
1072
+ tag: str | None = typer.Option(None, "--tag"),
1073
+ kind: str | None = typer.Option(None, "--kind"),
1074
+ mine: bool = typer.Option(False, "--mine"),
1075
+ agent: bool = typer.Option(False, "--agent", help="Only agent-authored decisions, for review."),
1076
+ pending: bool = typer.Option(False, "--pending", help="Only decisions awaiting confirmation."),
1077
+ ) -> None:
1078
+ """List a team's decisions (most recent first). Use --agent or --pending to
1079
+ review what your coding agents logged and confirm it with `stn decision confirm`."""
1080
+ tid = team or str(_current_team())
1081
+ params: dict[str, Any] = {"team": tid, "mine": mine, "agent": agent, "pending": pending}
1082
+ if tag:
1083
+ params["tag"] = tag
1084
+ if kind:
1085
+ params["kind"] = kind
1086
+ _print_decisions(_api_request("GET", "/v1/decisions", params=params))
1087
+
1088
+
1089
+ @decision_app.command("show")
1090
+ def decision_show(decision_id: int) -> None:
1091
+ """Show a single decision with its full rationale."""
1092
+ _print_decision_full(_api_request("GET", f"/v1/decisions/{decision_id}"))
1093
+
1094
+
1095
+ @decision_app.command("confirm")
1096
+ def decision_confirm(decision_id: int) -> None:
1097
+ """Confirm a pending (agent-authored or auto-extracted) decision — promote it
1098
+ from a suggestion into the team's record. See `stn decisions --pending`."""
1099
+ d = _api_request("POST", f"/v1/decisions/{decision_id}/confirm")
1100
+ console.print(f"confirmed decision #{d['id']}: {d['title']}")
1101
+
1102
+
1103
+ @decision_app.command("edit")
1104
+ def decision_edit(
1105
+ decision_id: int,
1106
+ body: str | None = typer.Argument(None, help="New rationale text."),
1107
+ title: str | None = typer.Option(None, "--title"),
1108
+ replace: bool = typer.Option(False, "--replace", help="Don't keep a version (typos)."),
1109
+ ) -> None:
1110
+ """Edit a decision; keeps the prior text as a version unless --replace."""
1111
+ payload: dict[str, Any] = {"replace": replace}
1112
+ if title is not None:
1113
+ payload["title"] = title
1114
+ if body is not None:
1115
+ payload["body"] = body
1116
+ if "body" not in payload and "title" not in payload:
1117
+ payload["body"] = typer.prompt("New rationale")
1118
+ d = _api_request("PATCH", f"/v1/decisions/{decision_id}", json=payload)
1119
+ console.print(f"updated decision #{d['id']}")
1120
+
1121
+
1122
+ # ── Episodes (the arc of a unit of work: plan → done → result) ───────────────
1123
+ @episode_app.command("start")
1124
+ def episode_start(
1125
+ title: str = typer.Argument(..., help="The unit of work, in one line."),
1126
+ plan: str | None = typer.Option(None, "--plan", help="What you intend to do (& why)."),
1127
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
1128
+ ) -> None:
1129
+ """Open an episode — the arc of one non-trivial unit of work (plan → done → result).
1130
+
1131
+ Reserved for genuine multi-step work whose plan→done delta is worth keeping; most
1132
+ work needs none — a lone decision or bug should be recorded standalone. Private
1133
+ while open; `stn episode close` promotes it to team-visible. Checkpoints/decisions
1134
+ /bugs recorded while it's open auto-attach to it."""
1135
+ tid = team or str(_current_team())
1136
+ payload: dict[str, Any] = {"title": title}
1137
+ if plan:
1138
+ payload["plan"] = plan
1139
+ ep = _api_request("POST", "/v1/episodes", params={"team": tid}, json=payload)
1140
+ _set_open_episode(ep)
1141
+ console.print(f"opened episode #{ep['id']}: {ep['title']} [dim](private until closed)[/dim]")
1142
+
1143
+
1144
+ @episode_app.command("close")
1145
+ def episode_close(
1146
+ episode_id: int,
1147
+ did: str | None = typer.Option(None, "--did", help="What actually happened."),
1148
+ result: str | None = typer.Option(None, "--result", help="The observable result."),
1149
+ surprise: str | None = typer.Option(
1150
+ None, "--surprise", help="What changed from the plan — the learning."),
1151
+ ) -> None:
1152
+ """Close an episode and promote it to team-visible: record outcome, result, and the
1153
+ surprise (the plan→done delta — the durable learning)."""
1154
+ payload: dict[str, Any] = {}
1155
+ if did is not None:
1156
+ payload["outcome"] = did
1157
+ if result is not None:
1158
+ payload["result"] = result
1159
+ if surprise is not None:
1160
+ payload["surprise"] = surprise
1161
+ ep = _api_request("POST", f"/v1/episodes/{episode_id}/close", json=payload)
1162
+ _clear_open_episode(episode_id)
1163
+ flag = " [yellow](outcome pending confirmation)[/yellow]" if ep.get("pending") else ""
1164
+ console.print(f"closed episode #{ep['id']} — now team-visible{flag}")
1165
+
1166
+
1167
+ @episode_app.command("abandon")
1168
+ def episode_abandon(episode_id: int) -> None:
1169
+ """Abandon an open episode — it stays private (it never became shared memory)."""
1170
+ ep = _api_request("POST", f"/v1/episodes/{episode_id}/abandon")
1171
+ _clear_open_episode(episode_id)
1172
+ console.print(f"abandoned episode #{ep['id']}")
1173
+
1174
+
1175
+ @episode_app.command("confirm")
1176
+ def episode_confirm(episode_id: int) -> None:
1177
+ """Confirm an episode's agent-asserted outcome/result (clears the pending flag)."""
1178
+ ep = _api_request("POST", f"/v1/episodes/{episode_id}/confirm")
1179
+ console.print(f"confirmed episode #{ep['id']}: {ep['title']}")
1180
+
1181
+
1182
+ @episode_app.command("show")
1183
+ def episode_show(episode_id: int) -> None:
1184
+ """Show one episode's full arc (plan → outcome → result → surprise)."""
1185
+ _print_episode_full(_api_request("GET", f"/v1/episodes/{episode_id}"))
1186
+
1187
+
1188
+ @app.command()
1189
+ def episodes(
1190
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
1191
+ open_: bool = typer.Option(False, "--open", help="Only open episodes."),
1192
+ mine: bool = typer.Option(False, "--mine"),
1193
+ pending: bool = typer.Option(False, "--pending", help="Only episodes awaiting confirmation."),
1194
+ ) -> None:
1195
+ """List a team's episodes (most recent first). Open ones are private to you until
1196
+ closed; closed ones are team-visible shared memory."""
1197
+ tid = team or str(_current_team())
1198
+ params: dict[str, Any] = {"team": tid, "mine": mine, "pending": pending}
1199
+ if open_:
1200
+ params["status"] = "open"
1201
+ _print_episodes(_api_request("GET", "/v1/episodes", params=params))
1202
+
1203
+
1204
+ # ── Bugs (first-class symptom → root cause → fix → where) ────────────────────
1205
+ @bug_app.command("add")
1206
+ def bug_add(
1207
+ symptom: str = typer.Argument(..., help="What went wrong (the observed symptom)."),
1208
+ cause: str | None = typer.Option(None, "--cause", help="Root cause."),
1209
+ fix: str | None = typer.Option(None, "--fix", help="How it was fixed."),
1210
+ where: str | None = typer.Option(None, "--where", help="File / commit / area."),
1211
+ episode: int | None = typer.Option(
1212
+ None, "--episode", help="Attach to this episode (defaults to the open one)."),
1213
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
1214
+ ) -> None:
1215
+ """Record a bug hit (and usually fixed) during the work — team-visible memory a
1216
+ future agent can search by {symptom, cause, fix, where}. Defaults to 'fixed' when
1217
+ a --fix is given, else 'open'."""
1218
+ tid = team or str(_current_team())
1219
+ payload: dict[str, Any] = {"symptom": symptom}
1220
+ if cause is not None:
1221
+ payload["root_cause"] = cause
1222
+ if fix is not None:
1223
+ payload["fix"] = fix
1224
+ if where is not None:
1225
+ payload["where_ref"] = where
1226
+ payload["episode_id"] = episode if episode is not None else _open_episode_id()
1227
+ b = _api_request("POST", "/v1/bugs", params={"team": tid}, json=payload)
1228
+ console.print(f"logged bug #{b['id']} [dim]({b['status']})[/dim]: {b['symptom'][:60]}")
1229
+
1230
+
1231
+ @bug_app.command("list")
1232
+ def bug_list(
1233
+ team: str | None = typer.Option(None, "--team", help="Team (defaults to current)."),
1234
+ open_: bool = typer.Option(False, "--open", help="Only open (unfixed) bugs."),
1235
+ pending: bool = typer.Option(False, "--pending", help="Only bugs awaiting confirmation."),
1236
+ episode: int | None = typer.Option(None, "--episode", help="Only bugs in this episode."),
1237
+ ) -> None:
1238
+ """List a team's bugs (most recent first)."""
1239
+ tid = team or str(_current_team())
1240
+ params: dict[str, Any] = {"team": tid, "pending": pending}
1241
+ if open_:
1242
+ params["status"] = "open"
1243
+ if episode is not None:
1244
+ params["episode"] = episode
1245
+ _print_bugs(_api_request("GET", "/v1/bugs", params=params))
1246
+
1247
+
1248
+ @bug_app.command("fix")
1249
+ def bug_fix(
1250
+ bug_id: int,
1251
+ fix: str | None = typer.Argument(None, help="How it was fixed."),
1252
+ cause: str | None = typer.Option(None, "--cause", help="Root cause."),
1253
+ ) -> None:
1254
+ """Mark a bug fixed, recording the fix (and optionally the root cause)."""
1255
+ payload: dict[str, Any] = {"status": "fixed"}
1256
+ if fix is not None:
1257
+ payload["fix"] = fix
1258
+ if cause is not None:
1259
+ payload["root_cause"] = cause
1260
+ b = _api_request("PATCH", f"/v1/bugs/{bug_id}", json=payload)
1261
+ console.print(f"fixed bug #{b['id']}")
1262
+
1263
+
1264
+ @bug_app.command("confirm")
1265
+ def bug_confirm(bug_id: int) -> None:
1266
+ """Confirm a bug's agent-asserted root cause/fix (clears the pending flag)."""
1267
+ b = _api_request("POST", f"/v1/bugs/{bug_id}/confirm")
1268
+ console.print(f"confirmed bug #{b['id']}")
1269
+
1270
+
1271
+ @bug_app.command("show")
1272
+ def bug_show(bug_id: int) -> None:
1273
+ """Show one bug in full (symptom → cause → fix → where)."""
1274
+ _print_bug_full(_api_request("GET", f"/v1/bugs/{bug_id}"))
1275
+
1276
+
1277
+ @app.command()
1278
+ def search(
1279
+ query: str = typer.Argument(..., help="What to find."),
1280
+ type_: str | None = typer.Option(
1281
+ None, "--type",
1282
+ help="Restrict to one kind: decision | entry | checkpoint | repo | workitem "
1283
+ "| file | user | team | … (any entity type the server can return).",
1284
+ ),
1285
+ team: str | None = typer.Option(None, "--team"),
1286
+ since: str | None = typer.Option(None, "--since", help="YYYY-MM-DD"),
1287
+ limit: int = typer.Option(20, "--limit"),
1288
+ semantic: bool = typer.Option(
1289
+ True, "--semantic/--keyword",
1290
+ help="Blend semantic ranking when available (--keyword for exact-match only).",
1291
+ ),
1292
+ ) -> None:
1293
+ """Search everything you can reach — decisions, entries, your checkpoints, and
1294
+ (when the server has it on) every other entity by identity: repos, work items,
1295
+ files, people, teams … — with the connected context (tickets/PRs, supersession)
1296
+ shown beneath each hit."""
1297
+ params: dict[str, Any] = {"q": query, "limit": limit, "semantic": semantic}
1298
+ if type_:
1299
+ params["type"] = type_
1300
+ if team:
1301
+ params["team"] = team
1302
+ if since:
1303
+ params["since"] = _parse_date(since).isoformat()
1304
+ _print_search(_api_request("GET", "/v1/search", params=params))
1305
+
1306
+
1307
+ @app.command()
1308
+ def brag(
1309
+ period: str = typer.Option("monthly", "--period", help="weekly | monthly | quarterly."),
1310
+ from_: str | None = typer.Option(None, "--from"),
1311
+ to: str | None = typer.Option(None, "--to"),
1312
+ team: str | None = typer.Option(None, "--team"),
1313
+ github: bool = typer.Option(True, "--github/--no-github", help="Include GitHub reviews + commits (gh)."),
1314
+ narrate: bool = typer.Option(
1315
+ False, "--narrate",
1316
+ help="Narrate your accomplishments into connective prose with AI (paid + `stn ai on`).",
1317
+ ),
1318
+ ) -> None:
1319
+ """Generate a brag doc (Markdown) of your work over a period — for 1:1s/reviews.
1320
+
1321
+ With `gh` installed, adds the PRs you reviewed and the commits you authored in
1322
+ the period — review work that's invisible to a commit-only brag. With --narrate,
1323
+ your own accomplishments are rewritten into a short prose summary by AI (gated to
1324
+ paid + opt-in; the draft is yours to edit, never auto-shared).
1325
+ """
1326
+ params: dict[str, Any] = {}
1327
+ if from_:
1328
+ params["from"] = _parse_date(from_).isoformat()
1329
+ else:
1330
+ days = {"weekly": 7, "monthly": 30, "quarterly": 90}.get(period, 30)
1331
+ params["from"] = (date.today() - timedelta(days=days)).isoformat()
1332
+ if to:
1333
+ params["to"] = _parse_date(to).isoformat()
1334
+ if team:
1335
+ params["team"] = team
1336
+ md = _brag_markdown(_api_request("GET", "/v1/brag", params=params))
1337
+ if github and gh_available():
1338
+ since = params["from"]
1339
+ md += "\n\n" + _brag_github_markdown(gh_reviewed(since=since), gh_my_commits(since=since))
1340
+ if narrate:
1341
+ draft = _api_request("POST", "/v1/drafts", json={"mode": "brag", "text": md})
1342
+ console.print(f"*(AI-narrated from your activity — review before sharing)*\n\n"
1343
+ f"{draft['draft_text']}")
1344
+ return
1345
+ console.print(md)
1346
+
1347
+
1348
+ @app.command()
1349
+ def token(
1350
+ name: str = typer.Argument(..., help="A label for the token."),
1351
+ scope: list[str] = typer.Option(["decisions:read", "entries:read"], "--scope",
1352
+ help="Read scope (repeatable)."),
1353
+ ) -> None:
1354
+ """Create a scoped API token for programmatic read access (shown once)."""
1355
+ t = _api_request("POST", "/v1/tokens", json={"name": name, "scopes": list(scope)})
1356
+ console.print(f"created token [bold]{t['name']}[/bold] — copy it now, it won't be shown again:")
1357
+ console.print(f" {t['token']}")
1358
+
1359
+
1360
+ @app.command()
1361
+ def tokens() -> None:
1362
+ """List your API tokens (prefixes only)."""
1363
+ items = _api_request("GET", "/v1/tokens")
1364
+ if not items:
1365
+ console.print("no API tokens")
1366
+ return
1367
+ table = Table(box=None)
1368
+ table.add_column("id", justify="right")
1369
+ table.add_column("name")
1370
+ table.add_column("prefix")
1371
+ table.add_column("scopes")
1372
+ table.add_column("revoked")
1373
+ for t in items:
1374
+ table.add_row(str(t["id"]), t["name"], t["prefix"] + "…",
1375
+ ",".join(t.get("scopes") or []), "yes" if t.get("revoked") else "")
1376
+ console.print(table)
1377
+
1378
+
1379
+ @ai_app.command("on")
1380
+ def ai_on() -> None:
1381
+ """Enable AI draft-assist for your account (off by default)."""
1382
+ _api_request("POST", "/v1/ai/optin", json={"enabled": True})
1383
+ console.print("AI draft-assist enabled — try `stn push --auto`")
1384
+
1385
+
1386
+ @ai_app.command("off")
1387
+ def ai_off() -> None:
1388
+ """Disable AI draft-assist for your account."""
1389
+ _api_request("POST", "/v1/ai/optin", json={"enabled": False})
1390
+ console.print("AI draft-assist disabled")
1391
+
1392
+
1393
+ @app.command()
1394
+ def link(
1395
+ object_id: int = typer.Argument(..., help="The entry / checkpoint / decision id."),
1396
+ ref: str = typer.Argument(..., help="Ticket key (PROJ-123), a PR url, or any url."),
1397
+ type_: str = typer.Option("entry", "--type", help="entry | checkpoint | decision."),
1398
+ ) -> None:
1399
+ """Attach an external object (ticket/PR/url) to an entry, checkpoint, or decision."""
1400
+ linked = _api_request("POST", "/v1/links",
1401
+ json={"object_type": type_, "object_id": object_id, "ref": ref})
1402
+ console.print(f"linked {linked['provider']}:{linked['external_id']} to {type_} #{object_id}")
1403
+
1404
+
1405
+ @app.command()
1406
+ def repos() -> None:
1407
+ """List the repos your work touches — the codebase axis of the context graph."""
1408
+ data = _api_request("GET", "/v1/repos")
1409
+ if not data:
1410
+ console.print("(no repos yet — capture work with git context: stn checkpoint)")
1411
+ return
1412
+ for r in data:
1413
+ console.print(f" #{r['id']} [bold]{r['full_name']}[/bold] [dim]{r['host']}[/dim]")
1414
+
1415
+
1416
+ @app.command()
1417
+ def repo(repo_id: int = typer.Argument(..., help="Repo id (from `stn repos`).")) -> None:
1418
+ """Show a repo: branches, the tracker projects it tracks (with mapping confidence),
1419
+ and the work connected to it."""
1420
+ data = _api_request("GET", f"/v1/repos/{repo_id}")
1421
+ console.print(f"[bold]{data['full_name']}[/bold] [dim]{data['canonical_url']}[/dim]")
1422
+ for p in data.get("projects", []):
1423
+ conf = p.get("mapping_confidence") or "?"
1424
+ console.print(f" ~ tracks {p['provider']}:{p['key'] or p['name']} ({conf})")
1425
+ if data.get("branches"):
1426
+ console.print(" branches: " + ", ".join(b["name"] for b in data["branches"][:20]))
1427
+ edges = [e for e in (data.get("neighbors") or {}).get("edges", [])
1428
+ if e["direction"] == "in" and e["rel"] == "in_repo"]
1429
+ for e in edges:
1430
+ console.print(f" - {e['node_type']} #{e['node_id']} {e.get('label', '')}")
1431
+
1432
+
1433
+ @app.command()
1434
+ def projects() -> None:
1435
+ """List the tracker projects (Jira/ClickUp/Monday/Linear/…) you can see."""
1436
+ data = _api_request("GET", "/v1/projects")
1437
+ if not data:
1438
+ console.print("(no tracker projects yet)")
1439
+ return
1440
+ for p in data:
1441
+ console.print(f" #{p['id']} [bold]{p['name'] or p['key']}[/bold] [dim]{p['provider']}[/dim]")
1442
+
1443
+
1444
+ @app.command()
1445
+ def project(
1446
+ project_id: int = typer.Argument(..., help="Project id (from `stn projects`)."),
1447
+ ) -> None:
1448
+ """Show a tracker project: its work items and the repos that track it."""
1449
+ data = _api_request("GET", f"/v1/projects/{project_id}")
1450
+ console.print(f"[bold]{data['name'] or data['key']}[/bold] [dim]{data['provider']}[/dim]")
1451
+ if data.get("repos"):
1452
+ console.print(" repos: " + ", ".join(r["full_name"] for r in data["repos"]))
1453
+ for w in data.get("work_items", []):
1454
+ state = f" ({w['state']})" if w.get("state") else ""
1455
+ console.print(f" - {w['external_id']}{state} {w.get('title', '')}")
1456
+
1457
+
1458
+ @app.command("files")
1459
+ def files(
1460
+ repo_id: int = typer.Argument(..., help="Repo id (from `stn repos`)."),
1461
+ prefix: str = typer.Option("", "--prefix", help="Directory to list ('' = repo root)."),
1462
+ ) -> None:
1463
+ """Browse a repo's file tree — sub-directories and files under a directory (Act V)."""
1464
+ data = _api_request("GET", "/v1/files", params={"repo": repo_id, "prefix": prefix})
1465
+ dirs, fs = data.get("directories", []), data.get("files", [])
1466
+ if not dirs and not fs:
1467
+ console.print("(no files indexed here yet — capture work with git context: stn checkpoint)")
1468
+ return
1469
+ for d in dirs:
1470
+ console.print(f" [bold]{d['path']}/[/bold]")
1471
+ for f in fs:
1472
+ console.print(f" #{f['id']} {f['path']}")
1473
+
1474
+
1475
+ @app.command("file-context")
1476
+ def file_context(
1477
+ path: str = typer.Argument(..., help="Repo-relative file/dir path, or path#symbol."),
1478
+ repo_id: int | None = typer.Option(None, "--repo", help="Repo id; omit to match any visible repo."),
1479
+ limit: int = typer.Option(20, "--limit", help="Max touchers to show."),
1480
+ ) -> None:
1481
+ """Show what's known about a file: decisions, who touched it and why, linked PRs/tickets."""
1482
+ params: dict[str, Any] = {"path": path, "limit": limit}
1483
+ if repo_id is not None:
1484
+ params["repo"] = repo_id
1485
+ data = _api_request("GET", "/v1/files/context", params=params)
1486
+ kind = "directory" if data.get("is_directory") else "file"
1487
+ sym = f"#{data['symbol']}" if data.get("symbol") else ""
1488
+ console.print(f"[bold]{data['path'] or '/'}{sym}[/bold] [dim]{kind}, "
1489
+ f"{data.get('file_count', 0)} file(s)[/dim]")
1490
+ items = data.get("items", [])
1491
+ if not items:
1492
+ console.print(" (no recorded context yet)")
1493
+ return
1494
+ for it in items:
1495
+ who = f"@{it['handle']}" if it.get("handle") else it["type"]
1496
+ s = f" [{it['symbol']}]" if it.get("symbol") else ""
1497
+ console.print(f" - {it['type']} #{it['id']}{s} [dim]{who}[/dim] {it.get('title', '')}")
1498
+ for e in (it.get("context") or {}).get("edges", []):
1499
+ if e["node_type"] in ("decision", "workitem"):
1500
+ console.print(f" → {e['rel']} {e['node_type']} #{e['node_id']} {e.get('label', '')}")
1501
+ for lk in (it.get("context") or {}).get("links", []):
1502
+ console.print(f" ~ {lk['provider']}:{lk['external_id']} {lk.get('title', '')}")
1503
+
1504
+
1505
+ @graph_app.command("neighbors")
1506
+ def graph_neighbors(
1507
+ type_: str = typer.Argument(..., help="entry | decision | checkpoint."),
1508
+ node_id: int = typer.Argument(..., help="The node id."),
1509
+ rel: str | None = typer.Option(None, "--rel", help="Filter to one relation."),
1510
+ ) -> None:
1511
+ """Show what's connected to a node — linked decisions/entries and external tickets/PRs."""
1512
+ params: dict[str, Any] = {"type": type_, "id": node_id}
1513
+ if rel:
1514
+ params["rel"] = rel
1515
+ data = _api_request("GET", "/v1/graph/neighbors", params=params)
1516
+ label = data.get("label") or ""
1517
+ console.print(f"[bold]{data['type']} #{data['id']}[/bold] {label}")
1518
+ if not data["edges"] and not data["links"]:
1519
+ console.print(" (no connections yet)")
1520
+ return
1521
+ for e in data["edges"]:
1522
+ arrow = "->" if e["direction"] == "out" else "<-"
1523
+ console.print(f" {arrow} {e['rel']} {e['node_type']} #{e['node_id']} {e.get('label', '')}")
1524
+ for lk in data["links"]:
1525
+ state = f" [{lk['state']}]" if lk.get("state") else ""
1526
+ console.print(f" ~ {lk['provider']}:{lk['external_id']}{state} {lk.get('title', '')}")
1527
+
1528
+
1529
+ @graph_app.command("path")
1530
+ def graph_path(
1531
+ type_: str = typer.Argument(..., help="entry | decision | checkpoint."),
1532
+ node_id: int = typer.Argument(..., help="The node id."),
1533
+ rel: str = typer.Option("supersedes", "--rel", help="Relation to follow."),
1534
+ depth: int = typer.Option(10, "--depth", help="Max hops to walk."),
1535
+ ) -> None:
1536
+ """Walk a chain of one relation (e.g. a decision's supersession history)."""
1537
+ data = _api_request("GET", "/v1/graph/path",
1538
+ params={"type": type_, "id": node_id, "rel": rel, "depth": depth})
1539
+ if not data:
1540
+ console.print("(no chain)")
1541
+ return
1542
+ for n in data:
1543
+ console.print(f" {n['depth']}. {n['type']} #{n['id']} {n.get('label', '')}")
1544
+
1545
+
1546
+ _HOOK_MARKER = "# stndp ambient capture"
1547
+
1548
+
1549
+ @hooks_app.command("install")
1550
+ def hooks_install() -> None:
1551
+ """Install a post-commit hook that captures each commit's context into the graph.
1552
+
1553
+ Runs `stn glob` after every commit — anchoring the commit/branch/repo to the
1554
+ context platform (the same capture `agent setup`'s SessionStart hook performs).
1555
+ `glob` is idempotent per commit, so it only writes new context. (Earlier versions
1556
+ wrote to a local-only log via `stn capture`, which never reached the server.)
1557
+ """
1558
+ git_dir = _git(["rev-parse", "--absolute-git-dir"], str(Path.cwd()))
1559
+ if not git_dir:
1560
+ console.print("not a git repository")
1561
+ raise typer.Exit(1)
1562
+ hooks_dir = Path(git_dir) / "hooks"
1563
+ hooks_dir.mkdir(parents=True, exist_ok=True)
1564
+ hook = hooks_dir / "post-commit"
1565
+ if hook.exists() and _HOOK_MARKER not in hook.read_text(encoding="utf-8", errors="replace"):
1566
+ console.print(f"a post-commit hook already exists at {hook}; not overwriting")
1567
+ raise typer.Exit(1)
1568
+ hook.write_text(
1569
+ f"#!/bin/sh\n{_HOOK_MARKER}\nstn glob --no-github >/dev/null 2>&1 || true\n",
1570
+ encoding="utf-8",
1571
+ )
1572
+ try:
1573
+ hook.chmod(0o755)
1574
+ except OSError:
1575
+ pass
1576
+ console.print(f"installed post-commit context capture (stn glob) at {hook}")
1577
+
1578
+
1579
+ @hooks_app.command("uninstall")
1580
+ def hooks_uninstall() -> None:
1581
+ """Remove the stndp post-commit hook."""
1582
+ git_dir = _git(["rev-parse", "--absolute-git-dir"], str(Path.cwd()))
1583
+ if not git_dir:
1584
+ console.print("not a git repository")
1585
+ raise typer.Exit(1)
1586
+ hook = Path(git_dir) / "hooks" / "post-commit"
1587
+ if hook.exists() and _HOOK_MARKER in hook.read_text(encoding="utf-8", errors="replace"):
1588
+ hook.unlink()
1589
+ console.print("removed ambient capture hook")
1590
+ else:
1591
+ console.print("no stndp hook installed")
1592
+
1593
+
1594
+ @app.command()
1595
+ def integrations() -> None:
1596
+ """List connected integrations (Jira/GitHub/…) reachable to you."""
1597
+ items = _api_request("GET", "/v1/integrations")
1598
+ if not items:
1599
+ console.print("no integrations connected — connect them on the dashboard")
1600
+ return
1601
+ table = Table(box=None)
1602
+ table.add_column("provider")
1603
+ table.add_column("name")
1604
+ table.add_column("scope")
1605
+ table.add_column("status")
1606
+ for i in items:
1607
+ table.add_row(i["provider"], i.get("display_name") or "—", i["scope"], i["status"])
1608
+ console.print(table)
1609
+
1610
+
1611
+ # Every dashboard surface the web app exposes, mapped to the `stn` command that shows
1612
+ # the same data (so every dashboard is reachable from the terminal) and its web path
1613
+ # (open with `stn dashboards --open <name>`). Grouped to mirror the dashboard's own nav.
1614
+ _DASHBOARDS: list[tuple[str, str, str, str, str]] = [
1615
+ # (group, name, web_path, cli_command, what it shows)
1616
+ ("the daily standup", "overview", "/dashboard/", "stn resume",
1617
+ "your working state: last checkpoint, the why, next step, open blockers"),
1618
+ ("the daily standup", "feed", "/dashboard/feed/", "stn team feed <team>",
1619
+ "the team's standup feed, with blocks and their resolutions"),
1620
+ ("the daily standup", "brief", "/dashboard/brief/", "stn team feed <team>",
1621
+ "the feed condensed to one line per person"),
1622
+ ("the daily standup", "wall", "/dashboard/wall/", "stn team feed <team>",
1623
+ "the feed as a card wall"),
1624
+ ("the daily standup", "tail", "/dashboard/tail/", "stn team feed <team>",
1625
+ "the feed as a newest-first activity tail"),
1626
+ ("the daily standup", "grid", "/dashboard/grid/", "stn team feed <team>",
1627
+ "the feed as a who×day grid"),
1628
+ ("the daily standup", "pulse", "/dashboard/pulse/", "stn team feed <team>",
1629
+ "team posting cadence at a glance"),
1630
+ ("the context platform", "search", "/dashboard/search/", "stn search <query>",
1631
+ "one search over decisions, standups and your checkpoints"),
1632
+ ("the context platform", "graph", "/dashboard/graph/", "stn graph neighbors <type> <id>",
1633
+ "the context graph — nodes and the edges between them"),
1634
+ ("the context platform", "decisions", "/dashboard/decisions/", "stn decisions",
1635
+ "the team's durable decisions and learnings"),
1636
+ ("the context platform", "repos", "/dashboard/repos/", "stn repos",
1637
+ "the repos your work touches"),
1638
+ ("the context platform", "projects", "/dashboard/projects/", "stn projects",
1639
+ "tracker projects (Jira/Linear/ClickUp/…) you can see"),
1640
+ ("the context platform", "timeline", "/dashboard/timeline/", "stn timeline <team>",
1641
+ "entries + decisions merged into one chronological thread"),
1642
+ ("the context platform", "integrations", "/dashboard/integrations/", "stn integrations",
1643
+ "connected integrations (GitHub/Slack/Jira/…)"),
1644
+ ("the context platform", "tokens", "/dashboard/tokens/", "stn tokens",
1645
+ "your scoped API tokens"),
1646
+ ("the context platform", "webhooks", "/dashboard/webhooks/", "",
1647
+ "inbound webhook endpoints (create/manage on the web)"),
1648
+ ("the teams", "teams", "/dashboard/settings/", "stn team list",
1649
+ "your teams and their members (the settings page)"),
1650
+ ]
1651
+
1652
+
1653
+ @app.command()
1654
+ def dashboards(
1655
+ open_: str | None = typer.Option(
1656
+ None, "--open", help="Open a dashboard by name in your browser (e.g. --open graph)."),
1657
+ web: bool = typer.Option(False, "--web", help="Also print the web URL for each dashboard."),
1658
+ ) -> None:
1659
+ """List every dashboard the web app exposes and the `stn` command that shows the
1660
+ same data from the terminal — so no dashboard is web-only-by-surprise. Use
1661
+ `--open <name>` to open one in your browser."""
1662
+ config = _load_config()
1663
+ base = config["web_url"].rstrip("/")
1664
+ if open_ is not None:
1665
+ match = next((d for d in _DASHBOARDS if d[1] == open_.strip().lower()), None)
1666
+ if match is None:
1667
+ names = ", ".join(d[1] for d in _DASHBOARDS)
1668
+ console.print(f"[red]unknown dashboard {open_!r}. Choose one of: {names}[/red]")
1669
+ raise typer.Exit(2)
1670
+ url = f"{base}{match[2]}"
1671
+ console.print(f"opening [cyan]{match[1]}[/cyan] → {url}")
1672
+ webbrowser.open(url)
1673
+ return
1674
+ last_group = None
1675
+ for group, name, path, cli_cmd, desc in _DASHBOARDS:
1676
+ if group != last_group:
1677
+ console.print(f"\n[bold]{group}[/bold]")
1678
+ last_group = group
1679
+ cli = cli_cmd if cli_cmd else "[dim](web only)[/dim]"
1680
+ suffix = f" [dim]{base}{path}[/dim]" if web else ""
1681
+ console.print(f" [cyan]{name:<13}[/cyan] {desc}")
1682
+ console.print(f" [dim]→[/dim] {cli}{suffix}")
1683
+ console.print("\n[dim]open any in your browser:[/dim] stn dashboards --open <name>")
1684
+
1685
+
1686
+ @app.command()
1687
+ def export(
1688
+ team: str | None = typer.Argument(None, help="Team slug or name."),
1689
+ output: Path = Path("stndp-export"),
1690
+ me: bool = typer.Option(False, "--me", help="Export your private checkpoints instead of team entries."),
1691
+ ) -> None:
1692
+ """Export your entries as Markdown — or, with --me, your private checkpoints."""
1693
+ output.mkdir(parents=True, exist_ok=True)
1694
+ if me:
1695
+ items = _api_request("GET", "/v1/checkpoints", params={"team": team} if team else None)
1696
+ for c in items:
1697
+ (output / f"checkpoint-{_safe_filename(c['id'])}.md").write_text(
1698
+ _checkpoint_markdown(c), encoding="utf-8")
1699
+ console.print(f"exported {len(items)} checkpoints to {output}")
1700
+ return
1701
+ if not team:
1702
+ console.print("pass a TEAM, or use --me to export your checkpoints")
1703
+ raise typer.Exit(1)
1704
+ entries = _api_request("GET", "/v1/entries", params={"team": team})
1705
+ for entry in entries:
1706
+ path = output / f"{_safe_filename(entry['date'], entry['entry_num'])}.md"
1707
+ path.write_text(_entry_markdown(entry), encoding="utf-8")
1708
+ console.print(f"exported {len(entries)} entries to {output}")
1709
+
1710
+
1711
+ @app.command()
1712
+ def mcp() -> None:
1713
+ """Run a local MCP server (stdio) exposing stndp to coding agents.
1714
+
1715
+ Add it to your agent once — e.g. .mcp.json: {"command": "stn", "args": ["mcp"]} —
1716
+ and the agent can resume, search, decide, and checkpoint your shared context.
1717
+ `stn agent setup` writes that config for you.
1718
+ """
1719
+ from stndp.mcp_server import serve
1720
+
1721
+ serve()
1722
+
1723
+
1724
+ _MCP_SERVER = {"command": "stn", "args": ["mcp"]}
1725
+ _RULES_MARKER = "## stndp — shared context"
1726
+ # Sentinel closing the managed block so `stn agent setup` can REFRESH it in place when
1727
+ # the rules change, instead of skipping (stale) or appending a duplicate.
1728
+ _RULES_END_MARKER = "<!-- stndp:end -->"
1729
+ # ── stndp usage guide — the single source of truth ────────────────────────────
1730
+ # This is the ONE place the "how to use stndp" workflow lives. `stn guide` prints it,
1731
+ # the MCP `guide` tool serves it, and the CLAUDE.md/AGENTS.md rules block is derived
1732
+ # from it (below) — so the CLI, the MCP server, and the rules files can never drift.
1733
+ GUIDE_INTRO = (
1734
+ "stndp is this project's shared memory layer — decisions, episodes (the arc of one "
1735
+ "unit of work), bugs, working state, blockers, and a context graph linking repos ↔ "
1736
+ "branches ↔ files ↔ commits ↔ tickets/PRs ↔ decisions. Use it by DEFAULT on every "
1737
+ "task: prefer what stndp already records over reasoning from scratch, and write back "
1738
+ "what you learn so the next agent/human inherits it. The same actions are available "
1739
+ "via the `stndp` MCP server and the `stn` CLI."
1740
+ )
1741
+
1742
+ # Ordered (key, title, body). CLI command names are used; the MCP tool names match
1743
+ # (file_context ↔ file-context, bug ↔ bug add). `stn guide <key>` prints one section.
1744
+ GUIDE_TOPICS: list[tuple[str, str, str]] = [
1745
+ ("start", "Start of every task — load context first", """\
1746
+ - `resume` — your last working state, any OPEN episode (the arc you were mid-flight on)
1747
+ and its plan, your open bugs, the why, the next step, and the open blockers on you.
1748
+ - `search <topic>` — BEFORE reasoning, proposing, or "starting fresh", check what was
1749
+ already decided, tried, or explicitly rejected (`search --type bug <symptom>` finds a
1750
+ bug someone already diagnosed). Don't re-derive what the graph knows.
1751
+ - `glob` captures this session's git/repo/commit/GitHub context into the graph; the
1752
+ SessionStart hook runs it automatically — run it yourself if you're not hooked up."""),
1753
+ ("files", "Before editing code — read the file's history", """\
1754
+ - `file-context <path>` (also `path#symbol`, or a directory) — the decisions, prior
1755
+ changes, who-touched-it-and-why, and linked PRs/tickets for that file. Do this before
1756
+ changing any non-trivial file so you don't quietly undo a deliberate choice."""),
1757
+ ("record", "While working — record at the right altitude", """\
1758
+ ONE ATOMIC CLAIM PER NODE. LINK, DON'T NARRATE. Don't fuse a decision + its design + an
1759
+ impl detail + the result into one record — that blob is unsearchable noise. Pick the
1760
+ smallest primitive that fits what you're recording:
1761
+ - `decide "<choice>" --why "<rationale>"` — when you PICK between alternatives or REJECT
1762
+ an approach (especially "why we did NOT do X"). Just the choice + why.
1763
+ - `bug add "<symptom>" --cause "…" --fix "…" --where "…"` — when you hit and fix a
1764
+ non-obvious bug. {symptom → cause → fix → where} is exactly what a future agent searches
1765
+ for; it's the single highest-value thing to record, so do it every time.
1766
+ - `episode start …` then `episode close` (with the **surprise** — what changed from plan
1767
+ to reality) — ONLY for a genuine multi-step arc whose plan→done delta is worth keeping.
1768
+ Checkpoints/decisions/bugs recorded while it's open auto-attach to it. MOST WORK NEEDS
1769
+ NO EPISODE — never wrap a lone decision, bug, or one-shot change in one.
1770
+ - `checkpoint "<what you're on>" --next "<next step>"` — a heartbeat at each pause, so
1771
+ state survives context loss and `resume` brings it back.
1772
+ - `link <id> <ref>` — attach a ticket/PR (PROJ-123 or a PR URL); mentioning a key/PR URL
1773
+ inline in any record auto-links it too. Raise blockers with `block`, clear with `resolve`."""),
1774
+ ("standup", "Posting the user's standup — draft, then ASK (never auto-post)", """\
1775
+ A standup update (`stn push`) is the user's own voice to their team — don't post one on
1776
+ your own initiative, and never auto-send one you generated. When asked to write their
1777
+ standup: gather the raw material first (`resume`, `brag`, the last `checkpoint`s/episodes,
1778
+ recent commits/GitHub activity), draft an update from it, SHOW the draft to the user, and
1779
+ run `stn push` only after they approve or edit it. The same goes for a `block` posted in
1780
+ their name. (Recording decisions, bugs, episodes and checkpoints as you work is the
1781
+ opposite — do that freely; agent-authored claims land `pending` for a human to confirm.)
1782
+
1783
+ Decisions/bugs/links/blocks are team-visible; checkpoints are private, and an episode is
1784
+ private while open and becomes team-visible when you close it. When in doubt, record it in
1785
+ stndp rather than only in your own context — unrecorded context is lost."""),
1786
+ ("discover", "Finding your way around stndp", """\
1787
+ - `stn guide [topic]` — this playbook (topics: start, files, record, standup, discover).
1788
+ - `stn --help` and `stn <command> --help` — every command, its flags and arguments.
1789
+ - `stn whoami` — who you're acting as and which team writes land under; `stn team use
1790
+ <team>` changes the current team so commands no longer need `--team`.
1791
+ - Over MCP: call the `guide` tool for this same playbook, and `whoami` to check identity."""),
1792
+ ]
1793
+ GUIDE_KEYS = [k for k, _, _ in GUIDE_TOPICS]
1794
+
1795
+
1796
+ def _guide_text(sections: list[tuple[str, str, str]], *, with_intro: bool) -> str:
1797
+ """Render guide sections to plain text — shared by `stn guide` and the MCP tool."""
1798
+ parts = [GUIDE_INTRO, ""] if with_intro else []
1799
+ for _, title, body in sections:
1800
+ parts += [title, body, ""]
1801
+ return "\n".join(parts).rstrip() + "\n"
1802
+
1803
+
1804
+ # The rules block is DERIVED from the guide: golden rules + a pointer to the live
1805
+ # `stn guide`, so an onboarded repo's CLAUDE.md/AGENTS.md stays a thin, stable summary
1806
+ # while the full, current workflow is always one `stn guide` away.
1807
+ _RULES_BLOCK = f"""\
1808
+ ## stndp — shared context (agents + humans)
1809
+
1810
+ {GUIDE_INTRO}
1811
+
1812
+ **The loop, every task:**
1813
+ 1. `resume` + `search <topic>` BEFORE reasoning — inherit what's already decided or tried.
1814
+ 2. `file-context <path>` before editing a non-trivial file.
1815
+ 3. Record as you go, one atomic claim per node: `decide … --why …`, `bug add …`,
1816
+ `checkpoint … --next …`; wrap a genuine multi-step arc in `episode start`/`episode close`.
1817
+ 4. NEVER auto-post the user's standup (`push`/`block`) — draft it, show it, and post only
1818
+ on their approval. Agent-authored decisions/bugs/episodes land `pending` for a human.
1819
+
1820
+ **Learn the workflow at runtime:** run `stn guide` for the full playbook (or
1821
+ `stn guide <topic>` for one of: {', '.join(GUIDE_KEYS)}), and `stn <command> --help` for
1822
+ any command. The same actions are on the `stndp` MCP server — call its `guide` tool.
1823
+ <!-- stndp:end -->
1824
+ """
1825
+
1826
+
1827
+ @app.command()
1828
+ def guide(
1829
+ topic: str | None = typer.Argument(
1830
+ None, help=f"One section: {', '.join(GUIDE_KEYS)}. Omit for the whole playbook."),
1831
+ json_out: bool = typer.Option(False, "--json", help="Machine-readable output for agents."),
1832
+ ) -> None:
1833
+ """How to use stndp: load context → record as you go → draft the standup.
1834
+
1835
+ The single source of truth for the workflow — the CLAUDE.md/AGENTS.md rules block
1836
+ and the MCP `guide` tool render this same content. New here? Start with `stn guide`,
1837
+ then `stn <command> --help` for any specific command.
1838
+ """
1839
+ sections = GUIDE_TOPICS
1840
+ if topic:
1841
+ key = topic.strip().lower()
1842
+ sections = [t for t in GUIDE_TOPICS if t[0] == key]
1843
+ if not sections:
1844
+ console.print(f"[red]unknown topic {topic!r}. Choose one of: {', '.join(GUIDE_KEYS)}[/red]")
1845
+ raise typer.Exit(2)
1846
+ if json_out:
1847
+ payload = {"intro": GUIDE_INTRO,
1848
+ "topics": [{"key": k, "title": ti, "body": b} for k, ti, b in sections]}
1849
+ # Plain stdout, NOT console.print: rich would word-wrap long lines and parse
1850
+ # `[...]` as markup, both of which corrupt machine-readable JSON.
1851
+ print(json.dumps(payload, indent=2))
1852
+ return
1853
+ console.print(_guide_text(sections, with_intro=not topic))
1854
+ if not topic:
1855
+ console.print("[dim]one section: stn guide <topic> · any command: stn <command> --help[/dim]")
1856
+
1857
+
1858
+ # The rules block lives in the file each agent reads natively. Claude Code → CLAUDE.md;
1859
+ # Cursor/Codex/Windsurf read AGENTS.md; Gemini CLI reads GEMINI.md. (`auto` dedupes, so a
1860
+ # project gets each file once.)
1861
+ _RULES_FILE = {"claude": "CLAUDE.md", "cursor": "AGENTS.md", "codex": "AGENTS.md",
1862
+ "gemini": "GEMINI.md", "windsurf": "AGENTS.md"}
1863
+
1864
+ # Targets whose MCP config uses the standard `{"mcpServers": {...}}` JSON shape. claude,
1865
+ # cursor, and gemini write a PROJECT-local file; windsurf a GLOBAL one (see below).
1866
+ _JSON_MCP_TARGETS = ("claude", "cursor", "gemini", "windsurf")
1867
+
1868
+ # Codex configures MCP servers in a TOML file (not JSON), global under ~/.codex
1869
+ # (overridable via CODEX_HOME). One table per server.
1870
+ _CODEX_MARKER = "[mcp_servers.stndp]"
1871
+ _CODEX_BLOCK = '[mcp_servers.stndp]\ncommand = "stn"\nargs = ["mcp"]\n'
1872
+
1873
+
1874
+ def _mcp_config_path(cwd: Path, target: str) -> Path | None:
1875
+ """Where each target reads its `mcpServers` JSON. Project-local for claude/cursor/
1876
+ gemini; global for windsurf (it has no per-project MCP config)."""
1877
+ if target == "claude":
1878
+ return cwd / ".mcp.json"
1879
+ if target == "cursor":
1880
+ return cwd / ".cursor" / "mcp.json"
1881
+ if target == "gemini": # Gemini CLI: project settings, `mcpServers` key.
1882
+ return cwd / ".gemini" / "settings.json"
1883
+ if target == "windsurf":
1884
+ return _windsurf_config_path()
1885
+ return None
1886
+
1887
+
1888
+ def _codex_config_path() -> Path:
1889
+ """Codex reads `~/.codex/config.toml` (overridable via CODEX_HOME)."""
1890
+ home = os.environ.get("CODEX_HOME") or str(Path.home() / ".codex")
1891
+ return Path(home) / "config.toml"
1892
+
1893
+
1894
+ def _home() -> Path:
1895
+ """The user's home dir — a seam so tests can redirect the global agent configs
1896
+ (Codex/Gemini/Windsurf) away from the real home without monkeypatching pathlib."""
1897
+ return Path.home()
1898
+
1899
+
1900
+ def _windsurf_config_path() -> Path:
1901
+ """Windsurf (Cascade) reads a single GLOBAL MCP config at
1902
+ `~/.codeium/windsurf/mcp_config.json` — the `{"mcpServers": {...}}` shape."""
1903
+ return _home() / ".codeium" / "windsurf" / "mcp_config.json"
1904
+
1905
+
1906
+ def _merge_mcp_config(path: Path) -> None:
1907
+ """Add the stndp server under `mcpServers`, preserving any existing config."""
1908
+ data: dict[str, Any] = {}
1909
+ if path.exists():
1910
+ try:
1911
+ data = json.loads(path.read_text(encoding="utf-8"))
1912
+ except json.JSONDecodeError:
1913
+ data = {}
1914
+ data.setdefault("mcpServers", {})["stndp"] = _MCP_SERVER
1915
+ path.parent.mkdir(parents=True, exist_ok=True)
1916
+ _atomic_write_text(path, json.dumps(data, indent=2) + "\n")
1917
+
1918
+
1919
+ def _write_codex_config(path: Path) -> bool:
1920
+ """Append the stndp server table to Codex's TOML config (idempotent by marker).
1921
+ A plain append, not a TOML rewrite — we don't pull a toml writer for one table,
1922
+ and appending never disturbs the user's existing config."""
1923
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
1924
+ if _CODEX_MARKER in existing:
1925
+ return False
1926
+ path.parent.mkdir(parents=True, exist_ok=True)
1927
+ _atomic_write_text(path, (existing.rstrip() + "\n\n" + _CODEX_BLOCK)
1928
+ if existing.strip() else _CODEX_BLOCK)
1929
+ return True
1930
+
1931
+
1932
+ def _write_rules_block(cwd: Path, filename: str) -> bool:
1933
+ """Write (or refresh) the managed stndp block in CLAUDE.md / AGENTS.md. Idempotent:
1934
+ a no-op when the current block is already present; an in-place replace when the rules
1935
+ have changed (so re-running `stn agent setup` upgrades an onboarded repo instead of
1936
+ appending a duplicate); otherwise appended, preserving any existing content."""
1937
+ path = cwd / filename
1938
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
1939
+ if _RULES_MARKER in existing:
1940
+ start = existing.index(_RULES_MARKER)
1941
+ end_tok = existing.find(_RULES_END_MARKER, start)
1942
+ # Replace the old block: between the markers if the end sentinel is present, else
1943
+ # from the start marker to EOF (a legacy block written before the sentinel).
1944
+ end = end_tok + len(_RULES_END_MARKER) if end_tok != -1 else len(existing)
1945
+ updated = existing[:start] + _RULES_BLOCK.rstrip() + existing[end:]
1946
+ if updated == existing:
1947
+ return False
1948
+ _atomic_write_text(path, updated)
1949
+ return True
1950
+ _atomic_write_text(path, (existing.rstrip() + "\n\n" + _RULES_BLOCK)
1951
+ if existing.strip() else _RULES_BLOCK)
1952
+ return True
1953
+
1954
+
1955
+ # Claude Code runs hooks from .claude/settings.json. A SessionStart hook that runs
1956
+ # `stn glob` makes every session capture git/repo/commit context into stndp with no
1957
+ # action from the agent — the deterministic half of autonomous capture (the model
1958
+ # can forget the rules; a hook can't).
1959
+ _GLOB_HOOK_CMD = "stn glob"
1960
+
1961
+
1962
+ def _write_claude_hooks(cwd: Path) -> bool:
1963
+ """Add a SessionStart → `stn glob` hook to .claude/settings.json, preserving any
1964
+ existing config. Idempotent: a no-op if the same hook is already wired."""
1965
+ path = cwd / ".claude" / "settings.json"
1966
+ data: dict[str, Any] = {}
1967
+ if path.exists():
1968
+ try:
1969
+ data = json.loads(path.read_text(encoding="utf-8"))
1970
+ except json.JSONDecodeError:
1971
+ data = {}
1972
+ session_start = data.setdefault("hooks", {}).setdefault("SessionStart", [])
1973
+ for group in session_start:
1974
+ if any(h.get("command") == _GLOB_HOOK_CMD for h in group.get("hooks", [])):
1975
+ return False
1976
+ session_start.append({"hooks": [{"type": "command", "command": _GLOB_HOOK_CMD}]})
1977
+ path.parent.mkdir(parents=True, exist_ok=True)
1978
+ _atomic_write_text(path, json.dumps(data, indent=2) + "\n")
1979
+ return True
1980
+
1981
+
1982
+ @agent_app.command("setup")
1983
+ def agent_setup(
1984
+ target: str = typer.Option(
1985
+ "auto", "--target",
1986
+ help="auto | claude | cursor | codex | gemini | windsurf."),
1987
+ rules: bool = typer.Option(True, "--rules/--no-rules",
1988
+ help="Also write a CLAUDE.md / AGENTS.md / GEMINI.md rules block."),
1989
+ hooks: bool = typer.Option(True, "--hooks/--no-hooks",
1990
+ help="Also wire a Claude Code SessionStart hook to `stn glob`."),
1991
+ ) -> None:
1992
+ """Wire up the stndp MCP server for your coding agent (one config line), a rules
1993
+ block, and a SessionStart hook so every session auto-captures context.
1994
+
1995
+ `auto` always configures Claude Code + Cursor (their config is project-local and
1996
+ harmless), and adds Codex / Gemini CLI / Windsurf when it detects you use them.
1997
+ """
1998
+ cwd = Path.cwd()
1999
+ if target == "auto":
2000
+ targets = ["claude", "cursor"]
2001
+ # Add the global-config agents only when detected, so we never drop a config
2002
+ # file for a tool the user doesn't run.
2003
+ if _codex_config_path().parent.exists():
2004
+ targets.append("codex")
2005
+ if (_home() / ".gemini").exists():
2006
+ targets.append("gemini")
2007
+ if _windsurf_config_path().parent.exists():
2008
+ targets.append("windsurf")
2009
+ else:
2010
+ targets = [target]
2011
+ written: list[str] = []
2012
+ rules_files: set[str] = set()
2013
+ for t in targets:
2014
+ if t == "codex":
2015
+ path = _codex_config_path()
2016
+ if _write_codex_config(path):
2017
+ written.append(str(path))
2018
+ elif t in _JSON_MCP_TARGETS:
2019
+ path = _mcp_config_path(cwd, t)
2020
+ _merge_mcp_config(path)
2021
+ # Project-local paths show relative; global ones (windsurf) show absolute.
2022
+ try:
2023
+ written.append(str(path.relative_to(cwd)).replace("\\", "/"))
2024
+ except ValueError:
2025
+ written.append(str(path))
2026
+ if t == "claude" and hooks and _write_claude_hooks(cwd):
2027
+ written.append(".claude/settings.json")
2028
+ else:
2029
+ console.print(f"unknown target: {t}")
2030
+ continue
2031
+ rules_files.add(_RULES_FILE.get(t, "AGENTS.md"))
2032
+ if rules:
2033
+ for fn in sorted(rules_files):
2034
+ if _write_rules_block(cwd, fn):
2035
+ written.append(fn)
2036
+ if written:
2037
+ console.print("configured " + ", ".join(written)
2038
+ + " — restart your agent to load the stndp tools")
2039
+ console.print("[dim]verify with `stn agent doctor`; learn the workflow with `stn guide`[/dim]")
2040
+ else:
2041
+ console.print("nothing to configure")
2042
+
2043
+
2044
+ @agent_app.command("doctor")
2045
+ def agent_doctor() -> None:
2046
+ """Verify this repo + machine are wired for agents: login, the MCP server, the rules
2047
+ block, the MCP config, and the SessionStart hook. Prints a checklist and exits
2048
+ non-zero if a critical piece (login or the MCP server) is missing."""
2049
+ cwd = Path.cwd()
2050
+ ok, bad, warn = "[green]✓[/green]", "[red]✗[/red]", "[yellow]•[/yellow]"
2051
+ critical_failed = False
2052
+
2053
+ config = _load_config()
2054
+ if config.get("token") and config.get("user_id"):
2055
+ console.print(f"{ok} logged in as @{config.get('handle')} (team={config.get('team_id')})")
2056
+ else:
2057
+ console.print(f"{bad} not logged in — run `stn login`")
2058
+ critical_failed = True
2059
+
2060
+ try:
2061
+ from stndp.mcp_server import handle_message
2062
+ init = handle_message({"jsonrpc": "2.0", "id": 1, "method": "initialize"})
2063
+ name = (((init or {}).get("result") or {}).get("serverInfo") or {}).get("name")
2064
+ if name == "stndp":
2065
+ tools = handle_message({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})
2066
+ n = len(((tools or {}).get("result") or {}).get("tools") or [])
2067
+ console.print(f"{ok} MCP server starts ({n} tools) — `stn mcp`")
2068
+ else:
2069
+ console.print(f"{bad} MCP server did not initialize")
2070
+ critical_failed = True
2071
+ except Exception as exc: # noqa: BLE001 — report any import/runtime failure as a check
2072
+ console.print(f"{bad} MCP server failed to start: {exc}")
2073
+ critical_failed = True
2074
+
2075
+ # Project-local JSON configs (relative) + global ones (absolute), all carrying our
2076
+ # server entry. Windsurf's path is global; codex is a TOML table.
2077
+ names: list[str] = []
2078
+ for t in ("claude", "cursor", "gemini"):
2079
+ p = _mcp_config_path(cwd, t)
2080
+ if p and p.exists() and "stndp" in p.read_text(encoding="utf-8", errors="replace"):
2081
+ names.append(str(p.relative_to(cwd)).replace("\\", "/"))
2082
+ wind = _windsurf_config_path()
2083
+ if wind.exists() and "stndp" in wind.read_text(encoding="utf-8", errors="replace"):
2084
+ names.append("~/.codeium/windsurf/mcp_config.json")
2085
+ codex = _codex_config_path()
2086
+ if codex.exists() and _CODEX_MARKER in codex.read_text(encoding="utf-8", errors="replace"):
2087
+ names.append("~/.codex/config.toml")
2088
+ if names:
2089
+ console.print(f"{ok} MCP config: {', '.join(names)}")
2090
+ else:
2091
+ console.print(f"{warn} no MCP config in this repo — run `stn agent setup`")
2092
+
2093
+ rules_here = [fn for fn in ("CLAUDE.md", "AGENTS.md", "GEMINI.md")
2094
+ if (cwd / fn).exists()
2095
+ and _RULES_MARKER in (cwd / fn).read_text(encoding="utf-8", errors="replace")]
2096
+ if rules_here:
2097
+ console.print(f"{ok} rules block: {', '.join(rules_here)}")
2098
+ else:
2099
+ console.print(f"{warn} no stndp rules block here — run `stn agent setup`")
2100
+
2101
+ settings = cwd / ".claude" / "settings.json"
2102
+ hook_ok = False
2103
+ if settings.exists():
2104
+ try:
2105
+ data = json.loads(settings.read_text(encoding="utf-8"))
2106
+ hook_ok = any(h.get("command") == _GLOB_HOOK_CMD
2107
+ for grp in data.get("hooks", {}).get("SessionStart", [])
2108
+ for h in grp.get("hooks", []))
2109
+ except (ValueError, OSError, AttributeError):
2110
+ hook_ok = False
2111
+ console.print(f"{ok} SessionStart hook -> stn glob" if hook_ok
2112
+ else f"{warn} no SessionStart hook (Claude Code) — `stn agent setup`")
2113
+
2114
+ if critical_failed:
2115
+ raise typer.Exit(1)
2116
+ console.print("\n[green]ready[/green] — agents can use stndp here. Learn the workflow: stn guide")
2117
+
2118
+
2119
+ _LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost")
2120
+
2121
+
2122
+ def _assert_token_transport_safe(url: str) -> None:
2123
+ """Refuse to attach the bearer token to a non-https host that isn't loopback.
2124
+
2125
+ STNDP_API_URL/STNDP_WEB_URL come from the environment with no scheme/host
2126
+ check, and the token is sent on every API call. A plain-http endpoint on a
2127
+ public host would leak the token in clear text (and an attacker-controlled
2128
+ one would simply capture it), so allow http ONLY for loopback (dev), and
2129
+ require https everywhere else.
2130
+ """
2131
+ from urllib.parse import urlsplit
2132
+
2133
+ parts = urlsplit(url)
2134
+ scheme = (parts.scheme or "").lower()
2135
+ host = (parts.hostname or "").lower()
2136
+ if scheme == "https":
2137
+ return
2138
+ if scheme == "http" and host in _LOOPBACK_HOSTS:
2139
+ return
2140
+ console.print(
2141
+ f"[red]refusing to send your auth token to {scheme or '?'}://{host or '?'}: "
2142
+ f"only https (or http on localhost) is allowed.[/red]")
2143
+ raise typer.Exit(1)
2144
+
2145
+
2146
+ def _api_request(method: str, path: str, **kwargs: Any) -> Any:
2147
+ config = _load_config()
2148
+ url = f"{config['api_url'].rstrip('/')}{path}"
2149
+ headers = _headers(config) | kwargs.get("headers", {})
2150
+ if "Authorization" in headers:
2151
+ _assert_token_transport_safe(url)
2152
+ kwargs["headers"] = headers
2153
+ return _request(method, url, **kwargs)
2154
+
2155
+
2156
+ def _web_request(method: str, path: str, *, config: dict[str, Any] | None = None, auth: bool = False, **kwargs: Any) -> Any:
2157
+ config = config or _load_config()
2158
+ url = f"{config['web_url'].rstrip('/')}{path}"
2159
+ if auth:
2160
+ headers = _headers(config) | kwargs.get("headers", {})
2161
+ if "Authorization" in headers:
2162
+ _assert_token_transport_safe(url)
2163
+ kwargs["headers"] = headers
2164
+ return _request(method, url, **kwargs)
2165
+
2166
+
2167
+ def _request(method: str, url: str, **kwargs: Any) -> Any:
2168
+ try:
2169
+ response = httpx.request(method, url, timeout=10, **kwargs)
2170
+ response.raise_for_status()
2171
+ except httpx.HTTPStatusError as exc:
2172
+ # Surface the API's friendly `detail` (e.g. unknown-handle message) plainly.
2173
+ detail = exc.response.text
2174
+ try:
2175
+ detail = exc.response.json().get("detail", detail)
2176
+ except (ValueError, AttributeError):
2177
+ # non-JSON body, or JSON that isn't an object — keep the raw text.
2178
+ pass
2179
+ console.print(f"[red]{detail}[/red]")
2180
+ raise typer.Exit(1) from exc
2181
+ except httpx.HTTPError as exc:
2182
+ console.print(f"API request failed: {exc}")
2183
+ raise typer.Exit(1) from exc
2184
+ if response.status_code == 204:
2185
+ return None
2186
+ try:
2187
+ return response.json()
2188
+ except ValueError:
2189
+ console.print("[red]Unexpected non-JSON response from the API.[/red]")
2190
+ raise typer.Exit(1) from None
2191
+
2192
+
2193
+ def _load_config() -> dict[str, Any]:
2194
+ config = DEFAULT_CONFIG.copy()
2195
+ if CONFIG_PATH.exists():
2196
+ try:
2197
+ data = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
2198
+ except (ValueError, OSError):
2199
+ console.print("[yellow]Config is unreadable — run `stn login` to recreate it.[/yellow]")
2200
+ data = {}
2201
+ if isinstance(data, dict):
2202
+ config |= data
2203
+ # api_url/web_url are always environment-derived, never read from disk.
2204
+ config["api_url"], config["web_url"] = _resolve_urls()
2205
+ return config
2206
+
2207
+
2208
+ def _save_config(config: dict[str, Any]) -> None:
2209
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
2210
+ # Endpoints are resolved from the environment, so never persist them.
2211
+ config = {k: v for k, v in config.items() if k not in ("api_url", "web_url")}
2212
+ if config.get("handle"):
2213
+ config["handle"] = str(config["handle"]).lower()
2214
+ data = json.dumps(DEFAULT_CONFIG | config, indent=2)
2215
+ # Write atomically with owner-only perms from creation: the file holds a bearer
2216
+ # token, so it must never exist world-readable, even briefly between write and
2217
+ # chmod. mkstemp creates the temp file mode 0o600 (POSIX); os.replace swaps it
2218
+ # in atomically so a crash mid-write can't truncate the existing config.
2219
+ fd, tmp = tempfile.mkstemp(dir=str(CONFIG_PATH.parent), prefix=".config-", suffix=".tmp")
2220
+ try:
2221
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
2222
+ handle.write(data)
2223
+ try:
2224
+ os.chmod(tmp, 0o600)
2225
+ except OSError:
2226
+ pass
2227
+ os.replace(tmp, CONFIG_PATH)
2228
+ except BaseException:
2229
+ Path(tmp).unlink(missing_ok=True)
2230
+ raise
2231
+ # Belt-and-suspenders on the final path. NOTE: on Windows chmod only toggles the
2232
+ # read-only bit, not a real ACL — the token's protection there is the user's
2233
+ # profile directory permissions, not this call.
2234
+ try:
2235
+ CONFIG_PATH.chmod(0o600)
2236
+ except OSError:
2237
+ pass
2238
+
2239
+
2240
+ def _save_auth_response(config: dict[str, Any], response: dict[str, Any]) -> None:
2241
+ user = response["user"]
2242
+ team = response.get("team")
2243
+ config.update(
2244
+ {
2245
+ "token": response["token"],
2246
+ "email": user["email"],
2247
+ "user_id": user["id"],
2248
+ "team_id": team["id"] if team else None,
2249
+ "handle": user["handle"],
2250
+ "team_slug": team["slug"] if team else None,
2251
+ }
2252
+ )
2253
+ _save_config(config)
2254
+
2255
+
2256
+ def _current_team() -> int:
2257
+ config = _load_config()
2258
+ team_id = config.get("team_id")
2259
+ if not team_id:
2260
+ console.print("no current team set; pass --team TEAM_ID")
2261
+ raise typer.Exit(1)
2262
+ return int(team_id)
2263
+
2264
+
2265
+ def _headers(config: dict[str, Any]) -> dict[str, str]:
2266
+ token = config.get("token")
2267
+ if not token:
2268
+ console.print("not logged in; run stn login")
2269
+ raise typer.Exit(1)
2270
+ headers = {"Authorization": f"Bearer {token}"}
2271
+ # Scope requests to the selected workspace (the API validates membership and
2272
+ # falls back to the user's current workspace when absent).
2273
+ if slug := config.get("workspace_slug"):
2274
+ headers["X-Stndp-Workspace"] = str(slug)
2275
+ return headers
2276
+
2277
+
2278
+ def _write_cache(entry: dict[str, Any]) -> None:
2279
+ date = str(entry.get("date") or "")
2280
+ # Validate the shape before using it in a path: this both prevents a malformed
2281
+ # date from crashing the command *after* a successful push (the cache is just
2282
+ # local bookkeeping) and stops a stray path separator from escaping CACHE_ROOT.
2283
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
2284
+ if date:
2285
+ console.print(f"[yellow]skipping local cache: unexpected date {date!r}[/yellow]")
2286
+ return
2287
+ year, month = date[:4], date[5:7]
2288
+ try:
2289
+ path = CACHE_ROOT / year / month / f"{date}.md"
2290
+ path.parent.mkdir(parents=True, exist_ok=True)
2291
+ existing = path.read_text(encoding="utf-8") if path.exists() else ""
2292
+ path.write_text((existing + "\n\n" if existing else "") + _entry_markdown(entry),
2293
+ encoding="utf-8")
2294
+ except OSError as exc:
2295
+ # Never fail an already-successful push on local cache I/O.
2296
+ console.print(f"[yellow]could not write local cache: {exc}[/yellow]")
2297
+
2298
+
2299
+ def _pushed_line(entry: dict[str, Any], *, verb: str) -> str:
2300
+ is_blocked = entry.get("kind") == "blocked"
2301
+ label = "blocker" if is_blocked else "entry"
2302
+ # Show the entry's real id (not the per-day entry_num): it's the handle every
2303
+ # other command takes — `stn edit/delete/show/resolve/link <id>`.
2304
+ line = f"{verb} {label} #{entry['id']} for {entry['date']}"
2305
+ mentions = entry.get("mentions") or []
2306
+ if mentions:
2307
+ who = ", ".join(f"@{h}" for h in mentions)
2308
+ line += f" — blocked by {who}" if is_blocked else f" — mentions {who}"
2309
+ return line
2310
+
2311
+
2312
+ def _entry_markdown(entry: dict[str, Any]) -> str:
2313
+ return (
2314
+ "---\n"
2315
+ f"date: {entry['date']}\n"
2316
+ f"author: @{entry['handle']}\n"
2317
+ f"entry: {entry['entry_num']}\n"
2318
+ "---\n\n"
2319
+ f"{entry['content']}\n"
2320
+ )
2321
+
2322
+
2323
+ def _print_entries(entries: list[dict[str, Any]]) -> None:
2324
+ if not entries:
2325
+ console.print("no entries")
2326
+ return
2327
+
2328
+ table = Table(box=None)
2329
+ table.add_column("date")
2330
+ table.add_column("author")
2331
+ table.add_column("id", justify="right")
2332
+ table.add_column("update")
2333
+ for entry in entries:
2334
+ table.add_row(entry["date"], f"@{entry['handle']}", str(entry["id"]), entry["content"])
2335
+ console.print(table)
2336
+
2337
+
2338
+ def _unresolved_blockers(entry: dict[str, Any]) -> list[str]:
2339
+ """Named blockers on a block who haven't cleared their part yet."""
2340
+ resolved = {r["blocker"] for r in (entry.get("resolutions") or [])}
2341
+ return [h for h in (entry.get("mentions") or []) if h not in resolved]
2342
+
2343
+
2344
+ def _print_feed(entries: list[dict[str, Any]]) -> None:
2345
+ """Render a team feed line-by-line, with each block's resolution entries
2346
+ hanging off it like a file tree (├─ / └─)."""
2347
+ if not entries:
2348
+ console.print("no entries")
2349
+ return
2350
+ for entry in entries:
2351
+ head = (
2352
+ f"[cyan]@{entry['handle']}[/cyan] "
2353
+ f"[dim]{entry['date']} #{entry['id']}[/dim]"
2354
+ )
2355
+ if entry.get("kind") == "blocked":
2356
+ tag = "[red][blocked][/red]" if _unresolved_blockers(entry) else "[green][resolved][/green]"
2357
+ sev = entry.get("severity", "normal")
2358
+ if sev and sev != "normal":
2359
+ tag += f" [yellow]\\[{sev}][/yellow]"
2360
+ head += f" {tag}"
2361
+ console.print(f"{head} {entry['content']}")
2362
+ resolutions = entry.get("resolutions") or []
2363
+ for i, res in enumerate(resolutions):
2364
+ branch = "└─" if i == len(resolutions) - 1 else "├─"
2365
+ console.print(
2366
+ f" [dim]{branch}[/dim] [green]resolved[/green] @{res['blocker']}"
2367
+ f" [dim]by @{res['by']}[/dim]: {res['content']}"
2368
+ )
2369
+
2370
+
2371
+ def _parse_date(value: str) -> date:
2372
+ try:
2373
+ return date.fromisoformat(value)
2374
+ except ValueError as exc:
2375
+ console.print("--from must be YYYY-MM-DD")
2376
+ raise typer.Exit(1) from exc
2377
+
2378
+
2379
+ # ── checkpoint / resume helpers ──────────────────────────────────────────────
2380
+ _GIT_TIMEOUT = 2.0 # hard cap (seconds) on any single git call
2381
+ _REMOTE_CREDS_RE = re.compile(r"//[^/@\s]+@") # strip user[:pass]@ from a remote URL
2382
+
2383
+
2384
+ def _redact_remote(url: str | None) -> str | None:
2385
+ return _REMOTE_CREDS_RE.sub("//", url) if url else url
2386
+
2387
+
2388
+ def _git(args: list[str], cwd: str) -> str | None:
2389
+ try:
2390
+ out = subprocess.run(
2391
+ ["git", *args], cwd=cwd, capture_output=True, text=True, timeout=_GIT_TIMEOUT,
2392
+ )
2393
+ except (OSError, subprocess.SubprocessError):
2394
+ return None
2395
+ return out.stdout.strip() if out.returncode == 0 else None
2396
+
2397
+
2398
+ def capture_git(cwd: Path | str | None = None) -> dict[str, Any] | None:
2399
+ """Capture local git context for a checkpoint (branch, HEAD, dirtiness, recent
2400
+ commits). Pure-local, no network, each git call hard-timeouts; returns None
2401
+ outside a repo. Credentials in the remote URL are redacted."""
2402
+ cwd = str(cwd or Path.cwd())
2403
+ if _git(["rev-parse", "--is-inside-work-tree"], cwd) != "true":
2404
+ return None
2405
+ branch = _git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
2406
+ sha = _git(["rev-parse", "HEAD"], cwd)
2407
+ subject = _git(["log", "-1", "--pretty=%s"], cwd)
2408
+ remote = _redact_remote(_git(["remote", "get-url", "origin"], cwd))
2409
+ status_out = _git(["status", "--porcelain"], cwd) or ""
2410
+ dirty = bool(status_out.strip())
2411
+ recently_edited = [line[3:].strip() for line in status_out.splitlines() if line.strip()][:20]
2412
+ recent = (_git(["log", "-10", "--pretty=%h %s"], cwd) or "").splitlines()
2413
+ repo = remote.rstrip("/").rsplit("/", 1)[-1].removesuffix(".git") if remote else None
2414
+ return {
2415
+ "git_branch": branch or None,
2416
+ "git_commit_sha": sha or None,
2417
+ "git_commit_msg": subject or None,
2418
+ "git_remote_url": remote or None,
2419
+ "repo_name": repo or None,
2420
+ "git_dirty": dirty,
2421
+ "source_tool": "git",
2422
+ "raw": {
2423
+ "git": {
2424
+ "branch": branch,
2425
+ "head": {"sha": sha, "subject": subject},
2426
+ "remote": remote,
2427
+ "dirty": dirty,
2428
+ "recent_commits": [c for c in recent if c],
2429
+ },
2430
+ # Editor signal without an editor: files touched in the working tree.
2431
+ "editor": {"recently_edited": recently_edited},
2432
+ },
2433
+ }
2434
+
2435
+
2436
+ def _read_editor_state() -> dict[str, Any] | None:
2437
+ """Read editor state a VSCode/editor extension may have written — the integration
2438
+ contract: JSON at $STNDP_EDITOR_STATE (or ~/.stndp/editor.json), shaped
2439
+ {"tool": "vscode", "active_file": "...", "open_files": ["...", ...]}."""
2440
+ path = os.environ.get("STNDP_EDITOR_STATE") or str(Path.home() / ".stndp" / "editor.json")
2441
+ try:
2442
+ p = Path(path)
2443
+ if not p.exists():
2444
+ return None
2445
+ data = json.loads(p.read_text(encoding="utf-8"))
2446
+ except (OSError, json.JSONDecodeError, ValueError):
2447
+ return None
2448
+ return data if isinstance(data, dict) else None
2449
+
2450
+
2451
+ def capture_editor(cwd: Path | str | None = None) -> dict[str, Any]:
2452
+ """Editor context for a checkpoint. Always derives `recently_edited` from the
2453
+ working tree (git status), so it works with no extension; enriches it with the
2454
+ active/open files when a VSCode (or other) extension has written a state file."""
2455
+ cwd = str(cwd or Path.cwd())
2456
+ status_out = _git(["status", "--porcelain"], cwd) or ""
2457
+ editor: dict[str, Any] = {
2458
+ "tool": "git",
2459
+ "recently_edited": [ln[3:].strip() for ln in status_out.splitlines() if ln.strip()][:20],
2460
+ }
2461
+ state = _read_editor_state()
2462
+ if state:
2463
+ editor["tool"] = state.get("tool") or "vscode"
2464
+ if state.get("active_file"):
2465
+ editor["active_file"] = str(state["active_file"])
2466
+ if isinstance(state.get("open_files"), list):
2467
+ editor["open_files"] = [str(f) for f in state["open_files"][:50]]
2468
+ # Active symbol (class/function the cursor is in) — the symbol facet (Act V).
2469
+ # The editor/agent already knows it, so we just pass the string through; it
2470
+ # stamps the file's `touches` edge so context can be queried at `path#symbol`.
2471
+ if state.get("active_symbol"):
2472
+ editor["active_symbol"] = str(state["active_symbol"])[:200]
2473
+ return editor
2474
+
2475
+
2476
+ # ── Novel context sources (opt-in, local-first, secret-redacted) ─────────────
2477
+ # Redaction rules applied to captured shell history before it leaves the machine.
2478
+ # Catch keyword=value (incl. env-var names like AWS_SECRET_ACCESS_KEY), bearer
2479
+ # tokens, and known high-entropy token shapes even without a keyword.
2480
+ _REDACT_RULES = [
2481
+ (re.compile(r"(?i)([\w.\-]*(?:secret|token|password|passwd|api[_-]?key|auth)[\w.\-]*\s*[=:]\s*)(\S+)"),
2482
+ r"\1***"),
2483
+ (re.compile(r"(?i)(\bbearer\s+)\S+"), r"\1***"),
2484
+ (re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{16,}|sk-[A-Za-z0-9-]{20,}|AKIA[0-9A-Z]{16})\b"), "***"),
2485
+ (re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"), "***"),
2486
+ ]
2487
+
2488
+
2489
+ def _redact_secrets(line: str) -> str:
2490
+ for pattern, repl in _REDACT_RULES:
2491
+ line = pattern.sub(repl, line)
2492
+ return line
2493
+
2494
+
2495
+ def capture_shell(limit: int = 20, history_path: Path | str | None = None) -> list[str]:
2496
+ """Recent shell commands (opt-in) — the truest record of what you did, invisible
2497
+ to git. Secret-redacted; local-only. Returns [] if no history is found."""
2498
+ candidates = (
2499
+ [Path(history_path)] if history_path
2500
+ else [Path.home() / ".bash_history", Path.home() / ".zsh_history"]
2501
+ )
2502
+ for path in candidates:
2503
+ try:
2504
+ if not path.exists():
2505
+ continue
2506
+ lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
2507
+ except OSError:
2508
+ continue
2509
+ cmds = [ln.strip() for ln in lines if ln.strip()][-limit:]
2510
+ return [_redact_secrets(c) for c in cmds]
2511
+ return []
2512
+
2513
+
2514
+ def capture_ai_session(path: Path | str) -> list[str]:
2515
+ """Extract the prompts from an AI-assistant session log (jsonl or plain text) —
2516
+ your prompts are your intent (design §8.5). Opt-in; truncated per line."""
2517
+ path = Path(path)
2518
+ if not path.exists():
2519
+ return []
2520
+ prompts: list[str] = []
2521
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
2522
+ line = line.strip()
2523
+ if not line:
2524
+ continue
2525
+ try:
2526
+ obj = json.loads(line)
2527
+ except (json.JSONDecodeError, ValueError):
2528
+ obj = None
2529
+ if isinstance(obj, dict):
2530
+ # A structured message — keep only the user's prompts, skip the rest.
2531
+ if obj.get("role") == "user" and obj.get("content"):
2532
+ prompts.append(str(obj["content"])[:500])
2533
+ continue
2534
+ prompts.append(line[:500]) # plain-text line
2535
+ return prompts[:50]
2536
+
2537
+
2538
+ def _capture_context(no_context: bool, shell: bool, ai_session: str | None,
2539
+ github: bool = False, editor: bool = False) -> dict[str, Any] | None:
2540
+ """Build a snapshot from git plus any opt-in novel sources (shell/AI/GitHub/editor)."""
2541
+ snap = None if no_context else capture_git()
2542
+ extra: dict[str, Any] = {}
2543
+ if shell:
2544
+ extra["shell"] = capture_shell()
2545
+ if ai_session:
2546
+ extra["ai_session"] = capture_ai_session(ai_session)
2547
+ if github:
2548
+ gh = capture_github()
2549
+ if gh:
2550
+ extra["github"] = gh
2551
+ if not extra and not editor:
2552
+ return snap
2553
+ if snap is None:
2554
+ snap = {"source_tool": "cli", "raw": {}}
2555
+ snap.setdefault("raw", {})
2556
+ snap["raw"].update(extra)
2557
+ if editor:
2558
+ snap["raw"]["editor"] = capture_editor()
2559
+ return snap
2560
+
2561
+
2562
+ # ── GitHub via the user's `gh` CLI (network; opt-in / non-blocking surfaces) ──
2563
+ _GH_TIMEOUT = 8.0
2564
+
2565
+
2566
+ def _gh(args: list[str], timeout: float = _GH_TIMEOUT) -> str | None:
2567
+ """Run a `gh` command, returning stdout on success or None (absent/error)."""
2568
+ try:
2569
+ out = subprocess.run(["gh", *args], capture_output=True, text=True, timeout=timeout)
2570
+ except (OSError, subprocess.SubprocessError):
2571
+ return None
2572
+ return out.stdout if out.returncode == 0 else None
2573
+
2574
+
2575
+ def _gh_json(args: list[str]) -> list[dict[str, Any]]:
2576
+ out = _gh(args)
2577
+ if not out:
2578
+ return []
2579
+ try:
2580
+ data = json.loads(out)
2581
+ return data if isinstance(data, list) else []
2582
+ except (json.JSONDecodeError, ValueError):
2583
+ return []
2584
+
2585
+
2586
+ def gh_available() -> bool:
2587
+ """True if `gh` is installed and authenticated."""
2588
+ return _gh(["auth", "status"], timeout=4.0) is not None
2589
+
2590
+
2591
+ def gh_review_queue(limit: int = 20) -> list[dict[str, Any]]:
2592
+ """PRs awaiting YOUR review (review-requested:@me, open)."""
2593
+ return _gh_json(["search", "prs", "review-requested:@me", "--state", "open",
2594
+ "--json", "number,title,url,repository", "--limit", str(limit)])
2595
+
2596
+
2597
+ def gh_reviewed(since: str | None = None, limit: int = 30) -> list[dict[str, Any]]:
2598
+ """PRs you reviewed (reviewed-by:@me)."""
2599
+ query = "reviewed-by:@me"
2600
+ if since:
2601
+ query += f" updated:>={since}"
2602
+ return _gh_json(["search", "prs", query, "--state", "all",
2603
+ "--json", "number,title,url,repository", "--limit", str(limit)])
2604
+
2605
+
2606
+ def gh_my_commits(since: str | None = None, limit: int = 30) -> list[dict[str, Any]]:
2607
+ """Commits you authored. `@me` only resolves via the --author flag (not in a raw
2608
+ `author:` query qualifier for commit search), so use the flag form."""
2609
+ args = ["search", "commits", "--author", "@me",
2610
+ "--json", "sha,commit,repository,url", "--limit", str(limit)]
2611
+ if since:
2612
+ args += ["--author-date", f">={since}"]
2613
+ return _gh_json(args)
2614
+
2615
+
2616
+ def _valid_pr_ref(number_or_url: str) -> bool:
2617
+ """True only for a bare PR number or a github PR URL.
2618
+
2619
+ `external_id` flows here from the server into `gh pr view <id>` as a
2620
+ positional arg. An id starting with `-` would be parsed by gh as a flag
2621
+ (argument injection), so reject anything that isn't a plain number (optionally
2622
+ `#`-prefixed) or a recognizable github .../pull/<n> URL.
2623
+ """
2624
+ s = str(number_or_url).strip()
2625
+ if re.fullmatch(r"#?\d+", s):
2626
+ return True
2627
+ return _repo_from_url(s) is not None
2628
+
2629
+
2630
+ def gh_pr(number_or_url: str, repo: str | None = None) -> dict[str, Any] | None:
2631
+ """Resolve a single PR's title/state via gh."""
2632
+ ref = str(number_or_url).strip()
2633
+ if not _valid_pr_ref(ref):
2634
+ return None
2635
+ args = ["pr", "view", ref.lstrip("#"), "--json", "number,title,state,url"]
2636
+ # Only attach --repo for a bare number; a full URL already pins the repo, and
2637
+ # a bad repo value (also server-derived) shouldn't widen the positional.
2638
+ if repo and re.fullmatch(r"#?\d+", ref):
2639
+ args += ["--repo", repo]
2640
+ out = _gh(args)
2641
+ if not out:
2642
+ return None
2643
+ try:
2644
+ return json.loads(out)
2645
+ except (json.JSONDecodeError, ValueError):
2646
+ return None
2647
+
2648
+
2649
+ def capture_github() -> dict[str, Any] | None:
2650
+ """Local GitHub context via the user's `gh` CLI: your review queue, recent
2651
+ reviews, and recent commits. Network — returns None if gh is absent/unauthed."""
2652
+ if not gh_available():
2653
+ return None
2654
+ return {
2655
+ "review_queue": gh_review_queue(),
2656
+ "recent_reviews": gh_reviewed(),
2657
+ "recent_commits": gh_my_commits(),
2658
+ }
2659
+
2660
+
2661
+ def _checkpoint_dir() -> Path:
2662
+ path = CACHE_ROOT / "checkpoints"
2663
+ path.mkdir(parents=True, exist_ok=True)
2664
+ return path
2665
+
2666
+
2667
+ # ── Open-episode pointer ──────────────────────────────────────────────────────
2668
+ # A genuine arc spans many checkpoints/decisions/bugs. Tracking the open episode
2669
+ # locally lets `checkpoint`/`decide`/`bug` auto-attach to it (the API stays stateless;
2670
+ # a stale pointer is silently ignored server-side). `episode start` writes it; `close`
2671
+ # /`abandon` clear it.
2672
+ def _open_episode_path() -> Path:
2673
+ return CACHE_ROOT / "episode" / "open.json"
2674
+
2675
+
2676
+ def _set_open_episode(ep: dict[str, Any]) -> None:
2677
+ _atomic_write_text(
2678
+ _open_episode_path(),
2679
+ json.dumps({"id": ep["id"], "team_id": ep.get("team_id"), "title": ep.get("title", "")}),
2680
+ )
2681
+
2682
+
2683
+ def _current_open_episode() -> dict[str, Any] | None:
2684
+ p = _open_episode_path()
2685
+ if not p.exists():
2686
+ return None
2687
+ try:
2688
+ return json.loads(p.read_text(encoding="utf-8"))
2689
+ except (json.JSONDecodeError, ValueError, OSError):
2690
+ return None
2691
+
2692
+
2693
+ def _open_episode_id() -> int | None:
2694
+ ep = _current_open_episode()
2695
+ return ep.get("id") if ep else None
2696
+
2697
+
2698
+ def _clear_open_episode(episode_id: int | None = None) -> None:
2699
+ """Forget the open-episode pointer. With `episode_id`, only clear when it matches —
2700
+ so closing an older arc never drops a pointer to one you've opened since."""
2701
+ p = _open_episode_path()
2702
+ if not p.exists():
2703
+ return
2704
+ if episode_id is not None:
2705
+ cur = _current_open_episode()
2706
+ if cur and cur.get("id") != episode_id:
2707
+ return
2708
+ try:
2709
+ p.unlink()
2710
+ except OSError:
2711
+ pass
2712
+
2713
+
2714
+ def _mirror_checkpoint(ckpt: dict[str, Any]) -> None:
2715
+ """Mirror a checkpoint locally so `stn resume --offline` works without a network."""
2716
+ root = _checkpoint_dir()
2717
+ payload = json.dumps(ckpt)
2718
+ # Atomic writes so an interrupted mirror can't leave a truncated JSON file that
2719
+ # `stn resume --offline` (when you're least able to debug) then crashes on.
2720
+ _atomic_write_text(root / f"{_safe_filename(ckpt['id'])}.json", payload)
2721
+ key = _safe_filename(ckpt.get("team_id") or "personal")
2722
+ _atomic_write_text(root / f"latest-{key}.json", payload)
2723
+
2724
+
2725
+ def _read_mirror() -> dict[str, Any]:
2726
+ """The most recently mirrored checkpoint (newest `latest-*.json`), for offline resume."""
2727
+ empty = {"checkpoint": None, "blockers_on_me": [], "my_open_blocks": []}
2728
+ root = CACHE_ROOT / "checkpoints"
2729
+ files = (
2730
+ sorted(root.glob("latest-*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
2731
+ if root.exists() else []
2732
+ )
2733
+ # Try newest-first and skip any corrupt/truncated mirror rather than crashing
2734
+ # the offline resume on a bad file.
2735
+ for f in files:
2736
+ try:
2737
+ ckpt = json.loads(f.read_text(encoding="utf-8"))
2738
+ except (json.JSONDecodeError, ValueError, OSError):
2739
+ continue
2740
+ return {"checkpoint": ckpt, "blockers_on_me": [], "my_open_blocks": []}
2741
+ return empty
2742
+
2743
+
2744
+ def _render_resume(data: dict[str, Any], *, offline: bool = False) -> None:
2745
+ ckpt = data.get("checkpoint")
2746
+ if not ckpt:
2747
+ console.print("no active checkpoint — run `stn checkpoint`")
2748
+ else:
2749
+ when = str(ckpt.get("created_at", ""))[:16].replace("T", " ")
2750
+ tag = " [dim](offline)[/dim]" if offline else ""
2751
+ console.print(f"[bold]resuming[/bold] checkpoint #{ckpt['id']} [dim]{when}[/dim]{tag}")
2752
+ console.print(f" focus: {ckpt['focus']}")
2753
+ if ckpt.get("reason"):
2754
+ console.print(f" why: {ckpt['reason']}")
2755
+ if ckpt.get("next_step"):
2756
+ console.print(f" next: {ckpt['next_step']}")
2757
+ snap = ckpt.get("snapshot")
2758
+ if snap and snap.get("git_branch"):
2759
+ dirty = " [yellow]*dirty*[/yellow]" if snap.get("git_dirty") else ""
2760
+ sha = (snap.get("git_commit_sha") or "")[:7]
2761
+ console.print(f" git: {snap['git_branch']} @ {sha}{dirty}")
2762
+ for lk in (data.get("links") or []):
2763
+ state = f" — {lk['state']}" if lk.get("state") else ""
2764
+ console.print(f" link: {lk['provider']}:{lk['external_id']}{state}")
2765
+ on_me = data.get("blockers_on_me") or []
2766
+ if on_me:
2767
+ console.print("\n[red]blocking you:[/red]")
2768
+ for e in on_me:
2769
+ console.print(f" #{e['id']} @{e['handle']}: {e['content']}")
2770
+ mine = data.get("my_open_blocks") or []
2771
+ if mine:
2772
+ console.print("\n[yellow]your open blocks:[/yellow]")
2773
+ for e in mine:
2774
+ console.print(f" #{e['id']}: {e['content']}")
2775
+ queue = data.get("review_queue") or []
2776
+ if queue:
2777
+ console.print("\n[blue]awaiting your review:[/blue]")
2778
+ for pr in queue:
2779
+ repo = (pr.get("repository") or {}).get("name") or ""
2780
+ console.print(f" #{pr.get('number')} {pr.get('title', '')} [dim]{repo}[/dim]")
2781
+
2782
+
2783
+ def _print_checkpoints(items: list[dict[str, Any]]) -> None:
2784
+ if not items:
2785
+ console.print("no checkpoints")
2786
+ return
2787
+ table = Table(box=None)
2788
+ table.add_column("id", justify="right")
2789
+ table.add_column("when")
2790
+ table.add_column("status")
2791
+ table.add_column("focus")
2792
+ for c in items:
2793
+ when = str(c.get("created_at", ""))[:16].replace("T", " ")
2794
+ table.add_row(str(c["id"]), when, c["status"], c["focus"])
2795
+ console.print(table)
2796
+
2797
+
2798
+ def _checkpoint_markdown(c: dict[str, Any]) -> str:
2799
+ lines = ["---", f"id: {c['id']}", f"created: {c.get('created_at', '')}",
2800
+ f"status: {c['status']}", "---", "", c["focus"]]
2801
+ if c.get("reason"):
2802
+ lines += ["", f"**Why:** {c['reason']}"]
2803
+ if c.get("next_step"):
2804
+ lines += ["", f"**Next:** {c['next_step']}"]
2805
+ return "\n".join(lines) + "\n"
2806
+
2807
+
2808
+ # ── episode / bug renderers ───────────────────────────────────────────────────
2809
+ def _print_episodes(items: list[dict[str, Any]]) -> None:
2810
+ if not items:
2811
+ console.print("no episodes")
2812
+ return
2813
+ table = Table(box=None)
2814
+ table.add_column("id", justify="right")
2815
+ table.add_column("status")
2816
+ table.add_column("by")
2817
+ table.add_column("title")
2818
+ for e in items:
2819
+ flag = ""
2820
+ if e.get("is_private"):
2821
+ flag += " [dim](private)[/dim]"
2822
+ if e.get("pending"):
2823
+ flag += " [yellow](pending — confirm)[/yellow]"
2824
+ by = f"@{e['handle']}"
2825
+ if e.get("agent_label"):
2826
+ by += f" [cyan]⚙{e['agent_label']}[/cyan]"
2827
+ table.add_row(str(e["id"]), e["status"], by, e["title"] + flag)
2828
+ console.print(table)
2829
+
2830
+
2831
+ def _print_episode_full(e: dict[str, Any]) -> None:
2832
+ flags = []
2833
+ if e.get("is_private"):
2834
+ flags.append("private")
2835
+ if e.get("pending"):
2836
+ flags.append("pending — confirm")
2837
+ tag = f" [yellow]({', '.join(flags)})[/yellow]" if flags else ""
2838
+ console.print(f"[bold]#{e['id']} {e['title']}[/bold] [dim]({e['status']})[/dim]{tag}")
2839
+ console.print(f"[dim]@{e['handle']}[/dim]")
2840
+ for label, key in (("plan", "plan"), ("outcome", "outcome"),
2841
+ ("result", "result"), ("surprise", "surprise")):
2842
+ if e.get(key):
2843
+ console.print(f" {label:<9}{e[key]}")
2844
+
2845
+
2846
+ def _print_bugs(items: list[dict[str, Any]]) -> None:
2847
+ if not items:
2848
+ console.print("no bugs")
2849
+ return
2850
+ table = Table(box=None)
2851
+ table.add_column("id", justify="right")
2852
+ table.add_column("status")
2853
+ table.add_column("by")
2854
+ table.add_column("symptom")
2855
+ for b in items:
2856
+ flag = " [yellow](pending — confirm)[/yellow]" if b.get("pending") else ""
2857
+ by = f"@{b['handle']}"
2858
+ if b.get("agent_label"):
2859
+ by += f" [cyan]⚙{b['agent_label']}[/cyan]"
2860
+ table.add_row(str(b["id"]), b["status"], by, (b["symptom"][:60]) + flag)
2861
+ console.print(table)
2862
+
2863
+
2864
+ def _print_bug_full(b: dict[str, Any]) -> None:
2865
+ tag = " [yellow](pending — confirm)[/yellow]" if b.get("pending") else ""
2866
+ console.print(f"[bold]#{b['id']}[/bold] [dim]({b['status']})[/dim]{tag}")
2867
+ console.print(f"[dim]@{b['handle']}[/dim]")
2868
+ console.print(f" symptom: {b['symptom']}")
2869
+ if b.get("root_cause"):
2870
+ console.print(f" cause: {b['root_cause']}")
2871
+ if b.get("fix"):
2872
+ console.print(f" fix: {b['fix']}")
2873
+ if b.get("where_ref"):
2874
+ console.print(f" where: {b['where_ref']}")
2875
+ if b.get("episode_id"):
2876
+ console.print(f" episode: #{b['episode_id']}")
2877
+
2878
+
2879
+ # ── decisions / search / brag helpers ────────────────────────────────────────
2880
+ def _print_decisions(items: list[dict[str, Any]]) -> None:
2881
+ if not items:
2882
+ console.print("no decisions")
2883
+ return
2884
+ table = Table(box=None)
2885
+ table.add_column("id", justify="right")
2886
+ table.add_column("kind")
2887
+ table.add_column("by")
2888
+ table.add_column("title")
2889
+ for d in items:
2890
+ flag = " [dim](superseded)[/dim]" if d.get("status") == "superseded" else ""
2891
+ if d.get("pending"):
2892
+ flag += " [yellow](pending — confirm)[/yellow]"
2893
+ by = f"@{d['handle']}"
2894
+ if d.get("agent_label"):
2895
+ by += f" [cyan]⚙{d['agent_label']}[/cyan]" # agent-authored
2896
+ title = d["title"] + flag
2897
+ if d.get("category"):
2898
+ title += f" [magenta]\\[{d['category']}][/magenta]"
2899
+ table.add_row(str(d["id"]), d["kind"], by, title)
2900
+ console.print(table)
2901
+
2902
+
2903
+ def _print_decision_full(d: dict[str, Any]) -> None:
2904
+ flag = " [dim](superseded)[/dim]" if d.get("status") == "superseded" else ""
2905
+ console.print(f"[bold]#{d['id']} {d['title']}[/bold]{flag}")
2906
+ meta = f"[dim]{d['kind']} · @{d['handle']} · {str(d.get('decided_on', ''))}[/dim]"
2907
+ if d.get("category"):
2908
+ sfx = " (unconfirmed)" if d.get("category_pending") else ""
2909
+ meta += f" [magenta]{d['category']}{sfx}[/magenta]"
2910
+ tags = d.get("tags") or []
2911
+ if tags:
2912
+ meta += " " + " ".join(f"[cyan]#{t}[/cyan]" for t in tags)
2913
+ console.print(meta)
2914
+ console.print("")
2915
+ console.print(d["body"])
2916
+
2917
+
2918
+ def _print_search(results: list[dict[str, Any]]) -> None:
2919
+ if not results:
2920
+ console.print("nothing found")
2921
+ return
2922
+ for r in results:
2923
+ when = str(r.get("when", ""))[:10]
2924
+ who = f" @{r['handle']}" if r.get("handle") else ""
2925
+ console.print(
2926
+ f"[magenta]{r['type']:<16}[/magenta] [dim]{when}{who}[/dim] "
2927
+ f"#{r['id']} {r['snippet']}"
2928
+ )
2929
+ ctx = r.get("context") or {}
2930
+ bits = [f"{lk['provider']}:{lk['external_id']}" for lk in ctx.get("links", [])]
2931
+ bits += [f"{e['rel']} {e['node_type']}#{e['node_id']}" for e in ctx.get("edges", [])]
2932
+ if bits:
2933
+ console.print(f" [dim]> {' '.join(bits[:5])}[/dim]")
2934
+
2935
+
2936
+ def _brag_markdown(data: dict[str, Any]) -> str:
2937
+ lines = [f"# Brag — {data['from']} to {data['to']}", ""]
2938
+ acc = data.get("accomplishments") or []
2939
+ if acc:
2940
+ lines.append("## Shipped")
2941
+ lines += [f"- {a['date']}: {a['content']}" for a in acc]
2942
+ lines.append("")
2943
+ dec = data.get("decisions") or []
2944
+ if dec:
2945
+ lines.append("## Decisions")
2946
+ lines += [f"- #{d['id']} {d['title']} ({d['kind']})" for d in dec]
2947
+ lines.append("")
2948
+ blk = data.get("blocks_cleared") or []
2949
+ if blk:
2950
+ lines.append("## Unblocked others")
2951
+ lines += [f"- {b['content']}" for b in blk]
2952
+ lines.append("")
2953
+ lines.append(f"_Checkpoints captured: {data.get('checkpoints', 0)}_")
2954
+ return "\n".join(lines)
2955
+
2956
+
2957
+ def _brag_github_markdown(reviewed: list[dict[str, Any]], commits: list[dict[str, Any]]) -> str:
2958
+ lines = ["## GitHub"]
2959
+ if reviewed:
2960
+ lines.append(f"- Reviewed {len(reviewed)} PRs:")
2961
+ lines += [
2962
+ f" - {p.get('title', '')} ({(p.get('repository') or {}).get('name', '')})"
2963
+ for p in reviewed[:10]
2964
+ ]
2965
+ if commits:
2966
+ lines.append(f"- Authored {len(commits)} commits")
2967
+ if len(lines) == 1:
2968
+ lines.append("- (no GitHub activity in range)")
2969
+ return "\n".join(lines)