@iseoane/agent-depot 0.2.0

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.
Files changed (36) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +465 -0
  4. package/package.json +69 -0
  5. package/skills/doctor-md-agents/README.md +51 -0
  6. package/skills/doctor-md-agents/SKILL.md +275 -0
  7. package/skills/doctor-md-agents/references/rule-model.md +56 -0
  8. package/skills/doctor-md-agents/references/structure.md +89 -0
  9. package/skills/doctor-md-agents/scorers/adherence.md +73 -0
  10. package/skills/doctor-md-agents/scorers/rule-value.md +49 -0
  11. package/skills/doctor-md-agents/scripts/collect_sessions.py +1135 -0
  12. package/skills/doctor-md-agents/scripts/extract_rules.py +704 -0
  13. package/skills/doctor-md-agents/scripts/render_report.py +644 -0
  14. package/skills/doctor-md-agents/scripts/scan_corrections.py +781 -0
  15. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +780 -0
  16. package/skills/doctor-md-agents/scripts/test_extract_rules.py +530 -0
  17. package/skills/doctor-md-agents/scripts/test_render_report.py +383 -0
  18. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +343 -0
  19. package/skills/doctor-md-agents/scripts/test_verify_citations.py +236 -0
  20. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +507 -0
  21. package/skills/doctor-md-agents/scripts/verify_citations.py +217 -0
  22. package/skills/doctor-md-skill/README.md +27 -0
  23. package/skills/doctor-md-skill/SKILL.md +212 -0
  24. package/skills/doctor-md-skill/assets/pierre-diffs.js +1531 -0
  25. package/skills/doctor-md-skill/references/skill-improvements.md +32 -0
  26. package/skills/doctor-md-skill/references/supported-harnesses.md +44 -0
  27. package/skills/doctor-md-skill/scorers/code-quality.md +41 -0
  28. package/skills/doctor-md-skill/scorers/efficiency.md +38 -0
  29. package/skills/doctor-md-skill/scripts/collect_sessions.py +1112 -0
  30. package/skills/doctor-md-skill/scripts/detect_machine_sessions.py +315 -0
  31. package/skills/doctor-md-skill/scripts/render_report.py +545 -0
  32. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +593 -0
  33. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +181 -0
  34. package/skills/doctor-md-skill/scripts/test_render_report.py +241 -0
  35. package/skills/doctor-md-skill/scripts/test_verify_citations.py +196 -0
  36. package/skills/doctor-md-skill/scripts/verify_citations.py +221 -0
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env python3
2
+ """Render an doctor-md-agents report.json (SKILL.md Step 6 schema) into one
3
+ self-contained report.html.
4
+
5
+ The page is meant to make the budget argument readable at a glance: how much
6
+ is spent on always-injected instruction files, which rules have zero
7
+ evidence behind them despite their cost, and what would fix that. It has no
8
+ external assets, no CDN scripts or stylesheets, and makes no network call --
9
+ everything (including any proposed replacement text found on disk next to
10
+ report.json) is inlined at render time, once, here.
11
+
12
+ Output (next to report.json by default, or --out):
13
+ report.html
14
+
15
+ Python 3.9+, stdlib only.
16
+ """
17
+
18
+ import argparse
19
+ import html
20
+ import json
21
+ import sys
22
+ import webbrowser
23
+ from pathlib import Path
24
+
25
+ VERDICT_ORDER = ("keep", "improve", "drop", "unknown")
26
+ VERDICT_LABELS = {
27
+ "keep": "keep", "improve": "improve", "drop": "drop", "unknown": "unknown",
28
+ }
29
+ ADHERENCE_LABELS = {
30
+ "followed": "followed", "mixed": "mixed", "violated": "violated",
31
+ "insufficient_evidence": "no evidence in sample",
32
+ }
33
+
34
+
35
+ def esc(value) -> str:
36
+ return html.escape(str(value if value is not None else ""), quote=True)
37
+
38
+
39
+ def esc_attr(value) -> str:
40
+ return html.escape(str(value if value is not None else ""), quote=True)
41
+
42
+
43
+ def open_report(path: Path) -> bool:
44
+ try:
45
+ return bool(webbrowser.open(path.absolute().as_uri(), new=2))
46
+ except (OSError, webbrowser.Error):
47
+ return False
48
+
49
+
50
+ def fmt_pct(part, whole) -> str:
51
+ if not whole:
52
+ return "0%"
53
+ return f"{round(100 * part / whole)}%"
54
+
55
+
56
+ def resolve_proposed_text(proposed_path, base_dir: Path):
57
+ if not proposed_path:
58
+ return None
59
+ candidate = Path(proposed_path)
60
+ if not candidate.is_absolute():
61
+ candidate = base_dir / candidate
62
+ try:
63
+ return candidate.read_text(encoding="utf-8", errors="replace")
64
+ except OSError:
65
+ return None
66
+
67
+
68
+ def render_diff(diff_text) -> str:
69
+ if not diff_text:
70
+ return ""
71
+ lines_html = []
72
+ for line in diff_text.splitlines():
73
+ cls = "diff-ctx"
74
+ if line.startswith("+++") or line.startswith("---"):
75
+ cls = "diff-hdr"
76
+ elif line.startswith("@@"):
77
+ cls = "diff-hunk"
78
+ elif line.startswith("+"):
79
+ cls = "diff-add"
80
+ elif line.startswith("-"):
81
+ cls = "diff-del"
82
+ lines_html.append(f'<span class="diff-line {cls}">{esc(line)}</span>')
83
+ return '<pre class="diff">' + "\n".join(lines_html) + "</pre>"
84
+
85
+
86
+ def render_proposed_block(rule, base_dir: Path) -> str:
87
+ proposed_path = rule.get("proposed_path")
88
+ diff_text = rule.get("diff")
89
+ proposed_text = resolve_proposed_text(proposed_path, base_dir)
90
+ if not proposed_path and not diff_text:
91
+ return ""
92
+ parts = []
93
+ if proposed_text:
94
+ parts.append(f'<div class="proposed-text"><pre>{esc(proposed_text)}</pre></div>')
95
+ elif proposed_path:
96
+ parts.append('<p class="muted">Proposed text file not found on disk.</p>')
97
+ if diff_text:
98
+ parts.append(render_diff(diff_text))
99
+ if not parts:
100
+ return ""
101
+ return (
102
+ "<details class=\"proposed\"><summary>proposed change</summary>"
103
+ + "".join(parts) + "</details>"
104
+ )
105
+
106
+
107
+ PAGE_CSS = """
108
+ * { box-sizing: border-box; }
109
+ :root {
110
+ --fg: #16151a; --muted: #5d5966; --muted-2: #8a8592; --accent: #4338ca;
111
+ --line: rgba(20, 16, 40, 0.14); --line-soft: rgba(20, 16, 40, 0.06);
112
+ --bg: #fdfcfe; --surface: #ffffff; --panel: #f4f3f8;
113
+ --keep: #1a7f4b; --improve: #b3730a; --drop: #b3261e; --unknown: #6b7280;
114
+ --add-bg: rgba(26, 127, 75, 0.12); --add-fg: #12613a;
115
+ --del-bg: rgba(179, 38, 30, 0.10); --del-fg: #8f221c;
116
+ }
117
+ @media (prefers-color-scheme: dark) {
118
+ :root {
119
+ --fg: #f1eef7; --muted: #bdb8c7; --muted-2: #948f9e; --accent: #a5b4fc;
120
+ --line: rgba(240, 235, 255, 0.18); --line-soft: rgba(240, 235, 255, 0.07);
121
+ --bg: #131019; --surface: #1b1725; --panel: #201c2b;
122
+ --keep: #6bcf9a; --improve: #e0ad4f; --drop: #f0857c; --unknown: #a7a2b0;
123
+ --add-bg: rgba(107, 207, 154, 0.14); --add-fg: #9be0bd;
124
+ --del-bg: rgba(240, 133, 124, 0.14); --del-fg: #f6b3ac;
125
+ }
126
+ }
127
+ html, body { background: var(--bg); }
128
+ body {
129
+ font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
130
+ color: var(--fg); margin: 0; padding: 0 0 64px; line-height: 1.5;
131
+ max-width: 100%; overflow-x: hidden;
132
+ }
133
+ .wrap { max-width: 1080px; margin: 0 auto; padding: 24px 16px; }
134
+ a { color: var(--accent); }
135
+ code { background: var(--panel); padding: 1px 5px; border-radius: 4px; font-size: 0.92em; }
136
+ h1 { font-size: 26px; margin: 0 0 4px; }
137
+ h2 { font-size: 19px; margin: 36px 0 10px; border-bottom: 1px solid var(--line); padding-bottom: 6px; }
138
+ h3 { font-size: 15px; margin: 20px 0 6px; }
139
+ .muted { color: var(--muted); }
140
+ .small { font-size: 12.5px; }
141
+ .meta-row { display: flex; flex-wrap: wrap; gap: 10px 24px; margin: 10px 0 4px; }
142
+ .meta-item { font-size: 13px; color: var(--muted); }
143
+ .meta-item b { color: var(--fg); font-variant-numeric: tabular-nums; }
144
+ .denominator-note { font-size: 12.5px; color: var(--muted-2); margin-top: 6px; }
145
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin: 14px 0; }
146
+ .card { border: 1px solid var(--line); background: var(--surface); border-radius: 10px; padding: 14px 16px; }
147
+ .card .label { font-weight: 600; margin-bottom: 6px; word-break: break-word; }
148
+ .card .stat-row { display: flex; justify-content: space-between; font-size: 13px; color: var(--muted); margin-top: 3px; }
149
+ .card .stat-row b { color: var(--fg); }
150
+ .verdict-pills { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; }
151
+ .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--line); }
152
+ .pill.keep { color: var(--keep); border-color: var(--keep); }
153
+ .pill.improve { color: var(--improve); border-color: var(--improve); }
154
+ .pill.drop { color: var(--drop); border-color: var(--drop); }
155
+ .pill.unknown { color: var(--unknown); border-color: var(--unknown); }
156
+ .badge { font-size: 11.5px; padding: 2px 8px; border-radius: 999px; display: inline-block; white-space: nowrap; }
157
+ .badge.keep { background: color-mix(in srgb, var(--keep) 16%, transparent); color: var(--keep); }
158
+ .badge.improve { background: color-mix(in srgb, var(--improve) 16%, transparent); color: var(--improve); }
159
+ .badge.drop { background: color-mix(in srgb, var(--drop) 16%, transparent); color: var(--drop); }
160
+ .badge.unknown { background: color-mix(in srgb, var(--unknown) 16%, transparent); color: var(--unknown); }
161
+ .badge.followed { background: color-mix(in srgb, var(--keep) 16%, transparent); color: var(--keep); }
162
+ .badge.mixed { background: color-mix(in srgb, var(--improve) 16%, transparent); color: var(--improve); }
163
+ .badge.violated { background: color-mix(in srgb, var(--drop) 16%, transparent); color: var(--drop); }
164
+ .badge.insufficient_evidence { background: color-mix(in srgb, var(--unknown) 16%, transparent); color: var(--unknown); }
165
+ /* Rule cards -- one card per rule, stacked in a single column. Prose
166
+ (rationale, evidence quotes) and short labelled metrics never share a
167
+ grid/table cell, which is what made the old 9-column table unreadable:
168
+ a rationale sentence forced into a narrow numeric column wraps to one
169
+ word per line. See ../references (no external file -- this comment is
170
+ the record) and the render_rule_card()/render_rule_cards_section()
171
+ functions below. */
172
+ .rule-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 10px 0 12px; }
173
+ .rule-toolbar .toolbar-label { font-size: 12px; color: var(--muted-2); margin-right: 2px; }
174
+ .rule-toolbar button { font: inherit; font-size: 12.5px; color: var(--fg); background: var(--surface);
175
+ border: 1px solid var(--line); border-radius: 999px; padding: 5px 12px; cursor: pointer; }
176
+ .rule-toolbar button::after { content: ""; opacity: 0.6; margin-left: 5px; }
177
+ .rule-toolbar button[data-dir="asc"] { border-color: var(--accent); color: var(--accent); }
178
+ .rule-toolbar button[data-dir="asc"]::after { content: "\\25B2"; }
179
+ .rule-toolbar button[data-dir="desc"] { border-color: var(--accent); color: var(--accent); }
180
+ .rule-toolbar button[data-dir="desc"]::after { content: "\\25BC"; }
181
+ .rule-cards { display: flex; flex-direction: column; gap: 12px; margin: 0 0 24px; min-width: 0; }
182
+ .rule-card { border: 1px solid var(--line); background: var(--surface); border-radius: 10px;
183
+ padding: 14px 16px; border-left-width: 3px; min-width: 0; max-width: 100%; overflow: hidden; }
184
+ .rule-card.v-keep { border-left-color: var(--keep); }
185
+ .rule-card.v-improve { border-left-color: var(--improve); }
186
+ .rule-card.v-drop { border-left-color: var(--drop); }
187
+ .rule-card.v-unknown { border-left-color: var(--unknown); }
188
+ .rule-card.has-harm { background: var(--del-bg); }
189
+ .rule-card-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
190
+ .rule-title { font-weight: 600; min-width: 0; overflow-wrap: anywhere; flex: 1 1 auto; }
191
+ .rule-card-head .badge { flex: 0 0 auto; }
192
+ .rule-card-sub { color: var(--muted); font-size: 11.5px; margin-top: 3px; min-width: 0; overflow-wrap: anywhere; }
193
+ .rule-card-sub code { overflow-wrap: anywhere; }
194
+ .rule-metrics { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; }
195
+ .metric { display: inline-flex; align-items: baseline; gap: 5px; font-size: 12.5px; min-width: 0; }
196
+ .metric-label { color: var(--muted-2); font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.03em; }
197
+ .metric-value { color: var(--fg); font-variant-numeric: tabular-nums; }
198
+ .rule-rationale { margin: 10px 0 0; font-size: 13.5px; line-height: 1.55; text-wrap: pretty; }
199
+ details.evidence { margin-top: 8px; }
200
+ details.evidence summary { cursor: pointer; font-size: 12.5px; color: var(--accent); }
201
+ ul.evidence-list { list-style: none; margin: 6px 0 0; padding: 0; }
202
+ ul.evidence-list li { border: 1px solid var(--line); border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; background: var(--panel); }
203
+ ul.evidence-list blockquote { margin: 6px 0 4px; padding-left: 8px; border-left: 2px solid var(--line); font-style: italic; }
204
+ .finding-block { border: 1px solid var(--drop); background: var(--del-bg); border-radius: 10px; padding: 16px 18px; margin: 14px 0 24px; }
205
+ .finding-block h3 { margin-top: 0; }
206
+ .missing-rule { border: 1px solid var(--line); background: var(--surface); border-radius: 10px; padding: 12px 16px; margin-bottom: 10px; }
207
+ .missing-rule .occ { font-size: 12.5px; color: var(--muted); }
208
+ details.proposed { margin-top: 8px; }
209
+ details.proposed summary { cursor: pointer; font-size: 12.5px; color: var(--accent); }
210
+ .proposed-text pre, pre.diff { background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
211
+ padding: 10px 12px; overflow-x: auto; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
212
+ font-size: 12px; white-space: pre; margin: 6px 0; }
213
+ pre.diff .diff-line { display: block; }
214
+ pre.diff .diff-add { background: var(--add-bg); color: var(--add-fg); }
215
+ pre.diff .diff-del { background: var(--del-bg); color: var(--del-fg); }
216
+ pre.diff .diff-hunk { color: var(--muted-2); }
217
+ pre.diff .diff-hdr { color: var(--muted-2); font-weight: 600; }
218
+ ul.notes { font-size: 13px; color: var(--muted); padding-left: 18px; }
219
+ ul.notes li { margin-bottom: 6px; }
220
+ .top-findings li { margin-bottom: 6px; }
221
+ .evidence-ids { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 11.5px; color: var(--muted); }
222
+ footer.page-footer { max-width: 1080px; margin: 40px auto 0; padding: 0 16px; font-size: 12px; color: var(--muted-2); }
223
+ """
224
+
225
+ SORT_SCRIPT = """
226
+ (function () {
227
+ document.querySelectorAll('.rule-toolbar').forEach(function (toolbar) {
228
+ var container = toolbar.nextElementSibling;
229
+ if (!container || !container.classList.contains('rule-cards')) return;
230
+ var buttons = toolbar.querySelectorAll('button[data-key]');
231
+ buttons.forEach(function (btn) {
232
+ btn.addEventListener('click', function () {
233
+ var key = btn.dataset.key;
234
+ var type = btn.dataset.type || 'string';
235
+ var dir = btn.dataset.dir === 'asc' ? 'desc' : 'asc';
236
+ buttons.forEach(function (other) { delete other.dataset.dir; });
237
+ btn.dataset.dir = dir;
238
+ var cards = Array.prototype.slice.call(container.querySelectorAll('.rule-card'));
239
+ cards.sort(function (a, b) {
240
+ var av = a.dataset[key];
241
+ var bv = b.dataset[key];
242
+ if (type === 'number') {
243
+ av = parseFloat(av) || 0;
244
+ bv = parseFloat(bv) || 0;
245
+ return dir === 'asc' ? av - bv : bv - av;
246
+ }
247
+ if (av < bv) return dir === 'asc' ? -1 : 1;
248
+ if (av > bv) return dir === 'asc' ? 1 : -1;
249
+ return 0;
250
+ });
251
+ cards.forEach(function (card) { container.appendChild(card); });
252
+ });
253
+ });
254
+ });
255
+ })();
256
+ """
257
+
258
+
259
+ def render_file_card(f, injected_total) -> str:
260
+ verdicts = f.get("verdicts") or {}
261
+ share = fmt_pct(f.get("est_tokens", 0), injected_total) if f.get("scope") != "referenced" else "n/a"
262
+ pills = "".join(
263
+ f'<span class="pill {v}">{esc(VERDICT_LABELS.get(v, v))}: {int(verdicts.get(v, 0))}</span>'
264
+ for v in VERDICT_ORDER
265
+ )
266
+ return f"""<div class="card">
267
+ <div class="label">{esc(f.get('label'))}</div>
268
+ <div class="stat-row"><span>scope</span><b>{esc(f.get('scope'))}</b></div>
269
+ <div class="stat-row"><span>rules</span><b>{esc(f.get('rules', 0))}</b></div>
270
+ <div class="stat-row"><span>est. tokens</span><b>{esc(f.get('est_tokens', 0))}</b></div>
271
+ <div class="stat-row"><span>share of injected total</span><b>{esc(share)}</b></div>
272
+ <div class="verdict-pills">{pills}</div>
273
+ </div>"""
274
+
275
+
276
+
277
+ # The class field says what an adherence failure was worth (scorers/adherence.md):
278
+ # "non-compliance" argues for rewriting the rule, "harm" is the only class
279
+ # that can argue for dropping it, "irrelevant" argues for narrowing its
280
+ # scope. Reuse the existing verdict badge palette so the distinction reads
281
+ # at a glance without inventing a second color language.
282
+ EVIDENCE_CLASS_BADGE = {"harm": "drop", "non-compliance": "improve", "irrelevant": "unknown"}
283
+
284
+
285
+ def render_classes_badges(classes) -> str:
286
+ classes = classes or {}
287
+ harm = int(classes.get("harm", 0) or 0)
288
+ noncompliance = int(classes.get("non-compliance", 0) or 0)
289
+ irrelevant = int(classes.get("irrelevant", 0) or 0)
290
+ if not (harm or noncompliance or irrelevant):
291
+ return '<span class="muted small">—</span>'
292
+ parts = []
293
+ if harm:
294
+ parts.append(
295
+ '<span class="badge drop" title="harm: following the rule made the outcome worse -- '
296
+ f'the strongest case for dropping it">harm {harm}</span>'
297
+ )
298
+ if noncompliance:
299
+ parts.append(
300
+ '<span class="badge improve" title="non-compliance: the rule was right and was not '
301
+ f'followed -- argues for rewriting it, never removal">non-compliance {noncompliance}</span>'
302
+ )
303
+ if irrelevant:
304
+ parts.append(
305
+ '<span class="badge unknown" title="irrelevant: applied where it did not belong">'
306
+ f'irrelevant {irrelevant}</span>'
307
+ )
308
+ return " ".join(parts)
309
+
310
+
311
+ def render_evidence_block(evidence) -> str:
312
+ if not evidence:
313
+ return ""
314
+ # Backward compatibility: the old shape was a bare list of session-id
315
+ # strings. Render it as a bare session list rather than crashing on it.
316
+ if all(isinstance(e, str) for e in evidence):
317
+ ids_html = ", ".join(esc(e) for e in evidence)
318
+ return (
319
+ '<details class="evidence"><summary>evidence</summary>'
320
+ f'<div class="evidence-ids">{ids_html}</div></details>'
321
+ )
322
+ items = []
323
+ for e in evidence:
324
+ if not isinstance(e, dict):
325
+ items.append(f'<li><span class="evidence-ids">{esc(e)}</span></li>')
326
+ continue
327
+ session_id = e.get("session_id", "")
328
+ cls = e.get("class", "")
329
+ quote = e.get("quote", "")
330
+ reason = e.get("reason", "")
331
+ badge_variant = EVIDENCE_CLASS_BADGE.get(cls, "unknown")
332
+ cls_badge = f'<span class="badge {esc_attr(badge_variant)}">{esc(cls)}</span>' if cls else ""
333
+ quote_html = f'<blockquote>{esc(quote)}</blockquote>' if quote else ""
334
+ reason_html = f'<div class="small muted">{esc(reason)}</div>' if reason else ""
335
+ items.append(
336
+ f'<li><span class="evidence-ids">{esc(session_id)}</span> {cls_badge}'
337
+ f'{quote_html}{reason_html}</li>'
338
+ )
339
+ return (
340
+ f'<details class="evidence"><summary>evidence ({esc(len(evidence))})</summary>'
341
+ f'<ul class="evidence-list">{"".join(items)}</ul></details>'
342
+ )
343
+
344
+
345
+ def render_rule_card(rule, base_dir: Path) -> str:
346
+ verdict = rule.get("verdict") or "unknown"
347
+ adherence = rule.get("adherence") or "insufficient_evidence"
348
+ title = rule.get("title") or ""
349
+ section = rule.get("section") or ""
350
+ evidence = rule.get("evidence") or []
351
+ classes = rule.get("classes") or {}
352
+ harm = int(classes.get("harm", 0) or 0)
353
+ noncompliance = int(classes.get("non-compliance", 0) or 0)
354
+ irrelevant = int(classes.get("irrelevant", 0) or 0)
355
+ applicable_sessions = rule.get("applicable_sessions", 0) or 0
356
+ est_tokens = rule.get("est_tokens", 0) or 0
357
+
358
+ # Step 6's schema carries no per-rule "unverified evidence" count -- it
359
+ # is derived here: a rule the judging pass actually reached
360
+ # (applicable in some transcript, or carrying a real adherence label)
361
+ # but whose evidence list came back empty means every citation it had
362
+ # failed verify_citations.py's quote check.
363
+ was_judged = bool(applicable_sessions) or adherence not in (None, "insufficient_evidence")
364
+ unverified_marker = ""
365
+ if was_judged and not evidence:
366
+ unverified_marker = (
367
+ ' <span class="badge unknown" title="Judges found this rule applicable, but no cited '
368
+ 'quote survived verify_citations.py -- this rule\'s case is unverified.">evidence unverified</span>'
369
+ )
370
+
371
+ classes_html = render_classes_badges(classes) if (harm or noncompliance or irrelevant) else ""
372
+ classes_metric = f'<span class="metric">{classes_html}</span>' if classes_html else ""
373
+
374
+ evidence_block = render_evidence_block(evidence)
375
+ proposed_block = render_proposed_block(rule, base_dir)
376
+ card_class = f"rule-card v-{esc_attr(verdict)}" + (" has-harm" if harm else "")
377
+
378
+ return f"""<div class="{card_class}" data-tokens="{esc_attr(est_tokens)}" data-sessions="{esc_attr(applicable_sessions)}" data-verdict="{esc_attr(verdict)}">
379
+ <div class="rule-card-head">
380
+ <div class="rule-title">{esc(title)}{unverified_marker}</div>
381
+ <span class="badge {esc_attr(verdict)}">{esc(VERDICT_LABELS.get(verdict, verdict))}</span>
382
+ </div>
383
+ <div class="rule-card-sub">{esc(section)} &middot; <code>{esc(rule.get('id'))}</code> &middot; línea {esc(rule.get('line', 0))}</div>
384
+ <div class="rule-metrics">
385
+ <span class="metric"><span class="metric-label">coste</span><span class="metric-value">{esc(est_tokens)} tokens</span></span>
386
+ <span class="metric"><span class="metric-label">sesiones</span><span class="metric-value">{esc(applicable_sessions)} / {esc(rule.get('judged_sessions', 0))}</span></span>
387
+ <span class="metric"><span class="metric-label">adherence</span><span class="badge {esc_attr(adherence)}">{esc(ADHERENCE_LABELS.get(adherence, adherence))}</span></span>
388
+ {classes_metric}
389
+ <span class="metric"><span class="metric-label">corrections</span><span class="metric-value">{esc(rule.get('corrections', 0))}</span></span>
390
+ </div>
391
+ <p class="rule-rationale">{esc(rule.get('rationale', ''))}</p>
392
+ {proposed_block}
393
+ {evidence_block}
394
+ </div>"""
395
+
396
+
397
+ def render_rule_cards_section(file_label, rules, base_dir: Path) -> str:
398
+ cards = "".join(render_rule_card(r, base_dir) for r in rules)
399
+ toolbar = """<div class="rule-toolbar">
400
+ <span class="toolbar-label">ordenar por</span>
401
+ <button type="button" data-key="tokens" data-type="number">coste</button>
402
+ <button type="button" data-key="sessions" data-type="number">sesiones</button>
403
+ <button type="button" data-key="verdict" data-type="string">veredicto</button>
404
+ </div>"""
405
+ return f"""{toolbar}
406
+ <div class="rule-cards">{cards}</div>"""
407
+
408
+
409
+ def render_no_evidence_block(rules, injected_total) -> str:
410
+ no_evidence = [
411
+ r for r in rules
412
+ if (r.get("adherence") or "insufficient_evidence") == "insufficient_evidence"
413
+ and not r.get("corrections")
414
+ ]
415
+ if not no_evidence:
416
+ return ""
417
+ token_sum = sum(r.get("est_tokens", 0) for r in no_evidence)
418
+ share = fmt_pct(token_sum, injected_total)
419
+ items = "".join(
420
+ f'<li><code>{esc(r.get("id"))}</code> — {esc(r.get("title"))} '
421
+ f'<span class="muted">({esc(r.get("est_tokens", 0))} tokens)</span></li>'
422
+ for r in sorted(no_evidence, key=lambda r: -r.get("est_tokens", 0))[:25]
423
+ )
424
+ more = len(no_evidence) - 25
425
+ more_html = f"<li class=\"muted\">...and {more} more.</li>" if more > 0 else ""
426
+ return f"""<div class="finding-block">
427
+ <h3>No evidence, {esc(len(no_evidence))} rules, {esc(share)} of the injected budget</h3>
428
+ <p class="small muted">These rules collected no adherence evidence in the sampled transcripts and no
429
+ corrections in the full corpus. That means the sample never saw them -- not that they are unused.</p>
430
+ <ul>{items}{more_html}</ul>
431
+ </div>"""
432
+
433
+
434
+ def render_structure_block(entry) -> str:
435
+ label = entry.get("file_label", "")
436
+ bare = int(entry.get("bare", 0) or 0)
437
+ wrapped = int(entry.get("wrapped", 0) or 0)
438
+ estimated = int(entry.get("estimated_conditions", 0) or 0)
439
+ blocks = entry.get("blocks") or []
440
+ proposed_path = entry.get("proposed_path")
441
+ diff_path = entry.get("diff_path")
442
+
443
+ rows = []
444
+ for block in blocks:
445
+ condition = block.get("condition", "")
446
+ basis = block.get("basis", "")
447
+ rule_ids = block.get("rule_ids") or []
448
+ sessions = block.get("sessions") or []
449
+ basis_badge = f'<span class="badge {esc_attr(basis) or "unknown"}">{esc(basis)}</span>' if basis else ""
450
+ rule_ids_html = ", ".join(f"<code>{esc(rid)}</code>" for rid in rule_ids)
451
+ sessions_html = (
452
+ f'<div class="small muted evidence-ids">sessions: {", ".join(esc(s) for s in sessions)}</div>'
453
+ if sessions else ""
454
+ )
455
+ rows.append(f"""<li>
456
+ <div><strong>{esc(condition)}</strong> {basis_badge}</div>
457
+ <div class="small">{rule_ids_html}</div>
458
+ {sessions_html}
459
+ </li>""")
460
+
461
+ links = []
462
+ if proposed_path:
463
+ links.append(f'<code>{esc(proposed_path)}</code>')
464
+ if diff_path:
465
+ links.append(f'<code>{esc(diff_path)}</code>')
466
+ links_html = f'<div class="small muted">{" &middot; ".join(links)}</div>' if links else ""
467
+
468
+ return f"""<div class="missing-rule">
469
+ <div class="rule-title">{esc(label)}</div>
470
+ <div class="occ">bare: {esc(bare)} &middot; wrapped: {esc(wrapped)} &middot; estimated conditions: {esc(estimated)}</div>
471
+ {links_html}
472
+ <ul class="evidence-list">{"".join(rows)}</ul>
473
+ </div>"""
474
+
475
+
476
+ def render_structure_section(structure) -> str:
477
+ if not structure:
478
+ return ""
479
+ blocks_html = "".join(render_structure_block(entry) for entry in structure)
480
+ return f"""<h2>Proposed restructure</h2>
481
+ <div>{blocks_html}</div>"""
482
+
483
+
484
+ def render_missing_rules(missing_rules) -> str:
485
+ if not missing_rules:
486
+ return "<p class=\"muted\">No missing-rule candidates with 2+ occurrences.</p>"
487
+ blocks = []
488
+ for m in missing_rules:
489
+ evidence = m.get("evidence") or []
490
+ evidence_html = ", ".join(esc(e) for e in evidence) if evidence else "—"
491
+ proposed_text = m.get("proposed_text")
492
+ proposed_html = (
493
+ f'<details class="proposed"><summary>proposed text</summary>'
494
+ f'<div class="proposed-text"><pre>{esc(proposed_text)}</pre></div></details>'
495
+ ) if proposed_text else ""
496
+ nearest = m.get("nearest_rule")
497
+ nearest_html = f'<div class="small muted">nearest existing rule: <code>{esc(nearest)}</code></div>' if nearest else ""
498
+ blocks.append(f"""<div class="missing-rule">
499
+ <div class="rule-title">{esc(m.get('title'))}</div>
500
+ <div class="occ">{esc(m.get('occurrences', 0))} occurrence(s) &middot; sessions:
501
+ <span class="evidence-ids">{evidence_html}</span></div>
502
+ {nearest_html}
503
+ {proposed_html}
504
+ </div>""")
505
+ return "".join(blocks)
506
+
507
+
508
+ def render_page(report: dict, base_dir: Path) -> str:
509
+ title = report.get("title") or "Agent Instructions Report"
510
+ generated_at = report.get("generated_at") or ""
511
+ harness = report.get("harness") or "n/a"
512
+ scope = report.get("scope") or "n/a"
513
+ windows = report.get("windows") or {}
514
+ stats = report.get("stats") or {}
515
+ files = report.get("files") or []
516
+ rules = report.get("rules") or []
517
+ missing_rules = report.get("missing_rules") or []
518
+ top_findings = report.get("top_findings") or []
519
+ notes = report.get("notes") or []
520
+
521
+ injected_total = stats.get("est_tokens", 0) or sum(
522
+ f.get("est_tokens", 0) for f in files if f.get("scope") != "referenced"
523
+ )
524
+
525
+ findings_total = stats.get("findings_total", 0) or 0
526
+ findings_unverified = stats.get("findings_unverified", 0) or 0
527
+ findings_verified = findings_total - findings_unverified
528
+
529
+ verification_note = ""
530
+ if findings_unverified:
531
+ # findings_unverified/findings_total are ints from report.json, esc()'d individually;
532
+ # the surrounding text is a static literal, so nothing here needs re-escaping.
533
+ verification_note = (
534
+ f" {esc(findings_unverified)} of {esc(findings_total)} judged findings failed quote "
535
+ "verification and are excluded from every rule's evidence below."
536
+ )
537
+
538
+ meta_html = f"""<div class="meta-row">
539
+ <div class="meta-item">scope: <b>{esc(scope)}</b></div>
540
+ <div class="meta-item">harness: <b>{esc(harness)}</b></div>
541
+ <div class="meta-item">corrections window: <b>{esc(windows.get('corrections_days', '?'))} days</b></div>
542
+ <div class="meta-item">adherence window: <b>{esc(windows.get('adherence_days', '?'))} days</b></div>
543
+ <div class="meta-item">sessions scanned (corrections): <b>{esc(stats.get('sessions_scanned', 0))}</b></div>
544
+ <div class="meta-item">sessions judged (adherence): <b>{esc(stats.get('sessions_judged', 0))}</b></div>
545
+ <div class="meta-item">corrections found: <b>{esc(stats.get('corrections_found', 0))}</b></div>
546
+ <div class="meta-item">corrections dropped as machine-written: <b>{esc(stats.get('corrections_dropped_as_machine', 0))}</b></div>
547
+ <div class="meta-item">findings verified: <b>{esc(findings_verified)}</b> / <b>{esc(findings_total)}</b></div>
548
+ <div class="meta-item">always-injected est. tokens: <b>{esc(injected_total)}</b></div>
549
+ </div>
550
+ <p class="denominator-note">Corrections and adherence have different denominators and are never divided by
551
+ each other: corrections come from {esc(stats.get('sessions_scanned', 0))} scanned sessions (the whole
552
+ window), adherence from {esc(stats.get('sessions_judged', 0))} judged transcripts (a sample).{verification_note}</p>"""
553
+
554
+ findings_html = "".join(f"<li>{esc(f)}</li>" for f in top_findings) or "<li class=\"muted\">No findings recorded.</li>"
555
+
556
+ cards_html = "".join(render_file_card(f, injected_total) for f in files)
557
+
558
+ rules_by_file = {}
559
+ for r in rules:
560
+ rules_by_file.setdefault(r.get("file_label", ""), []).append(r)
561
+ rule_sections_html = ""
562
+ for f in files:
563
+ label = f.get("label", "")
564
+ file_rules = rules_by_file.get(label, [])
565
+ if not file_rules:
566
+ continue
567
+ rule_sections_html += f"<h3>{esc(label)}</h3>" + render_rule_cards_section(label, file_rules, base_dir)
568
+
569
+ no_evidence_html = render_no_evidence_block(rules, injected_total)
570
+ structure_html = render_structure_section(report.get("structure") or [])
571
+ missing_html = render_missing_rules(missing_rules)
572
+ notes_html = "".join(f"<li>{esc(n)}</li>" for n in notes) or "<li class=\"muted\">No notes.</li>"
573
+
574
+ return f"""<!DOCTYPE html>
575
+ <html lang="en">
576
+ <head>
577
+ <meta charset="utf-8">
578
+ <meta name="viewport" content="width=device-width, initial-scale=1">
579
+ <meta name="color-scheme" content="light dark">
580
+ <title>{esc(title)}</title>
581
+ <style>{PAGE_CSS}</style>
582
+ </head>
583
+ <body>
584
+ <div class="wrap">
585
+ <h1>{esc(title)}</h1>
586
+ <p class="muted small">Generated {esc(generated_at)}</p>
587
+ {meta_html}
588
+
589
+ <h2>Top findings</h2>
590
+ <ul class="top-findings">{findings_html}</ul>
591
+
592
+ <h2>Files</h2>
593
+ <div class="cards">{cards_html}</div>
594
+
595
+ <h2>No-evidence budget</h2>
596
+ {no_evidence_html or '<p class="muted">Every rule collected at least some evidence.</p>'}
597
+
598
+ <h2>Rules by file</h2>
599
+ {rule_sections_html or '<p class="muted">No rules recorded.</p>'}
600
+
601
+ {structure_html}
602
+
603
+ <h2>Missing-rule candidates</h2>
604
+ {missing_html}
605
+
606
+ <h2>Notes</h2>
607
+ <ul class="notes">{notes_html}</ul>
608
+ </div>
609
+ <footer class="page-footer">Rendered locally by doctor-md-agents. Nothing on this page was uploaded anywhere.</footer>
610
+ <script>{SORT_SCRIPT}</script>
611
+ </body>
612
+ </html>"""
613
+
614
+
615
+ def parse_args(argv=None):
616
+ p = argparse.ArgumentParser(description=__doc__)
617
+ p.add_argument("report_path", help="path to report.json")
618
+ p.add_argument("--out", default=None, help="output HTML path (default: next to report.json)")
619
+ p.add_argument("--open", action="store_true", dest="open_browser")
620
+ return p.parse_args(argv)
621
+
622
+
623
+ def main(argv=None):
624
+ args = parse_args(argv)
625
+ report_path = Path(args.report_path).expanduser()
626
+ if not report_path.exists():
627
+ print(f"error: {report_path} not found", file=sys.stderr)
628
+ sys.exit(1)
629
+ report = json.loads(report_path.read_text(encoding="utf-8"))
630
+
631
+ out_path = Path(args.out).expanduser() if args.out else report_path.parent / "report.html"
632
+ out_path.parent.mkdir(parents=True, exist_ok=True)
633
+ out_path.write_text(render_page(report, report_path.parent), encoding="utf-8")
634
+
635
+ print(f"report: {out_path.absolute().as_uri()}")
636
+ if args.open_browser:
637
+ if open_report(out_path):
638
+ print(" opened in the default browser")
639
+ else:
640
+ print("warning: could not open the report in the default browser", file=sys.stderr)
641
+
642
+
643
+ if __name__ == "__main__":
644
+ main()