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/__init__.py +0 -0
- stndp/main.py +2969 -0
- stndp/mcp_server.py +582 -0
- stndp_cli-0.1.0.dist-info/METADATA +7 -0
- stndp_cli-0.1.0.dist-info/RECORD +8 -0
- stndp_cli-0.1.0.dist-info/WHEEL +5 -0
- stndp_cli-0.1.0.dist-info/entry_points.txt +2 -0
- stndp_cli-0.1.0.dist-info/top_level.txt +1 -0
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)
|