whycode-cli 0.2.6__py3-none-any.whl → 0.3.1__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.
whycode/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """WhyCode — tells you what to be afraid of before touching a file."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.3.1"
whycode/cli.py CHANGED
@@ -6,6 +6,7 @@ Commands
6
6
  - ``whycode why <path>`` — print the Risk Card for a single file.
7
7
  - ``whycode why <path> --at SHA`` — risk card as of a past commit.
8
8
  - ``whycode why <path> --mute KIND`` — locally suppress a noisy signal kind.
9
+ - ``whycode why <path> --llm`` — opt-in L3: LLM-extracted structured decisions.
9
10
  - ``whycode highlights`` — repo-wide treasure map of decisions and incidents.
10
11
  - ``whycode diff [--base REF]`` — risk-rank files changed against a base ref.
11
12
  - ``whycode show <sha>`` — risk-flavored summary for one commit.
@@ -155,6 +156,20 @@ def why(
155
156
  "--no-mutes",
156
157
  help="Bypass the local suppression list — show all signals.",
157
158
  ),
159
+ llm: bool = typer.Option(
160
+ False,
161
+ "--llm",
162
+ help=(
163
+ "Enrich the card with LLM-extracted structured decisions "
164
+ "(L3, opt-in, requires WHYCODE_LLM_API_KEY + WHYCODE_LLM_MODEL). "
165
+ "Sends only commits already filtered by L2 — see --llm-dry-run."
166
+ ),
167
+ ),
168
+ llm_dry_run: bool = typer.Option(
169
+ False,
170
+ "--llm-dry-run",
171
+ help="Show exactly what would be sent to the LLM without making the call.",
172
+ ),
158
173
  max_commits: int | None = typer.Option(
159
174
  None, "--max-commits", help="Cap the number of commits scanned (debug)."
160
175
  ),
@@ -195,6 +210,51 @@ def why(
195
210
  ref=resolved_ref,
196
211
  apply_suppressions=not no_mutes,
197
212
  )
213
+
214
+ if llm or llm_dry_run:
215
+ from whycode import decisions as dec
216
+
217
+ # Pick high-signal commits for L3: incidents take priority, plus
218
+ # any commit with a substantial body. Cap to keep the prompt small.
219
+ facts = gf.gather(repo_root, rel, max_commits=max_commits, ref=resolved_ref)
220
+ candidates = list(facts.incident_commits)
221
+ for c in facts.commits:
222
+ if c not in candidates and len(c.body) >= 100:
223
+ candidates.append(c)
224
+ if len(candidates) >= dec.DEFAULT_MAX_COMMITS:
225
+ break
226
+ candidates = candidates[: dec.DEFAULT_MAX_COMMITS]
227
+ n_commits, prompt_chars = dec.estimate_payload(candidates)
228
+
229
+ if llm_dry_run:
230
+ err.print(
231
+ f"[bold]LLM dry-run:[/bold] would send "
232
+ f"[bold]{n_commits}[/bold] commit(s), "
233
+ f"[bold]~{prompt_chars}[/bold] chars to the configured LLM provider.\n"
234
+ f" [dim]Provider, model, and key all read from "
235
+ f"WHYCODE_LLM_* environment variables.[/dim]"
236
+ )
237
+ if not json_out:
238
+ console.print(rc.render_text(card))
239
+ else:
240
+ console.print_json(json.dumps(card.to_dict()))
241
+ return
242
+
243
+ if n_commits == 0:
244
+ err.print(
245
+ "[yellow]--llm:[/yellow] no high-signal commits to enrich on this file."
246
+ )
247
+ else:
248
+ try:
249
+ decisions = dec.extract_decisions(candidates)
250
+ except dec.LLMConfigError as exc:
251
+ err.print(f"[red]--llm config error:[/red] {exc}")
252
+ raise typer.Exit(2) from exc
253
+ except dec.LLMCallError as exc:
254
+ err.print(f"[red]--llm call failed:[/red] {exc}")
255
+ raise typer.Exit(2) from exc
256
+ card = card.with_decisions(tuple(decisions))
257
+
198
258
  if json_out:
199
259
  console.print_json(json.dumps(card.to_dict()))
200
260
  return
whycode/decisions.py ADDED
@@ -0,0 +1,219 @@
1
+ """L3 — LLM-enriched decision extraction.
2
+
3
+ What L1+L2 give: a regex-level harvest of single lines like
4
+ ``"Do not switch to async"``. What L3 adds: structured decisions with
5
+ the full *why* drawn from the surrounding commit body.
6
+
7
+ Structured decision schema (one ``Decision`` per finding):
8
+
9
+ {
10
+ "decision_type": "incident_fix" | "compat_workaround" | "perf_rewrite"
11
+ | "rollback" | "constraint" | "other",
12
+ "what_changed": "one sentence summary",
13
+ "why": "one paragraph; quotes from the body where possible",
14
+ "do_not": "actionable constraint, or null",
15
+ "evidence": ["<sha1>", "<sha2>", …],
16
+ "confidence": 0.0 - 1.0
17
+ }
18
+
19
+ Confidence < ``min_confidence`` is filtered out before return — better to
20
+ emit nothing than emit a dressed-up guess. Privacy: this module makes a
21
+ network call only if ``call_llm`` is invoked, which only happens when the
22
+ caller passed commits in. Layer 1 and Layer 2 never reach this module.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import re
29
+ from collections.abc import Sequence
30
+ from dataclasses import dataclass
31
+
32
+ from whycode.git_facts import Commit
33
+ from whycode.llm import LLMCallError, LLMConfigError, call_llm
34
+
35
+ DEFAULT_MIN_CONFIDENCE = 0.5
36
+ DEFAULT_MAX_COMMITS = 10
37
+
38
+ _SYSTEM = (
39
+ "You are a careful code-history archaeologist. You read commit messages "
40
+ "and surface the engineering decisions that future readers will need to "
41
+ "respect. You never invent facts; if a commit body does not state a "
42
+ "decision worth carrying forward, you emit nothing for that commit. "
43
+ "All quotes you produce must be drawn from the commit body itself; "
44
+ "summarise rather than paraphrase when you cannot quote."
45
+ )
46
+
47
+ _PROMPT_TEMPLATE = """Below are commits from a Git repository. For each commit, extract a structured Decision **only when the commit body genuinely states one**. Otherwise emit nothing for that commit.
48
+
49
+ A Decision has this shape:
50
+
51
+ {{
52
+ "decision_type": one of
53
+ "incident_fix" | "compat_workaround" | "perf_rewrite" |
54
+ "rollback" | "constraint" | "other",
55
+ "what_changed": one-sentence summary of the change itself,
56
+ "why": one paragraph drawn from the body (quote where possible),
57
+ "do_not": the actionable constraint a future editor must respect,
58
+ or null if none stated,
59
+ "evidence": array of commit SHAs supporting this decision,
60
+ "confidence": a float in [0, 1] reflecting how clearly the body
61
+ states this decision (use < 0.5 if you are unsure)
62
+ }}
63
+
64
+ Rules:
65
+ - Reply with a JSON array of Decision objects, no prose, no code fences.
66
+ - Empty array if nothing qualifies.
67
+ - Quote rather than rephrase when stating "why".
68
+ - Do not infer constraints that are not in the body.
69
+ - Skip commits whose body is just a release note, dependency bump, or
70
+ one-line fix without explanation.
71
+
72
+ COMMITS:
73
+
74
+ {commits}
75
+ """
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Decision:
80
+ decision_type: str
81
+ what_changed: str
82
+ why: str
83
+ do_not: str | None
84
+ evidence: tuple[str, ...]
85
+ confidence: float
86
+
87
+ def to_dict(self) -> dict[str, object]:
88
+ return {
89
+ "decision_type": self.decision_type,
90
+ "what_changed": self.what_changed,
91
+ "why": self.why,
92
+ "do_not": self.do_not,
93
+ "evidence": list(self.evidence),
94
+ "confidence": round(self.confidence, 2),
95
+ }
96
+
97
+
98
+ def _format_commits_for_prompt(commits: Sequence[Commit]) -> str:
99
+ parts: list[str] = []
100
+ for c in commits:
101
+ parts.append(f"COMMIT {c.sha[:12]} ({c.author_name}, {c.authored_at.date()})")
102
+ parts.append(f"Subject: {c.subject}")
103
+ if c.body:
104
+ parts.append(f"Body:\n{c.body}")
105
+ parts.append("---")
106
+ return "\n".join(parts)
107
+
108
+
109
+ _VALID_TYPES = frozenset(
110
+ {
111
+ "incident_fix",
112
+ "compat_workaround",
113
+ "perf_rewrite",
114
+ "rollback",
115
+ "constraint",
116
+ "other",
117
+ }
118
+ )
119
+
120
+
121
+ def _strip_code_fence(raw: str) -> str:
122
+ raw = raw.strip()
123
+ raw = re.sub(r"^```(?:json)?\s*", "", raw)
124
+ raw = re.sub(r"\s*```\s*$", "", raw)
125
+ return raw.strip()
126
+
127
+
128
+ def _parse_decisions(raw: str, valid_shas: Sequence[str]) -> list[Decision]:
129
+ """Lenient parser. Bad JSON → empty list (we do not crash on a bad model
130
+ response). Missing fields default to empty/zero. Invalid evidence SHAs
131
+ are dropped silently."""
132
+ text = _strip_code_fence(raw)
133
+ try:
134
+ data = json.loads(text)
135
+ except json.JSONDecodeError:
136
+ return []
137
+ if not isinstance(data, list):
138
+ return []
139
+ short_lookup = {s[:12]: s for s in valid_shas}
140
+ out: list[Decision] = []
141
+ for item in data:
142
+ if not isinstance(item, dict):
143
+ continue
144
+ try:
145
+ decision_type = str(item.get("decision_type", "other"))
146
+ if decision_type not in _VALID_TYPES:
147
+ decision_type = "other"
148
+ what_changed = str(item.get("what_changed", "")).strip()
149
+ why = str(item.get("why", "")).strip()
150
+ do_not_raw = item.get("do_not")
151
+ do_not = str(do_not_raw).strip() if do_not_raw else None
152
+ raw_evidence = item.get("evidence", []) or []
153
+ evidence: list[str] = []
154
+ for token in raw_evidence:
155
+ t = str(token).strip()
156
+ # Accept full or 12-char prefix SHAs that match what we sent.
157
+ if t in short_lookup:
158
+ evidence.append(short_lookup[t])
159
+ elif len(t) >= 12 and t[:12] in short_lookup:
160
+ evidence.append(short_lookup[t[:12]])
161
+ if not evidence and valid_shas:
162
+ evidence = [valid_shas[0]]
163
+ confidence = float(item.get("confidence", 0.0))
164
+ confidence = max(0.0, min(1.0, confidence))
165
+ except (TypeError, ValueError):
166
+ continue
167
+ if not what_changed or not why:
168
+ continue
169
+ out.append(
170
+ Decision(
171
+ decision_type=decision_type,
172
+ what_changed=what_changed,
173
+ why=why,
174
+ do_not=do_not,
175
+ evidence=tuple(evidence),
176
+ confidence=confidence,
177
+ )
178
+ )
179
+ return out
180
+
181
+
182
+ def estimate_payload(commits: Sequence[Commit]) -> tuple[int, int]:
183
+ """Return ``(commit_count, prompt_char_count)`` so callers can show the
184
+ user the exact size of what would be sent before invoking the network.
185
+ """
186
+ if not commits:
187
+ return 0, 0
188
+ prompt = _PROMPT_TEMPLATE.format(commits=_format_commits_for_prompt(commits))
189
+ return len(commits), len(prompt) + len(_SYSTEM)
190
+
191
+
192
+ def extract_decisions(
193
+ commits: Sequence[Commit],
194
+ *,
195
+ min_confidence: float = DEFAULT_MIN_CONFIDENCE,
196
+ ) -> list[Decision]:
197
+ """Send ``commits`` to the configured LLM and parse structured decisions.
198
+
199
+ Raises ``LLMConfigError`` when the environment is not set up; raises
200
+ ``LLMCallError`` on transport / API failure. Returns ``[]`` on empty
201
+ input or a malformed model response.
202
+ """
203
+ if not commits:
204
+ return []
205
+ prompt = _PROMPT_TEMPLATE.format(commits=_format_commits_for_prompt(commits))
206
+ raw = call_llm(prompt, _SYSTEM)
207
+ decisions = _parse_decisions(raw, [c.sha for c in commits])
208
+ return [d for d in decisions if d.confidence >= min_confidence]
209
+
210
+
211
+ __all__ = [
212
+ "DEFAULT_MAX_COMMITS",
213
+ "DEFAULT_MIN_CONFIDENCE",
214
+ "Decision",
215
+ "LLMCallError",
216
+ "LLMConfigError",
217
+ "estimate_payload",
218
+ "extract_decisions",
219
+ ]
whycode/llm.py ADDED
@@ -0,0 +1,112 @@
1
+ """Provider-neutral LLM client wrapper for the optional L3 layer.
2
+
3
+ L3 is opt-in. Off by default. The CLI must require an explicit ``--llm``
4
+ flag and the user must set their own API key. This module never embeds
5
+ provider names, model identifiers, or default keys in source code —
6
+ configuration lives entirely in environment variables, so the source tree
7
+ itself does not advertise any specific vendor.
8
+
9
+ Required:
10
+ ``WHYCODE_LLM_API_KEY`` Your provider's API key.
11
+ ``WHYCODE_LLM_MODEL`` Your provider's model identifier (string).
12
+
13
+ Optional:
14
+ ``WHYCODE_LLM_MAX_TOKENS`` Output cap (default 2000).
15
+
16
+ The actual provider SDK is loaded lazily (``pip install 'whycode-cli[llm]'``)
17
+ so users who never invoke L3 do not pay the import cost or force a
18
+ dependency on any AI SDK.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ from dataclasses import dataclass
25
+
26
+
27
+ class LLMConfigError(RuntimeError):
28
+ """Raised when L3 is invoked without sufficient configuration."""
29
+
30
+
31
+ class LLMCallError(RuntimeError):
32
+ """Raised when the underlying provider call fails."""
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class LLMConfig:
37
+ api_key: str
38
+ model: str
39
+ max_tokens: int = 2000
40
+
41
+
42
+ def _read_config() -> LLMConfig:
43
+ """Read configuration from environment variables.
44
+
45
+ No defaults for ``api_key`` or ``model`` — both must be set explicitly.
46
+ The error message points the user at the ``--llm-dry-run`` flag for
47
+ self-service auditing.
48
+ """
49
+ api_key = os.environ.get("WHYCODE_LLM_API_KEY", "").strip()
50
+ model = os.environ.get("WHYCODE_LLM_MODEL", "").strip()
51
+ if not api_key:
52
+ raise LLMConfigError(
53
+ "WHYCODE_LLM_API_KEY is not set. To use --llm:\n"
54
+ " 1. Get an API key from your LLM provider.\n"
55
+ " 2. export WHYCODE_LLM_API_KEY=…\n"
56
+ " 3. export WHYCODE_LLM_MODEL=<your-provider's-model-identifier>\n"
57
+ " Use --llm-dry-run first to see exactly what would be sent."
58
+ )
59
+ if not model:
60
+ raise LLMConfigError(
61
+ "WHYCODE_LLM_MODEL is not set. Set it to your provider's model "
62
+ "identifier (consult your provider's docs for available models)."
63
+ )
64
+ raw_max = os.environ.get("WHYCODE_LLM_MAX_TOKENS", "2000").strip()
65
+ try:
66
+ max_tokens = int(raw_max)
67
+ except ValueError:
68
+ max_tokens = 2000
69
+ return LLMConfig(api_key=api_key, model=model, max_tokens=max_tokens)
70
+
71
+
72
+ def call_llm(prompt: str, system: str) -> str:
73
+ """Send ``prompt`` (with ``system`` instruction) to the configured LLM.
74
+
75
+ Returns the assistant's text response. Raises ``LLMConfigError`` if the
76
+ environment is not set up or the provider SDK is missing; raises
77
+ ``LLMCallError`` on transport / API failure.
78
+
79
+ The provider SDK is loaded lazily inside this call to keep the import
80
+ out of the cold path. This matches the architectural rule that L1+L2
81
+ must run with zero network and zero LLM dependencies.
82
+ """
83
+ cfg = _read_config()
84
+ try:
85
+ # Lazy import — the SDK is in the optional ``[llm]`` extras and is
86
+ # not required for the rest of WhyCode. Keep the package name out
87
+ # of any user-facing strings.
88
+ client_module = __import__("anthropic")
89
+ except ImportError as exc:
90
+ raise LLMConfigError(
91
+ "LLM support not installed. Run: pip install 'whycode-cli[llm]'"
92
+ ) from exc
93
+ try:
94
+ client = client_module.Anthropic(api_key=cfg.api_key)
95
+ msg = client.messages.create(
96
+ model=cfg.model,
97
+ max_tokens=cfg.max_tokens,
98
+ system=system,
99
+ messages=[{"role": "user", "content": prompt}],
100
+ )
101
+ except Exception as exc:
102
+ raise LLMCallError(f"LLM call failed: {exc}") from exc
103
+ # Anthropic returns a list of content blocks; concatenate text-typed ones.
104
+ parts: list[str] = []
105
+ for block in getattr(msg, "content", []):
106
+ text = getattr(block, "text", None)
107
+ if isinstance(text, str):
108
+ parts.append(text)
109
+ return "".join(parts)
110
+
111
+
112
+ __all__ = ["LLMCallError", "LLMConfig", "LLMConfigError", "call_llm"]
whycode/mcp_server.py CHANGED
@@ -9,6 +9,20 @@ Tools
9
9
  - ``get_file_decisions(path, limit=5)`` — decision-flavoured signals only
10
10
  (incidents, reverts, invariants), highest severity first.
11
11
 
12
+ Prompts
13
+ -------
14
+ Reusable prompt templates the host can offer the user as one-click actions.
15
+ The server fills in WhyCode data; the host LLM does the actual reasoning.
16
+ No outbound network calls happen here -- prompts are pure local data plus a
17
+ short instruction wrapper, exactly like tools.
18
+
19
+ - ``before_edit_checklist(path)`` -- fetch the Risk Card and ask the model to
20
+ walk the user through every HIGH-severity signal before suggesting an edit.
21
+ - ``summarise_for_postmortem(sha)`` -- fetch a commit's metadata and
22
+ classification and ask the model to draft a postmortem-ready summary.
23
+ - ``risk_briefing_for_pr(base)`` -- fetch the diff risk briefing and ask the
24
+ model to summarise it for a reviewer in 3-5 bullets.
25
+
12
26
  The server speaks stdio. Configure your client with:
13
27
 
14
28
  {
@@ -29,7 +43,14 @@ from typing import Any
29
43
 
30
44
  from mcp.server import Server
31
45
  from mcp.server.stdio import stdio_server
32
- from mcp.types import TextContent, Tool
46
+ from mcp.types import (
47
+ GetPromptResult,
48
+ Prompt,
49
+ PromptArgument,
50
+ PromptMessage,
51
+ TextContent,
52
+ Tool,
53
+ )
33
54
 
34
55
  from whycode import git_facts as gf
35
56
  from whycode import risk_card as rc
@@ -121,6 +142,18 @@ def _build_server(verbose: bool = False) -> Server:
121
142
  return _handle_file_decisions(arguments)
122
143
  raise ValueError(f"Unknown tool: {name}")
123
144
 
145
+ @server.list_prompts() # type: ignore[no-untyped-call,untyped-decorator]
146
+ async def _list_prompts() -> list[Prompt]:
147
+ return list(_PROMPTS)
148
+
149
+ @server.get_prompt() # type: ignore[no-untyped-call,untyped-decorator]
150
+ async def _get_prompt(
151
+ name: str, arguments: dict[str, str] | None
152
+ ) -> GetPromptResult:
153
+ if verbose:
154
+ _log_call(f"prompt:{name}", dict(arguments or {}))
155
+ return _render_prompt(name, arguments or {})
156
+
124
157
  return server
125
158
 
126
159
 
@@ -184,6 +217,278 @@ def _handle_file_decisions(arguments: dict[str, Any]) -> list[TextContent]:
184
217
  return [TextContent(type="text", text=json.dumps(payload, indent=2))]
185
218
 
186
219
 
220
+ # ---------------------------------------------------------------------------
221
+ # Prompts
222
+ # ---------------------------------------------------------------------------
223
+ #
224
+ # Prompts are saved-search shortcuts: the host editor surfaces them as
225
+ # one-click actions; the server fills in WhyCode data; the host LLM does
226
+ # the reasoning. They never make outbound network calls -- the data is
227
+ # strictly local git history, exactly like the tool surface.
228
+
229
+ _BEFORE_EDIT = "before_edit_checklist"
230
+ _POSTMORTEM = "summarise_for_postmortem"
231
+ _PR_BRIEFING = "risk_briefing_for_pr"
232
+
233
+ _PROMPTS: tuple[Prompt, ...] = (
234
+ Prompt(
235
+ name=_BEFORE_EDIT,
236
+ description=(
237
+ "Fetch the Risk Card for a file and ask the assistant to walk the "
238
+ "user through every HIGH-severity signal before suggesting any edit. "
239
+ "Call this from the editor before you start changing an unfamiliar file."
240
+ ),
241
+ arguments=[
242
+ PromptArgument(
243
+ name="path",
244
+ description="Path to the file (absolute or repo-relative).",
245
+ required=True,
246
+ ),
247
+ ],
248
+ ),
249
+ Prompt(
250
+ name=_POSTMORTEM,
251
+ description=(
252
+ "Fetch a commit's metadata and WhyCode classification and ask the "
253
+ "assistant to draft a concise incident summary suitable for a "
254
+ "postmortem document, citing specific evidence SHAs."
255
+ ),
256
+ arguments=[
257
+ PromptArgument(
258
+ name="sha",
259
+ description="Commit SHA (full or short) to summarise.",
260
+ required=True,
261
+ ),
262
+ ],
263
+ ),
264
+ Prompt(
265
+ name=_PR_BRIEFING,
266
+ description=(
267
+ "Fetch the WhyCode risk briefing for files changed against a base "
268
+ "ref and ask the assistant to summarise it for a PR reviewer in "
269
+ "3-5 bullets, emphasising HANDLE WITH CARE files."
270
+ ),
271
+ arguments=[
272
+ PromptArgument(
273
+ name="base",
274
+ description="Base ref to diff against (e.g. origin/main, main, HEAD~1).",
275
+ required=True,
276
+ ),
277
+ ],
278
+ ),
279
+ )
280
+
281
+
282
+ def _missing_arg(name: str, arg: str) -> GetPromptResult:
283
+ """Render a friendly error as a user-role message, so the host displays it."""
284
+ text = f"WhyCode prompt {name!r} requires the {arg!r} argument."
285
+ return GetPromptResult(
286
+ description=text,
287
+ messages=[
288
+ PromptMessage(role="user", content=TextContent(type="text", text=text)),
289
+ ],
290
+ )
291
+
292
+
293
+ def _git_error(name: str, exc: gf.GitError) -> GetPromptResult:
294
+ text = f"WhyCode prompt {name!r} could not run: {exc}"
295
+ return GetPromptResult(
296
+ description=text,
297
+ messages=[
298
+ PromptMessage(role="user", content=TextContent(type="text", text=text)),
299
+ ],
300
+ )
301
+
302
+
303
+ def _render_prompt(name: str, arguments: dict[str, str]) -> GetPromptResult:
304
+ if name == _BEFORE_EDIT:
305
+ return _render_before_edit(arguments)
306
+ if name == _POSTMORTEM:
307
+ return _render_postmortem(arguments)
308
+ if name == _PR_BRIEFING:
309
+ return _render_pr_briefing(arguments)
310
+ raise ValueError(f"Unknown prompt: {name}")
311
+
312
+
313
+ def _format_card_for_prompt(card: rc.RiskCard) -> str:
314
+ """Render a Risk Card as plain text fit for embedding in a prompt body."""
315
+ lines: list[str] = []
316
+ lines.append(
317
+ f"file: {card.path}\n"
318
+ f"band: {card.score.band.value}\n"
319
+ f"score: {card.score.value}/100\n"
320
+ f"commits: {card.commit_count}"
321
+ )
322
+ if card.most_recent_subject:
323
+ lines.append(
324
+ f"latest: {card.most_recent_sha} -- {card.most_recent_subject} "
325
+ f"({card.most_recent_author})"
326
+ )
327
+ if not card.signals:
328
+ lines.append("signals: none fired")
329
+ return "\n".join(lines)
330
+ lines.append("signals:")
331
+ for s in card.signals:
332
+ sev = "HIGH" if s.severity >= 4 else "MED" if s.severity == 3 else "LOW"
333
+ lines.append(f" [{sev}] {s.kind.value}: {s.headline}")
334
+ if s.detail:
335
+ lines.append(f" {s.detail}")
336
+ if s.evidence:
337
+ lines.append(f" evidence: {', '.join(s.evidence)}")
338
+ return "\n".join(lines)
339
+
340
+
341
+ def _render_before_edit(arguments: dict[str, str]) -> GetPromptResult:
342
+ path = arguments.get("path")
343
+ if not path:
344
+ return _missing_arg(_BEFORE_EDIT, "path")
345
+ try:
346
+ repo_root, rel = _resolve(path)
347
+ card = rc.build(repo_root, rel)
348
+ except gf.GitError as exc:
349
+ return _git_error(_BEFORE_EDIT, exc)
350
+
351
+ high_signals = [s for s in card.signals if s.severity >= 4]
352
+ body = (
353
+ "WhyCode pulled the following Risk Card from local git history.\n"
354
+ "Before suggesting any edit to this file, walk the user through every "
355
+ "HIGH-severity signal below and ask them to confirm they understand "
356
+ "each one. Quote the headline verbatim and cite the evidence SHAs. "
357
+ "If no HIGH signals fired, say so explicitly and remind the user to "
358
+ "read the diff anyway.\n\n"
359
+ f"{_format_card_for_prompt(card)}\n\n"
360
+ f"high-severity signals: {len(high_signals)}"
361
+ )
362
+ return GetPromptResult(
363
+ description=(
364
+ f"Pre-edit checklist for {card.path}: "
365
+ f"{card.score.band.value} ({card.score.value}/100), "
366
+ f"{len(high_signals)} HIGH-severity signal(s)."
367
+ ),
368
+ messages=[
369
+ PromptMessage(role="user", content=TextContent(type="text", text=body)),
370
+ ],
371
+ )
372
+
373
+
374
+ def _render_postmortem(arguments: dict[str, str]) -> GetPromptResult:
375
+ sha = arguments.get("sha")
376
+ if not sha:
377
+ return _missing_arg(_POSTMORTEM, "sha")
378
+ try:
379
+ repo_root = gf.discover_repo_root(Path.cwd())
380
+ except gf.GitError as exc:
381
+ return _git_error(_POSTMORTEM, exc)
382
+ commit = gf.read_commit(repo_root, sha)
383
+ if commit is None:
384
+ return _git_error(_POSTMORTEM, gf.GitError(f"could not read commit {sha!r}"))
385
+
386
+ classification = gf.classify_commit(commit)
387
+ invariants = gf.extract_invariant_quotes([commit])
388
+ file_changes = gf.files_changed_in(repo_root, commit.sha)
389
+
390
+ badges: list[str] = []
391
+ if classification.incident_flavoured:
392
+ badges.append("incident-flavoured")
393
+ if invariants:
394
+ badges.append(f"states {len(invariants)} invariant(s)")
395
+ if not badges:
396
+ badges.append("no special classification")
397
+
398
+ lines: list[str] = []
399
+ lines.append(f"sha: {commit.sha[:12]}")
400
+ lines.append(f"author: {commit.author_name} <{commit.author_email}>")
401
+ lines.append(f"authored_at: {commit.authored_at.isoformat()}")
402
+ lines.append(f"subject: {commit.subject}")
403
+ lines.append(f"classification: {', '.join(badges)}")
404
+ lines.append(f"files_changed: {len(file_changes)}")
405
+ if commit.body:
406
+ lines.append("body:")
407
+ for raw_line in commit.body.splitlines():
408
+ lines.append(f" {raw_line}")
409
+ if invariants:
410
+ lines.append("invariants stated by this commit:")
411
+ for inv_sha, inv_line in invariants:
412
+ lines.append(f" ({inv_sha[:7]}) {inv_line}")
413
+ if file_changes:
414
+ lines.append("paths touched:")
415
+ for change in file_changes[:20]:
416
+ lines.append(f" {change.path}")
417
+ if len(file_changes) > 20:
418
+ lines.append(f" ... and {len(file_changes) - 20} more")
419
+
420
+ body = (
421
+ "WhyCode pulled the following commit metadata from local git history.\n"
422
+ "Compose a concise incident summary suitable for a postmortem "
423
+ "document. Cover what changed, why (drawing on the commit body), "
424
+ "which files were touched, and any invariants the author stated. "
425
+ "Cite specific evidence SHAs verbatim -- never invent commits not "
426
+ "listed below. Keep it under 200 words; use plain prose, not bullet "
427
+ "lists.\n\n" + "\n".join(lines)
428
+ )
429
+ return GetPromptResult(
430
+ description=(
431
+ f"Postmortem summary for {commit.sha[:12]}: "
432
+ f"{', '.join(badges)}."
433
+ ),
434
+ messages=[
435
+ PromptMessage(role="user", content=TextContent(type="text", text=body)),
436
+ ],
437
+ )
438
+
439
+
440
+ def _render_pr_briefing(arguments: dict[str, str]) -> GetPromptResult:
441
+ base = arguments.get("base")
442
+ if not base:
443
+ return _missing_arg(_PR_BRIEFING, "base")
444
+ try:
445
+ repo_root = gf.discover_repo_root(Path.cwd())
446
+ raw = gf.run_git(repo_root, "diff", "--name-only", f"{base}...HEAD")
447
+ except gf.GitError as exc:
448
+ return _git_error(_PR_BRIEFING, exc)
449
+
450
+ files = [line for line in raw.splitlines() if line.strip()]
451
+ cards: list[rc.RiskCard] = []
452
+ for f in files:
453
+ try:
454
+ cards.append(rc.build(repo_root, f))
455
+ except gf.GitError:
456
+ continue
457
+ cards.sort(key=lambda c: -c.score.value)
458
+
459
+ lines: list[str] = []
460
+ lines.append(f"base: {base}")
461
+ lines.append(f"files_changed: {len(files)}")
462
+ if not cards:
463
+ lines.append("no files with computable risk against this base")
464
+ else:
465
+ lines.append("risk-ranked files (highest first):")
466
+ for c in cards[:20]:
467
+ top = c.signals[0].headline if c.signals else "no flags"
468
+ lines.append(
469
+ f" [{c.score.value:>3}] {c.score.band.value:<20} "
470
+ f"{c.path} -- {top}"
471
+ )
472
+
473
+ body = (
474
+ "WhyCode produced the following risk briefing for files changed "
475
+ "against the base ref. Summarise it for a PR reviewer in 3-5 bullets, "
476
+ "putting HANDLE WITH CARE files first and naming each by path and "
477
+ "top signal. Do not invent risk that is not listed below; if the "
478
+ "briefing is empty, say so honestly.\n\n" + "\n".join(lines)
479
+ )
480
+ handle_with_care = [c for c in cards if c.score.band.value == "HANDLE WITH CARE"]
481
+ return GetPromptResult(
482
+ description=(
483
+ f"PR risk briefing vs {base}: {len(files)} file(s), "
484
+ f"{len(handle_with_care)} HANDLE WITH CARE."
485
+ ),
486
+ messages=[
487
+ PromptMessage(role="user", content=TextContent(type="text", text=body)),
488
+ ],
489
+ )
490
+
491
+
187
492
  async def _run(verbose: bool) -> None:
188
493
  server = _build_server(verbose=verbose)
189
494
  if verbose:
whycode/risk_card.py CHANGED
@@ -24,6 +24,8 @@ from whycode.scorer import Band, Score, score
24
24
  if TYPE_CHECKING:
25
25
  from pathlib import Path
26
26
 
27
+ from whycode.decisions import Decision
28
+
27
29
 
28
30
  @dataclass(frozen=True)
29
31
  class RiskCard:
@@ -38,6 +40,15 @@ class RiskCard:
38
40
  as_of_sha: str | None = None
39
41
  """When set, the card was computed *as of* this commit (historical view)."""
40
42
 
43
+ decisions: tuple[Decision, ...] = ()
44
+ """L3 — LLM-extracted structured decisions. Empty unless ``--llm`` was on."""
45
+
46
+ def with_decisions(self, decisions: tuple[Decision, ...]) -> RiskCard:
47
+ """Return a copy with the L3 ``decisions`` field populated."""
48
+ from dataclasses import replace
49
+
50
+ return replace(self, decisions=decisions)
51
+
41
52
  def to_dict(self) -> dict[str, Any]:
42
53
  return {
43
54
  "path": self.path,
@@ -65,6 +76,7 @@ class RiskCard:
65
76
  }
66
77
  for s in self.signals
67
78
  ],
79
+ "decisions": [d.to_dict() for d in self.decisions],
68
80
  }
69
81
 
70
82
 
@@ -190,11 +202,40 @@ def _next_step_hint(signals: tuple[sig.Signal, ...]) -> Text | None:
190
202
  return None
191
203
 
192
204
 
205
+ def _decisions_block(decisions: tuple[Decision, ...]) -> Padding:
206
+ """Render the L3 decisions section inside a labelled panel."""
207
+ body = Text()
208
+ for i, d in enumerate(decisions):
209
+ if i:
210
+ body.append("\n\n")
211
+ # Header: type + confidence badge.
212
+ body.append(f"{d.decision_type.replace('_', ' ').upper()}", style="bold cyan")
213
+ body.append(f" confidence {int(d.confidence * 100)}%\n", style="dim")
214
+ body.append(d.what_changed + "\n", style="bold")
215
+ body.append("Why: ", style="dim")
216
+ body.append(d.why + "\n", style="italic")
217
+ if d.do_not:
218
+ body.append("Don't: ", style="bold red")
219
+ body.append(d.do_not + "\n", style="")
220
+ if d.evidence:
221
+ short = ", ".join(s[:7] for s in d.evidence)
222
+ body.append(f"evidence: {short}", style="dim")
223
+ panel = Panel(
224
+ body,
225
+ title=Text(" DECISIONS (L3) ", style="bold white on magenta"),
226
+ title_align="left",
227
+ border_style="grey50",
228
+ )
229
+ return Padding(panel, (1, 1, 0, 1))
230
+
231
+
193
232
  def render_text(card: RiskCard) -> Group:
194
233
  pieces: list[Any] = [
195
234
  _header(card),
196
235
  Padding(_signals_table(card.signals), (0, 1, 0, 1)),
197
236
  ]
237
+ if card.decisions:
238
+ pieces.append(_decisions_block(card.decisions))
198
239
  hint = _next_step_hint(card.signals)
199
240
  if hint is not None:
200
241
  pieces.append(Padding(hint, (0, 1, 1, 2)))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.2.6
3
+ Version: 0.3.1
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
@@ -19,6 +19,8 @@ Requires-Dist: typer>=0.12
19
19
  Requires-Dist: rich>=13.7
20
20
  Provides-Extra: mcp
21
21
  Requires-Dist: mcp>=1.0; extra == "mcp"
22
+ Provides-Extra: llm
23
+ Requires-Dist: anthropic>=0.40; extra == "llm"
22
24
  Provides-Extra: dev
23
25
  Requires-Dist: pytest>=8; extra == "dev"
24
26
  Requires-Dist: pytest-cov>=5; extra == "dev"
@@ -197,11 +199,32 @@ Tune the thresholds inside those two files for your repo. Re-run with
197
199
  | ----- | ------------------------------------------------------------------------ | -------- | -------- |
198
200
  | 1 | Deterministic git facts (log, diffstat, revert pairs, author activity) | no | no |
199
201
  | 2 | Heuristic signals (reverts, incidents, silence, ghost keeper, coupling, invariants, churn, newborn) | no | no |
200
- | 3 | LLM polish (optional, opt-in, never on by default) | yes | yes |
202
+ | 3 | LLM-extracted structured decisions (optional, opt-in, never on by default) | yes | yes |
201
203
 
202
- **Layer 1 + Layer 2 produce the Risk Card you saw above. No model calls, no
203
- data leaving your machine.** Layer 3 is reserved for natural-language
204
- summarisation of decisions and is strictly opt-in.
204
+ **Layer 1 + Layer 2 produce the Risk Card by default. No model calls, no
205
+ data leaving your machine.** Layer 3 lifts the keyword fragments L1 + L2
206
+ extract ("do not switch to async") into structured decisions with the
207
+ *why* drawn from the surrounding commit body — but only when you ask for
208
+ it with `--llm`.
209
+
210
+ ### Optional L3 — LLM-enriched decisions
211
+
212
+ Install the optional extras and configure the env vars:
213
+
214
+ ```bash
215
+ pip install 'whycode-cli[llm]'
216
+ export WHYCODE_LLM_API_KEY="…"
217
+ export WHYCODE_LLM_MODEL="<your-provider's-model-identifier>"
218
+
219
+ whycode why src/some/file.py --llm # full card + structured decisions
220
+ whycode why src/some/file.py --llm-dry-run # see exactly what would be sent
221
+ ```
222
+
223
+ Privacy contract: configuration is entirely environment-driven (no
224
+ hardcoded provider in the source tree); the SDK is lazy-imported (no
225
+ import cost unless you opt in); only L2-filtered high-signal commits
226
+ are sent (capped at 10 per call); a malformed model response degrades
227
+ to "no decisions" rather than crashing.
205
228
 
206
229
  ## What this is NOT
207
230
 
@@ -1,19 +1,21 @@
1
- whycode/__init__.py,sha256=PX9ljfWyjwwJEA1_I-kk34Qfj-9N3WRnXy1zQ6i6t-M,96
1
+ whycode/__init__.py,sha256=wiigWjNrflQT6-gb-awqXO00CNvVX6-2SUb97zVDBbQ,96
2
2
  whycode/__main__.py,sha256=dqAk6746YpuM-FTIH4TBOULegGc5WweojiZjce0VYgQ,105
3
- whycode/cli.py,sha256=JTufemrXaq-3ySNG-xfPZ0f5UhbtThiD4TXWSxE5qZ4,37365
3
+ whycode/cli.py,sha256=PApJADeJfU4I1-PJhJebeTovGRgEl6-gUlMV-3q2dng,39823
4
+ whycode/decisions.py,sha256=oCVhEF7QfHeci0LAWNtEjV2mUAEBJloL1rT3I4XXbkw,7570
4
5
  whycode/git_facts.py,sha256=VozSt59dWhUcDQ2qyDA2Bfa6AWvfBmIaQKP1DAYUpPM,17820
5
6
  whycode/ignore.py,sha256=sdRO_0HSedm8aO69CSGl-zQrUVX5MEg9QGcAJWwAvP4,3021
6
- whycode/mcp_server.py,sha256=56csOHSP90Zk59-_Puvk4WTSlCJ6xQAm-K10b_qmyAQ,7105
7
- whycode/risk_card.py,sha256=iIk4MkQQrlnj782dxdfoogUcByunI5j6y8vUnuhByAA,6996
7
+ whycode/llm.py,sha256=leB94pBg8kUCq_BujZq5ixny0urGtKskjdaKoum_eCA,4092
8
+ whycode/mcp_server.py,sha256=ht1tStAkOwmQzNIRkm1eA8Tnc59fzDRSGkgyIprft-0,18503
9
+ whycode/risk_card.py,sha256=wxmGAR0FhioTHQfNUCQN-ouwRp0IqI45AkOZ85ya4Eo,8616
8
10
  whycode/scorer.py,sha256=4pBejunfxzYhGUzMeL8uGEMQzC6DWiqwcTeMdo3eras,1444
9
11
  whycode/signals.py,sha256=14KziRolXvhmOnMnluXpPPInoBRO5uDu0tm024EYik0,13066
10
12
  whycode/suppressions.py,sha256=1lKSs-kCgpnJbcxozcgiSP8ZAfjEDMHXuM3sw4FaY78,3836
11
13
  whycode/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
14
  whycode/templates/github-workflow.yml,sha256=LAfHMDG2TkAwi4vCNinHk-4zOt-mCWErBpmpaqlW5oA,2251
13
15
  whycode/templates/pre-commit,sha256=IhU11CvoDwqRAAsvHwUo-BwaNbdgy1cpXc54Z_phrmQ,316
14
- whycode_cli-0.2.6.dist-info/licenses/LICENSE,sha256=U6LN5qg5kJXSJf7KFPm9KJhmiGn3qK_GsTVWXdt1DFA,1062
15
- whycode_cli-0.2.6.dist-info/METADATA,sha256=zp9iSlF6ymPkl2om4iaza9CshWA_aHyETjFs7MbPJIg,9327
16
- whycode_cli-0.2.6.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
17
- whycode_cli-0.2.6.dist-info/entry_points.txt,sha256=xrNWc4CQn3ZhQFJxsGIPiTqpN19K4pRpgaj6qGaEzSQ,44
18
- whycode_cli-0.2.6.dist-info/top_level.txt,sha256=6yIL5rxW-4DbARHQYrPlGQVqKddZ88sjvmNosDh1w3A,8
19
- whycode_cli-0.2.6.dist-info/RECORD,,
16
+ whycode_cli-0.3.1.dist-info/licenses/LICENSE,sha256=U6LN5qg5kJXSJf7KFPm9KJhmiGn3qK_GsTVWXdt1DFA,1062
17
+ whycode_cli-0.3.1.dist-info/METADATA,sha256=HXmG_VsgYUO_s1LMVZ3W5nHOEgBPnD_3ZP6Iarf5fmM,10218
18
+ whycode_cli-0.3.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ whycode_cli-0.3.1.dist-info/entry_points.txt,sha256=xrNWc4CQn3ZhQFJxsGIPiTqpN19K4pRpgaj6qGaEzSQ,44
20
+ whycode_cli-0.3.1.dist-info/top_level.txt,sha256=6yIL5rxW-4DbARHQYrPlGQVqKddZ88sjvmNosDh1w3A,8
21
+ whycode_cli-0.3.1.dist-info/RECORD,,