@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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +465 -0
- package/package.json +69 -0
- package/skills/doctor-md-agents/README.md +51 -0
- package/skills/doctor-md-agents/SKILL.md +275 -0
- package/skills/doctor-md-agents/references/rule-model.md +56 -0
- package/skills/doctor-md-agents/references/structure.md +89 -0
- package/skills/doctor-md-agents/scorers/adherence.md +73 -0
- package/skills/doctor-md-agents/scorers/rule-value.md +49 -0
- package/skills/doctor-md-agents/scripts/collect_sessions.py +1135 -0
- package/skills/doctor-md-agents/scripts/extract_rules.py +704 -0
- package/skills/doctor-md-agents/scripts/render_report.py +644 -0
- package/skills/doctor-md-agents/scripts/scan_corrections.py +781 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +780 -0
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +530 -0
- package/skills/doctor-md-agents/scripts/test_render_report.py +383 -0
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +343 -0
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +236 -0
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +507 -0
- package/skills/doctor-md-agents/scripts/verify_citations.py +217 -0
- package/skills/doctor-md-skill/README.md +27 -0
- package/skills/doctor-md-skill/SKILL.md +212 -0
- package/skills/doctor-md-skill/assets/pierre-diffs.js +1531 -0
- package/skills/doctor-md-skill/references/skill-improvements.md +32 -0
- package/skills/doctor-md-skill/references/supported-harnesses.md +44 -0
- package/skills/doctor-md-skill/scorers/code-quality.md +41 -0
- package/skills/doctor-md-skill/scorers/efficiency.md +38 -0
- package/skills/doctor-md-skill/scripts/collect_sessions.py +1112 -0
- package/skills/doctor-md-skill/scripts/detect_machine_sessions.py +315 -0
- package/skills/doctor-md-skill/scripts/render_report.py +545 -0
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +593 -0
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +181 -0
- package/skills/doctor-md-skill/scripts/test_render_report.py +241 -0
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +196 -0
- 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()
|