claude-code-arcane 1.4.0 → 2.1.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 (84) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +5 -4
  3. package/dist/cli.js +2 -0
  4. package/docs/SKILLS-CATALOG.md +30 -3
  5. package/docs/USER-GUIDE.md +2 -2
  6. package/docs/coding-standards.md +26 -0
  7. package/package.json +2 -1
  8. package/profiles/job-hunt.yaml +12 -2
  9. package/profiles/second-brain.yaml +37 -0
  10. package/rules/drafter-reviewer.md +39 -0
  11. package/rules/vault-conventions.md +144 -0
  12. package/skills/brain-dump/SKILL.md +53 -0
  13. package/skills/cold-outreach/SKILL.md +10 -10
  14. package/skills/cold-outreach/references/recruiter-playbook.md +5 -5
  15. package/skills/cover-letter/SKILL.md +8 -8
  16. package/skills/cv-ats-export/SKILL.md +19 -6
  17. package/skills/cv-ats-export/scripts/cv_export.py +49 -4
  18. package/skills/cv-ats-export/scripts/verify_pdf.py +90 -0
  19. package/skills/cv-tailor/SKILL.md +7 -7
  20. package/skills/cv-tailor/references/ats-keywords.md +9 -9
  21. package/skills/hub-note/SKILL.md +68 -0
  22. package/skills/interview-prep/SKILL.md +7 -7
  23. package/skills/interview-prep/references/star-framework.md +10 -10
  24. package/skills/job-aplicar/SKILL.md +88 -0
  25. package/skills/job-hunt/SKILL.md +20 -13
  26. package/skills/job-hunt/references/templates/Aplicacion.md +6 -2
  27. package/skills/job-hunt/references/templates/CV Custom.md +1 -1
  28. package/skills/job-hunt/references/templates/Contacto.md +1 -1
  29. package/skills/job-hunt/references/templates/Dashboard.md +19 -15
  30. package/skills/job-hunt/references/templates/Perfil.md +2 -2
  31. package/skills/job-outcome/SKILL.md +55 -0
  32. package/skills/job-scrape/SKILL.md +95 -0
  33. package/skills/job-scrape/references/dedup-playbook.md +25 -0
  34. package/skills/job-scrape/references/getonbrd-cli.md +62 -0
  35. package/skills/job-scrape/references/getonbrd-urls.md +37 -0
  36. package/skills/job-scrape/references/linkedin-cli.md +64 -0
  37. package/skills/job-scrape/references/linkedin-urls.md +42 -0
  38. package/skills/job-scrape/references/seen_jobs.schema.json +68 -0
  39. package/skills/job-scrape/scripts/getonbrd_search.py +501 -0
  40. package/skills/job-scrape/scripts/linkedin_search.py +428 -0
  41. package/skills/job-search/SKILL.md +22 -19
  42. package/skills/job-search/references/scoring-rubric.md +8 -8
  43. package/skills/job-upskill/SKILL.md +58 -0
  44. package/skills/linkedin-optimize/SKILL.md +5 -5
  45. package/skills/master-profile/SKILL.md +17 -17
  46. package/skills/network-map/SKILL.md +7 -7
  47. package/skills/obsidian-bases/SKILL.md +85 -0
  48. package/skills/obsidian-bases/references/bases-syntax.md +123 -0
  49. package/skills/obsidian-canvas/SKILL.md +91 -0
  50. package/skills/obsidian-markdown/SKILL.md +110 -0
  51. package/skills/pdf-generator/scripts/fill_inspect.py +2 -0
  52. package/skills/pdf-generator/scripts/render_body.py +2 -0
  53. package/skills/personal-brand/SKILL.md +10 -10
  54. package/skills/personal-brand/references/post-pillars.md +14 -14
  55. package/skills/portfolio-site/SKILL.md +11 -11
  56. package/skills/review-dump/SKILL.md +108 -0
  57. package/skills/review-monthly/SKILL.md +56 -0
  58. package/skills/review-weekly/SKILL.md +91 -0
  59. package/skills/second-brain/SKILL.md +211 -0
  60. package/skills/second-brain/hooks/hooks.json +16 -0
  61. package/skills/second-brain/hooks/validate-note.sh +85 -0
  62. package/skills/second-brain/references/cross-project.md +57 -0
  63. package/skills/second-brain/references/templates/Atomic.md +22 -0
  64. package/skills/second-brain/references/templates/Daily.md +25 -0
  65. package/skills/second-brain/references/templates/Dashboard.base +70 -0
  66. package/skills/second-brain/references/templates/Dump.md +11 -0
  67. package/skills/second-brain/references/templates/Hub.md +22 -0
  68. package/skills/second-brain/references/templates/Monthly.md +29 -0
  69. package/skills/second-brain/references/templates/Weekly.md +29 -0
  70. package/skills/second-brain/references/templates/codebases.md +37 -0
  71. package/skills/second-brain/references/templates/hot.md +37 -0
  72. package/skills/second-brain/references/templates/vault-CLAUDE.md +127 -0
  73. package/skills/vault-audit/SKILL.md +72 -0
  74. package/skills/vault-audit/scripts/vault_audit.py +539 -0
  75. package/skills/vault-clip/SKILL.md +73 -0
  76. package/skills/vault-recall/SKILL.md +82 -0
  77. package/skills/vault-recall/scripts/vault_index.py +626 -0
  78. package/skills/vault-tidy/SKILL.md +76 -0
  79. package/skills/xlsx-generator/scripts/formula_check.py +2 -0
  80. package/skills/xlsx-generator/scripts/libreoffice_recalc.py +2 -0
  81. package/skills/xlsx-generator/scripts/xlsx_add_column.py +2 -0
  82. package/skills/xlsx-generator/scripts/xlsx_insert_row.py +2 -0
  83. package/skills/xlsx-generator/scripts/xlsx_reader.py +2 -0
  84. package/skills/zettel/SKILL.md +66 -0
@@ -0,0 +1,539 @@
1
+ """
2
+ vault_audit.py - Health report of an Obsidian vault. Counting only, never writes.
3
+
4
+ No dependencies beyond the Python 3 standard library. Walks the vault once,
5
+ resolves every [[wikilink]] the way Obsidian does, and emits the metrics a
6
+ review session needs to decide what to fix: orphans, broken links, stale notes,
7
+ tag sprawl, hollow notes, ambiguous filenames and frontmatter contract misses.
8
+
9
+ The `status` field of the contract (seed | provisional | evergreen | contested |
10
+ archived) changes what counts as a problem: evergreen notes are not stale, seeds
11
+ are allowed to be short but not forever, and archived ones are out of scope.
12
+
13
+ This exists because the same report built by reading notes through an agent
14
+ costs hundreds of thousands of tokens on a real vault and gets the counts wrong.
15
+ Python counts; the agent decides what the counts mean.
16
+
17
+ Usage:
18
+ python vault_audit.py <vault> [--format json|text] [flags]
19
+
20
+ Findings are capped per category (--limit) and every capped list reports its
21
+ real total, so a truncated report never reads as a clean one.
22
+
23
+ Errors go to stderr as {"error": "...", "code": "..."} with exit code 1.
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import os
29
+ import re
30
+ import sys
31
+ import time
32
+
33
+ # Vault plumbing and sync leftovers: never notes, and walking them makes the
34
+ # scan an order of magnitude slower on a synced vault.
35
+ SKIP_DIRS = {
36
+ ".obsidian", ".git", ".trash", ".stfolder", ".stversions",
37
+ "node_modules", "__pycache__", ".smart-env",
38
+ }
39
+
40
+ # Scanned so links into them resolve, but excluded from the findings that would
41
+ # be pure noise: a template is hollow and unlinked by design, and an archived
42
+ # note is stale by definition. Override with --exempt when the vault names those
43
+ # folders differently (its CLAUDE.md declares the mapping); --audit-all drops the
44
+ # exemption entirely.
45
+ EXEMPT_DIRS = ("Templates", "04_Archive")
46
+
47
+ ATTACHMENT_EXTS = {
48
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".pdf", ".mp3", ".mp4",
49
+ ".wav", ".m4a", ".mov", ".webm", ".canvas", ".base", ".excalidraw",
50
+ }
51
+
52
+ DEFAULT_STALE_DAYS = 180
53
+ DEFAULT_HOLLOW_WORDS = 30
54
+ DEFAULT_TASK_DAYS = 7
55
+ DEFAULT_SEED_DAYS = 30
56
+ DEFAULT_LIMIT = 25
57
+ DEFAULT_REQUIRED_FIELDS = ("created", "type")
58
+
59
+ # Maturity vocabulary of the frontmatter contract. Without it the audit cannot
60
+ # tell a reference note that finished its job from a stub that never grew, so it
61
+ # reports both as stale and the report stops being actionable.
62
+ STATUS_EVERGREEN = "evergreen"
63
+ STATUS_SEED = "seed"
64
+ STATUS_CONTESTED = "contested"
65
+ STATUS_ARCHIVED = "archived"
66
+
67
+ FENCED_CODE = re.compile(r"^```.*?^```", re.DOTALL | re.MULTILINE)
68
+ INLINE_CODE = re.compile(r"`[^`\n]*`")
69
+ WIKILINK = re.compile(r"(!?)\[\[([^\[\]\n]+?)\]\]")
70
+ # A tag can't start with a digit and can't be preceded by a word char, a slash
71
+ # or another '#'. That rules out '## Heading', '#1', and url.com/x#fragment.
72
+ INLINE_TAG = re.compile(r"(?<![\w/#])#([A-Za-z_][\w/-]*)")
73
+ TASK = re.compile(r"^[ \t]*[-*+] \[(.)\]", re.MULTILINE)
74
+ DATE_IN_NAME = re.compile(r"(\d{4})-(\d{2})-(\d{2})")
75
+
76
+
77
+ def write_error(error, code):
78
+ sys.stderr.write(json.dumps({"error": error, "code": code}) + "\n")
79
+
80
+
81
+ def strip_code(text):
82
+ """
83
+ Obsidian does not parse links or tags inside code, so neither do we. Without
84
+ this every snippet documenting wikilink syntax shows up as a broken link.
85
+ """
86
+ return INLINE_CODE.sub("", FENCED_CODE.sub("", text))
87
+
88
+
89
+ def parse_frontmatter(text):
90
+ """
91
+ Minimal YAML subset: 'key: value', inline lists and block lists. Enough for
92
+ the frontmatter contract and tags, and it never raises on a malformed file.
93
+ Returns (fields, body_offset_in_lines).
94
+ """
95
+ lines = text.split("\n")
96
+ if not lines or lines[0].strip() != "---":
97
+ return {}, 0
98
+
99
+ fields = {}
100
+ key = None
101
+ for index in range(1, len(lines)):
102
+ raw = lines[index]
103
+ if raw.strip() == "---":
104
+ return fields, index + 1
105
+ if raw.startswith((" - ", "- ", "\t- ")) and key:
106
+ # 'tags:' parsed a line earlier as an empty scalar; the first block
107
+ # item is what reveals it was a list all along.
108
+ if not isinstance(fields.get(key), list):
109
+ fields[key] = []
110
+ fields[key].append(raw.split("- ", 1)[1].strip().strip("\"'"))
111
+ continue
112
+ if ":" not in raw:
113
+ continue
114
+ key, _, value = raw.partition(":")
115
+ key = key.strip()
116
+ value = value.strip()
117
+ if value.startswith("[") and value.endswith("]"):
118
+ items = [v.strip().strip("\"'") for v in value[1:-1].split(",")]
119
+ fields[key] = [v for v in items if v]
120
+ else:
121
+ fields[key] = value.strip("\"'")
122
+
123
+ # No closing '---': treat the file as bodyless frontmatter rather than
124
+ # silently reading the whole note as YAML.
125
+ return fields, len(lines)
126
+
127
+
128
+ def collect_tags(fields, body):
129
+ tags = set()
130
+ declared = fields.get("tags")
131
+ if isinstance(declared, list):
132
+ tags.update(t.lstrip("#") for t in declared if t)
133
+ elif isinstance(declared, str) and declared:
134
+ tags.update(t.strip().lstrip("#") for t in declared.split(",") if t.strip())
135
+ tags.update(INLINE_TAG.findall(body))
136
+ return {t for t in tags if t}
137
+
138
+
139
+ def link_target(raw):
140
+ """'folder/Note#Heading|Alias' -> 'folder/Note'. Empty for a same-note link."""
141
+ target = raw.split("|", 1)[0].strip()
142
+ target = target.split("#", 1)[0].strip()
143
+ return target
144
+
145
+
146
+ def relative(path, root):
147
+ return os.path.relpath(path, root).replace(os.sep, "/")
148
+
149
+
150
+ def resolve_exempt(args):
151
+ """
152
+ Folders scanned so links into them resolve, but kept out of the findings. A
153
+ vault that renamed them (an adopted vault keeps its own layout) passes
154
+ --exempt; argparse cannot carry a default for an append action without
155
+ appending to it, so the fallback lives here.
156
+ """
157
+ if not getattr(args, "exempt", None):
158
+ return EXEMPT_DIRS
159
+ return tuple(d.strip().strip("/").replace("\\", "/") for d in args.exempt if d.strip())
160
+
161
+
162
+ def scan(root, args):
163
+ """One walk over the vault. Returns (notes, attachments)."""
164
+ exempt = resolve_exempt(args)
165
+ notes = {}
166
+ attachments = {}
167
+
168
+ for dirpath, dirnames, filenames in os.walk(root):
169
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".."))
170
+ for filename in sorted(filenames):
171
+ full = os.path.join(dirpath, filename)
172
+ rel = relative(full, root)
173
+ _, ext = os.path.splitext(filename)
174
+ ext = ext.lower()
175
+
176
+ if ext != ".md":
177
+ if ext in ATTACHMENT_EXTS:
178
+ attachments[rel] = {"path": rel, "embedded_by": []}
179
+ continue
180
+
181
+ try:
182
+ with open(full, "r", encoding="utf-8", errors="replace") as handle:
183
+ text = handle.read()
184
+ mtime = os.stat(full).st_mtime
185
+ except OSError as e:
186
+ write_error("cannot read {0}: {1}".format(rel, e), "READ_FAILED")
187
+ continue
188
+
189
+ fields, body_start = parse_frontmatter(text)
190
+ body = strip_code("\n".join(text.split("\n")[body_start:]))
191
+ links, embeds = [], []
192
+ for bang, raw in WIKILINK.findall(body):
193
+ target = link_target(raw)
194
+ if not target:
195
+ continue
196
+ (embeds if bang else links).append(target)
197
+
198
+ notes[rel] = {
199
+ "path": rel,
200
+ "name": os.path.splitext(filename)[0],
201
+ "fields": fields,
202
+ "tags": collect_tags(fields, body),
203
+ "links": links,
204
+ "embeds": embeds,
205
+ "words": len(body.split()),
206
+ "mtime": mtime,
207
+ "tasks": TASK.findall(body),
208
+ "status": str(fields.get("status") or "").strip().lower(),
209
+ "exempt": (rel.startswith(exempt)
210
+ or str(fields.get("status") or "").strip().lower() == STATUS_ARCHIVED)
211
+ and not args.audit_all,
212
+ }
213
+
214
+ return notes, attachments
215
+
216
+
217
+ def build_index(notes, attachments):
218
+ """
219
+ Obsidian resolves [[Name]] by unique basename anywhere in the vault, by path
220
+ when the link contains one, and by the `aliases` a note declares in its
221
+ frontmatter. Two files sharing a basename make every short link to that name
222
+ ambiguous, which is why by_name keeps every match.
223
+
224
+ Aliases are not a nicety here: hub notes exist to be linked by every name the
225
+ entity goes by, so ignoring them reports every [[Nacho]] as a broken link
226
+ while Obsidian resolves it fine.
227
+ """
228
+ by_path = {}
229
+ by_name = {}
230
+ by_alias = {}
231
+ for rel in list(notes) + list(attachments):
232
+ stem = rel[:-3] if rel.endswith(".md") else rel
233
+ by_path[stem.lower()] = rel
234
+ by_path[rel.lower()] = rel
235
+ by_name.setdefault(os.path.basename(stem).lower(), []).append(rel)
236
+ for rel, note in notes.items():
237
+ declared = (note.get("fields") or {}).get("aliases")
238
+ if isinstance(declared, str):
239
+ declared = [a.strip() for a in declared.split(",")]
240
+ for alias in declared or []:
241
+ if alias:
242
+ by_alias.setdefault(alias.strip().lower(), []).append(rel)
243
+ return by_path, by_name, by_alias
244
+
245
+
246
+ def resolve(target, by_path, by_name, by_alias=None):
247
+ """Returns (resolved_path_or_None, is_ambiguous)."""
248
+ key = target.lower().rstrip("/")
249
+ if key in by_path:
250
+ return by_path[key], False
251
+ for candidates in (by_name.get(os.path.basename(key), []), (by_alias or {}).get(key, [])):
252
+ if len(candidates) == 1:
253
+ return candidates[0], False
254
+ if len(candidates) > 1:
255
+ return candidates[0], True
256
+ return None, False
257
+
258
+
259
+ def audit(root, args):
260
+ # argparse's append action cannot carry a default without appending to it,
261
+ # so the fallback lives here: audit() has to stand on its own, not depend on
262
+ # main() having normalized its arguments first.
263
+ required = tuple(args.require) if args.require else DEFAULT_REQUIRED_FIELDS
264
+ notes, attachments = scan(root, args)
265
+ by_path, by_name, by_alias = build_index(notes, attachments)
266
+
267
+ backlinks = {rel: 0 for rel in notes}
268
+ broken = []
269
+ ambiguous_links = []
270
+ total_links = 0
271
+ now = time.time()
272
+
273
+ for note in notes.values():
274
+ for target in note["links"] + note["embeds"]:
275
+ total_links += 1
276
+ resolved, is_ambiguous = resolve(target, by_path, by_name, by_alias)
277
+ if resolved is None:
278
+ broken.append({"source": note["path"], "target": target})
279
+ continue
280
+ if is_ambiguous:
281
+ ambiguous_links.append({"source": note["path"], "target": target})
282
+ if resolved in backlinks:
283
+ backlinks[resolved] += 1
284
+ elif resolved in attachments:
285
+ attachments[resolved]["embedded_by"].append(note["path"])
286
+
287
+ tag_use = {}
288
+ for note in notes.values():
289
+ for tag in note["tags"]:
290
+ tag_use[tag] = tag_use.get(tag, 0) + 1
291
+
292
+ orphans, no_backlinks, stale, hollow, missing_fields, stale_tasks = [], [], [], [], [], []
293
+ stale_seeds, contested = [], []
294
+ by_status = {}
295
+ open_tasks = done_tasks = 0
296
+
297
+ for note in notes.values():
298
+ outgoing = len(note["links"]) + len(note["embeds"])
299
+ incoming = backlinks[note["path"]]
300
+ open_here = [t for t in note["tasks"] if t == " "]
301
+ open_tasks += len(open_here)
302
+ done_tasks += len([t for t in note["tasks"] if t in ("x", "X")])
303
+
304
+ if note["exempt"]:
305
+ continue
306
+
307
+ if not outgoing and not incoming:
308
+ orphans.append(note["path"])
309
+ elif not incoming:
310
+ no_backlinks.append(note["path"])
311
+
312
+ status = note["status"]
313
+ by_status[status or "(sin status)"] = by_status.get(status or "(sin status)", 0) + 1
314
+ if status == STATUS_CONTESTED:
315
+ contested.append(note["path"])
316
+
317
+ age_days = int((now - note["mtime"]) // 86400)
318
+
319
+ # A seed is allowed to be short, but not forever: a seed that never grew
320
+ # is the "notes go in, nothing matures" failure, and it is invisible
321
+ # unless the vault declares maturity. It reports here and nowhere else:
322
+ # the same note in stale and hollow too would read as three problems.
323
+ if status == STATUS_SEED:
324
+ if age_days >= args.seed_days:
325
+ stale_seeds.append({"path": note["path"], "days": age_days,
326
+ "words": note["words"]})
327
+ else:
328
+ # An evergreen note is meant to sit still: it already says what it
329
+ # has to say. Reporting it as stale trains the reader to ignore the
330
+ # whole list.
331
+ if age_days >= args.stale_days and status != STATUS_EVERGREEN:
332
+ stale.append({"path": note["path"], "days": age_days})
333
+ if note["words"] < args.hollow_words:
334
+ hollow.append({"path": note["path"], "words": note["words"]})
335
+
336
+ missing = [f for f in required if f not in note["fields"]]
337
+ if missing:
338
+ missing_fields.append({"path": note["path"], "missing": missing})
339
+
340
+ # Open tasks in a dated note (dump or daily) past the carry window: the
341
+ # vault convention says they should have been cancelled in place, not
342
+ # left hanging in a day that already closed.
343
+ dated = DATE_IN_NAME.search(os.path.basename(note["path"]))
344
+ if dated and open_here:
345
+ try:
346
+ note_age = int((now - time.mktime(time.strptime(dated.group(0), "%Y-%m-%d"))) // 86400)
347
+ except ValueError:
348
+ note_age = 0
349
+ if note_age >= args.task_days:
350
+ stale_tasks.append({
351
+ "path": note["path"], "days": note_age, "open": len(open_here),
352
+ })
353
+
354
+ note_count = len(notes)
355
+ audited = len([n for n in notes.values() if not n["exempt"]])
356
+
357
+ return {
358
+ "vault": root,
359
+ "scanned_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
360
+ "thresholds": {
361
+ "stale_days": args.stale_days,
362
+ "hollow_words": args.hollow_words,
363
+ "task_days": args.task_days,
364
+ "seed_days": args.seed_days,
365
+ "required_fields": list(required),
366
+ "exempt_dirs": [] if args.audit_all else list(resolve_exempt(args)),
367
+ },
368
+ "counts": {
369
+ "notes": note_count,
370
+ "notes_audited": audited,
371
+ "attachments": len(attachments),
372
+ "links": total_links,
373
+ "broken_links": len(broken),
374
+ "tags": len(tag_use),
375
+ "tasks_open": open_tasks,
376
+ "tasks_done": done_tasks,
377
+ "by_status": by_status,
378
+ },
379
+ "metrics": {
380
+ # The signal for whether this is a vault of ideas or a folder of
381
+ # documents. Counts embeds, since an embed is a real connection.
382
+ "avg_links_per_note": round(total_links / note_count, 2) if note_count else 0.0,
383
+ "orphan_rate": round(len(orphans) / audited, 3) if audited else 0.0,
384
+ "single_use_tag_rate": (
385
+ round(len([t for t, n in tag_use.items() if n == 1]) / len(tag_use), 3)
386
+ if tag_use else 0.0
387
+ ),
388
+ },
389
+ "findings": {
390
+ "orphans": orphans,
391
+ "no_backlinks": no_backlinks,
392
+ "broken_links": broken,
393
+ "ambiguous_links": ambiguous_links,
394
+ "ambiguous_names": [
395
+ {"name": name, "paths": paths}
396
+ for name, paths in sorted(by_name.items()) if len(paths) > 1
397
+ ],
398
+ "stale": sorted(stale, key=lambda s: -s["days"]),
399
+ "stale_seeds": sorted(stale_seeds, key=lambda s: -s["days"]),
400
+ "contested": contested,
401
+ "hollow": sorted(hollow, key=lambda h: h["words"]),
402
+ "single_use_tags": sorted(t for t, n in tag_use.items() if n == 1),
403
+ "orphan_attachments": sorted(
404
+ a["path"] for a in attachments.values() if not a["embedded_by"]
405
+ ),
406
+ "missing_frontmatter": missing_fields,
407
+ "stale_open_tasks": sorted(stale_tasks, key=lambda t: -t["days"]),
408
+ },
409
+ }
410
+
411
+
412
+ def cap(report, limit):
413
+ """
414
+ Truncate every finding list, recording the real total. A capped list that
415
+ does not say so reads as 'nothing more to fix', which is the one lie a
416
+ health report must never tell.
417
+ """
418
+ if limit <= 0:
419
+ return report
420
+ totals = {}
421
+ for name, items in report["findings"].items():
422
+ totals[name] = len(items)
423
+ if len(items) > limit:
424
+ report["findings"][name] = items[:limit]
425
+ report["totals"] = totals
426
+ report["truncated"] = sorted(k for k, n in totals.items() if n > limit)
427
+ return report
428
+
429
+
430
+ def format_text(report):
431
+ counts, metrics, totals = report["counts"], report["metrics"], report.get("totals", {})
432
+ out = [
433
+ "Vault: {0}".format(report["vault"]),
434
+ "{0} notes ({1} audited), {2} attachments, {3} links, {4} tags".format(
435
+ counts["notes"], counts["notes_audited"], counts["attachments"],
436
+ counts["links"], counts["tags"],
437
+ ),
438
+ "{0} links/note, {1} tasks open, {2} done".format(
439
+ metrics["avg_links_per_note"], counts["tasks_open"], counts["tasks_done"],
440
+ ),
441
+ "",
442
+ ]
443
+ labels = [
444
+ ("orphans", "orphans (no links in or out)"),
445
+ ("no_backlinks", "no backlinks (nothing points here)"),
446
+ ("broken_links", "broken links"),
447
+ ("ambiguous_names", "duplicate filenames (short links are ambiguous)"),
448
+ ("stale_open_tasks", "dated notes with tasks still open"),
449
+ ("stale", "stale notes"),
450
+ ("stale_seeds", "seeds that never grew"),
451
+ ("contested", "notes marked contested"),
452
+ ("hollow", "hollow notes"),
453
+ ("single_use_tags", "tags used once"),
454
+ ("orphan_attachments", "unused attachments"),
455
+ ("missing_frontmatter", "frontmatter contract misses"),
456
+ ("ambiguous_links", "links resolved ambiguously"),
457
+ ]
458
+ for key, label in labels:
459
+ total = totals.get(key, len(report["findings"][key]))
460
+ shown = len(report["findings"][key])
461
+ suffix = " (showing {0})".format(shown) if shown < total else ""
462
+ out.append("{0:>5} {1}{2}".format(total, label, suffix))
463
+ return "\n".join(out)
464
+
465
+
466
+ class JsonErrorParser(argparse.ArgumentParser):
467
+ """Keep the CLI's error contract: a JSON line on stderr and exit code 1."""
468
+
469
+ def error(self, message):
470
+ code = "NO_VAULT" if "vault" in message and "required" in message else "BAD_ARG"
471
+ write_error(message, code)
472
+ raise SystemExit(1)
473
+
474
+
475
+ def build_parser():
476
+ parser = JsonErrorParser(
477
+ prog="vault_audit.py",
478
+ description="Health report of an Obsidian vault. Read-only: it never writes "
479
+ "to the vault. Notes under Templates/ and 04_Archive/ are scanned so links "
480
+ "resolve but excluded from findings unless --audit-all.",
481
+ )
482
+ parser.add_argument("vault", help="path to the vault (the directory containing .obsidian/)")
483
+ parser.add_argument("--format", choices=["json", "text"], default="json")
484
+ parser.add_argument("--stale-days", type=int, default=DEFAULT_STALE_DAYS,
485
+ help="days without modification to count as stale. Default: 180")
486
+ parser.add_argument("--hollow-words", type=int, default=DEFAULT_HOLLOW_WORDS,
487
+ help="word count below which a note is hollow. Default: 30")
488
+ parser.add_argument("--task-days", type=int, default=DEFAULT_TASK_DAYS,
489
+ help="age of a dated note whose open tasks get flagged. Default: 7")
490
+ parser.add_argument("--require", action="append", metavar="FIELD",
491
+ help="frontmatter field every note must declare (repeatable). "
492
+ "Default: created, type")
493
+ parser.add_argument("-n", "--limit", type=int, default=DEFAULT_LIMIT,
494
+ help="max items per finding list; 0 for no cap. Default: 25")
495
+ parser.add_argument("--seed-days", type=int, default=DEFAULT_SEED_DAYS,
496
+ help="days after which a note still marked seed is flagged. Default: 30")
497
+ parser.add_argument("--exempt", action="append", metavar="DIR",
498
+ help="folder scanned but kept out of the findings (repeatable). "
499
+ "Default: Templates, 04_Archive")
500
+ parser.add_argument("--audit-all", action="store_true",
501
+ help="include the exempt folders in the findings too")
502
+ return parser
503
+
504
+
505
+ def force_utf8_streams():
506
+ """
507
+ On Windows stdout defaults to cp1252, so a note title with an accent raises
508
+ UnicodeEncodeError the moment output is piped to a file.
509
+ """
510
+ for stream in (sys.stdout, sys.stderr):
511
+ if hasattr(stream, "reconfigure"):
512
+ stream.reconfigure(encoding="utf-8")
513
+
514
+
515
+ def main():
516
+ force_utf8_streams()
517
+ args = build_parser().parse_args()
518
+ root = os.path.abspath(os.path.expanduser(args.vault))
519
+ if not os.path.isdir(root):
520
+ write_error("no such directory: {0}".format(args.vault), "NO_VAULT")
521
+ sys.exit(1)
522
+ if not os.path.isdir(os.path.join(root, ".obsidian")):
523
+ # Not fatal: a vault copied without its config, or a plain markdown
524
+ # folder, still audits fine. But the caller should know it is not one.
525
+ sys.stderr.write(
526
+ json.dumps({"warning": "no .obsidian/ found: auditing as a plain markdown folder",
527
+ "code": "NOT_A_VAULT"}) + "\n"
528
+ )
529
+
530
+ report = cap(audit(root, args), args.limit)
531
+ if args.format == "text":
532
+ print(format_text(report))
533
+ else:
534
+ print(json.dumps(report, indent=2, ensure_ascii=False))
535
+ return 0
536
+
537
+
538
+ if __name__ == "__main__":
539
+ main()
@@ -0,0 +1,73 @@
1
+ ---
2
+ name: vault-clip
3
+ description: "Guarda una pagina web en el vault como nota limpia: extrae el contenido sin menus ni banners, con frontmatter de fuente y un resumen propio. Triggers: clipear, guardar este articulo, clip web, guardar link en el vault, archivar esta pagina, web clipper."
4
+ argument-hint: "<url> [--full | --summary]"
5
+ category: "pkm"
6
+ user-invocable: true
7
+ allowed-tools: Read, Glob, Grep, Write, Edit, WebFetch
8
+ ---
9
+
10
+ # Vault Clip - Captura web
11
+
12
+ Convertís una URL en una nota del vault que se lee dentro de cinco años, cuando la página original ya no exista. Lo que guardás es el contenido, no el HTML.
13
+
14
+ URL: `$ARGUMENTS`
15
+
16
+ Aplica la rule `vault-conventions`.
17
+
18
+ **Rutas por rol:** los destinos se nombran por rol y la ruta real de cada uno sale del `## Rutas` del `CLAUDE.md` del vault. Los defaults entre paréntesis solo aplican si el vault no declara otra cosa.
19
+
20
+ ## Fase 1 - Traer el contenido
21
+
22
+ 1. **Ubicar el vault** y leer su `CLAUDE.md`.
23
+ 2. **Validar la URL** antes de tocarla: tiene que ser `http(s)` y pública. Si el usuario pasó algo que no es una URL, preguntar.
24
+ 3. **Traer la página con `WebFetch`** pidiendo el contenido principal en markdown, sin navegación, sidebars, banners de cookies, footers ni bloques de "artículos relacionados". Un artículo crudo son decenas de miles de tokens de los cuales el 80 por ciento es chrome del sitio.
25
+ 4. **Si la página es un paywall, un login o un JS shell vacío**, decirlo y parar. Ofrecer que el usuario pegue el texto a mano. No inventes el contenido a partir del título ni de lo que sepas del tema: un clip fabricado es peor que no tener el clip.
26
+
27
+ ## Fase 2 - Decidir qué se guarda
28
+
29
+ | Modo | Qué guarda | Cuándo |
30
+ |---|---|---|
31
+ | `--summary` (default) | Resumen propio de 5 a 10 líneas + los puntos clave + link a la fuente | Casi siempre. El vault es para tus ideas, no para copias de internet. |
32
+ | `--full` | El texto completo limpio | Referencia técnica que vas a citar, o contenido que puede desaparecer |
33
+
34
+ En los dos modos, **el resumen lo escribís vos y va arriba**: si dentro de un año el usuario abre la nota, lo primero que tiene que ver es qué había ahí y por qué lo guardó, no el primer párrafo del autor.
35
+
36
+ ## Fase 3 - Escribir
37
+
38
+ Con approval sobre destino y modo, crear la nota en `<atomic>` (default `03_Resources/`):
39
+
40
+ ```yaml
41
+ ---
42
+ created: YYYY-MM-DD
43
+ type: clip
44
+ source: <URL>
45
+ author: <si la página lo declara>
46
+ status: seed
47
+ tags: []
48
+ ---
49
+ ```
50
+
51
+ Estructura del cuerpo:
52
+
53
+ 1. `# <Título del artículo>`
54
+ 2. **Por qué lo guardé:** una línea. Si el usuario no lo dijo, preguntar; es el campo que decide si la nota sirve o es basura acumulada.
55
+ 3. `## Resumen` con los puntos clave.
56
+ 4. `## Contenido` solo en modo `--full`, con el texto limpio.
57
+ 5. `## Relacionado`: al menos un `[[wikilink]]`. Un clip sin link es una huérfana con URL. Si no hay a qué linkearlo, el hub del tema falta: `/hub-note`.
58
+
59
+ Nombre del archivo: el título del artículo, no el slug de la URL. Sin caracteres que rompan en Windows (`: \ / | ? * < >`).
60
+
61
+ Clip COMPLETE cuando la nota tiene fuente, resumen propio y al menos un link.
62
+
63
+ ## Reglas
64
+
65
+ - Nunca guardar el HTML crudo ni el markdown con la navegación adentro.
66
+ - Nunca fabricar contenido que no vino de la página.
67
+ - Respetar el copyright: en modo `--full` la nota es de uso personal en el vault del usuario, y la fuente queda siempre atribuida. No proponer republicar nada.
68
+ - El clip es material crudo, no una nota atómica: si de acá sale una idea propia del usuario, esa idea es una nota aparte con `/zettel`.
69
+ - Sin guiones largos.
70
+
71
+ ## Handoff
72
+
73
+ Con el clip guardado, el siguiente paso es `/zettel` si el artículo produjo una idea que el usuario quiere hacer propia, o `/hub-note` si el tema todavía no tiene hub desde donde encontrar este clip.