@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,315 @@
1
+ #!/usr/bin/env python3
2
+ """Flag local session logs whose "user" turns look machine-generated rather
3
+ than typed by a human -- SKILL.md runs this between collection and scoring
4
+ so this skill does not grade a machine talking to itself.
5
+
6
+ `collect_sessions.py` already excludes sidechains by default, which covers
7
+ subagents. It does not cover top-level sessions launched by other tooling --
8
+ audit harnesses, judges, other skills -- whose "user" turn is a
9
+ programmatically built prompt. On a real corpus these can dominate: one scan
10
+ found 686 of 688 candidate user turns were such templates, median 4024
11
+ characters, repeated verbatim across hundreds of sessions. Grading those for
12
+ efficiency and code quality measures a machine talking to itself and
13
+ silently drags the user's grades around.
14
+
15
+ Three filters flag a session by the SHAPE of its content, never by naming a
16
+ specific tool or skill, so this script stays independent of whatever else
17
+ runs on this machine:
18
+
19
+ 1. --max-turn-chars the session's first user turn is longer than this;
20
+ a competent human rarely opens with more (default
21
+ 1200 chars).
22
+ 2. --template-threshold a normalized prefix of a user turn (any user
23
+ turn in the session, not only the first) that
24
+ repeats across this many or more DISTINCT
25
+ sessions is a template, not a human typing fresh
26
+ words each time -- every session containing that
27
+ prefix is flagged. The grouping key is only the
28
+ first --template-prefix-chars of the folded text
29
+ (default 160): a template's identity is its
30
+ opening, not whatever variable payload follows
31
+ it. The threshold counts DISTINCT SESSIONS, so the
32
+ same text repeated several times inside one
33
+ session is not, by itself, a template.
34
+ 3. self-session sentinel a turn containing MACHINE_SESSION_SENTINEL is
35
+ this skill's own scoring prompt sent to a judge
36
+ (see SKILL.md Step 2), not a human talking to an
37
+ agent.
38
+
39
+ Reuses the session-file discovery and parsing already implemented in
40
+ collect_sessions.py (Claude Code, Codex, Pi, Grok Build, ZCode) instead of
41
+ re-implementing five JSONL readers -- this scans the exact same raw logs
42
+ the collector reads.
43
+
44
+ Emits:
45
+ <out>/machine_sessions.json - every flagged session id (format
46
+ "<harness>:<session-id>", matching the
47
+ collector's own sampling key) with its
48
+ reasons and evidence, plus stats and
49
+ template groups for a human to audit.
50
+
51
+ Python 3.9+, stdlib only. Reads only local session logs, writes only under
52
+ --out, makes no network call, and never modifies a session file.
53
+ """
54
+
55
+ import argparse
56
+ import json
57
+ import os
58
+ import re
59
+ import sys
60
+ from datetime import datetime, timedelta, timezone
61
+ from pathlib import Path
62
+
63
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
64
+
65
+ from collect_sessions import ( # noqa: E402
66
+ find_claude_session_files, parse_claude_session,
67
+ find_codex_session_files, parse_codex_session,
68
+ find_pi_session_files, parse_pi_session,
69
+ find_grok_session_files, parse_grok_session,
70
+ find_zcode_session_files, parse_zcode_session,
71
+ )
72
+
73
+ # A turn carrying this marker is this skill's own scoring prompt sent to a
74
+ # judge (see SKILL.md Step 2), not a human turn. SKILL.md prepends this
75
+ # sentinel, on its own line, to every prompt this skill sends to a judge --
76
+ # including a delegated child agent's -- so any future run recognizes its
77
+ # own prior-run prompts in the logs and refuses to score them as if a human
78
+ # had written them.
79
+ MACHINE_SESSION_SENTINEL = "<!-- doctor-md-skill:self-session -->"
80
+
81
+ # Default grouping-key length for template detection (overridable via
82
+ # --template-prefix-chars). Deliberately short: a template's identity is
83
+ # its opening, not whatever variable payload (an id, a JSON blob) follows
84
+ # it -- a key long enough to reach into that payload makes every instance
85
+ # hash differently and defeats the filter on exactly the turns it exists
86
+ # to catch.
87
+ TEMPLATE_PREFIX_CHARS = 160
88
+ TEMPLATE_REPORT_PREFIX_CHARS = 160
89
+
90
+ DEFAULT_MAX_TURN_CHARS = 1200
91
+ DEFAULT_TEMPLATE_THRESHOLD = 3
92
+
93
+
94
+ def normalize_prefix(text, length=TEMPLATE_PREFIX_CHARS):
95
+ """Fold whitespace and take the first `length` chars -- the key used to
96
+ group repeated-template candidates regardless of the surrounding
97
+ whitespace or which session wrote them."""
98
+ return re.sub(r"\s+", " ", text).strip()[:length]
99
+
100
+
101
+ def iter_session_user_turns(entries):
102
+ """Yield the text of every ("user", text) entry, in order."""
103
+ for role, text in entries:
104
+ if role == "user" and text:
105
+ yield text
106
+
107
+
108
+ def session_contains_sentinel(entries):
109
+ return any(MACHINE_SESSION_SENTINEL in text for _role, text in entries if text)
110
+
111
+
112
+ HARNESS_SOURCES = {
113
+ "claude": (find_claude_session_files, parse_claude_session, "claude_home"),
114
+ "codex": (find_codex_session_files, parse_codex_session, "codex_home"),
115
+ "pi": (find_pi_session_files, parse_pi_session, "pi_home"),
116
+ "grok": (find_grok_session_files, parse_grok_session, "grok_home"),
117
+ "zcode": (find_zcode_session_files, parse_zcode_session, "zcode_home"),
118
+ }
119
+
120
+
121
+ def collect_candidates(homes, cutoff, include_subagents=False):
122
+ """Walk every configured harness home and return a list of per-session
123
+ records: {session_key, harness, first_user_turn, user_turns,
124
+ has_sentinel}. `homes` maps harness name -> Path (or None to skip)."""
125
+ records = []
126
+ for harness, (find_files, parse_session, home_key) in HARNESS_SOURCES.items():
127
+ home = homes.get(home_key)
128
+ if home is None:
129
+ continue
130
+ try:
131
+ # Only the Claude Code finder also walks sidechain files; every
132
+ # other finder takes just (home, cutoff).
133
+ if harness == "claude":
134
+ files = find_files(home, cutoff, include_subagents)
135
+ else:
136
+ files = find_files(home, cutoff)
137
+ except OSError:
138
+ continue
139
+ for _mtime, path in files:
140
+ parsed = parse_session(path, set(), include_subagents)
141
+ if parsed is None:
142
+ continue
143
+ meta, _stats, entries, _skills_used = parsed
144
+ user_turns = list(iter_session_user_turns(entries))
145
+ records.append({
146
+ "session_key": f"{harness}:{meta.get('id')}",
147
+ "harness": harness,
148
+ "file": str(path),
149
+ "user_turns": user_turns,
150
+ "first_user_turn": user_turns[0] if user_turns else None,
151
+ "has_sentinel": session_contains_sentinel(entries),
152
+ })
153
+ return records
154
+
155
+
156
+ def flag_sessions(records, max_turn_chars, template_threshold, template_prefix_chars):
157
+ """Apply the three filters and return (flagged, dropped_templates,
158
+ stats). `flagged` is a list of {session_key, harness, reasons,
159
+ evidence} sorted by session_key."""
160
+ # Pass 1: collect every (session_key, normalized prefix) pair across
161
+ # every user turn in every session -- not only the first turn, so a
162
+ # template that shows up mid-session is still caught, and so the same
163
+ # text repeated inside ONE session collapses to one entry per prefix
164
+ # (a set, not a count) before the distinct-session threshold is applied.
165
+ prefix_sessions = {} # normalized prefix -> set of session_key
166
+ prefix_occurrences = {} # normalized prefix -> total turn occurrences
167
+ for record in records:
168
+ seen_prefixes_this_session = set()
169
+ for turn in record["user_turns"]:
170
+ prefix = normalize_prefix(turn, length=template_prefix_chars)
171
+ if not prefix:
172
+ continue
173
+ prefix_occurrences[prefix] = prefix_occurrences.get(prefix, 0) + 1
174
+ if prefix not in seen_prefixes_this_session:
175
+ seen_prefixes_this_session.add(prefix)
176
+ prefix_sessions.setdefault(prefix, set()).add(record["session_key"])
177
+
178
+ templated_prefixes = {
179
+ prefix for prefix, sessions in prefix_sessions.items()
180
+ if len(sessions) >= template_threshold
181
+ }
182
+ dropped_templates = [
183
+ {
184
+ "prefix": prefix[:TEMPLATE_REPORT_PREFIX_CHARS],
185
+ "sessions": len(prefix_sessions[prefix]),
186
+ "occurrences": prefix_occurrences[prefix],
187
+ }
188
+ for prefix in templated_prefixes
189
+ ]
190
+ dropped_templates.sort(key=lambda d: (-d["sessions"], -d["occurrences"]))
191
+
192
+ flagged = []
193
+ long_first_turn_count = 0
194
+ templated_count = 0
195
+ sentinel_count = 0
196
+ for record in records:
197
+ reasons = []
198
+ evidence = {}
199
+
200
+ first_turn = record["first_user_turn"]
201
+ if first_turn is not None and len(first_turn) > max_turn_chars:
202
+ reasons.append("long_first_turn")
203
+ evidence["first_turn_chars"] = len(first_turn)
204
+ long_first_turn_count += 1
205
+
206
+ session_prefixes = {
207
+ normalize_prefix(turn, length=template_prefix_chars)
208
+ for turn in record["user_turns"]
209
+ }
210
+ matched = session_prefixes & templated_prefixes
211
+ if matched:
212
+ reasons.append("templated")
213
+ sample_prefix = sorted(matched)[0]
214
+ evidence["template_prefix"] = sample_prefix[:TEMPLATE_REPORT_PREFIX_CHARS]
215
+ evidence["template_sessions"] = len(prefix_sessions[sample_prefix])
216
+ templated_count += 1
217
+
218
+ if record["has_sentinel"]:
219
+ reasons.append("self_session_sentinel")
220
+ sentinel_count += 1
221
+
222
+ if reasons:
223
+ flagged.append({
224
+ "session_key": record["session_key"],
225
+ "harness": record["harness"],
226
+ "reasons": reasons,
227
+ "evidence": evidence,
228
+ })
229
+
230
+ flagged.sort(key=lambda f: f["session_key"])
231
+
232
+ stats = {
233
+ "sessions_scanned": len(records),
234
+ "sessions_flagged": len(flagged),
235
+ "flagged_long_first_turn": long_first_turn_count,
236
+ "flagged_templated": templated_count,
237
+ "flagged_self_session_sentinel": sentinel_count,
238
+ "template_groups": len(dropped_templates),
239
+ }
240
+ return flagged, dropped_templates, stats
241
+
242
+
243
+ def parse_args(argv=None):
244
+ p = argparse.ArgumentParser(description=__doc__)
245
+ p.add_argument("--out", required=True)
246
+ p.add_argument("--days", type=int, default=45, help="lookback window (default: 45)")
247
+ p.add_argument(
248
+ "--claude-home",
249
+ default=os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"),
250
+ help="Claude Code config directory (default: CLAUDE_CONFIG_DIR or ~/.claude)",
251
+ )
252
+ p.add_argument("--codex-home", default=os.environ.get("CODEX_HOME", "~/.codex"))
253
+ p.add_argument("--pi-home", default="~/.pi/agent")
254
+ p.add_argument("--grok-home", default="~/.grok")
255
+ p.add_argument("--zcode-home", default="~/.zcode")
256
+ p.add_argument("--include-subagents", action="store_true")
257
+ p.add_argument("--max-turn-chars", type=int, default=DEFAULT_MAX_TURN_CHARS,
258
+ help=f"a first user turn longer than this is flagged (default: {DEFAULT_MAX_TURN_CHARS})")
259
+ p.add_argument("--template-threshold", type=int, default=DEFAULT_TEMPLATE_THRESHOLD,
260
+ help="flag a user turn as a machine template when its normalized prefix "
261
+ f"repeats across this many or more distinct sessions (default: {DEFAULT_TEMPLATE_THRESHOLD})")
262
+ p.add_argument("--template-prefix-chars", type=int, default=TEMPLATE_PREFIX_CHARS,
263
+ help=f"chars of whitespace-folded text used as the template-matching key (default: {TEMPLATE_PREFIX_CHARS})")
264
+ return p.parse_args(argv)
265
+
266
+
267
+ def main(argv=None):
268
+ args = parse_args(argv)
269
+ out_dir = Path(args.out).expanduser()
270
+ out_dir.mkdir(parents=True, exist_ok=True)
271
+
272
+ homes = {
273
+ "claude_home": Path(args.claude_home).expanduser(),
274
+ "codex_home": Path(args.codex_home).expanduser(),
275
+ "pi_home": Path(args.pi_home).expanduser(),
276
+ "grok_home": Path(args.grok_home).expanduser(),
277
+ "zcode_home": Path(args.zcode_home).expanduser(),
278
+ }
279
+ cutoff = datetime.now(timezone.utc) - timedelta(days=args.days)
280
+
281
+ records = collect_candidates(homes, cutoff, args.include_subagents)
282
+ flagged, dropped_templates, stats = flag_sessions(
283
+ records, args.max_turn_chars, args.template_threshold, args.template_prefix_chars,
284
+ )
285
+
286
+ report = {
287
+ "generated_at": datetime.now(timezone.utc).isoformat(),
288
+ "window_days": args.days,
289
+ "max_turn_chars": args.max_turn_chars,
290
+ "template_threshold": args.template_threshold,
291
+ "template_prefix_chars": args.template_prefix_chars,
292
+ "sentinel": MACHINE_SESSION_SENTINEL,
293
+ "stats": stats,
294
+ "flagged_sessions": flagged,
295
+ "dropped_templates": dropped_templates,
296
+ }
297
+ out_path = out_dir / "machine_sessions.json"
298
+ out_path.write_text(json.dumps(report, indent=2))
299
+
300
+ print(f"machine_sessions.json: {out_path}")
301
+ print(f"sessions scanned: {stats['sessions_scanned']}")
302
+ print(f"sessions flagged: {stats['sessions_flagged']}")
303
+ print(f" long first turn: {stats['flagged_long_first_turn']}")
304
+ print(f" templated: {stats['flagged_templated']} ({stats['template_groups']} template group(s))")
305
+ print(f" self-session: {stats['flagged_self_session_sentinel']}")
306
+ if stats["sessions_scanned"] and stats["sessions_flagged"] > stats["sessions_scanned"] - stats["sessions_flagged"]:
307
+ print(
308
+ "warning: flagged sessions outnumber kept sessions -- this corpus is mostly "
309
+ "machine-driven and any surviving grades rest on very little",
310
+ file=sys.stderr,
311
+ )
312
+
313
+
314
+ if __name__ == "__main__":
315
+ main()