whycode-cli 0.3.0__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 +1 -1
- whycode/mcp_server.py +306 -1
- {whycode_cli-0.3.0.dist-info → whycode_cli-0.3.1.dist-info}/METADATA +1 -1
- {whycode_cli-0.3.0.dist-info → whycode_cli-0.3.1.dist-info}/RECORD +8 -8
- {whycode_cli-0.3.0.dist-info → whycode_cli-0.3.1.dist-info}/WHEEL +0 -0
- {whycode_cli-0.3.0.dist-info → whycode_cli-0.3.1.dist-info}/entry_points.txt +0 -0
- {whycode_cli-0.3.0.dist-info → whycode_cli-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {whycode_cli-0.3.0.dist-info → whycode_cli-0.3.1.dist-info}/top_level.txt +0 -0
whycode/__init__.py
CHANGED
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
|
|
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:
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
whycode/__init__.py,sha256=
|
|
1
|
+
whycode/__init__.py,sha256=wiigWjNrflQT6-gb-awqXO00CNvVX6-2SUb97zVDBbQ,96
|
|
2
2
|
whycode/__main__.py,sha256=dqAk6746YpuM-FTIH4TBOULegGc5WweojiZjce0VYgQ,105
|
|
3
3
|
whycode/cli.py,sha256=PApJADeJfU4I1-PJhJebeTovGRgEl6-gUlMV-3q2dng,39823
|
|
4
4
|
whycode/decisions.py,sha256=oCVhEF7QfHeci0LAWNtEjV2mUAEBJloL1rT3I4XXbkw,7570
|
|
5
5
|
whycode/git_facts.py,sha256=VozSt59dWhUcDQ2qyDA2Bfa6AWvfBmIaQKP1DAYUpPM,17820
|
|
6
6
|
whycode/ignore.py,sha256=sdRO_0HSedm8aO69CSGl-zQrUVX5MEg9QGcAJWwAvP4,3021
|
|
7
7
|
whycode/llm.py,sha256=leB94pBg8kUCq_BujZq5ixny0urGtKskjdaKoum_eCA,4092
|
|
8
|
-
whycode/mcp_server.py,sha256=
|
|
8
|
+
whycode/mcp_server.py,sha256=ht1tStAkOwmQzNIRkm1eA8Tnc59fzDRSGkgyIprft-0,18503
|
|
9
9
|
whycode/risk_card.py,sha256=wxmGAR0FhioTHQfNUCQN-ouwRp0IqI45AkOZ85ya4Eo,8616
|
|
10
10
|
whycode/scorer.py,sha256=4pBejunfxzYhGUzMeL8uGEMQzC6DWiqwcTeMdo3eras,1444
|
|
11
11
|
whycode/signals.py,sha256=14KziRolXvhmOnMnluXpPPInoBRO5uDu0tm024EYik0,13066
|
|
@@ -13,9 +13,9 @@ whycode/suppressions.py,sha256=1lKSs-kCgpnJbcxozcgiSP8ZAfjEDMHXuM3sw4FaY78,3836
|
|
|
13
13
|
whycode/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
14
|
whycode/templates/github-workflow.yml,sha256=LAfHMDG2TkAwi4vCNinHk-4zOt-mCWErBpmpaqlW5oA,2251
|
|
15
15
|
whycode/templates/pre-commit,sha256=IhU11CvoDwqRAAsvHwUo-BwaNbdgy1cpXc54Z_phrmQ,316
|
|
16
|
-
whycode_cli-0.3.
|
|
17
|
-
whycode_cli-0.3.
|
|
18
|
-
whycode_cli-0.3.
|
|
19
|
-
whycode_cli-0.3.
|
|
20
|
-
whycode_cli-0.3.
|
|
21
|
-
whycode_cli-0.3.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|