ozali 0.6.1 → 0.7.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
@@ -60,6 +60,7 @@ git clone <repo> && node ozali/cli/bin/ozali.mjs init
60
60
 
61
61
  ```bash
62
62
  ozali init # detecta agente, instala skills ozali + ozali-commit, aísla histórico, configura Engram
63
+ ozali workspace # multi-repo: escanea la carpeta raíz, remedia init/calibración y cablea la config conjunta
63
64
  ozali doctor # health-check read-only (fuente de verdad, Engram, Cloud, versión de cdk, Strict TDD…)
64
65
  ozali update # actualiza skills ozali + ozali-commit + ozali-jarvis + permisos; avisa si cdk quedó atrás
65
66
  ozali sync # lleva el histórico (docs + Engram) al repo de conocimiento de equipo
@@ -71,6 +72,31 @@ ozali audit # navega/audita la memoria de Engram del proyecto (o general)
71
72
  `--tui` para el navegador interactivo, `--search "<texto>"` para buscar y `--general` para forzar el
72
73
  alcance. Sin Engram, audita el histórico local de `.ozali/docs/`.
73
74
 
75
+ ### Workspaces multi-repo
76
+
77
+ Cuando abres en tu editor (VSCode, **Antigravity**) una **carpeta raíz con varios repos** que se
78
+ referencian entre sí, corre **una vez** en esa raíz:
79
+
80
+ ```bash
81
+ ozali workspace # escanea repos hijos, remedia los que faltan y escribe la config conjunta
82
+ ozali workspace --dry-run # solo muestra el inventario y el plan, sin escribir
83
+ ozali workspace --depth 2 # busca repos hasta 2 niveles (para raíces con subcarpetas de grupo)
84
+ ```
85
+
86
+ Qué hace, en orden: (1) **escanea** los repos hijos y reporta su estado —`✔ listo`, `⚠ sin calibrar`
87
+ (falta `cdk`) o `✖ sin init`—; (2) **remedia** con `ozali init` los que no lo tienen y te **guía** para
88
+ calibrar (correr la skill `ozali` en cada repo — eso lo hace el agente, no el CLI); (3) **infiere las
89
+ referencias** entre repos (dependencias npm cruzadas, submódulos git, `docker-compose`) y las confirma
90
+ contigo; y (4) escribe la config para **trabajar en conjunto**:
91
+
92
+ - `ozali-workspace.json` — manifiesto de miembros, estado y referencias.
93
+ - `<carpeta>.code-workspace` — workspace multi-root que abre todos los repos juntos.
94
+ - Orquestador **`ozali-workspace-jarvis`** en `CLAUDE.md`/`AGENTS.md` de la raíz: coordina los repos
95
+ según el manifiesto y delega la ejecución en el `cdk` de cada uno.
96
+
97
+ Es **idempotente**: re-córrelo cuando agregues repos o cambien las referencias. Detalle completo en
98
+ [`docs/workspaces.md`](docs/workspaces.md).
99
+
74
100
  ### Actualizar a una versión nueva
75
101
 
76
102
  ```bash
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 } from "../lib/commands.mjs";
6
+ import { init, doctor, update, sync, audit, cloud, workspace } 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,8 @@ ${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
+ workspace Multi-repo: escanea los repos de la carpeta raíz, remedia los que no tienen
18
+ ozali init, guía la calibración y escribe la config para trabajar en conjunto.
17
19
  doctor Health-check read-only del proyecto (fuente de verdad, Engram, versión de cdk, TDD…).
18
20
  update Actualiza la instalación (skills ozali + ozali-commit + ozali-jarvis + permisos)
19
21
  al paquete y avisa si la skill cdk quedó desactualizada.
@@ -26,6 +28,7 @@ ${c.bold("Opciones comunes:")}
26
28
  --dry-run (init) Muestra el plan sin escribir nada.
27
29
  --agent <a> (init) claude-code | opencode | both.
28
30
  --scope <s> (init) project | global.
31
+ --depth <n> (workspace) Niveles a escanear bajo la raíz (default 1).
29
32
  --knowledge-repo <p> (init) Ruta del repo de conocimiento.
30
33
  --no-engram (init) No usar Engram; arranca en modo docs.
31
34
  --no-trust (init) No marcar el workspace como confiable en Claude Code.
@@ -69,6 +72,7 @@ function parseArgs(argv) {
69
72
  else if (a === "--stats") opts.stats = true;
70
73
  else if (a === "--agent") opts.agent = argv[++i];
71
74
  else if (a === "--scope") opts.scope = argv[++i];
75
+ else if (a === "--depth") opts.depth = argv[++i];
72
76
  else if (a === "--knowledge-repo") opts.knowledgeRepo = argv[++i];
73
77
  else if (a === "-h" || a === "--help") opts.help = true;
74
78
  else if (a === "-v" || a === "--version") opts.version = true;
@@ -89,6 +93,7 @@ async function main() {
89
93
  console.log(`${c.bold("ozali")} ${c.dim("v" + pkgVersion())}`);
90
94
  switch (cmd) {
91
95
  case "init": return await init(cwd, opts);
96
+ case "workspace": return await workspace(cwd, opts);
92
97
  case "doctor": return doctor(cwd);
93
98
  case "update": return update(cwd, opts);
94
99
  case "sync": return sync(cwd, opts);
@@ -6,9 +6,9 @@ import {
6
6
  c, ok, warn, err, info, step,
7
7
  SKILL_SRC, COMMIT_SKILL_SRC, TEMPLATES_SRC, exists, ensureDir, copyDir, readJSON, writeJSON,
8
8
  ensureGitignore, tryExec, spawnCmd, which, engramAssetName,
9
- projectName, pkgVersion, DEFAULT_KNOWLEDGE, HOME, openURL,
9
+ projectName, pkgVersion, DEFAULT_KNOWLEDGE, HOME, openURL, gitInfo,
10
10
  } from "./util.mjs";
11
- import { detectAll } from "./detect.mjs";
11
+ import { detectAll, detectWorkspace, detectReferences } from "./detect.mjs";
12
12
  import { ask, confirm, select } from "./prompt.mjs";
13
13
 
14
14
  const CONFIG_PATH = (cwd) => path.join(cwd, ".ozali", "config.json");
@@ -623,17 +623,17 @@ const JARVIS_BEGIN = "<!-- ozali-jarvis:start -->";
623
623
  const JARVIS_END = "<!-- ozali-jarvis:end -->";
624
624
 
625
625
  /** Cuerpo del bloque jarvis para CLAUDE.md / AGENTS.md (sin el frontmatter del template). */
626
- function jarvisPersonaBody() {
627
- const tpl = fs.readFileSync(path.join(TEMPLATES_SRC, "ozali-jarvis.md"), "utf8");
626
+ function jarvisPersonaBody(tpl = "ozali-jarvis.md") {
627
+ const raw = fs.readFileSync(path.join(TEMPLATES_SRC, tpl), "utf8");
628
628
  // Quita el frontmatter YAML (--- ... ---) y deja el cuerpo markdown.
629
- return tpl.replace(/^---[\s\S]*?---\s*/, "").trim();
629
+ return raw.replace(/^---[\s\S]*?---\s*/, "").trim();
630
630
  }
631
631
 
632
632
  /** Inserta/actualiza un bloque marcado en un archivo markdown (idempotente). */
633
- function upsertMarkedBlock(file, body) {
634
- const block = `${JARVIS_BEGIN}\n${body}\n${JARVIS_END}`;
633
+ function upsertMarkedBlock(file, body, begin = JARVIS_BEGIN, end = JARVIS_END) {
634
+ const block = `${begin}\n${body}\n${end}`;
635
635
  let txt = exists(file) ? fs.readFileSync(file, "utf8") : "";
636
- const re = new RegExp(`${JARVIS_BEGIN}[\\s\\S]*?${JARVIS_END}`);
636
+ const re = new RegExp(`${begin}[\\s\\S]*?${end}`);
637
637
  let changed;
638
638
  if (re.test(txt)) {
639
639
  const next = txt.replace(re, block);
@@ -757,6 +757,193 @@ function ensureOpencodeProfile(cwd) {
757
757
  ok(`Perfil base de permisos de opencode en ${c.bold("opencode.json")} (lectura+comandos libres, ediciones confirman).`);
758
758
  }
759
759
 
760
+ // ========================================================= workspace =========
761
+ // Configuración multi-repo: escanea repos hijos de una carpeta raíz, remedia los
762
+ // que no tienen ozali init, guía la calibración (que hace el agente), y escribe
763
+ // 3 capas de config: manifiesto + .code-workspace + orquestador de workspace.
764
+ const WS_JARVIS_BEGIN = "<!-- ozali-workspace-jarvis:start -->";
765
+ const WS_JARVIS_END = "<!-- ozali-workspace-jarvis:end -->";
766
+ const WS_MANIFEST = (root) => path.join(root, "ozali-workspace.json");
767
+
768
+ const STATUS_LABEL = {
769
+ "ready": () => c.green("✔ listo"),
770
+ "needs-calibration": () => c.yellow("⚠ sin calibrar (falta cdk)"),
771
+ "missing-init": () => c.red("✖ sin init"),
772
+ };
773
+
774
+ export async function workspace(cwd, opts = {}) {
775
+ step("ozali workspace — configuración multi-repo");
776
+ const depth = opts.depth ? (parseInt(opts.depth, 10) || 1) : 1;
777
+
778
+ // Fase A — escaneo (read-only)
779
+ let ws = detectWorkspace(cwd, { depth });
780
+ if (ws.members.length === 0) {
781
+ warn("No encontré repositorios git como hijos de esta carpeta.");
782
+ info("Corre " + c.bold("ozali workspace") + " desde la carpeta que agrupa tus repos (o usa " + c.bold("--depth 2") + ").");
783
+ return 1;
784
+ }
785
+ step("Repos detectados");
786
+ printMembers(ws.members);
787
+
788
+ // Fase B — remediación de los que no tienen ozali init
789
+ const missing = ws.members.filter((m) => m.status === "missing-init");
790
+ if (missing.length && opts.dryRun) {
791
+ info(`(dry-run) Aquí correría ${c.bold("ozali init")} en: ${c.bold(missing.map((m) => m.dir).join(", "))}.`);
792
+ } else if (missing.length) {
793
+ step("Repos sin ozali init");
794
+ const base = inheritedConfig(ws.members);
795
+ const shared = {
796
+ agent: opts.agent || (base && base.agent),
797
+ scope: opts.scope || (base && base.scope),
798
+ knowledgeRepo: opts.knowledgeRepo || (base && base.knowledgeRepo),
799
+ };
800
+ for (const m of missing) {
801
+ const go = opts.yes ? true : await confirm(`¿Correr ${c.bold("ozali init")} en ${c.bold(m.dir)}?`, true);
802
+ if (!go) { info(`Saltado: ${m.dir}.`); continue; }
803
+ await init(m.path, { ...opts, ...shared });
804
+ }
805
+ ws = detectWorkspace(cwd, { depth }); // re-escanea tras remediar
806
+ }
807
+
808
+ // Guía de calibración (el CLI NO puede calibrar; lo hace el agente)
809
+ const needCal = ws.members.filter((m) => m.status === "needs-calibration");
810
+ if (needCal.length) {
811
+ step("Calibración pendiente (la hace tu agente, no el CLI)");
812
+ warn(`${needCal.length} repo(s) tienen ozali init pero aún no generan su skill ${c.bold("cdk")}.`);
813
+ for (const m of needCal) {
814
+ console.log(` • ${c.bold(m.dir)} → abre el repo en tu agente y corre la skill ${c.bold("ozali")} (${c.dim('"diagnostica el proyecto"')}).`);
815
+ }
816
+ }
817
+
818
+ // Fase C — referencias entre repos (auto-detección + confirmación)
819
+ const references = await confirmReferences(detectReferences(ws.members), opts);
820
+
821
+ if (opts.dryRun) { warn("--dry-run: no escribo nada. Plan mostrado arriba."); return 0; }
822
+
823
+ // Fase D — escritura de la configuración del workspace
824
+ step("Escribiendo configuración del workspace");
825
+ const manifest = writeWorkspaceManifest(cwd, ws.members, references, opts);
826
+ writeCodeWorkspace(cwd, ws.members);
827
+
828
+ const agent = manifest.agent;
829
+ if (agent === "claude-code" || agent === "both") {
830
+ ensureWorkspaceJarvisClaudeCode(cwd);
831
+ if (!opts.noTrust) await ensureClaudeWorkspaceTrust(cwd, opts);
832
+ }
833
+ if (agent === "opencode" || agent === "both") ensureWorkspaceJarvisOpencode(cwd);
834
+
835
+ if (gitInfo(cwd).isRepo) {
836
+ const { added } = ensureGitignore(cwd, [".claude/", ".engram/", ".ozali/"]);
837
+ if (added.length) ok(`.gitignore de la raíz actualizado: ${added.join(", ")}.`);
838
+ }
839
+
840
+ // Siguientes pasos
841
+ step("Siguientes pasos");
842
+ const wsFile = `${path.basename(cwd)}.code-workspace`;
843
+ console.log(` 1. Abre el workspace en tu editor: ${c.bold(wsFile)} ${c.dim("(VSCode/Antigravity → Open Workspace).")}`);
844
+ if (needCal.length) console.log(` 2. Calibra los repos pendientes (${c.bold(needCal.map((m) => m.dir).join(", "))}) corriendo la skill ${c.bold("ozali")} en cada uno.`);
845
+ console.log(` ${c.dim("Re-corre")} ${c.bold("ozali workspace")} ${c.dim("cuando agregues repos o cambien las referencias (es idempotente).")}`);
846
+ return 0;
847
+ }
848
+
849
+ /** Primer .ozali/config.json entre los miembros ya inicializados (para heredar defaults). */
850
+ function inheritedConfig(members) {
851
+ for (const m of members) {
852
+ const cfg = readJSON(path.join(m.path, ".ozali", "config.json"));
853
+ if (cfg) return cfg;
854
+ }
855
+ return null;
856
+ }
857
+
858
+ function printMembers(members) {
859
+ const pad = Math.max(4, ...members.map((m) => m.dir.length));
860
+ for (const m of members) {
861
+ const label = (STATUS_LABEL[m.status] || (() => m.status))();
862
+ const sot = m.sot.found ? c.dim(`sot:${m.sot.variant}`) : c.dim("sot:—");
863
+ const eng = m.engramProject ? c.dim(` engram:${m.engramProject}`) : "";
864
+ console.log(` ${c.bold(m.dir.padEnd(pad))} ${label} ${sot}${eng}`);
865
+ }
866
+ }
867
+
868
+ async function confirmReferences(detected, opts) {
869
+ if (detected.length === 0) { info("No detecté referencias automáticas entre los repos."); return []; }
870
+ step("Referencias detectadas entre repos");
871
+ for (const e of detected) console.log(` • ${c.bold(e.fromDir)} → ${c.bold(e.toDir)} ${c.dim("(" + e.kind + ")")}`);
872
+ if (opts.yes) return detected;
873
+ if (await confirm(`¿Registrar las ${detected.length} referencias detectadas?`, true)) return detected;
874
+ const kept = [];
875
+ for (const e of detected) {
876
+ if (await confirm(` ¿Registrar ${e.fromDir} → ${e.toDir} (${e.kind})?`, true)) kept.push(e);
877
+ }
878
+ return kept;
879
+ }
880
+
881
+ function writeWorkspaceManifest(root, members, references, opts) {
882
+ const existing = readJSON(WS_MANIFEST(root)) || {};
883
+ const base = inheritedConfig(members) || {};
884
+ const agent = opts.agent || existing.agent || base.agent || "claude-code";
885
+ const manifest = {
886
+ version: pkgVersion(),
887
+ root,
888
+ agent,
889
+ knowledgeRepo: opts.knowledgeRepo || existing.knowledgeRepo || base.knowledgeRepo || DEFAULT_KNOWLEDGE,
890
+ cloud: base.cloud || existing.cloud || { enabled: false },
891
+ members: members.map((m) => ({ path: m.dir, project: m.project, status: m.status, sot: m.sot.found ? m.sot.variant : null })),
892
+ references: references.map((e) => ({ from: e.fromDir, to: e.toDir, kind: e.kind })),
893
+ createdAt: existing.createdAt || new Date().toISOString(),
894
+ updatedAt: new Date().toISOString(),
895
+ };
896
+ writeJSON(WS_MANIFEST(root), manifest);
897
+ ok(`Manifiesto escrito en ${c.bold("ozali-workspace.json")} (${members.length} repos, ${references.length} referencias).`);
898
+ return manifest;
899
+ }
900
+
901
+ function writeCodeWorkspace(root, members) {
902
+ const file = path.join(root, `${path.basename(root)}.code-workspace`);
903
+ const existing = readJSON(file) || {};
904
+ const folders = Array.isArray(existing.folders) ? existing.folders.slice() : [];
905
+ const have = new Set(folders.map((f) => f && f.path));
906
+ for (const m of members) if (!have.has(m.dir)) folders.push({ path: m.dir });
907
+ const cfg = {
908
+ folders,
909
+ settings: existing.settings || {},
910
+ extensions: existing.extensions || { recommendations: ["anthropic.claude-code"] },
911
+ };
912
+ writeJSON(file, cfg);
913
+ ok(`Workspace de editor escrito en ${c.bold(path.basename(file))} (${folders.length} carpetas, multi-root).`);
914
+ }
915
+
916
+ function ensureWorkspaceJarvisClaudeCode(root) {
917
+ const claudeMd = path.join(root, "CLAUDE.md");
918
+ const ch = upsertMarkedBlock(claudeMd, jarvisPersonaBody("ozali-workspace-jarvis.md"), WS_JARVIS_BEGIN, WS_JARVIS_END);
919
+ ok(`ozali-workspace-jarvis ${ch ? "escrito" : "ya presente"} en ${c.bold("CLAUDE.md")} de la raíz.`);
920
+ const agentFile = path.join(root, ".claude", "agents", "ozali-workspace-jarvis.md");
921
+ ensureDir(path.dirname(agentFile));
922
+ fs.copyFileSync(path.join(TEMPLATES_SRC, "ozali-workspace-jarvis.md"), agentFile);
923
+ ok(`Subagente ${c.bold(".claude/agents/ozali-workspace-jarvis.md")} instalado.`);
924
+ }
925
+
926
+ function ensureWorkspaceJarvisOpencode(root) {
927
+ const agentsMd = path.join(root, "AGENTS.md");
928
+ const ch = upsertMarkedBlock(agentsMd, jarvisPersonaBody("ozali-workspace-jarvis.md"), WS_JARVIS_BEGIN, WS_JARVIS_END);
929
+ ok(`ozali-workspace-jarvis ${ch ? "escrito" : "ya presente"} en ${c.bold("AGENTS.md")} de la raíz.`);
930
+ const p = path.join(root, "opencode.json");
931
+ const cfg = readJSON(p, {});
932
+ cfg.$schema = cfg.$schema || "https://opencode.ai/config.json";
933
+ cfg.agent = cfg.agent || {};
934
+ if (!cfg.agent["ozali-workspace-jarvis"]) {
935
+ cfg.agent["ozali-workspace-jarvis"] = {
936
+ mode: "primary",
937
+ description: "Orquestador multi-repo: coordina repos hermanos según ozali-workspace.json.",
938
+ prompt: "{file:./AGENTS.md}",
939
+ };
940
+ writeJSON(p, cfg);
941
+ ok(`Agente ${c.bold("ozali-workspace-jarvis")} (primary) añadido a ${c.bold("opencode.json")}.`);
942
+ } else {
943
+ info("Agente ozali-workspace-jarvis ya presente en opencode.json.");
944
+ }
945
+ }
946
+
760
947
  // =========================================================== doctor ==========
761
948
  export function doctor(cwd) {
762
949
  step("ozali doctor — health-check (read-only)");
@@ -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 } from "./util.mjs";
4
+ import { exists, which, gitInfo, 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) {
@@ -109,3 +109,125 @@ export function detectAll(cwd) {
109
109
  testing: detectTesting(cwd),
110
110
  };
111
111
  }
112
+
113
+ // ===================================================== workspace (multi-repo) ==
114
+
115
+ const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", "vendor", "target"]);
116
+
117
+ /** ¿La raíz misma es el knowledge repo (o vive dentro de él)? Para no auto-escanearlo. */
118
+ function isKnowledgeRepo(dir) {
119
+ const k = path.resolve(DEFAULT_KNOWLEDGE);
120
+ const d = path.resolve(dir);
121
+ return d === k || d.startsWith(k + path.sep);
122
+ }
123
+
124
+ /**
125
+ * 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 ocultos/ignorados.
127
+ */
128
+ function collectRepoDirs(root, depth, level = 1, acc = []) {
129
+ let entries;
130
+ try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return acc; }
131
+ for (const e of entries) {
132
+ if (!e.isDirectory()) continue;
133
+ if (e.name.startsWith(".") || IGNORE_DIRS.has(e.name)) continue;
134
+ const full = path.join(root, e.name);
135
+ if (isKnowledgeRepo(full)) continue;
136
+ if (gitInfo(full).isRepo) { acc.push(full); continue; } // repo = hoja
137
+ if (level < depth) collectRepoDirs(full, depth, level + 1, acc);
138
+ }
139
+ return acc;
140
+ }
141
+
142
+ /** Estado ozali de un repo: missing-init | needs-calibration | ready. */
143
+ function memberStatus(hasConfig, hasCdk) {
144
+ if (!hasConfig) return "missing-init";
145
+ if (!hasCdk) return "needs-calibration";
146
+ return "ready";
147
+ }
148
+
149
+ /**
150
+ * Inventario multi-repo de una carpeta raíz. Read-only. Por cada repo git hijo arma
151
+ * su estado ozali (init/calibración), fuente de verdad, proyecto Engram y nombre de
152
+ * package.json (para inferir referencias). `existing` = manifiesto previo, si lo hay.
153
+ */
154
+ export function detectWorkspace(root, opts = {}) {
155
+ const depth = Math.max(1, opts.depth || 1);
156
+ const dirs = collectRepoDirs(root, depth);
157
+ const members = dirs.map((full) => {
158
+ const hasConfig = exists(path.join(full, ".ozali", "config.json"));
159
+ const hasCdk = exists(path.join(full, ".claude", "skills", "cdk", "SKILL.md"));
160
+ const engramCfg = readJSON(path.join(full, ".engram", "config.json"));
161
+ const pkg = readJSON(path.join(full, "package.json"));
162
+ const g = gitInfo(full);
163
+ return {
164
+ dir: path.relative(root, full) || path.basename(full),
165
+ path: full,
166
+ project: projectName(full),
167
+ pkgName: pkg && typeof pkg.name === "string" ? pkg.name : null,
168
+ sot: detectSourceOfTruth(full),
169
+ hasConfig,
170
+ hasCdk,
171
+ engramProject: engramCfg && engramCfg.project_name ? engramCfg.project_name : null,
172
+ status: memberStatus(hasConfig, hasCdk),
173
+ git: { branch: g.branch || null, remote: g.remote || null },
174
+ };
175
+ }).sort((a, b) => a.dir.localeCompare(b.dir));
176
+ const existing = readJSON(path.join(root, "ozali-workspace.json"));
177
+ return { root, members, existing };
178
+ }
179
+
180
+ /**
181
+ * Infiere referencias entre repos (aristas dirigidas {from, to, kind}). Zero-dep,
182
+ * best-effort: dependencias npm cruzadas, submódulos git y contextos de docker-compose.
183
+ * `from` depende de / apunta a `to`. Devuelve aristas únicas.
184
+ */
185
+ export function detectReferences(members) {
186
+ const byPkg = new Map();
187
+ const byDir = new Map();
188
+ for (const m of members) {
189
+ if (m.pkgName) byPkg.set(m.pkgName, m);
190
+ byDir.set(path.basename(m.dir), m);
191
+ }
192
+ const edges = [];
193
+ const seen = new Set();
194
+ const push = (from, to, kind) => {
195
+ if (!from || !to || from.dir === to.dir) return;
196
+ const key = `${from.dir}→${to.dir}:${kind}`;
197
+ if (seen.has(key)) return;
198
+ seen.add(key);
199
+ edges.push({ from: from.project, to: to.project, fromDir: from.dir, toDir: to.dir, kind });
200
+ };
201
+
202
+ for (const m of members) {
203
+ // 1) dependencias npm cruzadas
204
+ const pkg = readJSON(path.join(m.path, "package.json"));
205
+ if (pkg) {
206
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.peerDependencies };
207
+ for (const name of Object.keys(deps)) {
208
+ const target = byPkg.get(name);
209
+ if (target) push(m, target, "npm-dep");
210
+ }
211
+ }
212
+ // 2) submódulos git (.gitmodules → path de cada submódulo)
213
+ const gm = path.join(m.path, ".gitmodules");
214
+ if (exists(gm)) {
215
+ const txt = fs.readFileSync(gm, "utf8");
216
+ for (const match of txt.matchAll(/^\s*path\s*=\s*(.+)$/gim)) {
217
+ const target = byDir.get(path.basename(match[1].trim()));
218
+ if (target) push(m, target, "git-submodule");
219
+ }
220
+ }
221
+ // 3) docker-compose (build context que apunte a un repo hermano)
222
+ for (const f of ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]) {
223
+ const cf = path.join(m.path, f);
224
+ if (!exists(cf)) continue;
225
+ const txt = fs.readFileSync(cf, "utf8");
226
+ for (const match of txt.matchAll(/context:\s*(\.{1,2}\/\S+)/gi)) {
227
+ const target = byDir.get(path.basename(match[1].trim().replace(/\/+$/, "")));
228
+ if (target) push(m, target, "compose");
229
+ }
230
+ }
231
+ }
232
+ return edges;
233
+ }
@@ -36,7 +36,7 @@ test("--version imprime la versión del package.json", () => {
36
36
 
37
37
  test("--help menciona los comandos", () => {
38
38
  const { stdout } = run(["--help"]);
39
- for (const cmd of ["init", "doctor", "update", "sync", "audit"]) assert.match(stdout, new RegExp(cmd));
39
+ for (const cmd of ["init", "workspace", "doctor", "update", "sync", "audit"]) assert.match(stdout, new RegExp(cmd));
40
40
  });
41
41
 
42
42
  test("audit imprime cabecera y no rompe (general)", () => {
@@ -207,6 +207,67 @@ test("update también instala ozali-commit en repos previos", () => {
207
207
  }
208
208
  });
209
209
 
210
+ // ------------------------------------------------------------------ workspace
211
+ function wsRepo(root, name, { config = false, cdk = false, pkg = null } = {}) {
212
+ const dir = path.join(root, name);
213
+ fs.mkdirSync(dir, { recursive: true });
214
+ execFileSync("git", ["init", "-q"], { cwd: dir });
215
+ if (config) {
216
+ fs.mkdirSync(path.join(dir, ".ozali"), { recursive: true });
217
+ fs.writeFileSync(path.join(dir, ".ozali", "config.json"), JSON.stringify({ agent: "claude-code" }));
218
+ }
219
+ if (cdk) {
220
+ fs.mkdirSync(path.join(dir, ".claude", "skills", "cdk"), { recursive: true });
221
+ fs.writeFileSync(path.join(dir, ".claude", "skills", "cdk", "SKILL.md"), "---\nname: cdk\n---\n");
222
+ }
223
+ if (pkg) fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify(pkg));
224
+ }
225
+
226
+ test("workspace escanea y clasifica repos hijos sin escribir (dry-run)", () => {
227
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ozali-ws-"));
228
+ try {
229
+ wsRepo(root, "api", { config: true, cdk: true, pkg: { name: "api", version: "1.0.0" } });
230
+ wsRepo(root, "web", { config: true, pkg: { name: "web", dependencies: { api: "^1" } } });
231
+ wsRepo(root, "lib-bare", { pkg: { name: "lib" } });
232
+ const { stdout } = run(["workspace", "--dry-run"], root);
233
+ assert.match(stdout, /listo/, "clasifica api como listo");
234
+ assert.match(stdout, /sin calibrar/, "clasifica web como sin calibrar");
235
+ assert.match(stdout, /sin init/, "clasifica lib-bare como sin init");
236
+ assert.match(stdout, /web → api \(npm-dep\)/, "detecta la referencia npm web→api");
237
+ assert.match(stdout, /no escribo nada/, "dry-run no escribe");
238
+ assert.ok(!fs.existsSync(path.join(root, "ozali-workspace.json")), "dry-run: no hay manifiesto");
239
+ } finally {
240
+ fs.rmSync(root, { recursive: true, force: true });
241
+ }
242
+ });
243
+
244
+ test("workspace escribe manifiesto + .code-workspace + jarvis y es idempotente", () => {
245
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "ozali-ws-"));
246
+ try {
247
+ // Ambos ready (con cdk) → sin missing-init, no invoca init pesado.
248
+ wsRepo(root, "api", { config: true, cdk: true, pkg: { name: "api", version: "1.0.0" } });
249
+ wsRepo(root, "web", { config: true, cdk: true, pkg: { name: "web", dependencies: { api: "^1" } } });
250
+ run(["workspace", "--yes", "--no-trust"], root);
251
+
252
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, "ozali-workspace.json"), "utf8"));
253
+ assert.equal(manifest.members.length, 2, "manifiesto con 2 miembros");
254
+ assert.ok(manifest.references.some((r) => r.from === "web" && r.to === "api"), "referencia web→api en el manifiesto");
255
+
256
+ const wsFile = path.join(root, `${path.basename(root)}.code-workspace`);
257
+ assert.ok(fs.existsSync(wsFile), ".code-workspace escrito");
258
+ assert.equal(JSON.parse(fs.readFileSync(wsFile, "utf8")).folders.length, 2, "multi-root con 2 folders");
259
+ assert.match(fs.readFileSync(path.join(root, "CLAUDE.md"), "utf8"), /ozali-workspace-jarvis:start/, "bloque jarvis en CLAUDE.md");
260
+
261
+ // idempotencia: re-correr no duplica
262
+ run(["workspace", "--yes", "--no-trust"], root);
263
+ const claude = fs.readFileSync(path.join(root, "CLAUDE.md"), "utf8");
264
+ assert.equal((claude.match(/ozali-workspace-jarvis:start/g) || []).length, 1, "no duplica el bloque jarvis");
265
+ assert.equal(JSON.parse(fs.readFileSync(wsFile, "utf8")).folders.length, 2, "no duplica folders");
266
+ } finally {
267
+ fs.rmSync(root, { recursive: true, force: true });
268
+ }
269
+ });
270
+
210
271
  test("init --dry-run no escribe nada", () => {
211
272
  const dir = tmpProject();
212
273
  try {
@@ -0,0 +1,82 @@
1
+ # Workspaces multi-repo (`ozali workspace`)
2
+
3
+ ← [README](../README.md)
4
+
5
+ Objetivo: cuando abres en tu editor (VSCode, **Antigravity** — ambos de la familia VSCode) una
6
+ **carpeta raíz que agrupa varios repositorios relacionados** (una API, su front, librerías
7
+ compartidas), que el agente pueda **trabajar en conjunto** con todos, porque tienen **referencias
8
+ entre ellos**.
9
+
10
+ `ozali` opera por defecto **repo por repo**. El comando `ozali workspace` añade una capa de
11
+ coordinación **a nivel de la carpeta raíz**, sin quitarle autonomía a ningún repo miembro (cada uno
12
+ conserva su `ozali-jarvis`, su skill `cdk`, su fuente de verdad y su memoria Engram).
13
+
14
+ ## Uso
15
+
16
+ Corre **una vez** en la carpeta raíz (la que contiene los repos):
17
+
18
+ ```bash
19
+ ozali workspace # escanea, remedia y escribe la config conjunta
20
+ ozali workspace --dry-run # solo inventario + plan, sin escribir nada
21
+ ozali workspace --yes # no interactivo (acepta defaults y todas las referencias detectadas)
22
+ ozali workspace --depth 2 # busca repos hasta 2 niveles bajo la raíz
23
+ ozali workspace --no-trust # no marca la raíz como confiable en Claude Code
24
+ ```
25
+
26
+ Un CLI **no puede auto-dispararse** con solo hacer `cd`; por eso es un comando explícito e
27
+ **idempotente**: re-córrelo cuando agregues repos o cambien las referencias.
28
+
29
+ ## Qué hace, paso a paso
30
+
31
+ 1. **Escaneo (read-only).** Enumera los repos git hijos y reporta el estado ozali de cada uno:
32
+ - `✔ listo` — tiene `.ozali/config.json` **y** su skill `cdk` (`.claude/skills/cdk/SKILL.md`).
33
+ - `⚠ sin calibrar` — tiene `ozali init` pero **falta `cdk`** (aún no se calibró).
34
+ - `✖ sin init` — no tiene `.ozali/config.json`.
35
+
36
+ 2. **Remediación.** Por cada repo `✖ sin init`, ofrece correr **`ozali init`** (el "prepare":
37
+ instala skills, aísla histórico, configura Engram). Hereda `agent`/`scope`/`knowledge-repo` de un
38
+ repo ya inicializado para mantener el equipo consistente.
39
+
40
+ 3. **Guía de calibración.** El CLI **no calibra** (eso lo hace el agente). Para cada repo
41
+ `⚠ sin calibrar`, imprime la instrucción: abre ese repo en tu agente y corre la skill **`ozali`**
42
+ ("diagnostica el proyecto") para generar su `cdk`. El orquestador de workspace también queda
43
+ cableado para conducir esa calibración repo por repo.
44
+
45
+ 4. **Referencias (auto-detección + confirmación).** Infiere aristas dirigidas `from → to`:
46
+ - `npm-dep` — el `name` de un repo aparece en `dependencies`/`devDependencies`/`peerDependencies` de otro.
47
+ - `git-submodule` — un `path` de `.gitmodules` apunta a un repo hermano.
48
+ - `compose` — un `build.context` de `docker-compose.yml` apunta a un repo hermano.
49
+
50
+ Te muestra lo detectado y lo confirmas (con `--yes` se aceptan todas).
51
+
52
+ ## Qué escribe
53
+
54
+ En la carpeta raíz:
55
+
56
+ | Artefacto | Qué es |
57
+ |---|---|
58
+ | `ozali-workspace.json` | Manifiesto: miembros (ruta, proyecto, estado, fuente de verdad), referencias, `knowledgeRepo`, cloud. Fuente de verdad del workspace. |
59
+ | `<carpeta>.code-workspace` | Workspace **multi-root** de VSCode/Antigravity: abre todos los repos juntos. Merge idempotente si ya existe. |
60
+ | `CLAUDE.md` / `AGENTS.md` (raíz) | Bloque marcado del orquestador **`ozali-workspace-jarvis`** + subagente / agente de opencode. |
61
+
62
+ Si la raíz es a su vez un repo git, se añade `.claude/`, `.engram/` y `.ozali/` a su `.gitignore`
63
+ (los artefactos locales del agente no se commitean). `ozali-workspace.json` y `.code-workspace` **sí**
64
+ son commiteables/compartibles.
65
+
66
+ ## El orquestador `ozali-workspace-jarvis`
67
+
68
+ Es una persona de agente que **lee `ozali-workspace.json` en runtime** (no se re-templatiza al cambiar
69
+ miembros). Su trabajo:
70
+
71
+ - Recall-first **a nivel workspace**: ubica qué repo(s) toca la tarea y recupera memoria **solo** de
72
+ los proyectos involucrados.
73
+ - Verifica que el repo esté `listo` antes de tocar código; si está `sin init`/`sin calibrar`, lo dice.
74
+ - **Delega la ejecución** al `cdk` del repo correspondiente (plan, GATE, TDD, docs por hito).
75
+ - En cambios que **cruzan repos**, secuencia según las `references` (primero el proveedor/`to`, luego
76
+ el consumidor/`from`) y anota el cambio en la memoria de **ambos** repos.
77
+
78
+ ## Fuera de alcance (por ahora)
79
+
80
+ - Auto-sugerencia al abrir la carpeta (tarea de editor/hook).
81
+ - `ozali doctor`/`sync` agregados a nivel workspace.
82
+ - Detección de referencias más allá de npm/submódulos/compose (imports TS/py, `go.work`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ozali",
3
- "version": "0.6.1",
3
+ "version": "0.7.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,45 @@
1
+ ---
2
+ name: ozali-workspace-jarvis
3
+ description: Orquestador de un WORKSPACE multi-repo de ozali. Coordina varios repositorios hermanos que se referencian entre sí. Úsalo/actúa como jarvis de workspace para CUALQUIER trabajo que cruce repos — lee el manifiesto ozali-workspace.json, recupera contexto de todos los proyectos involucrados antes de actuar, y delega la ejecución de código en el ozali-jarvis y la skill cdk de CADA repo miembro.
4
+ ---
5
+
6
+ # ozali-workspace-jarvis — orquestador multi-repo
7
+
8
+ Eres **ozali-workspace-jarvis**, el orquestador de esta **carpeta raíz que agrupa varios
9
+ repositorios** relacionados. Existes porque los repos miembros **se referencian entre sí** y a veces
10
+ un cambio abarca a más de uno. Tu trabajo es mantener la coherencia **entre repos** y delegar la
11
+ ejecución disciplinada dentro de cada uno.
12
+
13
+ > Cada repo miembro conserva su propia autonomía: su `ozali-jarvis`, su skill `cdk`, su fuente de
14
+ > verdad (`.ai/`/`.ia/`) y su memoria Engram. Tú **no** los reemplazas: los **coordinas**.
15
+
16
+ ## 1. Al iniciar (recall-first, a nivel workspace)
17
+ - **Lee `ozali-workspace.json`** en la raíz: es la fuente de verdad de los **miembros**, su **estado**
18
+ (`ready` / `needs-calibration` / `missing-init`) y las **referencias** entre ellos (`from → to`).
19
+ - Ubica la tarea: ¿qué repo(s) toca y a través de qué referencias impacta a otros?
20
+ - Recupera contexto de memoria **de cada proyecto involucrado** (no de todos): confirma el proyecto de
21
+ cada repo con `mem_current_project` y usa `mem_search`/`mem_context` acotado a esos proyectos.
22
+ - **Recuperación selectiva:** nunca vuelques toda la memoria; trae solo lo necesario por repo.
23
+
24
+ ## 2. Antes de tocar código — verifica que el repo esté listo
25
+ - Si un miembro está `missing-init`, dilo y sugiere `ozali init` en ese repo (o re-correr
26
+ `ozali workspace` en la raíz).
27
+ - Si un miembro está `needs-calibration` (sin `cdk`), **condúcelo**: sugiere abrir ese repo y correr la
28
+ skill **`ozali`** ("diagnostica el proyecto") para generar su `cdk` antes de ejecutar cambios.
29
+
30
+ ## 3. Para ejecutar cambios → delega en el repo correcto
31
+ - Un cambio se ejecuta **dentro** del repo que le corresponde, usando **su** skill `cdk` (plan, GATE,
32
+ TDD, docs por hito). Tú preparas el contexto cruzado; el `cdk` del repo ejecuta.
33
+ - **Cambios que cruzan repos** (p. ej. un contrato de API que consume un front): secuencia el trabajo
34
+ siguiendo las `references` (primero el `to`/proveedor, luego el `from`/consumidor), y deja registrado
35
+ en la memoria de cada repo qué cambió y por qué, enlazando ambos lados.
36
+
37
+ ## 4. Al cerrar
38
+ - Resume por repo tocado con `mem_session_summary` (solo el agente top-level).
39
+ - Si el cambio afectó una referencia entre repos, anótalo en **ambos** proyectos para que el recall
40
+ futuro lo encuentre desde cualquiera de los dos.
41
+
42
+ ## Reglas duras
43
+ - La memoria **nunca bloquea**: si Engram no responde, sigue en modo `docs` y anótalo.
44
+ - **No inventes** referencias ni miembros: si algo no está en `ozali-workspace.json`, no lo asumas.
45
+ - No edites el histórico ni los planes congelados de ningún repo miembro.