ozali 0.7.1 → 0.9.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 +12 -0
- package/cli/bin/ozali.mjs +8 -2
- package/cli/lib/commands.mjs +254 -9
- package/cli/lib/detect.mjs +42 -3
- package/cli/lib/util.mjs +55 -0
- package/cli/test/smoke.test.mjs +91 -1
- package/docs/compilado-local.md +182 -0
- package/docs/guia-de-uso.md +50 -0
- package/docs/obsidian-integration.md +93 -0
- package/docs/workspaces.md +19 -5
- package/package.json +1 -1
- package/skill/SKILL.md +22 -0
- package/templates/obsidian-vault/.obsidian/app.json +12 -0
- package/templates/obsidian-vault/.obsidian/core-plugins.json +9 -0
- package/templates/obsidian-vault/MOCs/Bugfixes Recientes.md +9 -0
- package/templates/obsidian-vault/MOCs/Decisiones del Equipo.md +9 -0
- package/templates/obsidian-vault/MOCs/M/303/251tricas y Tokens.md" +9 -0
- package/templates/obsidian-vault/MOCs/Proyectos.md +17 -0
- package/templates/obsidian-vault/MOCs//303/215ndice Global.md" +19 -0
- package/templates/obsidian-vault/README.md +22 -0
- package/templates/ozali-workspace-jarvis.md +57 -13
package/README.md
CHANGED
|
@@ -80,7 +80,10 @@ referencian entre sí, corre **una vez** en esa raíz:
|
|
|
80
80
|
```bash
|
|
81
81
|
ozali workspace # escanea repos hijos, remedia los que faltan y escribe la config conjunta
|
|
82
82
|
ozali workspace --dry-run # solo muestra el inventario y el plan, sin escribir
|
|
83
|
+
ozali workspace --yes # no interactivo: acepta defaults y todas las referencias detectadas
|
|
83
84
|
ozali workspace --depth 2 # busca repos hasta 2 niveles (para raíces con subcarpetas de grupo)
|
|
85
|
+
ozali workspace --doctor # health-check de TODOS los repos miembros + resumen (solo CLI)
|
|
86
|
+
ozali workspace --update # actualiza skills/permisos/jarvis de TODOS los repos miembros (solo CLI)
|
|
84
87
|
```
|
|
85
88
|
|
|
86
89
|
Qué hace, en orden: (1) **escanea** los repos hijos y reporta su estado —`✔ listo`, `⚠ sin calibrar`
|
|
@@ -93,6 +96,13 @@ contigo; y (4) escribe la config para **trabajar en conjunto**:
|
|
|
93
96
|
- `<carpeta>.code-workspace` — workspace multi-root que abre todos los repos juntos.
|
|
94
97
|
- Orquestador **`ozali-workspace-jarvis`** en `CLAUDE.md`/`AGENTS.md` de la raíz: coordina los repos
|
|
95
98
|
según el manifiesto y delega la ejecución en el `cdk` de cada uno.
|
|
99
|
+
- La skill **`ozali`** en la raíz (`.claude/skills/ozali/`) para **calibrar los miembros desde el
|
|
100
|
+
workspace** (modo target), sin abrir cada proyecto.
|
|
101
|
+
|
|
102
|
+
**Operar todo desde la raíz:** `ozali workspace --doctor` y `--update` corren el health-check y la
|
|
103
|
+
actualización sobre **todos** los repos miembros (solo CLI). Y para calibrar los `⚠ sin calibrar`,
|
|
104
|
+
abre el agente en la raíz y pídele a `ozali-workspace-jarvis` que *"calibre los repos pendientes"*: los
|
|
105
|
+
recorre uno por uno (con su GATE) sin que cambies de proyecto.
|
|
96
106
|
|
|
97
107
|
Es **idempotente**: re-córrelo cuando agregues repos o cambien las referencias. Detalle completo en
|
|
98
108
|
[`docs/workspaces.md`](docs/workspaces.md).
|
|
@@ -152,10 +162,12 @@ Claude Code y opencode (perfiles de permisos para ambos en
|
|
|
152
162
|
|---|---|
|
|
153
163
|
| Guía de uso (usuarios generales) | [docs/guia-de-uso.md](docs/guia-de-uso.md) |
|
|
154
164
|
| Uso previsto (modelo mental) | [docs/intended-usage.md](docs/intended-usage.md) |
|
|
165
|
+
| Compilado y uso local (repo clonado) | [docs/compilado-local.md](docs/compilado-local.md) |
|
|
155
166
|
| Seguridad del instalador (npx/pnpm) | [docs/security.md](docs/security.md) |
|
|
156
167
|
| Histórico aislado y memoria de equipo | [docs/team-history.md](docs/team-history.md) |
|
|
157
168
|
| Desplegar Engram Cloud (VPS) | [docs/deploy-cloud-vps.md](docs/deploy-cloud-vps.md) |
|
|
158
169
|
| Desplegar Engram Cloud (Google Cloud) | [docs/deploy-cloud-gcloud.md](docs/deploy-cloud-gcloud.md) |
|
|
170
|
+
| Integración con Obsidian | [docs/obsidian-integration.md](docs/obsidian-integration.md) |
|
|
159
171
|
| Skill bootstrap | [skill/SKILL.md](skill/SKILL.md) |
|
|
160
172
|
| Calibración de testing + TDD | [skill/references/calibration-blueprint.md](skill/references/calibration-blueprint.md) |
|
|
161
173
|
| Contrato y versión de `cdk` | [skill/references/cdk-contract.md](skill/references/cdk-contract.md) |
|
package/cli/bin/ozali.mjs
CHANGED
|
@@ -16,6 +16,7 @@ ${c.bold("Comandos:")}
|
|
|
16
16
|
ozali-commit, aísla el histórico, configura Engram y el repo de conocimiento.
|
|
17
17
|
workspace Multi-repo: escanea los repos de la carpeta raíz, remedia los que no tienen
|
|
18
18
|
ozali init, guía la calibración y escribe la config para trabajar en conjunto.
|
|
19
|
+
Con --doctor / --update opera sobre TODOS los repos miembros desde la raíz.
|
|
19
20
|
doctor Health-check read-only del proyecto (fuente de verdad, Engram, versión de cdk, TDD…).
|
|
20
21
|
update Actualiza la instalación (skills ozali + ozali-commit + ozali-jarvis + permisos)
|
|
21
22
|
al paquete y avisa si la skill cdk quedó desactualizada.
|
|
@@ -29,6 +30,8 @@ ${c.bold("Opciones comunes:")}
|
|
|
29
30
|
--agent <a> (init) claude-code | opencode | both.
|
|
30
31
|
--scope <s> (init) project | global.
|
|
31
32
|
--depth <n> (workspace) Niveles a escanear bajo la raíz (default 1).
|
|
33
|
+
--doctor (workspace) Health-check de TODOS los repos miembros + resumen.
|
|
34
|
+
--update (workspace) Actualiza (skills/permisos/jarvis) TODOS los repos miembros.
|
|
32
35
|
--knowledge-repo <p> (init) Ruta del repo de conocimiento.
|
|
33
36
|
--no-engram (init) No usar Engram; arranca en modo docs.
|
|
34
37
|
--no-trust (init) No marcar el workspace como confiable en Claude Code.
|
|
@@ -60,6 +63,7 @@ function parseArgs(argv) {
|
|
|
60
63
|
else if (a === "--import") opts.import = true;
|
|
61
64
|
else if (a === "--push") opts.push = true;
|
|
62
65
|
else if (a === "--cloud") opts.cloud = true;
|
|
66
|
+
else if (a === "--obsidian") opts.obsidian = true;
|
|
63
67
|
else if (a === "--no-engram") opts.noEngram = true;
|
|
64
68
|
else if (a === "--no-trust") opts.noTrust = true;
|
|
65
69
|
else if (a === "--no-jarvis") opts.noJarvis = true;
|
|
@@ -73,6 +77,8 @@ function parseArgs(argv) {
|
|
|
73
77
|
else if (a === "--agent") opts.agent = argv[++i];
|
|
74
78
|
else if (a === "--scope") opts.scope = argv[++i];
|
|
75
79
|
else if (a === "--depth") opts.depth = argv[++i];
|
|
80
|
+
else if (a === "--doctor") opts.wsDoctor = true;
|
|
81
|
+
else if (a === "--update") opts.wsUpdate = true;
|
|
76
82
|
else if (a === "--knowledge-repo") opts.knowledgeRepo = argv[++i];
|
|
77
83
|
else if (a === "-h" || a === "--help") opts.help = true;
|
|
78
84
|
else if (a === "-v" || a === "--version") opts.version = true;
|
|
@@ -95,8 +101,8 @@ async function main() {
|
|
|
95
101
|
case "init": return await init(cwd, opts);
|
|
96
102
|
case "workspace": return await workspace(cwd, opts);
|
|
97
103
|
case "doctor": return doctor(cwd);
|
|
98
|
-
case "update": return update(cwd, opts);
|
|
99
|
-
case "sync": return sync(cwd, opts);
|
|
104
|
+
case "update": return await update(cwd, opts);
|
|
105
|
+
case "sync": return await sync(cwd, opts);
|
|
100
106
|
case "audit": return await audit(cwd, opts);
|
|
101
107
|
case "cloud": return await cloud(cwd, opts);
|
|
102
108
|
default:
|
package/cli/lib/commands.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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.
|
|
@@ -793,6 +879,10 @@ export async function workspace(cwd, opts = {}) {
|
|
|
793
879
|
step("Repos detectados");
|
|
794
880
|
printMembers(ws.members);
|
|
795
881
|
|
|
882
|
+
// Modos batch (Track 1): operan sobre los miembros del workspace ya existente y salen.
|
|
883
|
+
if (opts.wsDoctor) return workspaceDoctor(ws.members);
|
|
884
|
+
if (opts.wsUpdate) return await workspaceUpdate(ws.members, opts);
|
|
885
|
+
|
|
796
886
|
// Fase B — remediación de los que no tienen ozali init
|
|
797
887
|
const missing = ws.members.filter((m) => m.status === "missing-init");
|
|
798
888
|
if (missing.length && opts.dryRun) {
|
|
@@ -803,7 +893,7 @@ export async function workspace(cwd, opts = {}) {
|
|
|
803
893
|
const shared = {
|
|
804
894
|
agent: opts.agent || (base && base.agent),
|
|
805
895
|
scope: opts.scope || (base && base.scope),
|
|
806
|
-
knowledgeRepo: opts.knowledgeRepo || (base && base.knowledgeRepo),
|
|
896
|
+
knowledgeRepo: fromPortablePath(opts.knowledgeRepo || (base && base.knowledgeRepo), root),
|
|
807
897
|
};
|
|
808
898
|
for (const m of missing) {
|
|
809
899
|
const go = opts.yes ? true : await confirm(`¿Correr ${c.bold("ozali init")} en ${c.bold(m.dir)}?`, true);
|
|
@@ -836,6 +926,7 @@ export async function workspace(cwd, opts = {}) {
|
|
|
836
926
|
const agent = manifest.agent;
|
|
837
927
|
if (agent === "claude-code" || agent === "both") {
|
|
838
928
|
ensureWorkspaceJarvisClaudeCode(cwd);
|
|
929
|
+
ensureWorkspaceOzaliSkill(cwd); // Track 2: skill ozali en la raíz → calibrar miembros desde el workspace
|
|
839
930
|
if (!opts.noTrust) await ensureClaudeWorkspaceTrust(cwd, opts);
|
|
840
931
|
}
|
|
841
932
|
if (agent === "opencode" || agent === "both") ensureWorkspaceJarvisOpencode(cwd);
|
|
@@ -849,11 +940,68 @@ export async function workspace(cwd, opts = {}) {
|
|
|
849
940
|
step("Siguientes pasos");
|
|
850
941
|
const wsFile = `${path.basename(cwd)}.code-workspace`;
|
|
851
942
|
console.log(` 1. Abre el workspace en tu editor: ${c.bold(wsFile)} ${c.dim("(VSCode/Antigravity → Open Workspace).")}`);
|
|
852
|
-
if (needCal.length)
|
|
943
|
+
if (needCal.length) {
|
|
944
|
+
console.log(` 2. Calibra los pendientes (${c.bold(needCal.map((m) => m.dir).join(", "))}) ${c.dim("sin salir del workspace:")}`);
|
|
945
|
+
console.log(` ${c.dim("abre el agente en la raíz y pide a")} ${c.bold("ozali-workspace-jarvis")} ${c.dim('que "calibre los repos pendientes"')}`);
|
|
946
|
+
console.log(` ${c.dim("(usa la skill")} ${c.bold("ozali")} ${c.dim("en modo target, repo por repo con su GATE).")}`);
|
|
947
|
+
}
|
|
948
|
+
console.log(` ${c.dim("• Salud de todos los repos:")} ${c.bold("ozali workspace --doctor")}`);
|
|
949
|
+
console.log(` ${c.dim("• Actualizar todos los repos:")} ${c.bold("ozali workspace --update")}`);
|
|
853
950
|
console.log(` ${c.dim("Re-corre")} ${c.bold("ozali workspace")} ${c.dim("cuando agregues repos o cambien las referencias (es idempotente).")}`);
|
|
854
951
|
return 0;
|
|
855
952
|
}
|
|
856
953
|
|
|
954
|
+
/** Track 1 — health-check de todos los miembros (doctor por repo) + resumen consolidado. */
|
|
955
|
+
function workspaceDoctor(members) {
|
|
956
|
+
const results = [];
|
|
957
|
+
for (const m of members) {
|
|
958
|
+
console.log("");
|
|
959
|
+
console.log(c.bold(c.magenta(`── ${m.dir} ──`)));
|
|
960
|
+
if (m.status === "missing-init") {
|
|
961
|
+
warn(`Sin ozali init → córrelo (o re-corre ${c.bold("ozali workspace")}).`);
|
|
962
|
+
results.push({ dir: m.dir, ok: false, note: "sin init" });
|
|
963
|
+
continue;
|
|
964
|
+
}
|
|
965
|
+
const code = doctor(m.path);
|
|
966
|
+
results.push({ dir: m.dir, ok: code === 0, note: code === 0 ? "todo en orden" : "puntos a atender" });
|
|
967
|
+
}
|
|
968
|
+
step("Resumen del workspace");
|
|
969
|
+
const pad = Math.max(4, ...members.map((m) => m.dir.length));
|
|
970
|
+
for (const r of results) {
|
|
971
|
+
console.log(` ${r.ok ? c.green("✔") : c.yellow("✖")} ${r.dir.padEnd(pad)} ${c.dim(r.note)}`);
|
|
972
|
+
}
|
|
973
|
+
return results.every((r) => r.ok) ? 0 : 1;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** Track 1 — update de todos los miembros ozali (skills/permisos/jarvis) + resumen. */
|
|
977
|
+
async function workspaceUpdate(members, opts) {
|
|
978
|
+
const results = [];
|
|
979
|
+
let failed = 0;
|
|
980
|
+
for (const m of members) {
|
|
981
|
+
console.log("");
|
|
982
|
+
console.log(c.bold(c.magenta(`── ${m.dir} ──`)));
|
|
983
|
+
if (m.status === "missing-init") {
|
|
984
|
+
warn("Sin ozali init → nada que actualizar (córrelo primero).");
|
|
985
|
+
results.push({ dir: m.dir, mark: c.yellow("—"), note: "sin init (saltado)" });
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
const code = await update(m.path, opts);
|
|
989
|
+
if (code !== 0) failed++;
|
|
990
|
+
results.push({ dir: m.dir, mark: code === 0 ? c.green("✔") : c.yellow("✖"), note: code === 0 ? "actualizado" : "revisar" });
|
|
991
|
+
}
|
|
992
|
+
step("Resumen del workspace");
|
|
993
|
+
const pad = Math.max(4, ...members.map((m) => m.dir.length));
|
|
994
|
+
for (const r of results) console.log(` ${r.mark} ${r.dir.padEnd(pad)} ${c.dim(r.note)}`);
|
|
995
|
+
info(`La skill ${c.bold("cdk")} la regenera el agente. Re-corre ${c.bold("ozali workspace")} para refrescar estados.`);
|
|
996
|
+
return failed > 0 ? 1 : 0;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/** Track 2 — instala la skill `ozali` en la raíz para calibrar miembros desde el workspace. */
|
|
1000
|
+
function ensureWorkspaceOzaliSkill(root) {
|
|
1001
|
+
copyDir(SKILL_SRC, path.join(root, ".claude", "skills", "ozali"));
|
|
1002
|
+
ok(`Skill ${c.bold("ozali")} instalada en la raíz (${c.bold(".claude/skills/ozali")}) para calibrar miembros desde el workspace.`);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
857
1005
|
/** Primer .ozali/config.json entre los miembros ya inicializados (para heredar defaults). */
|
|
858
1006
|
function inheritedConfig(members) {
|
|
859
1007
|
for (const m of members) {
|
|
@@ -894,7 +1042,10 @@ function writeWorkspaceManifest(root, members, references, opts) {
|
|
|
894
1042
|
version: pkgVersion(),
|
|
895
1043
|
root,
|
|
896
1044
|
agent,
|
|
897
|
-
knowledgeRepo:
|
|
1045
|
+
knowledgeRepo: toPortablePath(
|
|
1046
|
+
opts.knowledgeRepo || existing.knowledgeRepo || base.knowledgeRepo || DEFAULT_KNOWLEDGE,
|
|
1047
|
+
root
|
|
1048
|
+
),
|
|
898
1049
|
cloud: base.cloud || existing.cloud || { enabled: false },
|
|
899
1050
|
members: members.map((m) => ({ path: m.dir, project: m.project, status: m.status, sot: m.sot.found ? m.sot.variant : null })),
|
|
900
1051
|
references: references.map((e) => ({ from: e.fromDir, to: e.toDir, kind: e.kind })),
|
|
@@ -994,7 +1145,9 @@ export function doctor(cwd) {
|
|
|
994
1145
|
cloudMeta && cloudMeta.dashboard ? c.cyan(cloudMeta.dashboard) : ""].filter(Boolean).join(" · ")
|
|
995
1146
|
: "off (opt-in, git-sync activo)";
|
|
996
1147
|
add("Engram Cloud", true, cloudDetail);
|
|
997
|
-
|
|
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)");
|
|
998
1151
|
|
|
999
1152
|
// Strict TDD (de la fuente de verdad)
|
|
1000
1153
|
const tdd = readStrictTdd(cwd, env.sot);
|
|
@@ -1104,7 +1257,7 @@ function readStrictTdd(cwd, sot) {
|
|
|
1104
1257
|
// references), los perfiles de permisos y **crea/refresca ozali-jarvis** (clave para repos
|
|
1105
1258
|
// inicializados antes de 0.4.0). La skill `cdk` la regenera el AGENTE (no el CLI): se detecta
|
|
1106
1259
|
// y se guía la regeneración.
|
|
1107
|
-
export function update(cwd, opts = {}) {
|
|
1260
|
+
export async function update(cwd, opts = {}) {
|
|
1108
1261
|
step("ozali update — actualizar la instalación al paquete actual");
|
|
1109
1262
|
const env = detectAll(cwd);
|
|
1110
1263
|
const cfgPath = CONFIG_PATH(cwd);
|
|
@@ -1170,6 +1323,37 @@ export function update(cwd, opts = {}) {
|
|
|
1170
1323
|
info("cdk aún no generada en este repo. Corre la skill " + c.bold("ozali") + " en tu agente para crearla.");
|
|
1171
1324
|
}
|
|
1172
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
|
+
|
|
1173
1357
|
// 5) versión del config
|
|
1174
1358
|
if (cfg) { cfg.version = pkgVersion(); cfg.updatedAt = new Date().toISOString(); writeJSON(cfgPath, cfg); }
|
|
1175
1359
|
ok(`Instalación al día con ozali v${pkgVersion()}.`);
|
|
@@ -1212,11 +1396,11 @@ function cdkCanonicalVersion() {
|
|
|
1212
1396
|
}
|
|
1213
1397
|
|
|
1214
1398
|
// ============================================================= sync ===========
|
|
1215
|
-
export function sync(cwd, opts) {
|
|
1399
|
+
export async function sync(cwd, opts) {
|
|
1216
1400
|
step(`ozali sync${opts.import ? " --import" : ""}${opts.cloud ? " --cloud" : ""} — histórico ↔ repo de conocimiento`);
|
|
1217
1401
|
const cfg = readJSON(CONFIG_PATH(cwd));
|
|
1218
1402
|
if (!cfg || !cfg.knowledgeRepo) { warn("Sin repo de conocimiento configurado. Corre " + c.bold("ozali init") + "."); return 1; }
|
|
1219
|
-
const kRepo = cfg.knowledgeRepo;
|
|
1403
|
+
const kRepo = fromPortablePath(cfg.knowledgeRepo, cwd);
|
|
1220
1404
|
if (!exists(kRepo)) { err(`El repo de conocimiento no existe: ${kRepo}`); return 1; }
|
|
1221
1405
|
const project = cfg.project || projectName(cwd);
|
|
1222
1406
|
const projDir = path.join(kRepo, "projects", project);
|
|
@@ -1321,6 +1505,16 @@ export function sync(cwd, opts) {
|
|
|
1321
1505
|
if (exists(docsLocal)) { copyDir(docsLocal, path.join(projDir, "docs")); ok(`Docs copiados a projects/${project}/docs/.`); }
|
|
1322
1506
|
else info("No hay .ozali/docs/ que sincronizar todavía.");
|
|
1323
1507
|
|
|
1508
|
+
// 2.5) Obsidian vault export
|
|
1509
|
+
const vaultPath = path.join(kRepo, "obsidian");
|
|
1510
|
+
if (opts.obsidian) {
|
|
1511
|
+
await exportObsidianVault(kRepo, project, vaultPath);
|
|
1512
|
+
} else if (exists(vaultPath) && !opts.yes) {
|
|
1513
|
+
if (await confirm("¿Exportar memoria a Obsidian vault?", true)) {
|
|
1514
|
+
await exportObsidianVault(kRepo, project, vaultPath);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1324
1518
|
// 3) commit (push solo si hay remoto)
|
|
1325
1519
|
if (exists(path.join(kRepo, ".git"))) {
|
|
1326
1520
|
tryExec("git", ["add", "-A"], { cwd: kRepo });
|
|
@@ -1338,6 +1532,57 @@ export function sync(cwd, opts) {
|
|
|
1338
1532
|
return 0;
|
|
1339
1533
|
}
|
|
1340
1534
|
|
|
1535
|
+
/**
|
|
1536
|
+
* Exporta la memoria de Engram a un vault de Obsidian compatible.
|
|
1537
|
+
* 1) Copia templates base si no existen.
|
|
1538
|
+
* 2) Genera MOCs dinámicos (proyectos) desde knowledgeRepo/projects/.
|
|
1539
|
+
* 3) Ejecuta `engram obsidian-export`.
|
|
1540
|
+
*/
|
|
1541
|
+
async function exportObsidianVault(kRepo, project, vaultPath) {
|
|
1542
|
+
if (!tryExec("engram", ["--version"])) {
|
|
1543
|
+
warn("Engram no está disponible. No se puede exportar a Obsidian.");
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
ensureDir(vaultPath);
|
|
1547
|
+
// 1) Templates base
|
|
1548
|
+
const templateDir = path.join(TEMPLATES_SRC, "obsidian-vault");
|
|
1549
|
+
if (exists(templateDir)) {
|
|
1550
|
+
for (const entry of fs.readdirSync(templateDir, { withFileTypes: true })) {
|
|
1551
|
+
const src = path.join(templateDir, entry.name);
|
|
1552
|
+
const dst = path.join(vaultPath, entry.name);
|
|
1553
|
+
if (entry.isDirectory()) {
|
|
1554
|
+
if (!exists(dst)) copyDir(src, dst);
|
|
1555
|
+
} else if (!exists(dst)) {
|
|
1556
|
+
fs.copyFileSync(src, dst);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
// 2) MOC dinámico — Proyectos
|
|
1561
|
+
const projectsDir = path.join(kRepo, "projects");
|
|
1562
|
+
const projectsMoc = path.join(vaultPath, "MOCs", "Proyectos.md");
|
|
1563
|
+
if (exists(projectsDir)) {
|
|
1564
|
+
const projects = [];
|
|
1565
|
+
for (const entry of fs.readdirSync(projectsDir, { withFileTypes: true })) {
|
|
1566
|
+
if (entry.isDirectory()) projects.push(entry.name);
|
|
1567
|
+
}
|
|
1568
|
+
const list = projects.map((p) => `- [[${p}]] — proyecto activo`).join("\n");
|
|
1569
|
+
const body = fs.readFileSync(projectsMoc, "utf8");
|
|
1570
|
+
const updated = body.replace(
|
|
1571
|
+
/<!-- PROJECTS_START -->[\s\S]*?<!-- PROJECTS_END -->/,
|
|
1572
|
+
`<!-- PROJECTS_START -->\n${list || "- Sin proyectos activos todavía."}\n<!-- PROJECTS_END -->`
|
|
1573
|
+
);
|
|
1574
|
+
fs.writeFileSync(projectsMoc, updated);
|
|
1575
|
+
}
|
|
1576
|
+
// 3) Export Engram
|
|
1577
|
+
info("Exportando memoria a Obsidian vault…");
|
|
1578
|
+
const out = tryExec("engram", ["obsidian-export", "--vault", vaultPath, "--project", project, "--graph-config", "preserve"]);
|
|
1579
|
+
if (out !== null) {
|
|
1580
|
+
ok("Obsidian vault actualizado.");
|
|
1581
|
+
} else {
|
|
1582
|
+
warn("engram obsidian-export falló. Revisa que el vault esté cerrado en Obsidian.");
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1341
1586
|
// ============================================================ audit ===========
|
|
1342
1587
|
// Navega/audita la memoria de Engram: del proyecto actual o general (todos los
|
|
1343
1588
|
// proyectos). Sin contexto de proyecto en la ruta → general. Sin Engram → audita
|
package/cli/lib/detect.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// detect.mjs — detección read-only del entorno del proyecto destino.
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { exists, which, gitInfo, nodeMajor, HOME, readJSON, projectName, DEFAULT_KNOWLEDGE } from "./util.mjs";
|
|
4
|
+
import { exists, which, gitInfo, tryExec, nodeMajor, HOME, readJSON, projectName, DEFAULT_KNOWLEDGE } from "./util.mjs";
|
|
5
5
|
|
|
6
6
|
/** Variante de fuente de verdad: {found, doc, dir, variant} */
|
|
7
7
|
export function detectSourceOfTruth(cwd) {
|
|
@@ -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
|
};
|
|
@@ -121,9 +148,21 @@ function isKnowledgeRepo(dir) {
|
|
|
121
148
|
return d === k || d.startsWith(k + path.sep);
|
|
122
149
|
}
|
|
123
150
|
|
|
151
|
+
/**
|
|
152
|
+
* ¿`dir` es la RAÍZ de su propio repo git? No basta con estar dentro de un work tree
|
|
153
|
+
* (una subcarpeta de un repo también lo está): exigimos que el toplevel de git sea `dir`.
|
|
154
|
+
* Así, correr `ozali workspace` dentro de un repo NO trata sus subcarpetas como miembros.
|
|
155
|
+
*/
|
|
156
|
+
function isRepoRoot(dir) {
|
|
157
|
+
const top = tryExec("git", ["-C", dir, "rev-parse", "--show-toplevel"]);
|
|
158
|
+
if (!top) return false;
|
|
159
|
+
try { return fs.realpathSync(top) === fs.realpathSync(dir); } catch { return false; }
|
|
160
|
+
}
|
|
161
|
+
|
|
124
162
|
/**
|
|
125
163
|
* Junta las rutas de repos git bajo `root` hasta `depth` niveles. Un directorio que
|
|
126
|
-
* ES repo git se trata como hoja (no se desciende dentro). Salta
|
|
164
|
+
* ES la raíz de su propio repo git se trata como hoja (no se desciende dentro). Salta
|
|
165
|
+
* ocultos/ignorados.
|
|
127
166
|
*/
|
|
128
167
|
function collectRepoDirs(root, depth, level = 1, acc = []) {
|
|
129
168
|
let entries;
|
|
@@ -133,7 +172,7 @@ function collectRepoDirs(root, depth, level = 1, acc = []) {
|
|
|
133
172
|
if (e.name.startsWith(".") || IGNORE_DIRS.has(e.name)) continue;
|
|
134
173
|
const full = path.join(root, e.name);
|
|
135
174
|
if (isKnowledgeRepo(full)) continue;
|
|
136
|
-
if (
|
|
175
|
+
if (isRepoRoot(full)) { acc.push(full); continue; } // repo propio = hoja
|
|
137
176
|
if (level < depth) collectRepoDirs(full, depth, level + 1, acc);
|
|
138
177
|
}
|
|
139
178
|
return acc;
|
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");
|