drskill-core 0.3.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.
- drskill/__init__.py +0 -0
- drskill/checks/__init__.py +82 -0
- drskill/checks/budget.py +40 -0
- drskill/checks/duplicates.py +103 -0
- drskill/checks/filesystem.py +36 -0
- drskill/checks/heuristics.py +249 -0
- drskill/checks/injection.py +523 -0
- drskill/checks/lockfile.py +129 -0
- drskill/checks/shadowing.py +144 -0
- drskill/checks/spec.py +108 -0
- drskill/cli.py +521 -0
- drskill/data/__init__.py +0 -0
- drskill/data/harnesses.toml +973 -0
- drskill/deep.py +240 -0
- drskill/deep_llm.py +84 -0
- drskill/discovery.py +96 -0
- drskill/harnesses.py +62 -0
- drskill/interactive.py +48 -0
- drskill/ledger.py +133 -0
- drskill/models.py +79 -0
- drskill/pipeline.py +58 -0
- drskill/report.py +271 -0
- drskill/resolution.py +222 -0
- drskill/state.py +42 -0
- drskill/text.py +113 -0
- drskill/tokens.py +30 -0
- drskill_core-0.3.0.dist-info/METADATA +234 -0
- drskill_core-0.3.0.dist-info/RECORD +31 -0
- drskill_core-0.3.0.dist-info/WHEEL +4 -0
- drskill_core-0.3.0.dist-info/entry_points.txt +2 -0
- drskill_core-0.3.0.dist-info/licenses/LICENSE +21 -0
drskill/cli.py
ADDED
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import datetime as dt
|
|
4
|
+
import os
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
|
|
12
|
+
from drskill import deep, interactive, ledger, report, state
|
|
13
|
+
from drskill.ledger import Ack
|
|
14
|
+
from drskill.pipeline import run_scan
|
|
15
|
+
|
|
16
|
+
key_source = interactive.read_key # patched in tests
|
|
17
|
+
line_source = input # patched in tests
|
|
18
|
+
|
|
19
|
+
INIT_TEMPLATE = """\
|
|
20
|
+
# drskill configuration and decision ledger.
|
|
21
|
+
# Commit this file. Acks silence a finding until the skill content changes.
|
|
22
|
+
|
|
23
|
+
[budget]
|
|
24
|
+
catalog_tokens_max = 6000 # per-harness startup catalog budget (approximate tokens)
|
|
25
|
+
body_tokens_warn = 20000 # per-skill body ceiling (approximate tokens)
|
|
26
|
+
|
|
27
|
+
[thresholds]
|
|
28
|
+
near_duplicate = 0.85 # Jaccard similarity that counts as a near duplicate
|
|
29
|
+
description_overlap = 0.6 # cosine similarity that clusters descriptions
|
|
30
|
+
generic_min_distinct_tokens = 2 # fewer distinctive words than this is too vague
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
app = typer.Typer(add_completion=False, help="brew doctor for your agent's skill loadout")
|
|
34
|
+
console = Console()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _home() -> Path:
|
|
38
|
+
env = os.environ.get("DRSKILL_HOME")
|
|
39
|
+
return Path(env) if env else Path.home()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _validate_harness(harness: str | None) -> None:
|
|
43
|
+
if harness is None:
|
|
44
|
+
return
|
|
45
|
+
from drskill.harnesses import load_harnesses
|
|
46
|
+
|
|
47
|
+
ids = sorted(h.id for h in load_harnesses())
|
|
48
|
+
if harness not in ids:
|
|
49
|
+
console.print(
|
|
50
|
+
f"[red]error:[/red] unknown harness {escape(harness)}; "
|
|
51
|
+
f"valid ids: {escape(', '.join(ids))}"
|
|
52
|
+
)
|
|
53
|
+
raise typer.Exit(1)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _warn_if_undetected(
|
|
57
|
+
harness: str | None, root: Path, home: Path, global_mode: bool
|
|
58
|
+
) -> None:
|
|
59
|
+
if harness is None:
|
|
60
|
+
return
|
|
61
|
+
from drskill.harnesses import detect_harnesses
|
|
62
|
+
|
|
63
|
+
detected = {h.id for h in detect_harnesses(root, home, global_mode)}
|
|
64
|
+
if harness not in detected:
|
|
65
|
+
console.print(
|
|
66
|
+
f"[dim]note: harness {escape(harness)} is not detected on this "
|
|
67
|
+
"machine; scanning its search paths anyway[/dim]"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_config_or_exit(path: Path) -> ledger.Config:
|
|
72
|
+
try:
|
|
73
|
+
return ledger.load_config(path)
|
|
74
|
+
except ledger.LedgerError as e:
|
|
75
|
+
console.print(f"[red]error:[/red] {escape(str(e))}")
|
|
76
|
+
raise typer.Exit(1)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _load_effective_config_or_exit(
|
|
80
|
+
root: Path, home: Path, global_mode: bool
|
|
81
|
+
) -> ledger.Config:
|
|
82
|
+
try:
|
|
83
|
+
return ledger.load_effective_config(root, home, global_mode)
|
|
84
|
+
except ledger.LedgerError as e:
|
|
85
|
+
console.print(f"[red]error:[/red] {escape(str(e))}")
|
|
86
|
+
raise typer.Exit(1)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@app.callback()
|
|
90
|
+
def main() -> None:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _resolve_refs(refs: list[str], active: list) -> list:
|
|
95
|
+
"""Resolve 4-hex finding ids and bare check ids to active findings.
|
|
96
|
+
Exits 1 on no match or on an ambiguous id. Shared by ack and show."""
|
|
97
|
+
import re
|
|
98
|
+
|
|
99
|
+
from drskill.checks import REGISTRY
|
|
100
|
+
|
|
101
|
+
targets: list = []
|
|
102
|
+
for ref in refs:
|
|
103
|
+
if ref in REGISTRY:
|
|
104
|
+
matches = [f for f in active if f.check_id == ref]
|
|
105
|
+
if not matches:
|
|
106
|
+
console.print(f"[red]No active finding matches[/red] {escape(ref)}")
|
|
107
|
+
raise typer.Exit(1)
|
|
108
|
+
targets += [f for f in matches if f not in targets]
|
|
109
|
+
elif re.fullmatch(r"[0-9a-f]{4,64}", ref):
|
|
110
|
+
hits = [f for f in active if f.fingerprint.split(":", 1)[1].startswith(ref)]
|
|
111
|
+
if not hits:
|
|
112
|
+
console.print(f"[red]No active finding matches[/red] id {escape(ref)}")
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
if len(hits) > 1:
|
|
115
|
+
console.print(
|
|
116
|
+
f"[red]Ambiguous id[/red] {escape(ref)}: matches "
|
|
117
|
+
f"{len(hits)} findings; use more characters"
|
|
118
|
+
)
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
if hits[0] not in targets:
|
|
121
|
+
targets.append(hits[0])
|
|
122
|
+
else:
|
|
123
|
+
console.print(
|
|
124
|
+
f"[red]Not a finding id or check id:[/red] {escape(ref)}"
|
|
125
|
+
)
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
return targets
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@app.command()
|
|
131
|
+
def scan(
|
|
132
|
+
root: Path = typer.Option(Path("."), "--root", hidden=True),
|
|
133
|
+
global_mode: bool = typer.Option(False, "--global", help="analyze machine-level skills only"),
|
|
134
|
+
ci: bool = typer.Option(False, "--ci", help="exit 2 on unacknowledged warnings"),
|
|
135
|
+
as_json: bool = typer.Option(False, "--json", help="emit findings as JSON"),
|
|
136
|
+
detailed: bool = typer.Option(False, "--detailed", help="also print each harness's skill table"),
|
|
137
|
+
show_all: bool = typer.Option(False, "--all", help="with --detailed, include harnesses with no skills"),
|
|
138
|
+
harness: str | None = typer.Option(None, "--harness", help="scope the scan to one harness"),
|
|
139
|
+
deep_mode: bool = typer.Option(False, "--deep", help="judge flagged pairs with the configured model"),
|
|
140
|
+
max_calls: str = typer.Option("25", "--max-calls", help="model calls per --deep run: a number, or 'all' for no limit"),
|
|
141
|
+
) -> None:
|
|
142
|
+
"""Analyze every detected harness's skill set and report findings."""
|
|
143
|
+
_validate_harness(harness)
|
|
144
|
+
home = _home()
|
|
145
|
+
config = _load_effective_config_or_exit(root, home, global_mode)
|
|
146
|
+
judge = None
|
|
147
|
+
budget: int | None = None
|
|
148
|
+
if deep_mode:
|
|
149
|
+
if max_calls == "all":
|
|
150
|
+
budget = None
|
|
151
|
+
else:
|
|
152
|
+
try:
|
|
153
|
+
budget = int(max_calls)
|
|
154
|
+
if budget < 0:
|
|
155
|
+
raise ValueError
|
|
156
|
+
except ValueError:
|
|
157
|
+
console.print(
|
|
158
|
+
f"[red]--max-calls takes a number or 'all', not[/red] {escape(max_calls)}"
|
|
159
|
+
)
|
|
160
|
+
raise typer.Exit(1)
|
|
161
|
+
from drskill import deep_llm
|
|
162
|
+
|
|
163
|
+
deep.load_user_env(home)
|
|
164
|
+
try:
|
|
165
|
+
judge = deep_llm.build_judge(config.deep.model)
|
|
166
|
+
except deep_llm.DeepUnavailableError as e:
|
|
167
|
+
console.print(f"[red]{escape(str(e))}[/red]")
|
|
168
|
+
raise typer.Exit(1)
|
|
169
|
+
world, findings = run_scan(
|
|
170
|
+
root, home, global_mode, config, harness=harness, judge=judge, max_calls=budget
|
|
171
|
+
)
|
|
172
|
+
active, acked = ledger.filter_findings(findings, config)
|
|
173
|
+
if as_json:
|
|
174
|
+
print(report.to_json(active))
|
|
175
|
+
else:
|
|
176
|
+
_warn_if_undetected(harness, root, home, global_mode)
|
|
177
|
+
spath = state.state_path(root, home, global_mode)
|
|
178
|
+
report.render(
|
|
179
|
+
world, active, acked, console, seen=set(state.load_seen(spath))
|
|
180
|
+
)
|
|
181
|
+
# active plus acked, so an acked finding stays seen if later un-acked.
|
|
182
|
+
# A --harness scan sees only a slice of the project's findings, and
|
|
183
|
+
# writing it would prune every other harness's seen entries.
|
|
184
|
+
if harness is None:
|
|
185
|
+
state.mark_seen(
|
|
186
|
+
spath, [f.fingerprint for f in findings], dt.date.today()
|
|
187
|
+
)
|
|
188
|
+
if deep_mode:
|
|
189
|
+
last_error = getattr(judge, "last_error", None)
|
|
190
|
+
if last_error:
|
|
191
|
+
flat = " ".join(str(last_error).split())
|
|
192
|
+
console.print(
|
|
193
|
+
f"[yellow]deep: model calls are failing; last error: "
|
|
194
|
+
f"{escape(flat)}[/yellow]"
|
|
195
|
+
)
|
|
196
|
+
cache = deep.load_cache(deep.cache_dir(root, home, global_mode))
|
|
197
|
+
remaining = deep.unjudged_count(world, active, cache)
|
|
198
|
+
if remaining:
|
|
199
|
+
plural = "s" if remaining != 1 else ""
|
|
200
|
+
console.print(
|
|
201
|
+
f"deep: {remaining} flagged pair{plural} still unjudged; "
|
|
202
|
+
"raise --max-calls to judge more"
|
|
203
|
+
)
|
|
204
|
+
if detailed:
|
|
205
|
+
console.print()
|
|
206
|
+
report.render_harness_tables(
|
|
207
|
+
world, console, tokens=False, harness=harness, show_all=show_all
|
|
208
|
+
)
|
|
209
|
+
if any(f.severity == "error" for f in active):
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
if ci and any(f.severity == "warning" for f in active):
|
|
212
|
+
raise typer.Exit(2)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.command()
|
|
216
|
+
def ack(
|
|
217
|
+
refs: list[str] = typer.Argument(
|
|
218
|
+
None,
|
|
219
|
+
help="finding ids from the report, or a check id followed by skill names",
|
|
220
|
+
),
|
|
221
|
+
ack_all: bool = typer.Option(
|
|
222
|
+
False, "--all",
|
|
223
|
+
help="ack every active finding, or every finding of the named check",
|
|
224
|
+
),
|
|
225
|
+
note: str | None = typer.Option(None, "--note"),
|
|
226
|
+
force_local: bool = typer.Option(
|
|
227
|
+
False, "--local", help="record in the project ledger regardless of scope"
|
|
228
|
+
),
|
|
229
|
+
force_global: bool = typer.Option(
|
|
230
|
+
False, "--global-ack", help="record in the machine ledger (~/.drskill.toml)"
|
|
231
|
+
),
|
|
232
|
+
root: Path = typer.Option(Path("."), "--root", hidden=True),
|
|
233
|
+
global_mode: bool = typer.Option(False, "--global"),
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Acknowledge findings so they stay silent until the content changes."""
|
|
236
|
+
import re
|
|
237
|
+
|
|
238
|
+
if force_local and force_global:
|
|
239
|
+
console.print("[red]--local and --global-ack are mutually exclusive[/red]")
|
|
240
|
+
raise typer.Exit(1)
|
|
241
|
+
if global_mode and (force_local or force_global):
|
|
242
|
+
console.print("[red]--global mode already writes the machine ledger[/red]")
|
|
243
|
+
raise typer.Exit(1)
|
|
244
|
+
home = _home()
|
|
245
|
+
config = _load_effective_config_or_exit(root, home, global_mode)
|
|
246
|
+
world, findings = run_scan(root, home, global_mode, config)
|
|
247
|
+
active, _ = ledger.filter_findings(findings, config)
|
|
248
|
+
# Notes need no ack; sweeping them into the ledger would hide them and
|
|
249
|
+
# leave a stale ack behind if the verdict cache is ever pruned.
|
|
250
|
+
active = [f for f in active if f.severity != "note"]
|
|
251
|
+
from drskill.checks import REGISTRY
|
|
252
|
+
|
|
253
|
+
refs = refs or []
|
|
254
|
+
targets: list = []
|
|
255
|
+
if ack_all:
|
|
256
|
+
if not refs:
|
|
257
|
+
targets = list(active)
|
|
258
|
+
elif len(refs) == 1 and refs[0] in REGISTRY:
|
|
259
|
+
targets = [f for f in active if f.check_id == refs[0]]
|
|
260
|
+
else:
|
|
261
|
+
console.print("[red]--all takes no arguments, or exactly one check id[/red]")
|
|
262
|
+
raise typer.Exit(1)
|
|
263
|
+
if not targets:
|
|
264
|
+
console.print("[red]No active finding matches[/red]")
|
|
265
|
+
raise typer.Exit(1)
|
|
266
|
+
elif refs and refs[0] in REGISTRY:
|
|
267
|
+
check_id, skills = refs[0], refs[1:]
|
|
268
|
+
wanted = set(skills)
|
|
269
|
+
if wanted:
|
|
270
|
+
exact = [f for f in active if f.check_id == check_id and set(f.contributor_names) == wanted]
|
|
271
|
+
superset = [f for f in active if f.check_id == check_id and wanted <= set(f.contributor_names)]
|
|
272
|
+
matches = exact or superset
|
|
273
|
+
if len(matches) > 1:
|
|
274
|
+
console.print(f"[red]Ambiguous:[/red] {len(matches)} findings match; name all involved skills")
|
|
275
|
+
raise typer.Exit(1)
|
|
276
|
+
else:
|
|
277
|
+
# a bare check id acks the whole class of findings
|
|
278
|
+
matches = [f for f in active if f.check_id == check_id]
|
|
279
|
+
if not matches:
|
|
280
|
+
console.print(f"[red]No active finding matches[/red] {escape(check_id)} {escape(' '.join(skills))}")
|
|
281
|
+
raise typer.Exit(1)
|
|
282
|
+
targets = matches
|
|
283
|
+
elif refs and all(re.fullmatch(r"[0-9a-f]{4,64}", r) for r in refs):
|
|
284
|
+
targets = _resolve_refs(refs, active)
|
|
285
|
+
else:
|
|
286
|
+
console.print(
|
|
287
|
+
"[red]Nothing to ack:[/red] pass finding ids from the report, "
|
|
288
|
+
"a check id with skill names, or --all"
|
|
289
|
+
)
|
|
290
|
+
raise typer.Exit(1)
|
|
291
|
+
|
|
292
|
+
global_ledger = ledger.ledger_path(root, home, True)
|
|
293
|
+
dest_counts: dict[Path, int] = {}
|
|
294
|
+
for f in targets:
|
|
295
|
+
dest = ledger.ack_destination(
|
|
296
|
+
world, f, root, home, global_mode,
|
|
297
|
+
force_local=force_local, force_global=force_global,
|
|
298
|
+
)
|
|
299
|
+
ledger.append_ack(
|
|
300
|
+
dest,
|
|
301
|
+
Ack(check=f.check_id, skills=sorted(f.contributor_names),
|
|
302
|
+
fingerprint=f.fingerprint, note=note, date=dt.date.today()),
|
|
303
|
+
)
|
|
304
|
+
dest_counts[dest] = dest_counts.get(dest, 0) + 1
|
|
305
|
+
label = f"{f.check_id} " + ", ".join(f.contributor_names) if f.contributor_names else f.check_id
|
|
306
|
+
suffix = ""
|
|
307
|
+
if dest == global_ledger and not global_mode:
|
|
308
|
+
suffix = " → ~/.drskill.toml (machine-level skills)"
|
|
309
|
+
console.print(f"Acknowledged [bold]{escape(label)}[/bold]{escape(suffix)}")
|
|
310
|
+
for dest, n in dest_counts.items():
|
|
311
|
+
console.print(f"{n} finding{'s' if n != 1 else ''} → {escape(str(dest))}")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@app.command()
|
|
315
|
+
def show(
|
|
316
|
+
refs: list[str] = typer.Argument(..., help="finding ids or check ids"),
|
|
317
|
+
root: Path = typer.Option(Path("."), "--root", hidden=True),
|
|
318
|
+
global_mode: bool = typer.Option(False, "--global"),
|
|
319
|
+
harness: str | None = typer.Option(None, "--harness"),
|
|
320
|
+
) -> None:
|
|
321
|
+
"""Print the full evidence for specific findings."""
|
|
322
|
+
_validate_harness(harness)
|
|
323
|
+
home = _home()
|
|
324
|
+
config = _load_effective_config_or_exit(root, home, global_mode)
|
|
325
|
+
world, findings = run_scan(root, home, global_mode, config, harness=harness)
|
|
326
|
+
active, _ = ledger.filter_findings(findings, config)
|
|
327
|
+
targets = _resolve_refs(refs, active)
|
|
328
|
+
ordered = report.sort_findings(world, targets, set())
|
|
329
|
+
report.print_findings(
|
|
330
|
+
world, ordered, console, seen={f.fingerprint for f in targets}
|
|
331
|
+
) # seen = everything: show never tags new
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.command()
|
|
335
|
+
def review(
|
|
336
|
+
root: Path = typer.Option(Path("."), "--root", hidden=True),
|
|
337
|
+
global_mode: bool = typer.Option(False, "--global"),
|
|
338
|
+
harness: str | None = typer.Option(None, "--harness"),
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Walk the findings one at a time and decide each with one keypress."""
|
|
341
|
+
refusal = interactive.can_interact()
|
|
342
|
+
if refusal:
|
|
343
|
+
console.print(escape(refusal))
|
|
344
|
+
raise typer.Exit(1)
|
|
345
|
+
_validate_harness(harness)
|
|
346
|
+
home = _home()
|
|
347
|
+
config = _load_effective_config_or_exit(root, home, global_mode)
|
|
348
|
+
world, findings = run_scan(root, home, global_mode, config, harness=harness)
|
|
349
|
+
active, _ = ledger.filter_findings(findings, config)
|
|
350
|
+
active = [f for f in active if f.severity != "note"]
|
|
351
|
+
if not active:
|
|
352
|
+
console.print("[green]No findings to review.[/green]")
|
|
353
|
+
return
|
|
354
|
+
spath = state.state_path(root, home, global_mode)
|
|
355
|
+
seen = set(state.load_seen(spath))
|
|
356
|
+
ordered = report.sort_findings(world, active, seen)
|
|
357
|
+
acked: list[tuple] = [] # (finding, destination path)
|
|
358
|
+
fixes: list[str] = []
|
|
359
|
+
displayed: set[str] = set()
|
|
360
|
+
undecided = 0
|
|
361
|
+
quit_early = False
|
|
362
|
+
for idx, f in enumerate(ordered, start=1):
|
|
363
|
+
console.print(f"[dim]{idx} of {len(ordered)}[/dim]")
|
|
364
|
+
report.print_findings(world, [f], console, seen=seen)
|
|
365
|
+
displayed.add(f.fingerprint)
|
|
366
|
+
console.print(
|
|
367
|
+
"[bold]a[/bold] ack · [bold]n[/bold] ack+note · [bold]f[/bold] queue fix"
|
|
368
|
+
" · [bold]s[/bold] skip · [bold]q[/bold] quit"
|
|
369
|
+
)
|
|
370
|
+
while True:
|
|
371
|
+
key = key_source()
|
|
372
|
+
if key in ("a", "n"):
|
|
373
|
+
ack_note = None
|
|
374
|
+
if key == "n":
|
|
375
|
+
try:
|
|
376
|
+
ack_note = line_source("note: ").strip() or None
|
|
377
|
+
except KeyboardInterrupt:
|
|
378
|
+
quit_early = True
|
|
379
|
+
break
|
|
380
|
+
dest = ledger.ack_destination(world, f, root, home, global_mode)
|
|
381
|
+
ledger.append_ack(dest, Ack(
|
|
382
|
+
check=f.check_id, skills=sorted(f.contributor_names),
|
|
383
|
+
fingerprint=f.fingerprint, note=ack_note,
|
|
384
|
+
date=dt.date.today(),
|
|
385
|
+
))
|
|
386
|
+
acked.append((f, dest))
|
|
387
|
+
break
|
|
388
|
+
if key == "f":
|
|
389
|
+
if f.fix_commands:
|
|
390
|
+
fixes.extend(f.fix_commands)
|
|
391
|
+
else:
|
|
392
|
+
undecided += 1 # nothing to queue; the finding stays open
|
|
393
|
+
break
|
|
394
|
+
if key == "s":
|
|
395
|
+
undecided += 1
|
|
396
|
+
break
|
|
397
|
+
if key in ("q", "\x03"): # q or ctrl-c
|
|
398
|
+
quit_early = True
|
|
399
|
+
break
|
|
400
|
+
console.print("[dim]a/n/f/s/q[/dim]")
|
|
401
|
+
if quit_early:
|
|
402
|
+
undecided += len(ordered) - idx + 1
|
|
403
|
+
break
|
|
404
|
+
_review_summary(acked, fixes, undecided, home)
|
|
405
|
+
if harness is None:
|
|
406
|
+
# only what was displayed becomes seen; keep already-seen entries
|
|
407
|
+
# that still correspond to current findings alive through the prune
|
|
408
|
+
current = {f.fingerprint for f in findings}
|
|
409
|
+
state.mark_seen(spath, displayed | (seen & current), dt.date.today())
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _review_summary(
|
|
413
|
+
acked: list[tuple], fixes: list[str], undecided: int, home: Path
|
|
414
|
+
) -> None:
|
|
415
|
+
from drskill.report import short_id
|
|
416
|
+
|
|
417
|
+
for f, dest in acked:
|
|
418
|
+
if dest == home / ".drskill.toml":
|
|
419
|
+
where = " → ~/.drskill.toml"
|
|
420
|
+
else:
|
|
421
|
+
where = f" → {dest.name}"
|
|
422
|
+
console.print(
|
|
423
|
+
f"acked [bold]{escape(short_id(f))}[/bold] "
|
|
424
|
+
f"{escape(f.check_id)}{escape(where)}"
|
|
425
|
+
)
|
|
426
|
+
if fixes:
|
|
427
|
+
block = "\n".join(fixes)
|
|
428
|
+
console.print("\nqueued fix commands:\n")
|
|
429
|
+
# display is sanitized; the clipboard gets the raw command text
|
|
430
|
+
console.print(escape(report._sanitize(block)))
|
|
431
|
+
if _to_clipboard(block):
|
|
432
|
+
console.print("[dim](copied to clipboard)[/dim]")
|
|
433
|
+
if undecided:
|
|
434
|
+
console.print(
|
|
435
|
+
f"\n{undecided} finding{'s' if undecided != 1 else ''} left undecided"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _to_clipboard(text: str) -> bool:
|
|
440
|
+
import shutil
|
|
441
|
+
import subprocess
|
|
442
|
+
|
|
443
|
+
for cmd in (["pbcopy"], ["xclip", "-selection", "clipboard"], ["xsel", "-ib"]):
|
|
444
|
+
if shutil.which(cmd[0]):
|
|
445
|
+
try:
|
|
446
|
+
subprocess.run(cmd, input=text.encode(), check=True, timeout=5)
|
|
447
|
+
return True
|
|
448
|
+
except (OSError, subprocess.SubprocessError):
|
|
449
|
+
return False
|
|
450
|
+
return False
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
@app.command("list")
|
|
454
|
+
def list_cmd(
|
|
455
|
+
tokens: bool = typer.Option(False, "--tokens"),
|
|
456
|
+
harness: str | None = typer.Option(None, "--harness"),
|
|
457
|
+
show_all: bool = typer.Option(False, "--all", help="include harnesses with no skills"),
|
|
458
|
+
root: Path = typer.Option(Path("."), "--root", hidden=True),
|
|
459
|
+
global_mode: bool = typer.Option(False, "--global"),
|
|
460
|
+
) -> None:
|
|
461
|
+
"""Show each harness's effective skill set."""
|
|
462
|
+
_validate_harness(harness)
|
|
463
|
+
home = _home()
|
|
464
|
+
config = _load_effective_config_or_exit(root, home, global_mode)
|
|
465
|
+
world, _findings = run_scan(root, home, global_mode, config, harness=harness)
|
|
466
|
+
_warn_if_undetected(harness, root, home, global_mode)
|
|
467
|
+
report.render_harness_tables(
|
|
468
|
+
world, console, tokens=tokens, harness=harness, show_all=show_all
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@app.command()
|
|
473
|
+
def cache(
|
|
474
|
+
action: str = typer.Argument(..., help="stats or prune"),
|
|
475
|
+
root: Path = typer.Option(Path("."), "--root", hidden=True),
|
|
476
|
+
global_mode: bool = typer.Option(False, "--global", help="use the machine cache"),
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Inspect or prune the committed deep verdict cache."""
|
|
479
|
+
home = _home()
|
|
480
|
+
cdir = deep.cache_dir(root, home, global_mode)
|
|
481
|
+
entries = deep.load_cache(cdir)
|
|
482
|
+
if action == "stats":
|
|
483
|
+
console.print(f"{len(entries)} cached verdicts in {escape(str(cdir))}")
|
|
484
|
+
if not entries:
|
|
485
|
+
return
|
|
486
|
+
for name, count in sorted(Counter(v.verdict for v in entries.values()).items()):
|
|
487
|
+
console.print(f" {escape(name)}: {count}")
|
|
488
|
+
for name, count in sorted(Counter(v.model for v in entries.values()).items()):
|
|
489
|
+
console.print(f" {escape(name)}: {count}")
|
|
490
|
+
dates = sorted(v.date for v in entries.values())
|
|
491
|
+
console.print(f" oldest {escape(dates[0])}, newest {escape(dates[-1])}")
|
|
492
|
+
elif action == "prune":
|
|
493
|
+
config = _load_effective_config_or_exit(root, home, global_mode)
|
|
494
|
+
world, findings = run_scan(root, home, global_mode, config)
|
|
495
|
+
valid = {deep.pair_key(a, b) for a, b in deep.flagged_pairs(world, findings)}
|
|
496
|
+
# Walk the files, not the parsed entries, so corrupt files (which
|
|
497
|
+
# load_cache skips) are pruned instead of lingering forever.
|
|
498
|
+
removed = kept = 0
|
|
499
|
+
for p in sorted(cdir.glob("*.json")) if cdir.is_dir() else []:
|
|
500
|
+
if p.stem in valid and p.stem in entries:
|
|
501
|
+
kept += 1
|
|
502
|
+
else:
|
|
503
|
+
p.unlink()
|
|
504
|
+
removed += 1
|
|
505
|
+
console.print(f"removed {removed} stale verdicts, kept {kept}")
|
|
506
|
+
else:
|
|
507
|
+
console.print(
|
|
508
|
+
f"[red]Unknown action:[/red] {escape(action)} (use stats or prune)"
|
|
509
|
+
)
|
|
510
|
+
raise typer.Exit(1)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@app.command()
|
|
514
|
+
def init(root: Path = typer.Option(Path("."), "--root", hidden=True)) -> None:
|
|
515
|
+
"""Write a starter drskill.toml with default budgets and thresholds."""
|
|
516
|
+
path = root / "drskill.toml"
|
|
517
|
+
if path.exists():
|
|
518
|
+
console.print(f"[red]{path} already exists[/red]; not overwriting")
|
|
519
|
+
raise typer.Exit(1)
|
|
520
|
+
path.write_text(INIT_TEMPLATE)
|
|
521
|
+
console.print(f"Wrote {path}")
|
drskill/data/__init__.py
ADDED
|
File without changes
|