ozali 0.8.0 → 0.10.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/README.md CHANGED
@@ -137,7 +137,8 @@ interactivo te pregunta (default sí), y con `--yes` lo instala automáticamente
137
137
  descarga el **binario precompilado** correcto según tu arquitectura (amd64/arm64) a `~/.local/bin`
138
138
  (sin sudo); en **macOS** usa brew → `go install` con el binario como fallback; en **Windows**
139
139
  usa `go install`. Si algún paso falla, imprime instrucciones específicas de tu SO. Si prefieres no
140
- instalarlo, pasa `--no-engram` (arranca en modo `docs`). Opcionalmente habilita **Engram Cloud**
140
+ instalarlo, pasa `--no-engram` (arranca en modo `docs`). Más tarde puedes instalarlo con
141
+ `ozali install-engram` bajo demanda. Opcionalmente habilita **Engram Cloud**
141
142
  (réplica de equipo opt-in) además del git-sync.
142
143
 
143
144
  `init` también crea **ozali-jarvis**, un **orquestador always-on**: persona en `CLAUDE.md`/`AGENTS.md`
@@ -167,6 +168,7 @@ Claude Code y opencode (perfiles de permisos para ambos en
167
168
  | Histórico aislado y memoria de equipo | [docs/team-history.md](docs/team-history.md) |
168
169
  | Desplegar Engram Cloud (VPS) | [docs/deploy-cloud-vps.md](docs/deploy-cloud-vps.md) |
169
170
  | Desplegar Engram Cloud (Google Cloud) | [docs/deploy-cloud-gcloud.md](docs/deploy-cloud-gcloud.md) |
171
+ | Integración con Obsidian | [docs/obsidian-integration.md](docs/obsidian-integration.md) |
170
172
  | Skill bootstrap | [skill/SKILL.md](skill/SKILL.md) |
171
173
  | Calibración de testing + TDD | [skill/references/calibration-blueprint.md](skill/references/calibration-blueprint.md) |
172
174
  | Contrato y versión de `cdk` | [skill/references/cdk-contract.md](skill/references/cdk-contract.md) |
package/cli/bin/ozali.mjs CHANGED
@@ -3,7 +3,7 @@
3
3
  // Seguridad: sin dependencias ni scripts de instalación. Ejecuta seguro con
4
4
  // `pnpm dlx ozali` o `npx --ignore-scripts ozali`. Ver docs/security.md.
5
5
  import { c, err, pkgVersion } from "../lib/util.mjs";
6
- import { init, doctor, update, sync, audit, cloud, workspace } from "../lib/commands.mjs";
6
+ import { init, doctor, update, sync, audit, cloud, workspace, installEngramCmd } from "../lib/commands.mjs";
7
7
 
8
8
  const HELP = `
9
9
  ${c.bold("ozali")} ${c.dim("v" + pkgVersion())} — bootstrap de IA por equipo (TDD/SDD + memoria Engram)
@@ -14,6 +14,9 @@ ${c.bold("Uso:")}
14
14
  ${c.bold("Comandos:")}
15
15
  init Detecta el agente (Claude Code/opencode), instala las skills ozali y
16
16
  ozali-commit, aísla el histórico, configura Engram y el repo de conocimiento.
17
+ install-engram
18
+ Instala (o reinstala) Engram bajo demanda, registra su MCP en el agente
19
+ y activa modo hybrid. Úsalo cuando saltaste la instalación en init.
17
20
  workspace Multi-repo: escanea los repos de la carpeta raíz, remedia los que no tienen
18
21
  ozali init, guía la calibración y escribe la config para trabajar en conjunto.
19
22
  Con --doctor / --update opera sobre TODOS los repos miembros desde la raíz.
@@ -63,6 +66,8 @@ function parseArgs(argv) {
63
66
  else if (a === "--import") opts.import = true;
64
67
  else if (a === "--push") opts.push = true;
65
68
  else if (a === "--cloud") opts.cloud = true;
69
+ else if (a === "--obsidian") opts.obsidian = true;
70
+ else if (a === "--force") opts.force = true;
66
71
  else if (a === "--no-engram") opts.noEngram = true;
67
72
  else if (a === "--no-trust") opts.noTrust = true;
68
73
  else if (a === "--no-jarvis") opts.noJarvis = true;
@@ -100,10 +105,11 @@ async function main() {
100
105
  case "init": return await init(cwd, opts);
101
106
  case "workspace": return await workspace(cwd, opts);
102
107
  case "doctor": return doctor(cwd);
103
- case "update": return update(cwd, opts);
104
- case "sync": return sync(cwd, opts);
108
+ case "update": return await update(cwd, opts);
109
+ case "sync": return await sync(cwd, opts);
105
110
  case "audit": return await audit(cwd, opts);
106
111
  case "cloud": return await cloud(cwd, opts);
112
+ case "install-engram": return await installEngramCmd(cwd, opts);
107
113
  default:
108
114
  err(`comando desconocido "${cmd}". Usa ${c.bold("ozali --help")}.`);
109
115
  return 1;
@@ -7,6 +7,7 @@ import {
7
7
  SKILL_SRC, COMMIT_SKILL_SRC, TEMPLATES_SRC, exists, ensureDir, copyDir, readJSON, writeJSON,
8
8
  ensureGitignore, tryExec, spawnCmd, which, engramAssetName, pickEngramAsset,
9
9
  projectName, pkgVersion, DEFAULT_KNOWLEDGE, HOME, openURL, gitInfo,
10
+ toPortablePath, fromPortablePath,
10
11
  } from "./util.mjs";
11
12
  import { detectAll, detectWorkspace, detectReferences } from "./detect.mjs";
12
13
  import { ask, confirm, select } from "./prompt.mjs";
@@ -115,7 +116,8 @@ export async function init(cwd, opts) {
115
116
  ], 0);
116
117
 
117
118
  // Repo de conocimiento (histórico aislado)
118
- const knowledgeRepo = opts.knowledgeRepo || await ask("Ruta del repo de conocimiento (histórico aislado)", DEFAULT_KNOWLEDGE);
119
+ const knowledgeRepoRaw = opts.knowledgeRepo || await ask("Ruta del repo de conocimiento (histórico aislado)", DEFAULT_KNOWLEDGE);
120
+ const knowledgeRepo = fromPortablePath(knowledgeRepoRaw, cwd);
119
121
 
120
122
  // Engram
121
123
  let memoryMode = "docs";
@@ -124,6 +126,24 @@ export async function init(cwd, opts) {
124
126
  } else if (env.engram.available) {
125
127
  memoryMode = "hybrid";
126
128
  ok(`Engram disponible (${env.engram.bin}). Modo de memoria: ${c.bold("hybrid")} (docs + Engram).`);
129
+ // Verificar si hay versión nueva con cooldown de seguridad
130
+ const versionCheck = checkEngramVersion();
131
+ if (versionCheck && versionCheck.canUpgrade) {
132
+ warn(`Hay una nueva versión de Engram: ${c.bold(versionCheck.latest)} (tienes ${versionCheck.current}).`);
133
+ if (await confirm("¿Actualizar Engram ahora?", false)) {
134
+ info("Actualizando Engram…");
135
+ if (process.platform === "darwin" && which("brew")) {
136
+ spawnCmd("brew", ["upgrade", "gentleman-programming/tap/engram"]);
137
+ } else if (which("go")) {
138
+ spawnCmd("go", ["install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"]);
139
+ } else {
140
+ warn("No se puede auto-actualizar sin Homebrew (macOS) o Go. Descarga manual:");
141
+ info(" " + c.cyan(versionCheck.url));
142
+ }
143
+ }
144
+ } else if (versionCheck && versionCheck.cooldown) {
145
+ info(`Engram ${c.bold(versionCheck.latest)} está disponible pero aún en cooldown de seguridad (24h). Se activará el ${new Date(new Date(versionCheck.publishedAt).getTime() + 24*60*60*1000).toLocaleDateString()}.`);
146
+ }
127
147
  } else {
128
148
  warn("Engram no está instalado.");
129
149
  // --dry-run no instala; --yes usa el default (sí); interactivo pregunta.
@@ -155,6 +175,23 @@ export async function init(cwd, opts) {
155
175
  }
156
176
  }
157
177
 
178
+ // Obsidian (opt-in) — ofrecer instalación si no detectado
179
+ if (!env.obsidian.installed) {
180
+ warn("Obsidian no detectado. Es el visualizador recomendado para el vault de conocimiento.");
181
+ if (!opts.dryRun) {
182
+ const installObsidian = opts.yes ? false : await confirm("¿Abrir la página de descarga de Obsidian?", false);
183
+ if (installObsidian) {
184
+ const url = process.platform === "darwin" ? "https://obsidian.md/download"
185
+ : process.platform === "win32" ? "https://obsidian.md/download"
186
+ : "https://obsidian.md/download";
187
+ openURL(url);
188
+ info("Descarga e instala Obsidian, luego corre " + c.bold("ozali sync --obsidian") + " para generar el vault.");
189
+ }
190
+ }
191
+ } else {
192
+ ok(`Obsidian detectado (${c.dim(env.obsidian.path)}).`);
193
+ }
194
+
158
195
  // Engram Cloud (opt-in) — réplica de equipo además del git-sync. Solo si Engram quedó disponible.
159
196
  let cloud = { enabled: false };
160
197
  if (memoryMode === "hybrid" && !opts.dryRun) {
@@ -220,7 +257,9 @@ export async function init(cwd, opts) {
220
257
 
221
258
  // 5) config local (gitignored)
222
259
  const config = {
223
- version: pkgVersion(), agent, scope, knowledgeRepo, memoryMode, cloud,
260
+ version: pkgVersion(), agent, scope,
261
+ knowledgeRepo: toPortablePath(knowledgeRepo, cwd),
262
+ memoryMode, cloud,
224
263
  project: projectName(cwd), createdAt: new Date().toISOString(),
225
264
  };
226
265
  writeJSON(CONFIG_PATH(cwd), config);
@@ -244,6 +283,53 @@ export async function init(cwd, opts) {
244
283
  return 0;
245
284
  }
246
285
 
286
+ /**
287
+ * Consulta la API de releases de Engram y compara con la versión instalada.
288
+ * Si hay una versión estable más reciente cuyo release tenga >24h de antigüedad,
289
+ * advierte al usuario y ofrece upgrade. Si la versión nueva tiene <24h, ignora
290
+ * (cooldown de seguridad contra supply-chain attacks).
291
+ * Devuelve { current, latest, url, canUpgrade } o null si no hay info.
292
+ */
293
+ function checkEngramVersion() {
294
+ const currentRaw = tryExec("engram", ["version"]);
295
+ if (!currentRaw) return null;
296
+ const current = currentRaw.trim().replace(/^engram\s+/, "");
297
+ const raw = fetchText(ENGRAM_RELEASES_LIST);
298
+ if (!raw) return null;
299
+ let releases;
300
+ try { releases = JSON.parse(raw); } catch { return null; }
301
+ if (!Array.isArray(releases)) return null;
302
+ const now = Date.now();
303
+ const COOLDOWN_MS = 24 * 60 * 60 * 1000;
304
+ for (const r of releases) {
305
+ if (!r || r.draft || r.prerelease) continue;
306
+ const m = /^v(\d+\.\d+\.\d+)$/.exec(r.tag_name || "");
307
+ if (!m) continue;
308
+ const latest = m[1];
309
+ if (compareSemver(latest, current) <= 0) break; // no hay nada más nuevo
310
+ const published = r.published_at ? new Date(r.published_at).getTime() : 0;
311
+ if (!published || now - published < COOLDOWN_MS) {
312
+ // Versión muy reciente — mostrar como disponible pero con cooldown activo
313
+ return { current, latest, url: r.html_url, canUpgrade: false, cooldown: true, publishedAt: r.published_at };
314
+ }
315
+ // Versión estable con cooldown cumplido
316
+ const asset = pickEngramAsset([r], process.platform, process.arch);
317
+ return { current, latest, url: asset ? asset.url : r.html_url, canUpgrade: true, cooldown: false, publishedAt: r.published_at };
318
+ }
319
+ return { current, latest: current, canUpgrade: false };
320
+ }
321
+
322
+ /** Comparación semver simple: devuelve >0 si a>b, <0 si a<b, 0 si iguales. */
323
+ function compareSemver(a, b) {
324
+ const pa = a.split(".").map((n) => parseInt(n, 10));
325
+ const pb = b.split(".").map((n) => parseInt(n, 10));
326
+ for (let i = 0; i < 3; i++) {
327
+ const na = pa[i] || 0, nb = pb[i] || 0;
328
+ if (na !== nb) return na - nb;
329
+ }
330
+ return 0;
331
+ }
332
+
247
333
  /**
248
334
  * Instala Engram con la mejor ruta disponible para el SO actual.
249
335
  * Linux: binario precompilado (auto-descarga, sin toolchain) con brew/go como alternativas.
@@ -795,7 +881,7 @@ export async function workspace(cwd, opts = {}) {
795
881
 
796
882
  // Modos batch (Track 1): operan sobre los miembros del workspace ya existente y salen.
797
883
  if (opts.wsDoctor) return workspaceDoctor(ws.members);
798
- if (opts.wsUpdate) return workspaceUpdate(ws.members, opts);
884
+ if (opts.wsUpdate) return await workspaceUpdate(ws.members, opts);
799
885
 
800
886
  // Fase B — remediación de los que no tienen ozali init
801
887
  const missing = ws.members.filter((m) => m.status === "missing-init");
@@ -807,7 +893,7 @@ export async function workspace(cwd, opts = {}) {
807
893
  const shared = {
808
894
  agent: opts.agent || (base && base.agent),
809
895
  scope: opts.scope || (base && base.scope),
810
- knowledgeRepo: opts.knowledgeRepo || (base && base.knowledgeRepo),
896
+ knowledgeRepo: fromPortablePath(opts.knowledgeRepo || (base && base.knowledgeRepo), root),
811
897
  };
812
898
  for (const m of missing) {
813
899
  const go = opts.yes ? true : await confirm(`¿Correr ${c.bold("ozali init")} en ${c.bold(m.dir)}?`, true);
@@ -888,7 +974,7 @@ function workspaceDoctor(members) {
888
974
  }
889
975
 
890
976
  /** Track 1 — update de todos los miembros ozali (skills/permisos/jarvis) + resumen. */
891
- function workspaceUpdate(members, opts) {
977
+ async function workspaceUpdate(members, opts) {
892
978
  const results = [];
893
979
  let failed = 0;
894
980
  for (const m of members) {
@@ -899,7 +985,7 @@ function workspaceUpdate(members, opts) {
899
985
  results.push({ dir: m.dir, mark: c.yellow("—"), note: "sin init (saltado)" });
900
986
  continue;
901
987
  }
902
- const code = update(m.path, opts);
988
+ const code = await update(m.path, opts);
903
989
  if (code !== 0) failed++;
904
990
  results.push({ dir: m.dir, mark: code === 0 ? c.green("✔") : c.yellow("✖"), note: code === 0 ? "actualizado" : "revisar" });
905
991
  }
@@ -956,7 +1042,10 @@ function writeWorkspaceManifest(root, members, references, opts) {
956
1042
  version: pkgVersion(),
957
1043
  root,
958
1044
  agent,
959
- knowledgeRepo: opts.knowledgeRepo || existing.knowledgeRepo || base.knowledgeRepo || DEFAULT_KNOWLEDGE,
1045
+ knowledgeRepo: toPortablePath(
1046
+ opts.knowledgeRepo || existing.knowledgeRepo || base.knowledgeRepo || DEFAULT_KNOWLEDGE,
1047
+ root
1048
+ ),
960
1049
  cloud: base.cloud || existing.cloud || { enabled: false },
961
1050
  members: members.map((m) => ({ path: m.dir, project: m.project, status: m.status, sot: m.sot.found ? m.sot.variant : null })),
962
1051
  references: references.map((e) => ({ from: e.fromDir, to: e.toDir, kind: e.kind })),
@@ -1056,7 +1145,9 @@ export function doctor(cwd) {
1056
1145
  cloudMeta && cloudMeta.dashboard ? c.cyan(cloudMeta.dashboard) : ""].filter(Boolean).join(" · ")
1057
1146
  : "off (opt-in, git-sync activo)";
1058
1147
  add("Engram Cloud", true, cloudDetail);
1059
- add("Repo de conocimiento", !!(cfg && cfg.knowledgeRepo && exists(cfg.knowledgeRepo)), cfg ? cfg.knowledgeRepo : "sin configurar (ozali init)");
1148
+ const kRepoPortable = cfg && cfg.knowledgeRepo;
1149
+ const kRepoResolved = kRepoPortable ? fromPortablePath(kRepoPortable, cwd) : null;
1150
+ add("Repo de conocimiento", !!(kRepoResolved && exists(kRepoResolved)), kRepoResolved || "sin configurar (ozali init)");
1060
1151
 
1061
1152
  // Strict TDD (de la fuente de verdad)
1062
1153
  const tdd = readStrictTdd(cwd, env.sot);
@@ -1166,7 +1257,7 @@ function readStrictTdd(cwd, sot) {
1166
1257
  // references), los perfiles de permisos y **crea/refresca ozali-jarvis** (clave para repos
1167
1258
  // inicializados antes de 0.4.0). La skill `cdk` la regenera el AGENTE (no el CLI): se detecta
1168
1259
  // y se guía la regeneración.
1169
- export function update(cwd, opts = {}) {
1260
+ export async function update(cwd, opts = {}) {
1170
1261
  step("ozali update — actualizar la instalación al paquete actual");
1171
1262
  const env = detectAll(cwd);
1172
1263
  const cfgPath = CONFIG_PATH(cwd);
@@ -1232,6 +1323,37 @@ export function update(cwd, opts = {}) {
1232
1323
  info("cdk aún no generada en este repo. Corre la skill " + c.bold("ozali") + " en tu agente para crearla.");
1233
1324
  }
1234
1325
 
1326
+ // 4.5) Engram version check (cooldown 24h)
1327
+ if (env.engram.available) {
1328
+ const versionCheck = checkEngramVersion();
1329
+ if (versionCheck && versionCheck.canUpgrade) {
1330
+ warn(`Hay una nueva versión de Engram: ${c.bold(versionCheck.latest)} (tienes ${versionCheck.current}).`);
1331
+ if (await confirm("¿Actualizar Engram ahora?", false)) {
1332
+ info("Actualizando Engram…");
1333
+ if (process.platform === "darwin" && which("brew")) {
1334
+ spawnCmd("brew", ["upgrade", "gentleman-programming/tap/engram"]);
1335
+ } else if (which("go")) {
1336
+ spawnCmd("go", ["install", "github.com/Gentleman-Programming/engram/cmd/engram@latest"]);
1337
+ } else {
1338
+ warn("No se puede auto-actualizar sin Homebrew (macOS) o Go. Descarga manual:");
1339
+ info(" " + c.cyan(versionCheck.url));
1340
+ }
1341
+ }
1342
+ } else if (versionCheck && versionCheck.cooldown) {
1343
+ info(`Engram ${c.bold(versionCheck.latest)} está disponible pero aún en cooldown de seguridad (24h). Se activará el ${new Date(new Date(versionCheck.publishedAt).getTime() + 24*60*60*1000).toLocaleDateString()}.`);
1344
+ }
1345
+ }
1346
+
1347
+ // 4.6) Obsidian check
1348
+ if (!env.obsidian.installed) {
1349
+ warn("Obsidian no detectado. Es el visualizador recomendado para el vault de conocimiento.");
1350
+ const installObsidian = opts.yes ? false : await confirm("¿Abrir la página de descarga de Obsidian?", false);
1351
+ if (installObsidian) {
1352
+ openURL("https://obsidian.md/download");
1353
+ info("Descarga e instala Obsidian, luego corre " + c.bold("ozali sync --obsidian") + " para generar el vault.");
1354
+ }
1355
+ }
1356
+
1235
1357
  // 5) versión del config
1236
1358
  if (cfg) { cfg.version = pkgVersion(); cfg.updatedAt = new Date().toISOString(); writeJSON(cfgPath, cfg); }
1237
1359
  ok(`Instalación al día con ozali v${pkgVersion()}.`);
@@ -1273,12 +1395,89 @@ function cdkCanonicalVersion() {
1273
1395
  }
1274
1396
  }
1275
1397
 
1398
+ // =========================================================== install-engram ===
1399
+ export async function installEngramCmd(cwd, opts) {
1400
+ step("ozali install-engram — instalar o reparar Engram bajo demanda");
1401
+ const env = detectAll(cwd);
1402
+ const cfg = readJSON(CONFIG_PATH(cwd));
1403
+ let needsInstall = !env.engram.available;
1404
+
1405
+ if (env.engram.available) {
1406
+ const online = tryExec("engram", ["doctor"], { cwd }) !== null;
1407
+ if (online && !opts.force) {
1408
+ ok(`Engram ya está instalado y responde (${env.engram.bin}).`);
1409
+ info("Usa --force si quieres forzar una reinstalación.");
1410
+ } else if (online && opts.force) {
1411
+ warn("Forzando reinstalación de Engram (--force)…");
1412
+ needsInstall = true;
1413
+ } else {
1414
+ warn("Engram está en PATH pero no responde (engram doctor falló). Reinstalando…");
1415
+ needsInstall = true;
1416
+ }
1417
+ }
1418
+
1419
+ if (needsInstall) {
1420
+ if (opts.dryRun) {
1421
+ info("(dry-run) Aquí instalaría Engram.");
1422
+ return 0;
1423
+ }
1424
+ const installed = installEngram();
1425
+ if (!installed) {
1426
+ warn("No se pudo instalar Engram automáticamente.");
1427
+ printEngramManualInstructions(cfg?.agent || "both");
1428
+ return 1;
1429
+ }
1430
+ }
1431
+
1432
+ // Configurar agente MCP
1433
+ let agent = opts.agent || (cfg && cfg.agent);
1434
+ if (!agent && !opts.yes) {
1435
+ agent = await select("¿Para qué agente configuro Engram?", [
1436
+ { value: "claude-code", label: "Claude Code" },
1437
+ { value: "opencode", label: "opencode" },
1438
+ { value: "both", label: "Ambos" },
1439
+ ], 2);
1440
+ } else if (!agent) {
1441
+ agent = "both";
1442
+ }
1443
+
1444
+ if (agent === "claude-code" || agent === "both") {
1445
+ info("Registrando MCP en Claude Code…");
1446
+ spawnCmd("engram", ["setup", "claude-code"]);
1447
+ }
1448
+ if (agent === "opencode" || agent === "both") {
1449
+ info("Registrando MCP en opencode…");
1450
+ spawnCmd("engram", ["setup", "opencode"]);
1451
+ }
1452
+
1453
+ // Actualizar config de ozali si existe
1454
+ if (cfg) {
1455
+ let wrote = false;
1456
+ if (cfg.memoryMode === "docs") {
1457
+ cfg.memoryMode = "hybrid";
1458
+ wrote = true;
1459
+ ok("Modo de memoria actualizado a " + c.bold("hybrid") + " en .ozali/config.json.");
1460
+ }
1461
+ if (!cfg.agent) {
1462
+ cfg.agent = agent;
1463
+ wrote = true;
1464
+ }
1465
+ if (wrote) writeJSON(CONFIG_PATH(cwd), cfg);
1466
+ } else {
1467
+ warn("No hay configuración de ozali en esta ruta (corre " + c.bold("ozali init") + " para completar el setup).");
1468
+ info("Engram quedó instalado; solo falta el config de ozali para integrarlo al flujo.");
1469
+ }
1470
+
1471
+ ok("Engram listo. Reinicia tu agente para que cargue el servidor MCP de Engram.");
1472
+ return 0;
1473
+ }
1474
+
1276
1475
  // ============================================================= sync ===========
1277
- export function sync(cwd, opts) {
1476
+ export async function sync(cwd, opts) {
1278
1477
  step(`ozali sync${opts.import ? " --import" : ""}${opts.cloud ? " --cloud" : ""} — histórico ↔ repo de conocimiento`);
1279
1478
  const cfg = readJSON(CONFIG_PATH(cwd));
1280
1479
  if (!cfg || !cfg.knowledgeRepo) { warn("Sin repo de conocimiento configurado. Corre " + c.bold("ozali init") + "."); return 1; }
1281
- const kRepo = cfg.knowledgeRepo;
1480
+ const kRepo = fromPortablePath(cfg.knowledgeRepo, cwd);
1282
1481
  if (!exists(kRepo)) { err(`El repo de conocimiento no existe: ${kRepo}`); return 1; }
1283
1482
  const project = cfg.project || projectName(cwd);
1284
1483
  const projDir = path.join(kRepo, "projects", project);
@@ -1383,6 +1582,16 @@ export function sync(cwd, opts) {
1383
1582
  if (exists(docsLocal)) { copyDir(docsLocal, path.join(projDir, "docs")); ok(`Docs copiados a projects/${project}/docs/.`); }
1384
1583
  else info("No hay .ozali/docs/ que sincronizar todavía.");
1385
1584
 
1585
+ // 2.5) Obsidian vault export
1586
+ const vaultPath = path.join(kRepo, "obsidian");
1587
+ if (opts.obsidian) {
1588
+ await exportObsidianVault(kRepo, project, vaultPath);
1589
+ } else if (exists(vaultPath) && !opts.yes) {
1590
+ if (await confirm("¿Exportar memoria a Obsidian vault?", true)) {
1591
+ await exportObsidianVault(kRepo, project, vaultPath);
1592
+ }
1593
+ }
1594
+
1386
1595
  // 3) commit (push solo si hay remoto)
1387
1596
  if (exists(path.join(kRepo, ".git"))) {
1388
1597
  tryExec("git", ["add", "-A"], { cwd: kRepo });
@@ -1400,6 +1609,57 @@ export function sync(cwd, opts) {
1400
1609
  return 0;
1401
1610
  }
1402
1611
 
1612
+ /**
1613
+ * Exporta la memoria de Engram a un vault de Obsidian compatible.
1614
+ * 1) Copia templates base si no existen.
1615
+ * 2) Genera MOCs dinámicos (proyectos) desde knowledgeRepo/projects/.
1616
+ * 3) Ejecuta `engram obsidian-export`.
1617
+ */
1618
+ async function exportObsidianVault(kRepo, project, vaultPath) {
1619
+ if (!tryExec("engram", ["--version"])) {
1620
+ warn("Engram no está disponible. No se puede exportar a Obsidian.");
1621
+ return;
1622
+ }
1623
+ ensureDir(vaultPath);
1624
+ // 1) Templates base
1625
+ const templateDir = path.join(TEMPLATES_SRC, "obsidian-vault");
1626
+ if (exists(templateDir)) {
1627
+ for (const entry of fs.readdirSync(templateDir, { withFileTypes: true })) {
1628
+ const src = path.join(templateDir, entry.name);
1629
+ const dst = path.join(vaultPath, entry.name);
1630
+ if (entry.isDirectory()) {
1631
+ if (!exists(dst)) copyDir(src, dst);
1632
+ } else if (!exists(dst)) {
1633
+ fs.copyFileSync(src, dst);
1634
+ }
1635
+ }
1636
+ }
1637
+ // 2) MOC dinámico — Proyectos
1638
+ const projectsDir = path.join(kRepo, "projects");
1639
+ const projectsMoc = path.join(vaultPath, "MOCs", "Proyectos.md");
1640
+ if (exists(projectsDir)) {
1641
+ const projects = [];
1642
+ for (const entry of fs.readdirSync(projectsDir, { withFileTypes: true })) {
1643
+ if (entry.isDirectory()) projects.push(entry.name);
1644
+ }
1645
+ const list = projects.map((p) => `- [[${p}]] — proyecto activo`).join("\n");
1646
+ const body = fs.readFileSync(projectsMoc, "utf8");
1647
+ const updated = body.replace(
1648
+ /<!-- PROJECTS_START -->[\s\S]*?<!-- PROJECTS_END -->/,
1649
+ `<!-- PROJECTS_START -->\n${list || "- Sin proyectos activos todavía."}\n<!-- PROJECTS_END -->`
1650
+ );
1651
+ fs.writeFileSync(projectsMoc, updated);
1652
+ }
1653
+ // 3) Export Engram
1654
+ info("Exportando memoria a Obsidian vault…");
1655
+ const out = tryExec("engram", ["obsidian-export", "--vault", vaultPath, "--project", project, "--graph-config", "preserve"]);
1656
+ if (out !== null) {
1657
+ ok("Obsidian vault actualizado.");
1658
+ } else {
1659
+ warn("engram obsidian-export falló. Revisa que el vault esté cerrado en Obsidian.");
1660
+ }
1661
+ }
1662
+
1403
1663
  // ============================================================ audit ===========
1404
1664
  // Navega/audita la memoria de Engram: del proyecto actual o general (todos los
1405
1665
  // proyectos). Sin contexto de proyecto en la ruta → general. Sin Engram → audita
@@ -47,6 +47,32 @@ export function detectEngram() {
47
47
  return { available: !!bin, bin: bin || null };
48
48
  }
49
49
 
50
+ /** Detecta si Obsidian está instalado. Devuelve { installed, path } por SO. */
51
+ export function detectObsidian() {
52
+ const plat = process.platform;
53
+ if (plat === "darwin") {
54
+ const appPaths = [
55
+ "/Applications/Obsidian.app",
56
+ path.join(HOME, "Applications", "Obsidian.app"),
57
+ ];
58
+ for (const p of appPaths) if (exists(p)) return { installed: true, path: p };
59
+ const bin = which("obsidian");
60
+ if (bin) return { installed: true, path: bin };
61
+ } else if (plat === "linux") {
62
+ const bin = which("obsidian");
63
+ if (bin) return { installed: true, path: bin };
64
+ const flatpak = path.join(HOME, ".var", "app", "md.obsidian.Obsidian");
65
+ if (exists(flatpak)) return { installed: true, path: flatpak };
66
+ const snap = "/snap/bin/obsidian";
67
+ if (exists(snap)) return { installed: true, path: snap };
68
+ } else if (plat === "win32") {
69
+ const localAppData = process.env.LOCALAPPDATA || path.join(HOME, "AppData", "Local");
70
+ const exe = path.join(localAppData, "Obsidian", "Obsidian.exe");
71
+ if (exists(exe)) return { installed: true, path: exe };
72
+ }
73
+ return { installed: false, path: null };
74
+ }
75
+
50
76
  /** Metadatos compartibles de Engram Cloud del proyecto (sin secretos). */
51
77
  export function detectCloud(cwd) {
52
78
  const metaPath = path.join(cwd, ".ozali", "cloud.json");
@@ -105,6 +131,7 @@ export function detectAll(cwd) {
105
131
  agents: detectAgents(cwd),
106
132
  skill: detectInstalledSkill(cwd),
107
133
  engram: detectEngram(),
134
+ obsidian: detectObsidian(),
108
135
  cloud: detectCloud(cwd),
109
136
  testing: detectTesting(cwd),
110
137
  };
package/cli/lib/util.mjs CHANGED
@@ -193,5 +193,60 @@ export function nodeMajor() {
193
193
  return parseInt(process.versions.node.split(".")[0], 10);
194
194
  }
195
195
 
196
+ // ---- rutas portables (cross-platform / cross-team) --------------------------
197
+
198
+ /**
199
+ * Convierte un path absoluto a formato portable para guardar en config.json.
200
+ * Reglas (en orden de prioridad):
201
+ * 1. Si está bajo `os.homedir()`, reemplazar prefijo por `~`.
202
+ * 2. Si `base` (cwd del proyecto) está definido y el path está bajo `base`,
203
+ * devolver relativo a `base`.
204
+ * 3. Dejar absoluto (legacy; emitir warning en el caller si es necesario).
205
+ */
206
+ export function toPortablePath(absPath, base = null) {
207
+ if (!absPath) return absPath;
208
+ // Normalizar con realpath para resolver symlinks (macOS: /var → /private/var)
209
+ let normalized;
210
+ try { normalized = fs.realpathSync(absPath); } catch { normalized = path.resolve(absPath); }
211
+ const home = os.homedir();
212
+ // 1) home-relative → ~
213
+ if (home && (normalized === home || normalized.startsWith(home + path.sep))) {
214
+ return "~" + normalized.slice(home.length);
215
+ }
216
+ // 2) base-relative
217
+ if (base) {
218
+ let baseNorm;
219
+ try { baseNorm = fs.realpathSync(base); } catch { baseNorm = path.resolve(base); }
220
+ if (normalized.startsWith(baseNorm + path.sep)) {
221
+ return path.relative(baseNorm, normalized);
222
+ }
223
+ }
224
+ // 3) absoluto legacy
225
+ return normalized;
226
+ }
227
+
228
+ /**
229
+ * Expande un path portable a absoluto.
230
+ * Reglas:
231
+ * 1. Si empieza con `~`, expandir a `os.homedir()`.
232
+ * 2. Si es relativo, resolver contra `base` (cwd del proyecto).
233
+ * 3. Si ya es absoluto (legacy), devolver tal cual.
234
+ */
235
+ export function fromPortablePath(portablePath, base = null) {
236
+ if (!portablePath) return portablePath;
237
+ // 1) expandir ~
238
+ if (portablePath.startsWith("~")) {
239
+ const home = os.homedir();
240
+ const rest = portablePath.slice(1);
241
+ return home + (rest.startsWith(path.sep) || rest === "" ? rest : path.sep + rest);
242
+ }
243
+ // 2) relativo → absoluto contra base
244
+ if (base && !path.isAbsolute(portablePath)) {
245
+ return path.resolve(base, portablePath);
246
+ }
247
+ // 3) absoluto legacy o ya resuelto
248
+ return path.resolve(portablePath);
249
+ }
250
+
196
251
  export const HOME = os.homedir();
197
252
  export const DEFAULT_KNOWLEDGE = path.join(HOME, ".ozali", "knowledge");
@@ -6,7 +6,7 @@ import fs from "node:fs";
6
6
  import os from "node:os";
7
7
  import path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { engramAssetName, pickEngramAsset } from "../lib/util.mjs";
9
+ import { engramAssetName, pickEngramAsset, toPortablePath, fromPortablePath } from "../lib/util.mjs";
10
10
 
11
11
  const HERE = path.dirname(fileURLToPath(import.meta.url));
12
12
  const BIN = path.resolve(HERE, "..", "bin", "ozali.mjs");
@@ -383,6 +383,49 @@ test("engramAssetName respeta la convención de release por SO/arch", () => {
383
383
  assert.equal(engramAssetName("freebsd", "x64", "1.17.0"), null, "SO no soportado → null");
384
384
  });
385
385
 
386
+ test("toPortablePath convierte absolutos a ~ o relativo", () => {
387
+ const home = os.homedir();
388
+ const cwd = "/project";
389
+ assert.equal(toPortablePath(path.join(home, ".ozali", "knowledge"), cwd), "~/.ozali/knowledge");
390
+ assert.equal(toPortablePath("/project/.k", "/project"), ".k");
391
+ assert.equal(toPortablePath("/outside", "/project"), "/outside");
392
+ });
393
+
394
+ test("fromPortablePath expande ~ y resuelve relativos", () => {
395
+ const home = os.homedir();
396
+ const cwd = "/project";
397
+ assert.equal(fromPortablePath("~/.ozali/knowledge", cwd), path.join(home, ".ozali", "knowledge"));
398
+ assert.equal(fromPortablePath(".k", cwd), "/project/.k");
399
+ assert.equal(fromPortablePath("/abs", cwd), "/abs");
400
+ });
401
+
402
+ test("init guarda knowledgeRepo como path portable", () => {
403
+ const dir = tmpProject();
404
+ try {
405
+ run(["init", "--yes", "--no-engram", "--no-trust", "--no-jarvis", "--agent", "claude-code", "--scope", "project", "--knowledge-repo", path.join(dir, ".k")], dir);
406
+ const cfg = JSON.parse(fs.readFileSync(path.join(dir, ".ozali", "config.json"), "utf8"));
407
+ assert.ok(cfg.knowledgeRepo, "config tiene knowledgeRepo");
408
+ assert.ok(!path.isAbsolute(cfg.knowledgeRepo), "knowledgeRepo NO debe ser absoluto");
409
+ assert.equal(cfg.knowledgeRepo, ".k", "knowledgeRepo debe ser relativo al proyecto");
410
+ } finally {
411
+ fs.rmSync(dir, { recursive: true, force: true });
412
+ }
413
+ });
414
+
415
+ test("sync resuelve knowledgeRepo portable correctamente", () => {
416
+ const dir = tmpProject();
417
+ try {
418
+ run(["init", "--yes", "--no-engram", "--no-trust", "--no-jarvis", "--agent", "claude-code", "--scope", "project", "--knowledge-repo", path.join(dir, ".k")], dir);
419
+ // Crear algo en .ozali/docs/ para que sync tenga qué copiar
420
+ fs.mkdirSync(path.join(dir, ".ozali", "docs"), { recursive: true });
421
+ fs.writeFileSync(path.join(dir, ".ozali", "docs", "test.md"), "# test\n");
422
+ const { stdout } = run(["sync", "--yes"], dir);
423
+ assert.match(stdout, /copiados|Docs copiados/, "sync debe encontrar el repo de conocimiento");
424
+ } finally {
425
+ fs.rmSync(dir, { recursive: true, force: true });
426
+ }
427
+ });
428
+
386
429
  test("pickEngramAsset ignora tags no-semver sin binarios (pi-v*) y draft/prerelease", () => {
387
430
  // Shape real de la API de GitHub: el 'latest' es pi-v0.1.9 (0 assets); los binarios
388
431
  // viven en tags vX.Y.Z. Además metemos un prerelease más nuevo que debe saltarse.
@@ -81,11 +81,11 @@ Lleva el histórico y la memoria al repo de conocimiento del equipo. Tus compañ
81
81
  | Comando | Qué hace |
82
82
  |---|---|
83
83
  | `ozali init` | Prepara el proyecto (lo corres una vez por repo). |
84
+ | `ozali install-engram` | Instala (o reinstala) Engram y registra su MCP cuando saltaste la instalación en `init`. |
84
85
  | `ozali workspace` | Configura varios repos de una carpeta para trabajar en conjunto (lo corres en la carpeta raíz). |
85
86
  | `ozali doctor` | Revisa que todo esté bien. Es solo lectura, no cambia nada. |
86
87
  | `ozali update` | Actualiza skill ozali + ozali-jarvis + permisos (y guía cómo regenerar cdk). |
87
88
  | `ozali sync` | Sube el histórico/memoria al repo de equipo. |
88
- | `ozali sync --import` | Baja lo que el equipo ya guardó. |
89
89
  | `ozali audit` | Navega/audita la memoria de Engram (qué se ha hecho). |
90
90
 
91
91
  ¿Algo no funciona? Corre **`ozali doctor`** primero: te dice qué falta y cómo arreglarlo.
@@ -146,8 +146,8 @@ no puede, te deja las instrucciones. Con Engram, tu agente **recuerda** decision
146
146
  convenciones — y los recupera en futuras sesiones.
147
147
 
148
148
  ¿No quieres Engram ahora? Pasa `--no-engram` y ozali arranca en modo `docs` (la memoria se queda en
149
- documentos legibles). Cuando quieras activarlo, instálalo y corre `ozali doctor`: el modo sube a
150
- `hybrid` solo.
149
+ documentos legibles). Cuando quieras activarlo, corre `ozali install-engram` lo instala, configura
150
+ el MCP en tu agente y sube el modo a `hybrid` de una vez.
151
151
 
152
152
  Dos detalles si lo usas en equipo:
153
153
 
@@ -0,0 +1,93 @@
1
+ # Integración con Obsidian
2
+
3
+ ← [README](README.md)
4
+
5
+ Ozali puede exportar la memoria de equipo (Engram) y el histórico de hitos a un **vault de Obsidian**: un espacio navegable con wikilinks, grafo de conocimiento y Mapas de Contenido (MOCs).
6
+
7
+ ## ¿Qué es el vault?
8
+
9
+ Es una carpeta `obsidian/` dentro de tu **repo de conocimiento** (`ozali-knowledge/` o `~/.ozali/knowledge/`). Contiene:
10
+
11
+ - **README.md** — índice humano del vault
12
+ - **MOCs/** — Mapas de Contenido auto-generados (proyectos, decisiones, bugfixes, métricas)
13
+ - **Memoria/** — exportación de Engram (decisiones, bugfixes, resúmenes técnicos)
14
+ - **.obsidian/** — configuración compartida del equipo (plugins core, hotkeys)
15
+
16
+ ## Requisitos
17
+
18
+ - [Obsidian](https://obsidian.md) instalado (opt-in; ozali lo detecta y ofrece descargar)
19
+ - Engram instalado (`engram obsidian-export` es el motor de exportación)
20
+
21
+ ## Uso
22
+
23
+ ### Generar / actualizar el vault
24
+
25
+ ```bash
26
+ # Desde cualquier proyecto ozali
27
+ ozali sync --obsidian
28
+ ```
29
+
30
+ Esto:
31
+ 1. Exporta Engram al vault (`engram obsidian-export`)
32
+ 2. Actualiza los MOCs dinámicos (lista de proyectos, etc.)
33
+ 3. Commit en el repo de conocimiento
34
+
35
+ ### Sin el flag `--obsidian`
36
+
37
+ ```bash
38
+ ozali sync
39
+ ```
40
+
41
+ Si el vault ya existe, ozali pregunta: *"¿Exportar memoria a Obsidian vault?"*. Responde `sí` para actualizar.
42
+
43
+ ### Abrir el vault en Obsidian
44
+
45
+ 1. Abre Obsidian
46
+ 2. Selecciona **"Open folder as vault"**
47
+ 3. Navega a tu `ozali-knowledge/obsidian/` (o `~/.ozali/knowledge/obsidian/`)
48
+
49
+ ### Estructura recomendada del repo de conocimiento
50
+
51
+ ```
52
+ ozali-knowledge/
53
+ .git/
54
+ engram/ ← export chunks de Engram
55
+ projects/
56
+ {proyecto}/
57
+ docs/ ← docs por hito
58
+ obsidian/ ← 🆕 VAULT DE OBSIDIAN
59
+ .obsidian/
60
+ README.md
61
+ MOCs/
62
+ Memoria/
63
+ ```
64
+
65
+ ## Configuración compartida
66
+
67
+ La carpeta `.obsidian/` del vault **sí se commitea** en el repo de conocimiento. Contiene:
68
+ - Plugins core habilitados (graph, backlinks, quick switcher)
69
+ - Config de lectura (readable line length)
70
+
71
+ **No se commitean** settings personales: temas, snippets, `workspace.json`, notas `Daily/`.
72
+
73
+ ## Flujo de trabajo típico
74
+
75
+ 1. Trabajas en un hito con `cdk` → ozali genera docs en `.ozali/docs/`
76
+ 2. `ozali sync` → lleva docs + Engram al repo de conocimiento
77
+ 3. `ozali sync --obsidian` → regenera el vault con la memoria actualizada
78
+ 4. Abres Obsidian → exploras el grafo, wikilinks, decisiones del equipo
79
+
80
+ ## Troubleshooting
81
+
82
+ | Síntoma | Causa | Solución |
83
+ |---|---|---|
84
+ | `engram obsidian-export` falla | Vault abierto en Obsidian (lock de archivos) | Cierra el vault en Obsidian y reintenta |
85
+ | MOCs vacíos | Sin proyectos en `knowledgeRepo/projects/` | Corre `ozali sync` primero para crear la estructura |
86
+ | No se detecta Obsidian | Instalado en path no estándar | Ignora la advertencia; abre el vault manualmente |
87
+
88
+ ## Cerebro vs Vault
89
+
90
+ - **Cerebro** (`AI.md` + `.ai/`) vive en **cada repo** — es la fuente de verdad.
91
+ - **Vault** (`obsidian/`) vive en el **repo de conocimiento** — es la lectura humana acumulada.
92
+
93
+ No duplicamos el cerebro en el vault. Los MOCs usan **markdown links** para referenciar `AI.md` fuera del vault, y **wikilinks** para todo lo que está dentro (`Memoria/`, `MOCs/`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ozali",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "Bootstrap interactivo de IA por equipo: calibra el proyecto, genera la skill de ejecución (cdk) con TDD/SDD, y mantiene memoria de equipo (Engram) con histórico aislado.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,12 @@
1
+ {
2
+ "alwaysUpdateLinks": true,
3
+ "newFileLocation": "folder",
4
+ "newFileFolderPath": "Memoria",
5
+ "attachmentFolderPath": "Memoria",
6
+ "readableLineLength": true,
7
+ "strictLineBreaks": false,
8
+ "showLineNumber": false,
9
+ "spellcheck": false,
10
+ "tabSize": 2,
11
+ "useTab": false
12
+ }
@@ -0,0 +1,9 @@
1
+ [
2
+ "graph",
3
+ "backlink",
4
+ "page-preview",
5
+ "command-palette",
6
+ "markdown-importer",
7
+ "switcher",
8
+ "outline"
9
+ ]
@@ -0,0 +1,9 @@
1
+ # 🗺️ Bugfixes Recientes
2
+
3
+ Correcciones con impacto en otros módulos o que revelan gotchas del repo.
4
+
5
+ > Este MOC se regenera automáticamente con `ozali sync --obsidian`.
6
+
7
+ <!-- BUGFIXES_START -->
8
+ <!-- Se rellena automáticamente por ozali sync --obsidian desde Engram -->
9
+ <!-- BUGFIXES_END -->
@@ -0,0 +1,9 @@
1
+ # 🗺️ Decisiones del Equipo
2
+
3
+ Decisiones de arquitectura, trade-offs y convenciones que afectan a todos.
4
+
5
+ > Este MOC se regenera automáticamente con `ozali sync --obsidian`.
6
+
7
+ <!-- DECISIONS_START -->
8
+ <!-- Se rellena automáticamente por ozali sync --obsidian desde Engram -->
9
+ <!-- DECISIONS_END -->
@@ -0,0 +1,9 @@
1
+ # 🗺️ Métricas y Tokens
2
+
3
+ Resumen agregado de uso de tokens y eficiencia del equipo.
4
+
5
+ > Este MOC se regenera automáticamente con `ozali sync --obsidian`.
6
+
7
+ <!-- METRICS_START -->
8
+ <!-- Se rellena automáticamente por ozali sync --obsidian desde Engram -->
9
+ <!-- METRICS_END -->
@@ -0,0 +1,17 @@
1
+ # 🗺️ Proyectos
2
+
3
+ Proyectos activos en el workspace ozali.
4
+
5
+ > Este MOC se regenera automáticamente con `ozali sync --obsidian`.
6
+
7
+ ## Activos
8
+
9
+ <!-- PROJECTS_START -->
10
+ <!-- Se rellena automáticamente por ozali sync --obsidian -->
11
+ <!-- PROJECTS_END -->
12
+
13
+ ## Agregar un nuevo proyecto
14
+
15
+ 1. Corre `ozali init` en el repo del proyecto.
16
+ 2. Corre `ozali sync --obsidian` desde cualquier proyecto.
17
+ 3. El proyecto aparecerá aquí automáticamente.
@@ -0,0 +1,19 @@
1
+ # 🗺️ Índice Global
2
+
3
+ Bienvenido al vault de ozali. Usa este índice para navegar la memoria del equipo.
4
+
5
+ ## Mapas de Contenido (MOCs)
6
+
7
+ - [[MOCs/Proyectos]] — Todos los proyectos del equipo
8
+ - [[MOCs/Decisiones del Equipo]] — Decisiones arquitectónicas y trade-offs
9
+ - [[MOCs/Bugfixes Recientes]] — Correcciones con impacto cruzado
10
+ - [[MOCs/Métricas y Tokens]] — Consumo de tokens y eficiencia
11
+
12
+ ## Cómo actualizar este vault
13
+
14
+ ```bash
15
+ # Desde cualquier proyecto ozali
16
+ ozali sync --obsidian
17
+ ```
18
+
19
+ > El vault se regenera automáticamente. No edites `Memoria/` a mano.
@@ -0,0 +1,22 @@
1
+ # Vault de Ozali — Memoria del Equipo
2
+
3
+ Este vault contiene la memoria acumulada de los proyectos gestionados con ozali.
4
+ Se actualiza automáticamente con `ozali sync --obsidian`.
5
+
6
+ ## 📁 Estructura
7
+
8
+ - [[MOCs/Índice Global]] — Navegación principal
9
+ - [[MOCs/Proyectos]] — Lista de proyectos activos
10
+ - [[MOCs/Decisiones del Equipo]] — Decisiones de arquitectura y trade-offs
11
+ - [[MOCs/Bugfixes Recientes]] — Correcciones que afectan a otros
12
+ - [[MOCs/Métricas y Tokens]] — Uso de tokens y ahorro por recall
13
+ - `Memoria/` — Exportación de Engram (decisiones, bugfixes, métricas)
14
+
15
+ ## 🔗 Fuentes de verdad (cerebros)
16
+
17
+ Los documentos de arquitectura y reglas de cada proyecto viven en su repo:
18
+
19
+ - Abre el workspace en tu editor para acceder al `AI.md` / `IA.md` de cada proyecto.
20
+
21
+ > No edites los archivos en `Memoria/` manualmente — se regeneran automáticamente.
22
+ > Usa `MOCs/` y `Daily/` para tus notas personales.