@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +117 -3
  3. package/dist/src/app-cli.js +172 -0
  4. package/dist/src/app-flow.js +457 -0
  5. package/dist/src/app-latest.js +63 -0
  6. package/dist/src/app-owned-skills.js +42 -0
  7. package/dist/src/app-recipes.js +116 -0
  8. package/dist/src/app-schema.js +45 -0
  9. package/dist/src/app-version.js +46 -0
  10. package/dist/src/atomic-file.js +26 -0
  11. package/dist/src/cli.js +1256 -0
  12. package/dist/src/git-source.js +409 -0
  13. package/dist/src/path-safety.js +31 -0
  14. package/dist/src/process-runner.js +41 -0
  15. package/dist/src/project-installation.js +1411 -0
  16. package/dist/src/project-manifest.js +511 -0
  17. package/dist/src/skill-adoption.js +77 -0
  18. package/dist/src/skill-discovery.js +467 -0
  19. package/dist/src/skill-hosts.js +103 -0
  20. package/dist/src/skill-install.js +374 -0
  21. package/dist/src/skill-removal.js +119 -0
  22. package/dist/src/skill-update.js +244 -0
  23. package/dist/src/source-state.js +240 -0
  24. package/dist/src/sources.js +391 -0
  25. package/dist/src/tui/adoption-actions.js +144 -0
  26. package/dist/src/tui/app-actions.js +157 -0
  27. package/dist/src/tui/app.js +63 -0
  28. package/dist/src/tui/batch.js +17 -0
  29. package/dist/src/tui/bulk-actions.js +243 -0
  30. package/dist/src/tui/catalog-actions.js +9 -0
  31. package/dist/src/tui/catalog-browse.js +59 -0
  32. package/dist/src/tui/catalog-filter.js +20 -0
  33. package/dist/src/tui/catalog-flow.js +107 -0
  34. package/dist/src/tui/catalog-installs.js +193 -0
  35. package/dist/src/tui/catalog-panel.js +47 -0
  36. package/dist/src/tui/catalog-state.js +92 -0
  37. package/dist/src/tui/catalog-tree.js +35 -0
  38. package/dist/src/tui/catalog-view.js +89 -0
  39. package/dist/src/tui/environment.js +1 -0
  40. package/dist/src/tui/installations-browse.js +136 -0
  41. package/dist/src/tui/installations-mode.js +1 -0
  42. package/dist/src/tui/installations-panel.js +27 -0
  43. package/dist/src/tui/installations-rows.js +11 -0
  44. package/dist/src/tui/installations-tree.js +80 -0
  45. package/dist/src/tui/installations-view.js +154 -0
  46. package/dist/src/tui/installations.js +172 -0
  47. package/dist/src/tui/keys.js +31 -0
  48. package/dist/src/tui/manage-actions.js +174 -0
  49. package/dist/src/tui/manage-panel.js +65 -0
  50. package/dist/src/tui/mode-keys.js +61 -0
  51. package/dist/src/tui/panel-parts.js +23 -0
  52. package/dist/src/tui/render.js +8 -0
  53. package/dist/src/tui/sources-mode.js +17 -0
  54. package/dist/src/tui/sources-view.js +276 -0
  55. package/dist/src/tui/theme.js +29 -0
  56. package/dist/src/tui/tree-navigation.js +68 -0
  57. package/dist/src/tui/tree.js +53 -0
  58. package/dist/src/tui/unmanaged-actions.js +178 -0
  59. package/dist/src/tui/updates-flow.js +89 -0
  60. package/dist/src/tui/updates-keys.js +104 -0
  61. package/dist/src/tui/updates-mode.js +1 -0
  62. package/dist/src/tui/updates-model.js +48 -0
  63. package/dist/src/tui/updates-panel.js +44 -0
  64. package/dist/src/tui/updates-view.js +101 -0
  65. package/dist/src/tui/updates.js +230 -0
  66. package/dist/src/tui/view-state.js +42 -0
  67. package/dist/src/tui/window.js +31 -0
  68. package/dist/src/unmanaged-removal.js +60 -0
  69. package/dist/src/update-batch.js +376 -0
  70. package/dist/src/update-flow.js +135 -0
  71. package/dist/src/usage-error.js +7 -0
  72. package/dist/src/user-global-skill-inventory.js +565 -0
  73. package/dist/src/user-method.js +14 -0
  74. package/package.json +7 -4
  75. package/skills/agent-depot-apprecipe/SKILL.md +44 -0
  76. package/skills/agent-depot-apprecipe/assets/example.md +25 -0
  77. package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
  78. package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
  79. package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
  80. package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
  81. package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
  82. package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
  83. package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
  84. package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
  85. package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
  86. 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()