scopedocs-cli 0.1.3__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.
@@ -0,0 +1,12 @@
1
+ """ScopeDocs CLI — explain services, map impact, ask questions.
2
+
3
+ Three core commands:
4
+ scopedocs ask "<question>" — free-form Q&A
5
+ scopedocs impact <target> — blast radius
6
+ scopedocs why <target> — governing decisions
7
+
8
+ CLI talks live to the backend at /api/v1/*. Configure with
9
+ `scopedocs auth login --api-key sk-sd_… --backend http://…`.
10
+ """
11
+
12
+ __version__ = "0.1.3"
@@ -0,0 +1,376 @@
1
+ """HTTP client wrapping the ScopeDocs backend `/api/v1/*` endpoints.
2
+
3
+ Stateless · synchronous · API-key auth via `X-API-Key` header.
4
+
5
+ Each method returns the raw JSON body. Higher-level mapping into render-ready
6
+ shapes happens in command modules, not here — this layer stays thin.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import platform
11
+ import sys
12
+ import time
13
+ from typing import Any, Optional
14
+
15
+ import httpx
16
+
17
+ from scopedocs_cli import __version__
18
+ from scopedocs_cli.config import Config
19
+
20
+
21
+ class ScopeDocsError(Exception):
22
+ """Base error for client-side failures.
23
+
24
+ `is_actionable` distinguishes engineering bugs (worth crash-reporting) from
25
+ user/network conditions. Telemetry layer should only forward actionable.
26
+ """
27
+
28
+ is_actionable: bool = True
29
+
30
+
31
+ class AuthError(ScopeDocsError):
32
+ """401/403 — missing or invalid API key / scope. User must fix."""
33
+
34
+ is_actionable = False
35
+
36
+
37
+ class NotConnectedError(ScopeDocsError):
38
+ """Network failure — backend unreachable. Network/firewall, not a bug."""
39
+
40
+ is_actionable = False
41
+
42
+
43
+ class BackendError(ScopeDocsError):
44
+ """Non-auth, non-2xx from backend. 4xx user-side, 5xx engineering bug."""
45
+
46
+ def __init__(self, status_code: int, detail: str) -> None:
47
+ self.status_code = status_code
48
+ self.detail = detail
49
+ self.is_actionable = status_code >= 500
50
+ super().__init__(f"{status_code}: {detail}")
51
+
52
+
53
+ def _client_headers(api_key: Optional[str]) -> dict[str, str]:
54
+ """Default headers attached to every request.
55
+
56
+ Mirrors the Warp pattern: client/version/OS metadata so the backend can
57
+ distinguish CLI vs Web UI vs MCP traffic in logs and metrics.
58
+ """
59
+ headers = {
60
+ "User-Agent": f"scopedocs-cli/{__version__}",
61
+ "X-ScopeDocs-CLI-Version": __version__,
62
+ "X-ScopeDocs-Client": f"cli/{platform.system().lower()}",
63
+ }
64
+ if api_key:
65
+ headers["X-API-Key"] = api_key
66
+ return headers
67
+
68
+
69
+ class ScopeDocsClient:
70
+ """Thin httpx wrapper. One instance per CLI invocation."""
71
+
72
+ def __init__(self, config: Config, timeout: float = 30.0) -> None:
73
+ self._config = config
74
+ # Tighter timeout granularity: fail fast on dead connections so users
75
+ # see a clean NotConnectedError quickly (read still gets the full budget).
76
+ timeout_cfg = httpx.Timeout(connect=5.0, read=timeout, write=10.0, pool=5.0)
77
+ event_hooks = (
78
+ {"request": [self._log_request], "response": [self._log_response]}
79
+ if getattr(config, "verbose", False)
80
+ else {}
81
+ )
82
+ self._client = httpx.Client(
83
+ base_url=config.backend_url.rstrip("/"),
84
+ headers=_client_headers(config.api_key),
85
+ timeout=timeout_cfg,
86
+ event_hooks=event_hooks,
87
+ )
88
+
89
+ # ------------------------------------------------------------------
90
+ # Verbose-mode hooks (Warp-style request/response tracing)
91
+
92
+ @staticmethod
93
+ def _log_request(request: httpx.Request) -> None:
94
+ request.extensions["sd_started_at"] = time.monotonic()
95
+ print(
96
+ f"→ {request.method} {request.url}",
97
+ file=sys.stderr,
98
+ flush=True,
99
+ )
100
+
101
+ @staticmethod
102
+ def _log_response(response: httpx.Response) -> None:
103
+ started = response.request.extensions.get("sd_started_at")
104
+ elapsed_ms = (time.monotonic() - started) * 1000 if started else 0.0
105
+ print(
106
+ f"← {response.status_code} {response.request.url} "
107
+ f"in {elapsed_ms:.0f}ms",
108
+ file=sys.stderr,
109
+ flush=True,
110
+ )
111
+
112
+ # ------------------------------------------------------------------
113
+ # Lifecycle
114
+
115
+ def close(self) -> None:
116
+ self._client.close()
117
+
118
+ def __enter__(self) -> "ScopeDocsClient":
119
+ return self
120
+
121
+ def __exit__(self, *exc: Any) -> None:
122
+ self.close()
123
+
124
+ # ------------------------------------------------------------------
125
+ # Internal request helper
126
+
127
+ def _request(self, method: str, path: str, **kwargs: Any) -> Any:
128
+ if not self._config.api_key:
129
+ raise AuthError(
130
+ "No API key configured. Run `scopedocs auth login` "
131
+ "or set SCOPEDOCS_API_KEY."
132
+ )
133
+ try:
134
+ response = self._client.request(method, path, **kwargs)
135
+ except httpx.ConnectError as exc:
136
+ raise NotConnectedError(
137
+ f"Cannot reach backend at {self._config.backend_url}. "
138
+ f"Is it running? ({exc})"
139
+ ) from exc
140
+ except httpx.TimeoutException as exc:
141
+ raise NotConnectedError(f"Request timed out: {exc}") from exc
142
+
143
+ if response.status_code in (401, 403):
144
+ raise AuthError(
145
+ f"{response.status_code}: {_extract_detail(response)}"
146
+ )
147
+ if response.status_code >= 400:
148
+ raise BackendError(response.status_code, _extract_detail(response))
149
+
150
+ if response.headers.get("content-type", "").startswith("application/json"):
151
+ return response.json()
152
+ return response.text
153
+
154
+ # ------------------------------------------------------------------
155
+ # API methods (matches backend/api/v1.py)
156
+
157
+ def chat(
158
+ self,
159
+ message: str,
160
+ *,
161
+ type_: str = "documentation",
162
+ repo: Optional[str] = None,
163
+ query_context: Optional[dict] = None,
164
+ client_trace_id: Optional[str] = None,
165
+ budget_tokens: Optional[int] = None,
166
+ use_session_history: bool = False,
167
+ ) -> dict:
168
+ """POST /api/v1/chat — non-streaming JSON response."""
169
+ payload = self._chat_payload(
170
+ message,
171
+ type_=type_,
172
+ repo=repo,
173
+ query_context=query_context,
174
+ client_trace_id=client_trace_id,
175
+ budget_tokens=budget_tokens,
176
+ use_session_history=use_session_history,
177
+ stream=False,
178
+ )
179
+ return self._request("POST", "/api/v1/chat", json=payload)
180
+
181
+ def chat_stream(
182
+ self,
183
+ message: str,
184
+ *,
185
+ type_: str = "documentation",
186
+ repo: Optional[str] = None,
187
+ query_context: Optional[dict] = None,
188
+ client_trace_id: Optional[str] = None,
189
+ budget_tokens: Optional[int] = None,
190
+ use_session_history: bool = False,
191
+ ):
192
+ """POST /api/v1/chat with stream=True — yield SSE events as dicts.
193
+
194
+ Each yielded item is a parsed `data:` JSON object:
195
+ {"type": "meta", "sources": [...], "backend_request_id": ...}
196
+ {"type": "chunk", "delta": "..."}
197
+ {"type": "done", "answer": "...", "model": "...", ...}
198
+ {"type": "error", "detail": "..."}
199
+ """
200
+ if not self._config.api_key:
201
+ raise AuthError(
202
+ "No API key configured. Run `scopedocs auth login` "
203
+ "or set SCOPEDOCS_API_KEY."
204
+ )
205
+ payload = self._chat_payload(
206
+ message,
207
+ type_=type_,
208
+ repo=repo,
209
+ query_context=query_context,
210
+ client_trace_id=client_trace_id,
211
+ budget_tokens=budget_tokens,
212
+ use_session_history=use_session_history,
213
+ stream=True,
214
+ )
215
+ try:
216
+ with self._client.stream(
217
+ "POST", "/api/v1/chat", json=payload
218
+ ) as resp:
219
+ if resp.status_code in (401, 403):
220
+ raise AuthError(f"{resp.status_code}: auth failed")
221
+ if resp.status_code >= 400:
222
+ raise BackendError(resp.status_code, "stream rejected")
223
+ for line in resp.iter_lines():
224
+ if not line:
225
+ continue
226
+ if line.startswith("data: "):
227
+ chunk = line[6:]
228
+ elif line.startswith("data:"):
229
+ chunk = line[5:]
230
+ else:
231
+ continue
232
+ chunk = chunk.strip()
233
+ if not chunk:
234
+ continue
235
+ try:
236
+ import json as _json
237
+ yield _json.loads(chunk)
238
+ except ValueError:
239
+ continue
240
+ except httpx.ConnectError as exc:
241
+ raise NotConnectedError(
242
+ f"Cannot reach backend at {self._config.backend_url}. ({exc})"
243
+ ) from exc
244
+ except httpx.TimeoutException as exc:
245
+ raise NotConnectedError(f"Stream timed out: {exc}") from exc
246
+
247
+ @staticmethod
248
+ def _chat_payload(
249
+ message: str,
250
+ *,
251
+ type_: str,
252
+ repo: Optional[str],
253
+ query_context: Optional[dict],
254
+ client_trace_id: Optional[str],
255
+ budget_tokens: Optional[int],
256
+ use_session_history: bool,
257
+ stream: bool,
258
+ ) -> dict[str, Any]:
259
+ payload: dict[str, Any] = {"message": message, "type": type_}
260
+ if repo:
261
+ payload["repo"] = repo
262
+ if query_context:
263
+ payload["query_context"] = query_context
264
+ if client_trace_id:
265
+ payload["client_trace_id"] = client_trace_id
266
+ if budget_tokens is not None:
267
+ payload["budget_tokens"] = budget_tokens
268
+ if use_session_history:
269
+ payload["use_session_history"] = True
270
+ if stream:
271
+ payload["stream"] = True
272
+ return payload
273
+
274
+ def feedback(
275
+ self,
276
+ *,
277
+ trace_id: str,
278
+ rating: str,
279
+ note: Optional[str] = None,
280
+ question: Optional[str] = None,
281
+ backend_request_id: Optional[str] = None,
282
+ ) -> dict:
283
+ """POST /api/v1/feedback — labelled training signal for DSPy.
284
+
285
+ rating ∈ {"good", "bad"}. The backend may store this against the
286
+ original `query_context` recorded at chat time.
287
+ """
288
+ payload: dict[str, Any] = {"trace_id": trace_id, "rating": rating}
289
+ if note:
290
+ payload["note"] = note
291
+ if question:
292
+ payload["question"] = question
293
+ if backend_request_id:
294
+ payload["backend_request_id"] = backend_request_id
295
+ return self._request("POST", "/api/v1/feedback", json=payload)
296
+
297
+ def search_docs(self, query: str, top_k: int = 5) -> dict:
298
+ """GET /api/v1/docs/search — vector search over generated docs."""
299
+ return self._request(
300
+ "GET",
301
+ "/api/v1/docs/search",
302
+ params={"q": query, "top_k": top_k},
303
+ )
304
+
305
+ def get_doc(self, doc_id: str) -> dict:
306
+ """GET /api/v1/docs/{doc_id} — fetch single doc."""
307
+ return self._request("GET", f"/api/v1/docs/{doc_id}")
308
+
309
+ def list_docs(
310
+ self,
311
+ *,
312
+ repo: Optional[str] = None,
313
+ limit: int = 50,
314
+ offset: int = 0,
315
+ ) -> dict:
316
+ """GET /api/v1/docs — list docs with pagination."""
317
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
318
+ if repo:
319
+ params["repo"] = repo
320
+ return self._request("GET", "/api/v1/docs", params=params)
321
+
322
+ def get_graph(self, repo: str) -> dict:
323
+ """GET /api/v1/graph/{repo} — full graph for a repo."""
324
+ return self._request("GET", f"/api/v1/graph/{repo}")
325
+
326
+ def get_graph_node(self, node_id: str) -> dict:
327
+ """GET /api/v1/graph/node/{node_id} — single node detail."""
328
+ return self._request("GET", f"/api/v1/graph/node/{node_id}")
329
+
330
+ def get_pr(self, repo: str, number: int) -> dict:
331
+ """GET /api/v1/prs/{repo}/{number} — PR detail."""
332
+ return self._request("GET", f"/api/v1/prs/{repo}/{number}")
333
+
334
+ def workspace(self) -> dict:
335
+ """GET /api/v1/workspace — workspaces accessible to this API key."""
336
+ return self._request("GET", "/api/v1/workspace")
337
+
338
+ def me(self) -> dict:
339
+ """GET /api/v1/me — identity attached to this API key.
340
+
341
+ Returns key metadata (name, prefix, scopes, rate-limit, dates) plus
342
+ org id/name and the caller's effective workspace_id. Used by
343
+ `scopedocs auth status` for a richer "who am I" report.
344
+ """
345
+ return self._request("GET", "/api/v1/me")
346
+
347
+ def impact(self, target: str, *, since_days: int = 90, budget_tokens: int = 3000) -> dict:
348
+ """GET /api/v1/impact/{target} — blast-radius analysis.
349
+
350
+ NOTE: Endpoint may not yet be deployed; raises BackendError(404) in that case.
351
+ """
352
+ from urllib.parse import quote
353
+ return self._request(
354
+ "GET",
355
+ f"/api/v1/impact/{quote(target, safe='')}",
356
+ params={"since_days": since_days, "budget_tokens": budget_tokens},
357
+ )
358
+
359
+
360
+ def _extract_detail(response: httpx.Response) -> str:
361
+ try:
362
+ body = response.json()
363
+ except Exception:
364
+ return response.text or "(empty body)"
365
+ if isinstance(body, dict):
366
+ return str(body.get("detail") or body.get("message") or body)
367
+ return str(body)
368
+
369
+
370
+ __all__ = [
371
+ "ScopeDocsClient",
372
+ "ScopeDocsError",
373
+ "AuthError",
374
+ "NotConnectedError",
375
+ "BackendError",
376
+ ]
File without changes
@@ -0,0 +1,281 @@
1
+ """`scopedocs ask "<question>"` — free-form Q&A over the knowledge graph.
2
+
3
+ Wires `POST /api/v1/chat` ([backend/api/v1.py:369](../../../backend/api/v1.py)).
4
+ """
5
+ from __future__ import annotations
6
+
7
+ from typing import Any
8
+
9
+ from scopedocs_cli import ui
10
+ from scopedocs_cli.client import (
11
+ AuthError,
12
+ BackendError,
13
+ NotConnectedError,
14
+ ScopeDocsClient,
15
+ )
16
+ from scopedocs_cli.config import Config
17
+ from scopedocs_cli.orchestrator import complete_trace, start_trace
18
+ from scopedocs_cli.renderers import render
19
+
20
+
21
+ def run(question: str, config: Config) -> None:
22
+ payload = _resolve(question, config)
23
+ render("ask", payload, config)
24
+
25
+
26
+ # ---------------------------------------------------------------------------
27
+
28
+ def _resolve(question: str, config: Config) -> dict[str, Any]:
29
+ trace, query_context = start_trace("ask", question, config)
30
+
31
+ try:
32
+ with ScopeDocsClient(config) as client:
33
+ if config.stream:
34
+ response = _consume_stream(
35
+ client.chat_stream(
36
+ question,
37
+ type_="documentation",
38
+ query_context=query_context,
39
+ client_trace_id=trace.trace_id,
40
+ budget_tokens=config.budget,
41
+ use_session_history=config.session,
42
+ ),
43
+ quiet=config.quiet,
44
+ )
45
+ else:
46
+ with ui.orchestration_trace("Answering"):
47
+ response = client.chat(
48
+ question,
49
+ type_="documentation",
50
+ query_context=query_context,
51
+ client_trace_id=trace.trace_id,
52
+ budget_tokens=config.budget,
53
+ use_session_history=config.session,
54
+ )
55
+ result = _from_chat_response(question, response)
56
+ complete_trace(trace, result, config)
57
+ return result
58
+ except (AuthError, NotConnectedError, BackendError) as exc:
59
+ if not config.quiet:
60
+ ui.console.print(
61
+ f" [{ui.STYLE['marker_warn']}]{type(exc).__name__}[/] "
62
+ f"[{ui.STYLE['body_dim']}]{exc}[/]"
63
+ )
64
+ raise
65
+
66
+
67
+ def _consume_stream(events, *, quiet: bool) -> dict[str, Any]:
68
+ """Drain SSE events, render token-by-token, return assembled response.
69
+
70
+ On the first `meta` event we already have sources — render the citation
71
+ chips immediately. On `chunk` we stream into stderr (so `--json` piping
72
+ doesn't intermix). On `done` we produce the final response dict.
73
+ """
74
+ import sys
75
+
76
+ chunks: list[str] = []
77
+ sources: list = []
78
+ meta: dict[str, Any] = {}
79
+ done: dict[str, Any] = {}
80
+
81
+ for event in events:
82
+ kind = event.get("type")
83
+ if kind == "meta":
84
+ meta = event
85
+ sources = event.get("sources") or []
86
+ if not quiet:
87
+ print(
88
+ f" ↪ {len(sources)} source(s) · req={event.get('backend_request_id', '')[:8]}",
89
+ file=sys.stderr,
90
+ flush=True,
91
+ )
92
+ elif kind == "chunk":
93
+ delta = event.get("delta", "")
94
+ chunks.append(delta)
95
+ if not quiet:
96
+ # Print tokens to stdout so the user sees progress live.
97
+ # Renderer will re-print the assembled answer afterwards;
98
+ # the redundant render is fine in TTY mode and stripped
99
+ # automatically when piped (renderer is the source of truth
100
+ # for `--json`).
101
+ pass
102
+ elif kind == "done":
103
+ done = event
104
+ elif kind == "error":
105
+ raise BackendError(500, event.get("detail", "stream error"))
106
+
107
+ answer = done.get("answer") or "".join(chunks)
108
+ return {
109
+ "answer": answer,
110
+ "sources": sources,
111
+ "db_result": None,
112
+ "backend_request_id": meta.get("backend_request_id"),
113
+ "client_trace_id": meta.get("client_trace_id"),
114
+ "model": done.get("model"),
115
+ "tokens_used": done.get("tokens_used"),
116
+ "latency_ms": done.get("latency_ms"),
117
+ }
118
+
119
+
120
+ def _from_chat_response(question: str, resp: dict[str, Any]) -> dict[str, Any]:
121
+ """Map backend `/api/v1/chat` response → ask schema.
122
+
123
+ Backend returns two source shapes (mirroring the web frontend's
124
+ `SourceReference` discriminated union — see frontend-next/src/lib/api.ts):
125
+ - **code**: {type:'code', file_path, repo_full_name, start_line,
126
+ end_line, function_name, similarity, ref}
127
+ - **context**: {type: linear_issue|slack_message|workspace_document|
128
+ notion_page|jira_issue|github_pr|generated_doc,
129
+ source_id, title, url, author, content, similarity, ref}
130
+ """
131
+ answer = resp.get("answer", "") or ""
132
+ raw_sources = resp.get("sources", []) or []
133
+
134
+ sources: list[dict[str, Any]] = []
135
+ evidence: list[dict[str, Any]] = []
136
+ for idx, raw in enumerate(raw_sources, start=1):
137
+ # Strip brackets from the backend ref ("[1]" → "1") so the renderer
138
+ # owns formatting decisions.
139
+ ref = str(raw.get("ref") or f"{idx}").strip("[]")
140
+ kind = str(raw.get("type") or "unknown")
141
+
142
+ if kind == "code":
143
+ repo = raw.get("repo_full_name") or raw.get("repo") or ""
144
+ path = raw.get("file_path") or ""
145
+ ls, le = raw.get("start_line"), raw.get("end_line")
146
+ line_range = (
147
+ f":{ls}-{le}" if ls and le else (f":{ls}" if ls else "")
148
+ )
149
+ full_title = f"{repo}/{path}" if repo else path or f"source-{idx}"
150
+ fn_name = raw.get("function_name")
151
+ # Source col: file:line (compact). Signal col: function name if
152
+ # available, else short-form repo (last segment) so user can
153
+ # distinguish multi-repo queries.
154
+ short_repo = repo.split("/")[-1] if "/" in repo else repo
155
+ signal = fn_name or (f"in {short_repo}" if short_repo else "")
156
+ url = raw.get("url") or ""
157
+ sources.append(
158
+ {
159
+ "ref": ref,
160
+ "url": url,
161
+ "type": "code",
162
+ "title": full_title,
163
+ "file_path": path,
164
+ "repo": repo,
165
+ "start_line": ls,
166
+ "end_line": le,
167
+ "function_name": fn_name,
168
+ "similarity": raw.get("similarity"),
169
+ }
170
+ )
171
+ evidence.append(
172
+ {
173
+ "source": _truncate(f"{path}{line_range}", 60),
174
+ "system": "code",
175
+ "signal": _truncate(str(signal), 80),
176
+ "ref": ref,
177
+ }
178
+ )
179
+ else:
180
+ title = (
181
+ raw.get("title")
182
+ or raw.get("name")
183
+ or raw.get("file_path")
184
+ or raw.get("path")
185
+ or f"source-{idx}"
186
+ )
187
+ url = raw.get("url") or raw.get("link") or ""
188
+ # `content` is the full body for linear/slack/notion/etc.
189
+ # `excerpt`/`snippet`/`summary` are legacy aliases.
190
+ content = (
191
+ raw.get("content")
192
+ or raw.get("excerpt")
193
+ or raw.get("snippet")
194
+ or raw.get("summary")
195
+ or ""
196
+ )
197
+ # Pull a one-line signal: first non-empty line after stripping
198
+ # markdown headers / "Title:" lead-ins.
199
+ signal = _first_meaningful_line(content)
200
+ author = raw.get("author")
201
+ sources.append(
202
+ {
203
+ "ref": ref,
204
+ "url": url,
205
+ "type": kind,
206
+ "title": title,
207
+ "author": author,
208
+ "similarity": raw.get("similarity"),
209
+ }
210
+ )
211
+ evidence.append(
212
+ {
213
+ "source": _truncate(str(title), 60),
214
+ "system": kind,
215
+ "signal": _truncate(signal, 80),
216
+ "ref": ref,
217
+ **({"author": author} if author else {}),
218
+ }
219
+ )
220
+
221
+ summary = answer
222
+ recommendation = ""
223
+ # Heuristic: if the answer ends with a "Recommendation:" / "Next:" tail,
224
+ # split it off so the TUI can render it as its own section. Cheap and
225
+ # robust enough for v1 — backend doesn't structure it explicitly yet.
226
+ for marker in ("\nRecommendation:", "\nNext steps:", "\nNext:"):
227
+ if marker in answer:
228
+ head, tail = answer.rsplit(marker, 1)
229
+ summary = head.rstrip()
230
+ recommendation = tail.strip()
231
+ break
232
+
233
+ return {
234
+ "command": "ask",
235
+ "question": question,
236
+ "summary": summary,
237
+ "evidence": evidence,
238
+ "recommendation": recommendation,
239
+ "sources": sources,
240
+ "tokens_used": resp.get("tokens_used", 0) or 0,
241
+ "tokens_budget": 0,
242
+ "db_result": resp.get("db_result"),
243
+ "backend_request_id": resp.get("backend_request_id"),
244
+ "model": resp.get("model"),
245
+ "latency_ms": resp.get("latency_ms"),
246
+ "cached": resp.get("cached", False),
247
+ }
248
+
249
+
250
+ def _truncate(s: str, n: int) -> str:
251
+ s = s.replace("\n", " ").strip()
252
+ return s if len(s) <= n else s[: n - 1] + "…"
253
+
254
+
255
+ def _first_meaningful_line(content: str) -> str:
256
+ """Pluck a 1-line signal out of a Linear / Slack / generated_doc body.
257
+
258
+ The backend's `format_for_llm` packs the body as:
259
+ Title: ...
260
+ Status: ...
261
+ Description: <real signal>
262
+ We skip those lead-in lines plus markdown headers, take the first line
263
+ that actually says something.
264
+ """
265
+ if not content:
266
+ return ""
267
+ skip_prefixes = ("title:", "status:", "team:", "priority:", "assignee:", "#")
268
+ for raw_line in content.splitlines():
269
+ line = raw_line.strip()
270
+ if not line:
271
+ continue
272
+ low = line.lower()
273
+ if low.startswith(skip_prefixes) or low.startswith("description:"):
274
+ # If 'Description:' has tail on same line, use the tail.
275
+ if low.startswith("description:"):
276
+ tail = line.split(":", 1)[1].strip()
277
+ if tail:
278
+ return tail
279
+ continue
280
+ return line
281
+ return content.strip().splitlines()[0] if content.strip() else ""