claude-code-arcane 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +6 -4
  3. package/agents/audio/audio-director.md +90 -0
  4. package/agents/audio/audio-programmer.md +97 -0
  5. package/agents/audio/audio-qa.md +95 -0
  6. package/agents/audio/composer.md +88 -0
  7. package/agents/audio/technical-sound-designer.md +90 -0
  8. package/agents/audio/voice-director.md +96 -0
  9. package/docs/SKILLS-CATALOG.md +45 -2
  10. package/docs/USER-GUIDE.md +2 -2
  11. package/package.json +1 -1
  12. package/profiles/audio.yaml +45 -0
  13. package/profiles/second-brain.yaml +37 -0
  14. package/rules/gamedev/audio-code.md +45 -0
  15. package/rules/vault-conventions.md +144 -0
  16. package/skills/adaptive-music/SKILL.md +171 -0
  17. package/skills/adaptive-music/references/anti-patterns.md +53 -0
  18. package/skills/audio-audit/SKILL.md +164 -0
  19. package/skills/audio-audit/references/anti-patterns.md +65 -0
  20. package/skills/audio-audit/scripts/check_assets.py +208 -0
  21. package/skills/audio-bible/SKILL.md +119 -0
  22. package/skills/audio-bible/references/anti-patterns.md +39 -0
  23. package/skills/audio-mix/SKILL.md +151 -0
  24. package/skills/audio-mix/references/anti-patterns.md +65 -0
  25. package/skills/audio-spec/SKILL.md +107 -0
  26. package/skills/audio-spec/references/anti-patterns.md +45 -0
  27. package/skills/brain-dump/SKILL.md +53 -0
  28. package/skills/hub-note/SKILL.md +68 -0
  29. package/skills/middleware-integration/SKILL.md +186 -0
  30. package/skills/middleware-integration/references/anti-patterns.md +69 -0
  31. package/skills/midi-compose/SKILL.md +136 -0
  32. package/skills/midi-compose/references/smf-format.md +129 -0
  33. package/skills/midi-compose/scripts/compose_midi.py +326 -0
  34. package/skills/midi-compose/templates/example-composition.json +69 -0
  35. package/skills/music-composition/SKILL.md +106 -0
  36. package/skills/music-composition/references/form.md +57 -0
  37. package/skills/music-composition/references/game-music.md +82 -0
  38. package/skills/music-composition/references/harmony.md +82 -0
  39. package/skills/music-composition/references/melody.md +48 -0
  40. package/skills/music-composition/references/orchestration.md +69 -0
  41. package/skills/music-composition/references/rhythm-groove.md +49 -0
  42. package/skills/obsidian-bases/SKILL.md +85 -0
  43. package/skills/obsidian-bases/references/bases-syntax.md +123 -0
  44. package/skills/obsidian-canvas/SKILL.md +91 -0
  45. package/skills/obsidian-markdown/SKILL.md +110 -0
  46. package/skills/pdf-generator/scripts/fill_inspect.py +2 -0
  47. package/skills/pdf-generator/scripts/render_body.py +2 -0
  48. package/skills/procedural-audio/SKILL.md +157 -0
  49. package/skills/procedural-audio/references/anti-patterns.md +45 -0
  50. package/skills/review-dump/SKILL.md +108 -0
  51. package/skills/review-monthly/SKILL.md +56 -0
  52. package/skills/review-weekly/SKILL.md +91 -0
  53. package/skills/second-brain/SKILL.md +211 -0
  54. package/skills/second-brain/hooks/hooks.json +16 -0
  55. package/skills/second-brain/hooks/validate-note.sh +85 -0
  56. package/skills/second-brain/references/cross-project.md +57 -0
  57. package/skills/second-brain/references/templates/Atomic.md +22 -0
  58. package/skills/second-brain/references/templates/Daily.md +25 -0
  59. package/skills/second-brain/references/templates/Dashboard.base +70 -0
  60. package/skills/second-brain/references/templates/Dump.md +11 -0
  61. package/skills/second-brain/references/templates/Hub.md +22 -0
  62. package/skills/second-brain/references/templates/Monthly.md +29 -0
  63. package/skills/second-brain/references/templates/Weekly.md +29 -0
  64. package/skills/second-brain/references/templates/codebases.md +37 -0
  65. package/skills/second-brain/references/templates/hot.md +37 -0
  66. package/skills/second-brain/references/templates/vault-CLAUDE.md +127 -0
  67. package/skills/sfx-design/SKILL.md +130 -0
  68. package/skills/sfx-design/references/anti-patterns.md +53 -0
  69. package/skills/spatial-audio/SKILL.md +139 -0
  70. package/skills/spatial-audio/references/anti-patterns.md +53 -0
  71. package/skills/ui-sound-design/SKILL.md +133 -0
  72. package/skills/ui-sound-design/references/anti-patterns.md +57 -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/voice-pipeline/SKILL.md +150 -0
  80. package/skills/voice-pipeline/references/anti-patterns.md +69 -0
  81. package/skills/xlsx-generator/scripts/formula_check.py +2 -0
  82. package/skills/xlsx-generator/scripts/libreoffice_recalc.py +2 -0
  83. package/skills/xlsx-generator/scripts/xlsx_add_column.py +2 -0
  84. package/skills/xlsx-generator/scripts/xlsx_insert_row.py +2 -0
  85. package/skills/xlsx-generator/scripts/xlsx_reader.py +2 -0
  86. package/skills/zettel/SKILL.md +66 -0
@@ -0,0 +1,57 @@
1
+ # UI Sound Design — Anti-patterns
2
+
3
+ ## Sonidos de UI al nivel del gameplay
4
+
5
+ Un click a −3 dB compite con la acción del juego y agota en minutos. El UI va entre −18 y −24 dB. Es el error más frecuente y el más fácil de arreglar.
6
+
7
+ ## Todo al mismo nivel
8
+
9
+ Si el hover y el error de transacción suenan igual de importantes, no hay jerarquía y el usuario deja de prestar atención a los dos. Nivel y duración proporcionales a la importancia del evento.
10
+
11
+ ## Todo duckea
12
+
13
+ Si cada navegación de menú baja la música, la música desaparece. Solo el nivel crítico duckea.
14
+
15
+ ## Sin cooldown en navegación
16
+
17
+ Recorrer un menú rápido con el stick dispara el mismo tick veinte veces en 300 ms. Suena a glitch. Cooldown mínimo en todo evento de navegación.
18
+
19
+ ## Tonos sostenidos en 2-6 kHz
20
+
21
+ Es la banda más sensible del oído y la que más fatiga. Un tono sostenido ahí cansa en una sesión. Transientes cortos.
22
+
23
+ ## Ignorar la sesión de audio en móvil
24
+
25
+ Entra una llamada, la app pierde el audio y no lo recupera. O el juego suena con el switch de silencio activado. Los dos son rechazo de store. Manejar interrupciones y cambios de ruta explícitamente.
26
+
27
+ ## Forzar volumen por encima del sistema
28
+
29
+ Normalizar hacia arriba o imponer un piso de volumen ignora una decisión explícita del usuario. Nunca.
30
+
31
+ ## Éxito y error que solo se distinguen por timbre
32
+
33
+ Con pérdida de agudos, en un speaker malo o con el volumen bajo, los dos suenan igual. La diferencia tiene que estar en el contorno: ascendente para éxito, descendente para error.
34
+
35
+ ## Audio como único canal de información crítica
36
+
37
+ Un usuario con hipoacusia, con el sonido apagado o en un lugar ruidoso pierde la información. Siempre acompañado de señal visual.
38
+
39
+ ## Haptic y audio desfasados
40
+
41
+ Arriba de ~30 ms se perciben como dos eventos y se siente roto. Y si el haptic está desactivado, el evento no puede quedarse sin ningún feedback.
42
+
43
+ ## Duplicar información entre audio y haptic
44
+
45
+ Si el haptic ya comunica intensidad, subir el audio para "reforzar" solo agrega molestia.
46
+
47
+ ## Depender del grave en dispositivos chicos
48
+
49
+ Un sonido de UI cuyo peso vive en 80 Hz no existe en el speaker de un teléfono. Con haptics, el grave es trabajo del haptic; sin haptics, el sonido tiene que leerse en medios.
50
+
51
+ ## Un set sin toggle
52
+
53
+ Los sonidos de navegación repetitivos tienen que poder apagarse, y apagarlos no debe silenciar los críticos. Controles de volumen separados de música y SFX.
54
+
55
+ ## Diseñar el set sin probar navegación real
56
+
57
+ Un sonido aprobado clic por clic se vuelve insoportable cuando el usuario recorre seis pantallas en cuatro segundos. Probar con flujos reales, a velocidad real.
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: vault-audit
3
+ description: "Reporte de salud del vault de Obsidian via script Python: huerfanas, links roto, notas stale, tag sprawl, nombres duplicados y frontmatter fuera de contrato. Read-only, no escribe nada. Triggers: auditar el vault, salud del vault, notas huerfanas, links roto, vault audit, que tan sano esta mi vault."
4
+ argument-hint: "[--vault <path>] [--stale-days N] [--seed-days N] [--exempt <dir>] [-n 0]"
5
+ category: "pkm"
6
+ user-invocable: true
7
+ allowed-tools: Read, Glob, Grep, Bash
8
+ ---
9
+
10
+ # Vault Audit - Salud del vault
11
+
12
+ Medís el estado real del vault y explicás qué significan los números. **Este skill es read-only:** no escribe ni una línea en el vault. Los arreglos necesitan approval del usuario y los aplica `/vault-tidy`.
13
+
14
+ Flags: `$ARGUMENTS`
15
+
16
+ ## Fase 1 - Correr el script
17
+
18
+ El conteo lo hace Python, no vos: leer nota por nota para contar links cuesta cientos de miles de tokens en un vault real y da números peores.
19
+
20
+ ```bash
21
+ python .claude/skills/vault-audit/scripts/vault_audit.py "<vault>" --format json
22
+ ```
23
+
24
+ Flags útiles: `--stale-days N` (default 180), `--hollow-words N` (default 30), `--task-days N` (default 7), `--seed-days N` (default 30), `--require <campo>` (repetible, default `created` y `type`), `--exempt <carpeta>` (repetible, default `Templates` y `04_Archive`), `-n 0` para no truncar listas, `--audit-all` para auditar también las carpetas exentas.
25
+
26
+ El script solo lee el filesystem: sin dependencias, corre sobre miles de notas en segundos.
27
+
28
+ **Los defaults no son la verdad del vault: el `CLAUDE.md` lo es.** Antes de correr, leer su contrato y traducirlo a flags: los campos de frontmatter a `--require`, y los roles `templates` y `archive` a `--exempt`. Auditar un vault adoptado con los defaults produce hallazgos falsos (sus plantillas aparecen como huérfanas huecas) y esconde los reales.
29
+
30
+ ## Fase 2 - Interpretar
31
+
32
+ Un hallazgo no es un problema hasta que sabés por qué está ahí. Las lecturas que importan:
33
+
34
+ | Hallazgo | Qué suele significar | Cuándo NO es un problema |
35
+ |---|---|---|
36
+ | `orphans` | Notas que nadie linkea y que no linkean nada: el síntoma del vault-cementerio | Casi nunca. Es el hallazgo que más vale atacar. |
37
+ | `no_backlinks` | Nada apunta a la nota, aunque ella sí apunta a otras | Notas de entrada (dashboards, hubs de primer nivel) |
38
+ | `broken_links` | Link a una nota que no existe. Los `aliases` del frontmatter ya se resuelven, igual que en Obsidian, así que un `[[Nacho]]` que apunta al hub "Ignacio Martinez" no aparece acá | **Un link roto es muchas veces una intención, no un error:** es la nota que el usuario todavía no escribió. Distinguí typo de intención antes de proponer nada. |
39
+ | `ambiguous_names` | Dos archivos con el mismo nombre en carpetas distintas | Nunca: hace que todo `[[link corto]]` a ese nombre resuelva de forma impredecible. Es el hallazgo más urgente aunque parezca menor. |
40
+ | `stale` | Sin tocar en 180 días o más. Las `evergreen` **no** aparecen acá: ya dijeron lo que tenían que decir | Referencia que ya hizo su trabajo pero que nadie marcó `evergreen` todavía |
41
+ | `stale_seeds` | Notas en `seed` hace más de 30 días | Casi nunca. Es la falla clásica del vault: las semillas nunca maduran. Es más grave que una huérfana porque no se ve. |
42
+ | `contested` | Notas marcadas como contradictorias y sin resolver | Nunca es un error del vault: es el vault haciendo su trabajo. Va al `/review-monthly`, no a `/vault-tidy`. |
43
+ | `hollow` | Menos de 30 palabras | Semilla reciente. Dejala crecer. |
44
+ | `single_use_tags` | Tags usados una sola vez | Inocuo salvo que sean cientos: ahí hay confusión de vocabulario, no organización. |
45
+ | `stale_open_tasks` | Tareas abiertas en un día que ya cerró | Es la regla de arrastre incumplida: había que cancelarlas en su día. |
46
+ | `missing_frontmatter` | Campos del contrato ausentes | Notas viejas anteriores al contrato |
47
+ | `orphan_attachments` | Imágenes y PDFs que nadie embebe | Ocupan espacio, no rompen nada |
48
+
49
+ El campo `status` del contrato (`seed | provisional | evergreen | contested | archived`) es lo que hace accionables a `stale` y `hollow`: sin él, una nota de referencia que ya cumplió y un stub abandonado se reportan igual. `counts.by_status` te dice cuánto del vault está declarado y cuánto no.
50
+
51
+ Dos métricas de tendencia valen más que cualquier lista: **`avg_links_per_note`** (si es baja, el vault es un archivo de documentos, no una red de ideas) y **`orphan_rate`**. Compará contra el monthly anterior si existe.
52
+
53
+ ## Fase 3 - Reportar
54
+
55
+ 1. Los conteos primero, en una tabla corta. Sin adornos.
56
+ 2. Los 3 a 5 hallazgos que valen la pena, ordenados por impacto real: `ambiguous_names` y `orphans` arriba, `hollow` y `single_use_tags` abajo o directamente omitidos si son ruido.
57
+ 3. **Si el reporte trae `truncated`, decirlo con el total real.** Un reporte truncado que no se anuncia se lee como un vault sano, y esa es la única mentira que este skill no puede cometer.
58
+ 4. Cerrar con una recomendación concreta: qué arreglaría primero y por qué.
59
+
60
+ Audit COMPLETE cuando el usuario sabe los números, cuáles importan y cuál es el primer arreglo.
61
+
62
+ ## Reglas
63
+
64
+ - No escribir en el vault. Ni una nota, ni un frontmatter, ni un link. Para eso está `/vault-tidy`.
65
+ - No dramatizar los números: un vault de 2000 notas con 40 huérfanas está sano.
66
+ - No proponer plugins como solución a un problema de higiene: menos plugins es más sano.
67
+ - No sugerir borrar nada. El vocabulario es archivar.
68
+ - Sin guiones largos en el reporte.
69
+
70
+ ## Handoff
71
+
72
+ Con el reporte listo, el siguiente paso es `/vault-tidy` para aplicar los arreglos elegidos con approval item por item. Si el audit muestra muchas huérfanas del mismo tema, el arreglo real es `/hub-note` para el hub que falta. `/review-monthly` corre este audit como parte del cierre del mes.
@@ -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()