@iseoane/agent-depot 0.2.0 → 0.3.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 +47 -0
- package/README.md +117 -3
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +457 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +1256 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +41 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +374 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +240 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +193 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +136 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +80 -0
- package/dist/src/tui/installations-view.js +154 -0
- package/dist/src/tui/installations.js +172 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +174 -0
- package/dist/src/tui/manage-panel.js +65 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +89 -0
- package/dist/src/tui/updates-keys.js +104 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +44 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +230 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +60 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +565 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +7 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -1,507 +0,0 @@
|
|
|
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
|
-
Emits:
|
|
25
|
-
<out>/rules.json - every graded file, every rule (id, heading path, line
|
|
26
|
-
range, char/token cost, gist, full text), and a
|
|
27
|
-
per-top-level-section rollup.
|
|
28
|
-
|
|
29
|
-
Python 3.9+, stdlib only. Reads only the files named on the command line or
|
|
30
|
-
found by auto-discovery; writes only under --out. Never touches the source
|
|
31
|
-
instruction files, never makes a network call.
|
|
32
|
-
"""
|
|
33
|
-
|
|
34
|
-
import argparse
|
|
35
|
-
import json
|
|
36
|
-
import os
|
|
37
|
-
import re
|
|
38
|
-
import sys
|
|
39
|
-
from datetime import datetime, timezone
|
|
40
|
-
from pathlib import Path
|
|
41
|
-
|
|
42
|
-
# ---------------------------------------------------------------------------
|
|
43
|
-
# Auto-discovery -- exactly the paths listed in references/rule-model.md
|
|
44
|
-
# under "Which files get graded".
|
|
45
|
-
# ---------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
GLOBAL_CANDIDATES = (
|
|
48
|
-
"~/.claude/CLAUDE.md",
|
|
49
|
-
"~/.codex/AGENTS.md",
|
|
50
|
-
"~/.agents/AGENTS.md",
|
|
51
|
-
"~/AGENTS.md",
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
PROJECT_CANDIDATES = (
|
|
55
|
-
"CLAUDE.md",
|
|
56
|
-
"AGENTS.md",
|
|
57
|
-
".claude/CLAUDE.md",
|
|
58
|
-
".github/copilot-instructions.md",
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
HEADING_RE = re.compile(r"^ {0,3}(#{1,6})(?:[ \t]+(.*))?$")
|
|
62
|
-
FENCE_RE = re.compile(r"^[ \t]*(`{3,}|~{3,})(.*)$")
|
|
63
|
-
# Top-level list item: -, *, +, or "1." (multi-digit), indented <=3 spaces,
|
|
64
|
-
# with required whitespace after the marker so "---" rules and "|---|"
|
|
65
|
-
# table rows never match.
|
|
66
|
-
LIST_ITEM_RE = re.compile(r"^( {0,3})([-*+]|\d+\.)[ \t]+(.*)$")
|
|
67
|
-
|
|
68
|
-
RULE_SIZE_WARNING_CHARS = 2000
|
|
69
|
-
MIN_RULES_PER_FILE_WARNING = 3
|
|
70
|
-
# A section splits into one rule per top-level list item once it has at
|
|
71
|
-
# least this many items. Below it, a lone bullet is just a heading with one
|
|
72
|
-
# paragraph -- not a list a reader would scan item by item -- so the whole
|
|
73
|
-
# section body stays one rule. See references/rule-model.md.
|
|
74
|
-
LIST_SPLIT_MIN_ITEMS = 2
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
# ---------------------------------------------------------------------------
|
|
78
|
-
# Fence / heading / list-item scanning
|
|
79
|
-
# ---------------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def find_fenced_line_numbers(lines):
|
|
83
|
-
"""Return the set of 1-based line numbers that fall inside a fenced code
|
|
84
|
-
block (including the opening and closing fence lines themselves)."""
|
|
85
|
-
fenced = set()
|
|
86
|
-
open_char = None
|
|
87
|
-
open_len = 0
|
|
88
|
-
in_fence = False
|
|
89
|
-
for i, line in enumerate(lines, start=1):
|
|
90
|
-
m = FENCE_RE.match(line)
|
|
91
|
-
if not in_fence:
|
|
92
|
-
if m:
|
|
93
|
-
marker = m.group(1)
|
|
94
|
-
open_char = marker[0]
|
|
95
|
-
open_len = len(marker)
|
|
96
|
-
in_fence = True
|
|
97
|
-
fenced.add(i)
|
|
98
|
-
else:
|
|
99
|
-
fenced.add(i)
|
|
100
|
-
if m:
|
|
101
|
-
marker = m.group(1)
|
|
102
|
-
trailing = m.group(2).strip()
|
|
103
|
-
if marker[0] == open_char and len(marker) >= open_len and trailing == "":
|
|
104
|
-
in_fence = False
|
|
105
|
-
open_char = None
|
|
106
|
-
open_len = 0
|
|
107
|
-
return fenced
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def parse_headings(lines, fenced):
|
|
111
|
-
"""Return [{"line_no", "level", "title"}] for ATX headings outside fences."""
|
|
112
|
-
headings = []
|
|
113
|
-
for i, line in enumerate(lines, start=1):
|
|
114
|
-
if i in fenced:
|
|
115
|
-
continue
|
|
116
|
-
m = HEADING_RE.match(line)
|
|
117
|
-
if not m:
|
|
118
|
-
continue
|
|
119
|
-
level = len(m.group(1))
|
|
120
|
-
raw_title = (m.group(2) or "").strip()
|
|
121
|
-
title = re.sub(r"[ \t]+#+\s*$", "", raw_title).strip()
|
|
122
|
-
headings.append({"line_no": i, "level": level, "title": title})
|
|
123
|
-
return headings
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
def build_sections(total_lines, headings):
|
|
127
|
-
"""Partition the file into sections: heading line + body up to the next
|
|
128
|
-
heading (any level) or EOF. A synthetic preamble section (no heading)
|
|
129
|
-
is emitted when content precedes the first heading. Each section carries
|
|
130
|
-
its full heading_path, computed via a level stack -- pushed for every
|
|
131
|
-
heading regardless of whether its body turns out to be whitespace-only,
|
|
132
|
-
so an empty parent still contributes to its children's path."""
|
|
133
|
-
sections = []
|
|
134
|
-
stack = [] # list of (level, title)
|
|
135
|
-
|
|
136
|
-
first_heading_line = headings[0]["line_no"] if headings else total_lines + 1
|
|
137
|
-
if first_heading_line > 1:
|
|
138
|
-
sections.append({
|
|
139
|
-
"heading_line": None,
|
|
140
|
-
"level": 0,
|
|
141
|
-
"title": "",
|
|
142
|
-
"heading_path": "",
|
|
143
|
-
"body_start": 1,
|
|
144
|
-
"body_end": first_heading_line - 1,
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
for idx, heading in enumerate(headings):
|
|
148
|
-
level = heading["level"]
|
|
149
|
-
while stack and stack[-1][0] >= level:
|
|
150
|
-
stack.pop()
|
|
151
|
-
stack.append((level, heading["title"]))
|
|
152
|
-
heading_path = " \u203a ".join(t for _, t in stack)
|
|
153
|
-
|
|
154
|
-
body_start = heading["line_no"] + 1
|
|
155
|
-
body_end = (
|
|
156
|
-
headings[idx + 1]["line_no"] - 1 if idx + 1 < len(headings) else total_lines
|
|
157
|
-
)
|
|
158
|
-
sections.append({
|
|
159
|
-
"heading_line": heading["line_no"],
|
|
160
|
-
"level": level,
|
|
161
|
-
"title": heading["title"],
|
|
162
|
-
"heading_path": heading_path,
|
|
163
|
-
"body_start": body_start,
|
|
164
|
-
"body_end": min(body_end, total_lines),
|
|
165
|
-
})
|
|
166
|
-
return sections
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _collapse_ws(text):
|
|
170
|
-
return re.sub(r"\s+", " ", text).strip()
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _first_line_title(text, limit=80):
|
|
174
|
-
first_line = text.splitlines()[0] if text else ""
|
|
175
|
-
collapsed = _collapse_ws(first_line)
|
|
176
|
-
return collapsed[:limit]
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
def split_section_into_rules(lines, section, fenced):
|
|
180
|
-
"""Return a list of rule dicts (without id/file_label) for one section's
|
|
181
|
-
body, following the split rules in rule-model.md."""
|
|
182
|
-
start, end = section["body_start"], section["body_end"]
|
|
183
|
-
if start > end:
|
|
184
|
-
return []
|
|
185
|
-
|
|
186
|
-
body_lines = lines[start - 1:end]
|
|
187
|
-
body_text = "\n".join(body_lines)
|
|
188
|
-
if body_text.strip() == "":
|
|
189
|
-
return []
|
|
190
|
-
|
|
191
|
-
item_starts = [
|
|
192
|
-
i for i in range(start, end + 1)
|
|
193
|
-
if i not in fenced and LIST_ITEM_RE.match(lines[i - 1])
|
|
194
|
-
]
|
|
195
|
-
|
|
196
|
-
rules = []
|
|
197
|
-
if len(item_starts) >= LIST_SPLIT_MIN_ITEMS:
|
|
198
|
-
# Prose before the first item.
|
|
199
|
-
if item_starts[0] > start:
|
|
200
|
-
prose_lines = lines[start - 1:item_starts[0] - 1]
|
|
201
|
-
prose_text = "\n".join(prose_lines)
|
|
202
|
-
if prose_text.strip() != "":
|
|
203
|
-
rules.append({
|
|
204
|
-
"start_line": start,
|
|
205
|
-
"end_line": item_starts[0] - 1,
|
|
206
|
-
"text": prose_text,
|
|
207
|
-
"title": _first_line_title(prose_text),
|
|
208
|
-
})
|
|
209
|
-
for i, item_start in enumerate(item_starts):
|
|
210
|
-
item_end = item_starts[i + 1] - 1 if i + 1 < len(item_starts) else end
|
|
211
|
-
item_lines = lines[item_start - 1:item_end]
|
|
212
|
-
item_text = "\n".join(item_lines)
|
|
213
|
-
rules.append({
|
|
214
|
-
"start_line": item_start,
|
|
215
|
-
"end_line": item_end,
|
|
216
|
-
"text": item_text,
|
|
217
|
-
"title": _first_line_title(item_text),
|
|
218
|
-
})
|
|
219
|
-
else:
|
|
220
|
-
title = section["title"] if section["heading_line"] else _first_line_title(body_text)
|
|
221
|
-
rules.append({
|
|
222
|
-
"start_line": start,
|
|
223
|
-
"end_line": end,
|
|
224
|
-
"text": body_text,
|
|
225
|
-
"title": title,
|
|
226
|
-
})
|
|
227
|
-
|
|
228
|
-
for rule in rules:
|
|
229
|
-
rule["section"] = section["heading_path"]
|
|
230
|
-
rule["top_level_title"] = (
|
|
231
|
-
section["heading_path"].split(" \u203a ", 1)[0] if section["heading_path"] else ""
|
|
232
|
-
)
|
|
233
|
-
return rules
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def extract_rules_from_text(text, file_label, scope):
|
|
237
|
-
"""Return (rules, section_rollups, file_meta) for one file's raw text.
|
|
238
|
-
|
|
239
|
-
file_meta = {"chars", "est_tokens", "rules"} (rules = count).
|
|
240
|
-
"""
|
|
241
|
-
lines = text.splitlines()
|
|
242
|
-
total_lines = len(lines)
|
|
243
|
-
fenced = find_fenced_line_numbers(lines)
|
|
244
|
-
headings = parse_headings(lines, fenced)
|
|
245
|
-
sections = build_sections(total_lines, headings)
|
|
246
|
-
|
|
247
|
-
rules = []
|
|
248
|
-
for section in sections:
|
|
249
|
-
rules.extend(split_section_into_rules(lines, section, fenced))
|
|
250
|
-
|
|
251
|
-
out_rules = []
|
|
252
|
-
rollup = {} # top_level_title -> {"chars", "rule_ids"}
|
|
253
|
-
for rule in rules:
|
|
254
|
-
chars = len(rule["text"])
|
|
255
|
-
est_tokens = chars // 4
|
|
256
|
-
rule_id = f"{file_label}#{rule['start_line']}"
|
|
257
|
-
gist = _collapse_ws(rule["text"])[:160]
|
|
258
|
-
out_rules.append({
|
|
259
|
-
"id": rule_id,
|
|
260
|
-
"file_label": file_label,
|
|
261
|
-
"scope": scope,
|
|
262
|
-
"section": rule["section"],
|
|
263
|
-
"title": rule["title"],
|
|
264
|
-
"start_line": rule["start_line"],
|
|
265
|
-
"end_line": rule["end_line"],
|
|
266
|
-
"chars": chars,
|
|
267
|
-
"est_tokens": est_tokens,
|
|
268
|
-
"gist": gist,
|
|
269
|
-
"text": rule["text"],
|
|
270
|
-
})
|
|
271
|
-
bucket = rollup.setdefault(rule["top_level_title"], {"chars": 0, "rule_ids": []})
|
|
272
|
-
bucket["chars"] += chars
|
|
273
|
-
bucket["rule_ids"].append(rule_id)
|
|
274
|
-
|
|
275
|
-
section_rollups = [
|
|
276
|
-
{
|
|
277
|
-
"file_label": file_label,
|
|
278
|
-
"title": title,
|
|
279
|
-
"chars": data["chars"],
|
|
280
|
-
"est_tokens": data["chars"] // 4,
|
|
281
|
-
"rule_ids": data["rule_ids"],
|
|
282
|
-
}
|
|
283
|
-
for title, data in rollup.items()
|
|
284
|
-
]
|
|
285
|
-
|
|
286
|
-
file_chars = len(text)
|
|
287
|
-
file_meta = {
|
|
288
|
-
"chars": file_chars,
|
|
289
|
-
"est_tokens": file_chars // 4,
|
|
290
|
-
"rules": len(out_rules),
|
|
291
|
-
}
|
|
292
|
-
return out_rules, section_rollups, file_meta
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
# ---------------------------------------------------------------------------
|
|
296
|
-
# File discovery
|
|
297
|
-
# ---------------------------------------------------------------------------
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
class FileEntry:
|
|
301
|
-
__slots__ = ("path", "scope", "label", "label_explicit")
|
|
302
|
-
|
|
303
|
-
def __init__(self, path, scope, label, label_explicit):
|
|
304
|
-
self.path = path
|
|
305
|
-
self.scope = scope
|
|
306
|
-
self.label = label
|
|
307
|
-
self.label_explicit = label_explicit
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
def _register(registry, order, path, scope, label, label_explicit):
|
|
311
|
-
"""Insert or merge a discovered/explicit file into the dedup registry.
|
|
312
|
-
|
|
313
|
-
Dedup key is the resolved real path. When the same file is seen twice:
|
|
314
|
-
- scope: an auto-discovered scope ("global"/"project") always wins over
|
|
315
|
-
the "referenced" default, because the file genuinely is always
|
|
316
|
-
injected -- demoting it would silently drop it from
|
|
317
|
-
injected_est_tokens.
|
|
318
|
-
- label: an explicit label (given after ":" in --file) always wins.
|
|
319
|
-
"""
|
|
320
|
-
try:
|
|
321
|
-
key = str(path.resolve())
|
|
322
|
-
except OSError:
|
|
323
|
-
key = str(path)
|
|
324
|
-
if key not in registry:
|
|
325
|
-
registry[key] = FileEntry(path, scope, label, label_explicit)
|
|
326
|
-
order.append(key)
|
|
327
|
-
return
|
|
328
|
-
existing = registry[key]
|
|
329
|
-
if existing.scope == "referenced" and scope != "referenced":
|
|
330
|
-
existing.scope = scope
|
|
331
|
-
if label_explicit:
|
|
332
|
-
existing.label = label
|
|
333
|
-
existing.label_explicit = True
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
def discover_files(repos, scope_arg, no_auto, explicit_files):
|
|
337
|
-
"""Return an ordered list of FileEntry, deduplicated by real path."""
|
|
338
|
-
registry = {}
|
|
339
|
-
order = []
|
|
340
|
-
|
|
341
|
-
if not no_auto:
|
|
342
|
-
if scope_arg in ("all", "global"):
|
|
343
|
-
seen_names = {}
|
|
344
|
-
for candidate in GLOBAL_CANDIDATES:
|
|
345
|
-
path = Path(candidate).expanduser()
|
|
346
|
-
if not path.is_file():
|
|
347
|
-
continue
|
|
348
|
-
name = path.name
|
|
349
|
-
if seen_names.get(name, 0) == 0:
|
|
350
|
-
label = f"global:{name}"
|
|
351
|
-
else:
|
|
352
|
-
label = f"global:{path.parent.name}/{name}"
|
|
353
|
-
seen_names[name] = seen_names.get(name, 0) + 1
|
|
354
|
-
_register(registry, order, path, "global", label, False)
|
|
355
|
-
|
|
356
|
-
if scope_arg in ("all", "project"):
|
|
357
|
-
for repo_arg in repos:
|
|
358
|
-
repo = Path(repo_arg).expanduser().resolve()
|
|
359
|
-
for candidate in PROJECT_CANDIDATES:
|
|
360
|
-
path = repo / candidate
|
|
361
|
-
if not path.is_file():
|
|
362
|
-
continue
|
|
363
|
-
label = f"{repo.name}:{candidate.split('/')[-1]}"
|
|
364
|
-
if candidate.count("/"):
|
|
365
|
-
label = f"{repo.name}:{candidate}"
|
|
366
|
-
_register(registry, order, path, "project", label, False)
|
|
367
|
-
|
|
368
|
-
for raw in explicit_files:
|
|
369
|
-
if ":" in raw and not _looks_like_windows_drive(raw):
|
|
370
|
-
file_part, label = raw.rsplit(":", 1)
|
|
371
|
-
else:
|
|
372
|
-
file_part, label = raw, None
|
|
373
|
-
path = Path(file_part).expanduser()
|
|
374
|
-
if not path.is_file():
|
|
375
|
-
print(f"warning: --file not found, skipping: {file_part}", file=sys.stderr)
|
|
376
|
-
continue
|
|
377
|
-
if label:
|
|
378
|
-
explicit_label, explicit = label, True
|
|
379
|
-
else:
|
|
380
|
-
explicit_label, explicit = _default_referenced_label(path, repos), False
|
|
381
|
-
_register(registry, order, path, "referenced", explicit_label, explicit)
|
|
382
|
-
|
|
383
|
-
return [registry[key] for key in order]
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
def _looks_like_windows_drive(raw):
|
|
387
|
-
# Defensive only; this skill targets POSIX-style paths but a lone
|
|
388
|
-
# "C:\..." shouldn't be mis-split on its drive-letter colon.
|
|
389
|
-
return re.match(r"^[A-Za-z]:[\\/]", raw) is not None
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
def _default_referenced_label(path, repos):
|
|
393
|
-
resolved = path.resolve()
|
|
394
|
-
for repo_arg in repos:
|
|
395
|
-
repo = Path(repo_arg).expanduser().resolve()
|
|
396
|
-
try:
|
|
397
|
-
resolved.relative_to(repo)
|
|
398
|
-
return f"{repo.name}:{path.name}"
|
|
399
|
-
except ValueError:
|
|
400
|
-
continue
|
|
401
|
-
try:
|
|
402
|
-
resolved.relative_to(Path.home())
|
|
403
|
-
return f"global:{path.name}"
|
|
404
|
-
except ValueError:
|
|
405
|
-
pass
|
|
406
|
-
return f"referenced:{path.name}"
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
# ---------------------------------------------------------------------------
|
|
410
|
-
# CLI
|
|
411
|
-
# ---------------------------------------------------------------------------
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
def parse_args(argv=None):
|
|
415
|
-
p = argparse.ArgumentParser(description=__doc__)
|
|
416
|
-
p.add_argument("--out", required=True, help="directory to write rules.json into")
|
|
417
|
-
p.add_argument("--repo", action="append", default=[], help="project root (repeatable)")
|
|
418
|
-
p.add_argument(
|
|
419
|
-
"--scope", choices=("all", "global", "project"), default="all",
|
|
420
|
-
help="which auto-discovery categories to run (default: all)",
|
|
421
|
-
)
|
|
422
|
-
p.add_argument(
|
|
423
|
-
"--file", action="append", default=[], dest="files",
|
|
424
|
-
help="explicit extra file, PATH or PATH:LABEL (repeatable)",
|
|
425
|
-
)
|
|
426
|
-
p.add_argument("--no-auto", action="store_true", help="skip auto-discovery")
|
|
427
|
-
return p.parse_args(argv)
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def main(argv=None):
|
|
431
|
-
args = parse_args(argv)
|
|
432
|
-
out_dir = Path(args.out).expanduser()
|
|
433
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
434
|
-
|
|
435
|
-
entries = discover_files(args.repo, args.scope, args.no_auto, args.files)
|
|
436
|
-
|
|
437
|
-
if not entries:
|
|
438
|
-
print("error: no instruction file found (auto-discovery empty and no --file given)",
|
|
439
|
-
file=sys.stderr)
|
|
440
|
-
sys.exit(1)
|
|
441
|
-
|
|
442
|
-
all_rules = []
|
|
443
|
-
all_sections = []
|
|
444
|
-
files_out = []
|
|
445
|
-
warnings = []
|
|
446
|
-
|
|
447
|
-
for entry in entries:
|
|
448
|
-
try:
|
|
449
|
-
text = entry.path.read_text(encoding="utf-8", errors="replace")
|
|
450
|
-
except OSError as exc:
|
|
451
|
-
print(f"warning: could not read {entry.path}: {exc}", file=sys.stderr)
|
|
452
|
-
continue
|
|
453
|
-
|
|
454
|
-
rules, sections, meta = extract_rules_from_text(text, entry.label, entry.scope)
|
|
455
|
-
all_rules.extend(rules)
|
|
456
|
-
all_sections.extend(sections)
|
|
457
|
-
files_out.append({
|
|
458
|
-
"label": entry.label,
|
|
459
|
-
"path": str(entry.path.resolve()),
|
|
460
|
-
"scope": entry.scope,
|
|
461
|
-
"chars": meta["chars"],
|
|
462
|
-
"est_tokens": meta["est_tokens"],
|
|
463
|
-
"rules": meta["rules"],
|
|
464
|
-
})
|
|
465
|
-
|
|
466
|
-
if meta["rules"] < MIN_RULES_PER_FILE_WARNING:
|
|
467
|
-
warnings.append(
|
|
468
|
-
f" ! {entry.label}: only {meta['rules']} rule(s) -- "
|
|
469
|
-
"granularity may have collapsed (see rule-model.md)"
|
|
470
|
-
)
|
|
471
|
-
largest = max((r["chars"] for r in rules), default=0)
|
|
472
|
-
if largest > RULE_SIZE_WARNING_CHARS:
|
|
473
|
-
warnings.append(
|
|
474
|
-
f" ! {entry.label}: largest rule is {largest} chars "
|
|
475
|
-
f"(> {RULE_SIZE_WARNING_CHARS}) -- too big to judge or obey"
|
|
476
|
-
)
|
|
477
|
-
|
|
478
|
-
injected_est_tokens = sum(
|
|
479
|
-
f["est_tokens"] for f in files_out if f["scope"] != "referenced"
|
|
480
|
-
)
|
|
481
|
-
|
|
482
|
-
report = {
|
|
483
|
-
"generated_at": datetime.now(timezone.utc).isoformat(),
|
|
484
|
-
"injected_est_tokens": injected_est_tokens,
|
|
485
|
-
"files": files_out,
|
|
486
|
-
"rules": all_rules,
|
|
487
|
-
"sections": all_sections,
|
|
488
|
-
}
|
|
489
|
-
(out_dir / "rules.json").write_text(json.dumps(report, indent=2))
|
|
490
|
-
|
|
491
|
-
print(f"rules.json: {out_dir / 'rules.json'}")
|
|
492
|
-
print(f"files graded: {len(files_out)}")
|
|
493
|
-
print(f"injected est tokens: {injected_est_tokens}")
|
|
494
|
-
for f in files_out:
|
|
495
|
-
largest = max((r["chars"] for r in all_rules if r["file_label"] == f["label"]), default=0)
|
|
496
|
-
print(
|
|
497
|
-
f" {f['label']:30s} scope={f['scope']:10s} rules={f['rules']:4d} "
|
|
498
|
-
f"est_tokens={f['est_tokens']:6d} largest_rule_chars={largest}"
|
|
499
|
-
)
|
|
500
|
-
if warnings:
|
|
501
|
-
print("granularity warnings:")
|
|
502
|
-
for w in warnings:
|
|
503
|
-
print(w)
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
if __name__ == "__main__":
|
|
507
|
-
main()
|