@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,704 @@
1
+ #!/usr/bin/env python3
2
+ """Split agent instruction files (CLAUDE.md, AGENTS.md, ...) into individually
3
+ gradable rules and emit a token-cost rollup.
4
+
5
+ Splitting follows references/rule-model.md literally:
6
+
7
+ 1. Cut on every ATX heading (# .. ######). A section is the heading plus
8
+ the body up to the next heading of any level. A section whose body is
9
+ only whitespace is dropped, but its heading still contributes to the
10
+ heading path of its child sections.
11
+ 2. Inside a section body, if there are 2 or more top-level list items
12
+ (-, *, +, or "1.", indented no more than 3 spaces), each item -- with
13
+ its indented continuation lines and any deeper-nested sub-items --
14
+ becomes its own rule. Prose before the first item becomes one more
15
+ rule. A single bullet under a heading is not enough to split: one
16
+ item alone is just a heading with one paragraph, not a list a reader
17
+ would scan item by item, so it stays part of its section.
18
+ 3. Otherwise the whole section body is one rule.
19
+
20
+ Fenced code blocks (``` or ~~~) are body text: headings and list markers
21
+ inside a fence never start a rule, regardless of the fence's own
22
+ indentation.
23
+
24
+ <important if="condition"> ... </important> blocks (references/structure.md)
25
+ are containers, recognized only when the tag sits alone on its own line: the
26
+ rules inside split the same way as any other section body and each one
27
+ carries `condition` (the block's condition text) in rules.json; a bare rule
28
+ outside any block has `condition: null`. The tags themselves are excluded
29
+ from a rule's text, chars and est_tokens -- they cost nothing to obey. A
30
+ nested <important if> (one opened while already inside another) is rejected
31
+ with a warning and kept as literal text under the outer condition rather
32
+ than flattened into it, and so is a stray closing tag with no matching
33
+ open. A file with no blocks at all extracts exactly as it did before this
34
+ feature existed, aside from the added `condition: null` key.
35
+
36
+ Emits:
37
+ <out>/rules.json - every graded file, every rule (id, heading path, line
38
+ range, char/token cost, gist, full text), and a
39
+ per-top-level-section rollup.
40
+
41
+ Python 3.9+, stdlib only. Reads only the files named on the command line or
42
+ found by auto-discovery; writes only under --out. Never touches the source
43
+ instruction files, never makes a network call.
44
+ """
45
+
46
+ import argparse
47
+ import json
48
+ import os
49
+ import re
50
+ import sys
51
+ from datetime import datetime, timezone
52
+ from pathlib import Path
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Auto-discovery -- exactly the paths listed in references/rule-model.md
56
+ # under "Which files get graded".
57
+ # ---------------------------------------------------------------------------
58
+
59
+ GLOBAL_CANDIDATES = (
60
+ "~/.claude/CLAUDE.md",
61
+ "~/.codex/AGENTS.md",
62
+ "~/.agents/AGENTS.md",
63
+ "~/AGENTS.md",
64
+ )
65
+
66
+ PROJECT_CANDIDATES = (
67
+ "CLAUDE.md",
68
+ "AGENTS.md",
69
+ ".claude/CLAUDE.md",
70
+ ".github/copilot-instructions.md",
71
+ )
72
+
73
+ HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*))?$")
74
+ FENCE_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})(.*)$")
75
+ # Top-level list item: -, *, +, or "1." (multi-digit), indented <=3 spaces,
76
+ # with required whitespace after the marker so "---" rules and "|---|"
77
+ # table rows never match.
78
+ LIST_ITEM_RE = re.compile(r"^( {0,3})([-*+]|\d+\.)[ \t]+(.*)$")
79
+ # <important if="condition"> containers (references/structure.md,
80
+ # rule-model.md). Recognized only when the tag sits alone on its own line,
81
+ # indented no more than 3 spaces -- an inline mention of the tag in prose
82
+ # (this file's own docs, for instance) must never be treated as a real
83
+ # block boundary.
84
+ IMPORTANT_OPEN_RE = re.compile(r'^[ \t]{0,3}<important\s+if="([^"]*)">[ \t]*$')
85
+ IMPORTANT_CLOSE_RE = re.compile(r"^[ \t]{0,3}</important>[ \t]*$")
86
+
87
+ RULE_SIZE_WARNING_CHARS = 2000
88
+ MIN_RULES_PER_FILE_WARNING = 3
89
+ # A section splits into one rule per top-level list item once it has at
90
+ # least this many items. Below it, a lone bullet is just a heading with one
91
+ # paragraph -- not a list a reader would scan item by item -- so the whole
92
+ # section body stays one rule. See references/rule-model.md.
93
+ LIST_SPLIT_MIN_ITEMS = 2
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # Fence / heading / list-item scanning
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ def find_fenced_line_numbers(lines):
102
+ """Return the set of 1-based line numbers that fall inside a fenced code
103
+ block (including the opening and closing fence lines themselves)."""
104
+ fenced = set()
105
+ open_char = None
106
+ open_len = 0
107
+ in_fence = False
108
+ for i, line in enumerate(lines, start=1):
109
+ m = FENCE_RE.match(line)
110
+ if not in_fence:
111
+ if m:
112
+ marker = m.group(1)
113
+ open_char = marker[0]
114
+ open_len = len(marker)
115
+ in_fence = True
116
+ fenced.add(i)
117
+ else:
118
+ fenced.add(i)
119
+ if m:
120
+ marker = m.group(1)
121
+ trailing = m.group(2).strip()
122
+ if marker[0] == open_char and len(marker) >= open_len and trailing == "":
123
+ in_fence = False
124
+ open_char = None
125
+ open_len = 0
126
+ return fenced
127
+
128
+
129
+ def parse_headings(lines, fenced):
130
+ """Return [{"line_no", "level", "title"}] for ATX headings outside fences."""
131
+ headings = []
132
+ for i, line in enumerate(lines, start=1):
133
+ if i in fenced:
134
+ continue
135
+ m = HEADING_RE.match(line)
136
+ if not m:
137
+ continue
138
+ level = len(m.group(1))
139
+ raw_title = (m.group(2) or "").strip()
140
+ title = re.sub(r"[ \t]+#+\s*$", "", raw_title).strip()
141
+ headings.append({"line_no": i, "level": level, "title": title})
142
+ return headings
143
+
144
+
145
+ def build_sections(total_lines, headings):
146
+ """Partition the file into sections: heading line + body up to the next
147
+ heading (any level) or EOF. A synthetic preamble section (no heading)
148
+ is emitted when content precedes the first heading. Each section carries
149
+ its full heading_path, computed via a level stack -- pushed for every
150
+ heading regardless of whether its body turns out to be whitespace-only,
151
+ so an empty parent still contributes to its children's path."""
152
+ sections = []
153
+ stack = [] # list of (level, title)
154
+
155
+ first_heading_line = headings[0]["line_no"] if headings else total_lines + 1
156
+ if first_heading_line > 1:
157
+ sections.append({
158
+ "heading_line": None,
159
+ "level": 0,
160
+ "title": "",
161
+ "heading_path": "",
162
+ "body_start": 1,
163
+ "body_end": first_heading_line - 1,
164
+ })
165
+
166
+ for idx, heading in enumerate(headings):
167
+ level = heading["level"]
168
+ while stack and stack[-1][0] >= level:
169
+ stack.pop()
170
+ stack.append((level, heading["title"]))
171
+ heading_path = " \u203a ".join(t for _, t in stack)
172
+
173
+ body_start = heading["line_no"] + 1
174
+ body_end = (
175
+ headings[idx + 1]["line_no"] - 1 if idx + 1 < len(headings) else total_lines
176
+ )
177
+ sections.append({
178
+ "heading_line": heading["line_no"],
179
+ "level": level,
180
+ "title": heading["title"],
181
+ "heading_path": heading_path,
182
+ "body_start": body_start,
183
+ "body_end": min(body_end, total_lines),
184
+ })
185
+ return sections
186
+
187
+
188
+ def parse_condition_blocks(lines, fenced):
189
+ """Scan the whole file for <important if="..."> containers, outside
190
+ fences. Returns (line_condition, tag_lines, warnings):
191
+
192
+ line_condition: {1-based line_no: condition string or None} for every
193
+ line in the file.
194
+ tag_lines: set of line numbers that are a *recognized* open or
195
+ close tag -- these are dropped from every rule's text
196
+ and do not count toward char/token cost.
197
+ warnings: human-readable strings for anything rejected: a
198
+ nested block (kept as literal text under the outer
199
+ condition, never flattened), a stray closing tag with
200
+ no matching open, or a block left open at EOF (kept
201
+ as literal text with no condition at all -- a missing
202
+ </important> must never silently wrap the rest of
203
+ the file, possibly crossing headings and swallowing
204
+ bare invariants, under a condition nobody closed).
205
+
206
+ Only one level of nesting is tracked. A second <important if="..."> seen
207
+ while already inside a block is not a new container: its line -- and
208
+ every line until a matching close (if any) -- stays literal body text
209
+ under the *outer* condition, exactly like ordinary prose. This is a
210
+ deliberate rejection, not a flattening: nothing about the inner text is
211
+ dropped or reinterpreted, only its own condition is discarded.
212
+ """
213
+ line_condition = {}
214
+ tag_lines = set()
215
+ warnings = []
216
+ # depth 0: outside any block. depth 1: inside the one real (outer)
217
+ # block. depth > 1: inside a rejected nested block -- its own open/close
218
+ # tags are literal text, and only the matching outer close (the one
219
+ # that brings depth back to 0) is a real tag.
220
+ depth = 0
221
+ current_condition = None
222
+ open_line = None
223
+
224
+ for i, raw in enumerate(lines, start=1):
225
+ if i in fenced:
226
+ line_condition[i] = current_condition
227
+ continue
228
+
229
+ m_open = IMPORTANT_OPEN_RE.match(raw)
230
+ m_close = IMPORTANT_CLOSE_RE.match(raw)
231
+
232
+ if m_open:
233
+ if depth == 0:
234
+ depth = 1
235
+ open_line = i
236
+ current_condition = m_open.group(1)
237
+ tag_lines.add(i)
238
+ line_condition[i] = current_condition
239
+ else:
240
+ warnings.append(
241
+ f"line {i}: nested <important if> block rejected, kept as "
242
+ "text under the outer condition (not flattened)"
243
+ )
244
+ depth += 1
245
+ line_condition[i] = current_condition
246
+ elif m_close:
247
+ if depth == 0:
248
+ warnings.append(
249
+ f"line {i}: stray </important> with no matching open tag "
250
+ "(kept as text)"
251
+ )
252
+ line_condition[i] = current_condition
253
+ elif depth == 1:
254
+ depth = 0
255
+ tag_lines.add(i)
256
+ line_condition[i] = current_condition
257
+ current_condition = None
258
+ open_line = None
259
+ else:
260
+ # Closes a rejected nested block -- literal, like its open.
261
+ depth -= 1
262
+ line_condition[i] = current_condition
263
+ else:
264
+ line_condition[i] = current_condition
265
+
266
+ if depth > 0:
267
+ # Never let a missing </important> silently wrap the rest of the
268
+ # file (potentially crossing headings, potentially swallowing
269
+ # invariants) under a condition nobody closed. Revert it entirely:
270
+ # the opening tag itself reverts to literal text, and every line
271
+ # from the open onward loses its condition. Closes seen at
272
+ # depth > 1 were already literal and never entered tag_lines, so
273
+ # only the outer open needs undoing here.
274
+ warnings.append(
275
+ f"line {open_line}: <important if> block never closed before "
276
+ "end of file (kept as text, condition discarded)"
277
+ )
278
+ tag_lines.discard(open_line)
279
+ for j in range(open_line, len(lines) + 1):
280
+ line_condition[j] = None
281
+
282
+ return line_condition, tag_lines, warnings
283
+
284
+
285
+ def build_condition_runs(start, end, line_condition, tag_lines):
286
+ """Split [start, end] (1-based, inclusive) into maximal runs of
287
+ consecutive non-tag lines sharing the same condition. A recognized tag
288
+ line is a run boundary and is itself excluded from every run."""
289
+ if start > end:
290
+ return []
291
+ runs = []
292
+ run_start = None
293
+ run_condition = None
294
+ for i in range(start, end + 1):
295
+ if i in tag_lines:
296
+ if run_start is not None:
297
+ runs.append((run_start, i - 1, run_condition))
298
+ run_start = None
299
+ continue
300
+ condition = line_condition.get(i)
301
+ if run_start is None:
302
+ run_start = i
303
+ run_condition = condition
304
+ elif condition != run_condition:
305
+ runs.append((run_start, i - 1, run_condition))
306
+ run_start = i
307
+ run_condition = condition
308
+ if run_start is not None:
309
+ runs.append((run_start, end, run_condition))
310
+ return runs
311
+
312
+
313
+ def _collapse_ws(text):
314
+ return re.sub(r"\s+", " ", text).strip()
315
+
316
+
317
+ def _first_line_title(text, limit=80):
318
+ first_line = text.splitlines()[0] if text else ""
319
+ collapsed = _collapse_ws(first_line)
320
+ return collapsed[:limit]
321
+
322
+
323
+ def _split_range_into_rules(lines, start, end, fenced, heading_title_fallback):
324
+ """Return rule dicts (without id/file_label/section/condition) for one
325
+ contiguous range of lines, following the list/prose split in
326
+ rule-model.md. `heading_title_fallback` is the title to use when the
327
+ range does not split into list items: the section's own heading title
328
+ when this range is a whole, unwrapped section under a heading, or None
329
+ to fall back to the first line of the body (prose, or a wrapped run that
330
+ is not itself a heading)."""
331
+ body_lines = lines[start - 1:end]
332
+ body_text = "\n".join(body_lines)
333
+
334
+ item_starts = [
335
+ i for i in range(start, end + 1)
336
+ if i not in fenced and LIST_ITEM_RE.match(lines[i - 1])
337
+ ]
338
+
339
+ rules = []
340
+ if len(item_starts) >= LIST_SPLIT_MIN_ITEMS:
341
+ # Prose before the first item.
342
+ if item_starts[0] > start:
343
+ prose_lines = lines[start - 1:item_starts[0] - 1]
344
+ prose_text = "\n".join(prose_lines)
345
+ if prose_text.strip() != "":
346
+ rules.append({
347
+ "start_line": start,
348
+ "end_line": item_starts[0] - 1,
349
+ "text": prose_text,
350
+ "title": _first_line_title(prose_text),
351
+ })
352
+ for i, item_start in enumerate(item_starts):
353
+ item_end = item_starts[i + 1] - 1 if i + 1 < len(item_starts) else end
354
+ item_lines = lines[item_start - 1:item_end]
355
+ item_text = "\n".join(item_lines)
356
+ rules.append({
357
+ "start_line": item_start,
358
+ "end_line": item_end,
359
+ "text": item_text,
360
+ "title": _first_line_title(item_text),
361
+ })
362
+ else:
363
+ title = heading_title_fallback if heading_title_fallback is not None else _first_line_title(body_text)
364
+ rules.append({
365
+ "start_line": start,
366
+ "end_line": end,
367
+ "text": body_text,
368
+ "title": title,
369
+ })
370
+ return rules
371
+
372
+
373
+ def split_section_into_rules(lines, section, fenced, line_condition, tag_lines):
374
+ """Return a list of rule dicts (without id/file_label) for one section's
375
+ body, following the split rules in rule-model.md, with the body first
376
+ cut into <important if> runs (parse_condition_blocks /
377
+ build_condition_runs). Each resulting rule carries `condition`: None for
378
+ a bare run, the block's condition text for a wrapped one."""
379
+ start, end = section["body_start"], section["body_end"]
380
+ if start > end:
381
+ return []
382
+
383
+ runs = [
384
+ (r_start, r_end, condition)
385
+ for (r_start, r_end, condition) in build_condition_runs(start, end, line_condition, tag_lines)
386
+ if "\n".join(lines[r_start - 1:r_end]).strip() != ""
387
+ ]
388
+ if not runs:
389
+ return []
390
+
391
+ # A section with no <important if> tags inside it collapses to exactly
392
+ # one run spanning its whole original body -- this is the byte-for-byte
393
+ # compatible case a file with no blocks at all must always hit, so it
394
+ # keeps the section's own heading as its title fallback, same as before
395
+ # this feature existed. Any other shape (a block inside the section, or
396
+ # a section split by one) always falls back to the first body line.
397
+ whole_section_is_one_run = (
398
+ len(runs) == 1 and runs[0][0] == start and runs[0][1] == end
399
+ )
400
+
401
+ rules = []
402
+ for r_start, r_end, condition in runs:
403
+ heading_title_fallback = (
404
+ section["title"] if (whole_section_is_one_run and section["heading_line"]) else None
405
+ )
406
+ run_rules = _split_range_into_rules(lines, r_start, r_end, fenced, heading_title_fallback)
407
+ for rule in run_rules:
408
+ rule["condition"] = condition
409
+ rules.extend(run_rules)
410
+
411
+ for rule in rules:
412
+ rule["section"] = section["heading_path"]
413
+ rule["top_level_title"] = (
414
+ section["heading_path"].split(" \u203a ", 1)[0] if section["heading_path"] else ""
415
+ )
416
+ return rules
417
+
418
+
419
+ def extract_rules_from_text(text, file_label, scope):
420
+ """Return (rules, section_rollups, file_meta) for one file's raw text.
421
+
422
+ file_meta = {"chars", "est_tokens", "rules"} (rules = count).
423
+ """
424
+ lines = text.splitlines()
425
+ total_lines = len(lines)
426
+ fenced = find_fenced_line_numbers(lines)
427
+ headings = parse_headings(lines, fenced)
428
+ sections = build_sections(total_lines, headings)
429
+ line_condition, tag_lines, condition_warnings = parse_condition_blocks(lines, fenced)
430
+
431
+ rules = []
432
+ for section in sections:
433
+ rules.extend(split_section_into_rules(lines, section, fenced, line_condition, tag_lines))
434
+
435
+ out_rules = []
436
+ rollup = {} # top_level_title -> {"chars", "rule_ids"}
437
+ for rule in rules:
438
+ chars = len(rule["text"])
439
+ est_tokens = chars // 4
440
+ rule_id = f"{file_label}#{rule['start_line']}"
441
+ gist = _collapse_ws(rule["text"])[:160]
442
+ out_rules.append({
443
+ "id": rule_id,
444
+ "file_label": file_label,
445
+ "scope": scope,
446
+ "section": rule["section"],
447
+ "title": rule["title"],
448
+ "start_line": rule["start_line"],
449
+ "end_line": rule["end_line"],
450
+ "chars": chars,
451
+ "est_tokens": est_tokens,
452
+ "gist": gist,
453
+ "text": rule["text"],
454
+ "condition": rule.get("condition"),
455
+ })
456
+ bucket = rollup.setdefault(rule["top_level_title"], {"chars": 0, "rule_ids": []})
457
+ bucket["chars"] += chars
458
+ bucket["rule_ids"].append(rule_id)
459
+
460
+ section_rollups = [
461
+ {
462
+ "file_label": file_label,
463
+ "title": title,
464
+ "chars": data["chars"],
465
+ "est_tokens": data["chars"] // 4,
466
+ "rule_ids": data["rule_ids"],
467
+ }
468
+ for title, data in rollup.items()
469
+ ]
470
+
471
+ file_chars = len(text)
472
+ file_meta = {
473
+ "chars": file_chars,
474
+ "est_tokens": file_chars // 4,
475
+ "rules": len(out_rules),
476
+ "warnings": condition_warnings,
477
+ }
478
+ return out_rules, section_rollups, file_meta
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # File discovery
483
+ # ---------------------------------------------------------------------------
484
+
485
+
486
+ class FileEntry:
487
+ __slots__ = ("path", "scope", "label", "label_explicit")
488
+
489
+ def __init__(self, path, scope, label, label_explicit):
490
+ self.path = path
491
+ self.scope = scope
492
+ self.label = label
493
+ self.label_explicit = label_explicit
494
+
495
+
496
+ def _register(registry, order, path, scope, label, label_explicit):
497
+ """Insert or merge a discovered/explicit file into the dedup registry.
498
+
499
+ Dedup key is the resolved real path. When the same file is seen twice:
500
+ - scope: an auto-discovered scope ("global"/"project") always wins over
501
+ the "referenced" default, because the file genuinely is always
502
+ injected -- demoting it would silently drop it from
503
+ injected_est_tokens.
504
+ - label: an explicit label (given after ":" in --file) always wins.
505
+ """
506
+ try:
507
+ key = str(path.resolve())
508
+ except OSError:
509
+ key = str(path)
510
+ if key not in registry:
511
+ registry[key] = FileEntry(path, scope, label, label_explicit)
512
+ order.append(key)
513
+ return
514
+ existing = registry[key]
515
+ if existing.scope == "referenced" and scope != "referenced":
516
+ existing.scope = scope
517
+ if label_explicit:
518
+ existing.label = label
519
+ existing.label_explicit = True
520
+
521
+
522
+ def discover_files(repos, scope_arg, no_auto, explicit_files):
523
+ """Return an ordered list of FileEntry, deduplicated by real path."""
524
+ registry = {}
525
+ order = []
526
+
527
+ if not no_auto:
528
+ if scope_arg in ("all", "global"):
529
+ seen_names = {}
530
+ for candidate in GLOBAL_CANDIDATES:
531
+ path = Path(candidate).expanduser()
532
+ if not path.is_file():
533
+ continue
534
+ name = path.name
535
+ if seen_names.get(name, 0) == 0:
536
+ label = f"global:{name}"
537
+ else:
538
+ label = f"global:{path.parent.name}/{name}"
539
+ seen_names[name] = seen_names.get(name, 0) + 1
540
+ _register(registry, order, path, "global", label, False)
541
+
542
+ if scope_arg in ("all", "project"):
543
+ for repo_arg in repos:
544
+ repo = Path(repo_arg).expanduser().resolve()
545
+ for candidate in PROJECT_CANDIDATES:
546
+ path = repo / candidate
547
+ if not path.is_file():
548
+ continue
549
+ label = f"{repo.name}:{candidate.split('/')[-1]}"
550
+ if candidate.count("/"):
551
+ label = f"{repo.name}:{candidate}"
552
+ _register(registry, order, path, "project", label, False)
553
+
554
+ for raw in explicit_files:
555
+ if ":" in raw and not _looks_like_windows_drive(raw):
556
+ file_part, label = raw.rsplit(":", 1)
557
+ else:
558
+ file_part, label = raw, None
559
+ path = Path(file_part).expanduser()
560
+ if not path.is_file():
561
+ print(f"warning: --file not found, skipping: {file_part}", file=sys.stderr)
562
+ continue
563
+ if label:
564
+ explicit_label, explicit = label, True
565
+ else:
566
+ explicit_label, explicit = _default_referenced_label(path, repos), False
567
+ _register(registry, order, path, "referenced", explicit_label, explicit)
568
+
569
+ return [registry[key] for key in order]
570
+
571
+
572
+ def _looks_like_windows_drive(raw):
573
+ # Defensive only; this skill targets POSIX-style paths but a lone
574
+ # "C:\..." shouldn't be mis-split on its drive-letter colon.
575
+ return re.match(r"^[A-Za-z]:[\\/]", raw) is not None
576
+
577
+
578
+ def _default_referenced_label(path, repos):
579
+ resolved = path.resolve()
580
+ for repo_arg in repos:
581
+ repo = Path(repo_arg).expanduser().resolve()
582
+ try:
583
+ resolved.relative_to(repo)
584
+ return f"{repo.name}:{path.name}"
585
+ except ValueError:
586
+ continue
587
+ try:
588
+ resolved.relative_to(Path.home())
589
+ return f"global:{path.name}"
590
+ except ValueError:
591
+ pass
592
+ return f"referenced:{path.name}"
593
+
594
+
595
+ # ---------------------------------------------------------------------------
596
+ # CLI
597
+ # ---------------------------------------------------------------------------
598
+
599
+
600
+ def parse_args(argv=None):
601
+ p = argparse.ArgumentParser(description=__doc__)
602
+ p.add_argument("--out", required=True, help="directory to write rules.json into")
603
+ p.add_argument("--repo", action="append", default=[], help="project root (repeatable)")
604
+ p.add_argument(
605
+ "--scope", choices=("all", "global", "project"), default="all",
606
+ help="which auto-discovery categories to run (default: all)",
607
+ )
608
+ p.add_argument(
609
+ "--file", action="append", default=[], dest="files",
610
+ help="explicit extra file, PATH or PATH:LABEL (repeatable)",
611
+ )
612
+ p.add_argument("--no-auto", action="store_true", help="skip auto-discovery")
613
+ p.add_argument(
614
+ "--only", action="store_true",
615
+ help="disable auto-discovery entirely; extract only the --file paths given "
616
+ "(requires at least one --file)",
617
+ )
618
+ args = p.parse_args(argv)
619
+ if args.only and not args.files:
620
+ p.error("--only requires --file (auto-discovery is disabled by --only, so "
621
+ "at least one --file path is needed)")
622
+ return args
623
+
624
+
625
+ def main(argv=None):
626
+ args = parse_args(argv)
627
+ out_dir = Path(args.out).expanduser()
628
+ out_dir.mkdir(parents=True, exist_ok=True)
629
+
630
+ entries = discover_files(args.repo, args.scope, args.no_auto or args.only, args.files)
631
+
632
+ if not entries:
633
+ print("error: no instruction file found (auto-discovery empty and no --file given)",
634
+ file=sys.stderr)
635
+ sys.exit(1)
636
+
637
+ all_rules = []
638
+ all_sections = []
639
+ files_out = []
640
+ warnings = []
641
+
642
+ for entry in entries:
643
+ try:
644
+ text = entry.path.read_text(encoding="utf-8", errors="replace")
645
+ except OSError as exc:
646
+ print(f"warning: could not read {entry.path}: {exc}", file=sys.stderr)
647
+ continue
648
+
649
+ rules, sections, meta = extract_rules_from_text(text, entry.label, entry.scope)
650
+ all_rules.extend(rules)
651
+ all_sections.extend(sections)
652
+ files_out.append({
653
+ "label": entry.label,
654
+ "path": str(entry.path.resolve()),
655
+ "scope": entry.scope,
656
+ "chars": meta["chars"],
657
+ "est_tokens": meta["est_tokens"],
658
+ "rules": meta["rules"],
659
+ })
660
+
661
+ if meta["rules"] < MIN_RULES_PER_FILE_WARNING:
662
+ warnings.append(
663
+ f" ! {entry.label}: only {meta['rules']} rule(s) -- "
664
+ "granularity may have collapsed (see rule-model.md)"
665
+ )
666
+ largest = max((r["chars"] for r in rules), default=0)
667
+ if largest > RULE_SIZE_WARNING_CHARS:
668
+ warnings.append(
669
+ f" ! {entry.label}: largest rule is {largest} chars "
670
+ f"(> {RULE_SIZE_WARNING_CHARS}) -- too big to judge or obey"
671
+ )
672
+ for condition_warning in meta.get("warnings", []):
673
+ warnings.append(f" ! {entry.label}: {condition_warning}")
674
+
675
+ injected_est_tokens = sum(
676
+ f["est_tokens"] for f in files_out if f["scope"] != "referenced"
677
+ )
678
+
679
+ report = {
680
+ "generated_at": datetime.now(timezone.utc).isoformat(),
681
+ "injected_est_tokens": injected_est_tokens,
682
+ "files": files_out,
683
+ "rules": all_rules,
684
+ "sections": all_sections,
685
+ }
686
+ (out_dir / "rules.json").write_text(json.dumps(report, indent=2))
687
+
688
+ print(f"rules.json: {out_dir / 'rules.json'}")
689
+ print(f"files graded: {len(files_out)}")
690
+ print(f"injected est tokens: {injected_est_tokens}")
691
+ for f in files_out:
692
+ largest = max((r["chars"] for r in all_rules if r["file_label"] == f["label"]), default=0)
693
+ print(
694
+ f" {f['label']:30s} scope={f['scope']:10s} rules={f['rules']:4d} "
695
+ f"est_tokens={f['est_tokens']:6d} largest_rule_chars={largest}"
696
+ )
697
+ if warnings:
698
+ print("granularity warnings:")
699
+ for w in warnings:
700
+ print(w)
701
+
702
+
703
+ if __name__ == "__main__":
704
+ main()