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.
- package/CHANGELOG.md +24 -0
- package/README.md +6 -4
- package/agents/audio/audio-director.md +90 -0
- package/agents/audio/audio-programmer.md +97 -0
- package/agents/audio/audio-qa.md +95 -0
- package/agents/audio/composer.md +88 -0
- package/agents/audio/technical-sound-designer.md +90 -0
- package/agents/audio/voice-director.md +96 -0
- package/docs/SKILLS-CATALOG.md +45 -2
- package/docs/USER-GUIDE.md +2 -2
- package/package.json +1 -1
- package/profiles/audio.yaml +45 -0
- package/profiles/second-brain.yaml +37 -0
- package/rules/gamedev/audio-code.md +45 -0
- package/rules/vault-conventions.md +144 -0
- package/skills/adaptive-music/SKILL.md +171 -0
- package/skills/adaptive-music/references/anti-patterns.md +53 -0
- package/skills/audio-audit/SKILL.md +164 -0
- package/skills/audio-audit/references/anti-patterns.md +65 -0
- package/skills/audio-audit/scripts/check_assets.py +208 -0
- package/skills/audio-bible/SKILL.md +119 -0
- package/skills/audio-bible/references/anti-patterns.md +39 -0
- package/skills/audio-mix/SKILL.md +151 -0
- package/skills/audio-mix/references/anti-patterns.md +65 -0
- package/skills/audio-spec/SKILL.md +107 -0
- package/skills/audio-spec/references/anti-patterns.md +45 -0
- package/skills/brain-dump/SKILL.md +53 -0
- package/skills/hub-note/SKILL.md +68 -0
- package/skills/middleware-integration/SKILL.md +186 -0
- package/skills/middleware-integration/references/anti-patterns.md +69 -0
- package/skills/midi-compose/SKILL.md +136 -0
- package/skills/midi-compose/references/smf-format.md +129 -0
- package/skills/midi-compose/scripts/compose_midi.py +326 -0
- package/skills/midi-compose/templates/example-composition.json +69 -0
- package/skills/music-composition/SKILL.md +106 -0
- package/skills/music-composition/references/form.md +57 -0
- package/skills/music-composition/references/game-music.md +82 -0
- package/skills/music-composition/references/harmony.md +82 -0
- package/skills/music-composition/references/melody.md +48 -0
- package/skills/music-composition/references/orchestration.md +69 -0
- package/skills/music-composition/references/rhythm-groove.md +49 -0
- package/skills/obsidian-bases/SKILL.md +85 -0
- package/skills/obsidian-bases/references/bases-syntax.md +123 -0
- package/skills/obsidian-canvas/SKILL.md +91 -0
- package/skills/obsidian-markdown/SKILL.md +110 -0
- package/skills/pdf-generator/scripts/fill_inspect.py +2 -0
- package/skills/pdf-generator/scripts/render_body.py +2 -0
- package/skills/procedural-audio/SKILL.md +157 -0
- package/skills/procedural-audio/references/anti-patterns.md +45 -0
- package/skills/review-dump/SKILL.md +108 -0
- package/skills/review-monthly/SKILL.md +56 -0
- package/skills/review-weekly/SKILL.md +91 -0
- package/skills/second-brain/SKILL.md +211 -0
- package/skills/second-brain/hooks/hooks.json +16 -0
- package/skills/second-brain/hooks/validate-note.sh +85 -0
- package/skills/second-brain/references/cross-project.md +57 -0
- package/skills/second-brain/references/templates/Atomic.md +22 -0
- package/skills/second-brain/references/templates/Daily.md +25 -0
- package/skills/second-brain/references/templates/Dashboard.base +70 -0
- package/skills/second-brain/references/templates/Dump.md +11 -0
- package/skills/second-brain/references/templates/Hub.md +22 -0
- package/skills/second-brain/references/templates/Monthly.md +29 -0
- package/skills/second-brain/references/templates/Weekly.md +29 -0
- package/skills/second-brain/references/templates/codebases.md +37 -0
- package/skills/second-brain/references/templates/hot.md +37 -0
- package/skills/second-brain/references/templates/vault-CLAUDE.md +127 -0
- package/skills/sfx-design/SKILL.md +130 -0
- package/skills/sfx-design/references/anti-patterns.md +53 -0
- package/skills/spatial-audio/SKILL.md +139 -0
- package/skills/spatial-audio/references/anti-patterns.md +53 -0
- package/skills/ui-sound-design/SKILL.md +133 -0
- package/skills/ui-sound-design/references/anti-patterns.md +57 -0
- package/skills/vault-audit/SKILL.md +72 -0
- package/skills/vault-audit/scripts/vault_audit.py +539 -0
- package/skills/vault-clip/SKILL.md +73 -0
- package/skills/vault-recall/SKILL.md +82 -0
- package/skills/vault-recall/scripts/vault_index.py +626 -0
- package/skills/vault-tidy/SKILL.md +76 -0
- package/skills/voice-pipeline/SKILL.md +150 -0
- package/skills/voice-pipeline/references/anti-patterns.md +69 -0
- package/skills/xlsx-generator/scripts/formula_check.py +2 -0
- package/skills/xlsx-generator/scripts/libreoffice_recalc.py +2 -0
- package/skills/xlsx-generator/scripts/xlsx_add_column.py +2 -0
- package/skills/xlsx-generator/scripts/xlsx_insert_row.py +2 -0
- package/skills/xlsx-generator/scripts/xlsx_reader.py +2 -0
- 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()
|