ozali 0.1.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.
@@ -0,0 +1,131 @@
1
+ // util.mjs — helpers de bajo nivel, CERO dependencias (solo node:*).
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { fileURLToPath } from "node:url";
6
+ import { execFileSync } from "node:child_process";
7
+
8
+ // ---- rutas del paquete ------------------------------------------------------
9
+ // Este archivo vive en <root>/cli/lib/util.mjs → la raíz del paquete es ../../..
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+ export const PKG_ROOT = path.resolve(HERE, "..", "..");
12
+ export const SKILL_SRC = path.join(PKG_ROOT, "skill");
13
+
14
+ export function pkgVersion() {
15
+ try {
16
+ const p = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
17
+ return p.version || "0.0.0";
18
+ } catch {
19
+ return "0.0.0";
20
+ }
21
+ }
22
+
23
+ // ---- colores (sin deps; respeta NO_COLOR y no-TTY) --------------------------
24
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
25
+ const wrap = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
26
+ export const c = {
27
+ bold: wrap("1"),
28
+ dim: wrap("2"),
29
+ red: wrap("31"),
30
+ green: wrap("32"),
31
+ yellow: wrap("33"),
32
+ blue: wrap("34"),
33
+ magenta: wrap("35"),
34
+ cyan: wrap("36"),
35
+ };
36
+
37
+ export const ok = (m) => console.log(`${c.green("✔")} ${m}`);
38
+ export const warn = (m) => console.log(`${c.yellow("⚠")} ${m}`);
39
+ export const err = (m) => console.error(`${c.red("✖")} ${m}`);
40
+ export const info = (m) => console.log(`${c.cyan("•")} ${m}`);
41
+ export const step = (m) => console.log(`\n${c.bold(c.magenta("▸ " + m))}`);
42
+
43
+ // ---- filesystem -------------------------------------------------------------
44
+ export function exists(p) {
45
+ try { fs.accessSync(p); return true; } catch { return false; }
46
+ }
47
+
48
+ export function ensureDir(p) {
49
+ fs.mkdirSync(p, { recursive: true });
50
+ }
51
+
52
+ /** Copia recursiva de un directorio (zero-dep). */
53
+ export function copyDir(src, dst) {
54
+ ensureDir(dst);
55
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
56
+ const s = path.join(src, entry.name);
57
+ const d = path.join(dst, entry.name);
58
+ if (entry.isDirectory()) copyDir(s, d);
59
+ else fs.copyFileSync(s, d);
60
+ }
61
+ }
62
+
63
+ export function readJSON(p, fallback = null) {
64
+ try { return JSON.parse(fs.readFileSync(p, "utf8")); } catch { return fallback; }
65
+ }
66
+
67
+ export function writeJSON(p, obj) {
68
+ ensureDir(path.dirname(p));
69
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2) + "\n");
70
+ }
71
+
72
+ // ---- ejecución de comandos (read-only / git) --------------------------------
73
+ /** Ejecuta un binario y devuelve stdout recortado, o null si falla. */
74
+ export function tryExec(cmd, args = [], opts = {}) {
75
+ try {
76
+ return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], ...opts }).trim();
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function which(bin) {
83
+ const finder = process.platform === "win32" ? "where" : "which";
84
+ return tryExec(finder, [bin]);
85
+ }
86
+
87
+ // ---- git --------------------------------------------------------------------
88
+ export function gitInfo(cwd) {
89
+ const inside = tryExec("git", ["rev-parse", "--is-inside-work-tree"], { cwd }) === "true";
90
+ if (!inside) return { isRepo: false };
91
+ return {
92
+ isRepo: true,
93
+ branch: tryExec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd }),
94
+ commit: tryExec("git", ["rev-parse", "--short", "HEAD"], { cwd }),
95
+ remote: tryExec("git", ["remote", "get-url", "origin"], { cwd }),
96
+ userName: tryExec("git", ["config", "user.name"], { cwd }),
97
+ userEmail: tryExec("git", ["config", "user.email"], { cwd }),
98
+ };
99
+ }
100
+
101
+ /** Nombre de proyecto normalizado (remoto git → minúsculas; si no, carpeta). */
102
+ export function projectName(cwd) {
103
+ const g = gitInfo(cwd);
104
+ if (g.isRepo && g.remote) {
105
+ const m = g.remote.replace(/\.git$/, "").match(/([^/:]+)$/);
106
+ if (m) return m[1].toLowerCase();
107
+ }
108
+ return path.basename(cwd).toLowerCase();
109
+ }
110
+
111
+ // ---- .gitignore idempotente -------------------------------------------------
112
+ export function ensureGitignore(cwd, entries) {
113
+ const gi = path.join(cwd, ".gitignore");
114
+ let body = exists(gi) ? fs.readFileSync(gi, "utf8") : "";
115
+ const lines = new Set(body.split(/\r?\n/));
116
+ const missing = entries.filter((e) => !lines.has(e));
117
+ if (missing.length === 0) return { added: [] };
118
+ const block = (body && !body.endsWith("\n") ? "\n" : "") +
119
+ "\n# ozali — histórico aislado (no commitear en el repo principal)\n" +
120
+ missing.join("\n") + "\n";
121
+ fs.writeFileSync(gi, body + block);
122
+ return { added: missing };
123
+ }
124
+
125
+ // ---- node version -----------------------------------------------------------
126
+ export function nodeMajor() {
127
+ return parseInt(process.versions.node.split(".")[0], 10);
128
+ }
129
+
130
+ export const HOME = os.homedir();
131
+ export const DEFAULT_KNOWLEDGE = path.join(HOME, ".ozali", "knowledge");
@@ -0,0 +1,88 @@
1
+ // smoke.test.mjs — pruebas básicas del CLI (node:test, sin dependencias).
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import { execFileSync } from "node:child_process";
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
11
+ const BIN = path.resolve(HERE, "..", "bin", "ozali.mjs");
12
+ const PKG_ROOT = path.resolve(HERE, "..", "..");
13
+
14
+ function run(args, cwd, expectFail = false) {
15
+ try {
16
+ const stdout = execFileSync(process.execPath, [BIN, ...args], { cwd, encoding: "utf8" });
17
+ return { code: 0, stdout };
18
+ } catch (e) {
19
+ if (!expectFail) throw e;
20
+ return { code: e.status, stdout: (e.stdout || "") + (e.stderr || "") };
21
+ }
22
+ }
23
+
24
+ function tmpProject() {
25
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ozali-test-"));
26
+ execFileSync("git", ["init", "-q"], { cwd: dir });
27
+ return dir;
28
+ }
29
+
30
+ test("--version imprime la versión del package.json", () => {
31
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
32
+ const { stdout } = run(["--version"]);
33
+ assert.equal(stdout.trim(), pkg.version);
34
+ });
35
+
36
+ test("--help menciona los 4 comandos", () => {
37
+ const { stdout } = run(["--help"]);
38
+ for (const cmd of ["init", "doctor", "update", "sync"]) assert.match(stdout, new RegExp(cmd));
39
+ });
40
+
41
+ test("comando desconocido sale con código 1", () => {
42
+ const { code } = run(["frobnicate"], process.cwd(), true);
43
+ assert.equal(code, 1);
44
+ });
45
+
46
+ test("doctor en proyecto vacío reporta pendientes (exit 1)", () => {
47
+ const dir = tmpProject();
48
+ try {
49
+ const { code, stdout } = run(["doctor"], dir, true);
50
+ assert.equal(code, 1);
51
+ assert.match(stdout, /health-check/);
52
+ } finally {
53
+ fs.rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("init --yes instala la skill, aísla el histórico y escribe config", () => {
58
+ const dir = tmpProject();
59
+ try {
60
+ run(["init", "--yes", "--agent", "claude-code", "--scope", "project", "--knowledge-repo", path.join(dir, ".k")], dir);
61
+ assert.ok(fs.existsSync(path.join(dir, ".claude", "skills", "ozali", "SKILL.md")), "SKILL.md instalada");
62
+ assert.ok(fs.existsSync(path.join(dir, ".ozali", "config.json")), "config escrita");
63
+ const gi = fs.readFileSync(path.join(dir, ".gitignore"), "utf8");
64
+ assert.match(gi, /\.ozali\//);
65
+ assert.match(gi, /\.engram\//);
66
+ } finally {
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ });
70
+
71
+ test("init --dry-run no escribe nada", () => {
72
+ const dir = tmpProject();
73
+ try {
74
+ run(["init", "--dry-run", "--yes"], dir);
75
+ assert.ok(!fs.existsSync(path.join(dir, ".ozali")), "no debe crear .ozali en dry-run");
76
+ assert.ok(!fs.existsSync(path.join(dir, ".claude")), "no debe crear .claude en dry-run");
77
+ } finally {
78
+ fs.rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ });
81
+
82
+ test("el paquete NO tiene dependencias ni lifecycle scripts (seguridad)", () => {
83
+ const pkg = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, "package.json"), "utf8"));
84
+ assert.equal(Object.keys(pkg.dependencies || {}).length, 0, "cero dependencias");
85
+ for (const s of ["preinstall", "install", "postinstall", "preuninstall", "postuninstall", "prepare"]) {
86
+ assert.ok(!(pkg.scripts && pkg.scripts[s]), `sin script de ciclo de vida: ${s}`);
87
+ }
88
+ });
@@ -0,0 +1,43 @@
1
+ # Uso previsto (modelo mental)
2
+
3
+ ← [README](../README.md)
4
+
5
+ Esta página explica **cómo se usa ozali**, no los flags. Si lees una sola página además del README, que sea esta.
6
+
7
+ ## La idea en una frase
8
+
9
+ `ozali` toma un repo existente y le instala un **método de trabajo con IA**: calibra el proyecto,
10
+ genera una skill de ejecución (`cdk`) con TDD/SDD, y mantiene **memoria de equipo** con el
11
+ histórico aislado del repo principal.
12
+
13
+ ## Las dos capas
14
+
15
+ | Capa | Quién la corre | Qué hace |
16
+ |---|---|---|
17
+ | **CLI `ozali`** | tú, en la terminal | instala/actualiza la skill, aísla el histórico, configura Engram (`init`/`doctor`/`update`/`sync`) |
18
+ | **Skill `ozali` → genera `cdk`** | tu agente (Claude Code/opencode) | diagnostica, calibra, y con tu aprobación (GATE) ejecuta el trabajo con 8 subagentes |
19
+
20
+ ## Flujo típico (primera vez en un repo)
21
+
22
+ 1. **`pnpm dlx ozali@<versión> init`** — el CLI detecta tu agente, instala la skill, aísla
23
+ `.ozali/` y `.engram/` en el `.gitignore`, y configura el repo de conocimiento.
24
+ 2. **Abre tu agente** y escribe `"diagnostica el proyecto"` (o `ozali`). La skill:
25
+ - valida/genera la fuente de verdad `AI.md` + `.ai/`;
26
+ - **calibra testing y resuelve Strict TDD** (Fase 3.5);
27
+ - te presenta un **plan** (🛑 GATE) y, al aprobarlo, genera la skill `cdk` con sus 8 subagentes.
28
+ 3. **Trabaja con `cdk`**: cada solicitud/hito produce 6 documentos legibles **y** se espeja a
29
+ Engram (modo híbrido). El plan aprobado se congela; la bitácora es append-only.
30
+ 4. **`ozali sync`** — lleva el histórico (docs + Engram) al repo de conocimiento de equipo.
31
+
32
+ ## Día a día
33
+
34
+ - Pídele cosas a `cdk` en tu agente (`"crea el endpoint X"`, `"corrige el bug Y"`,
35
+ `cdk --auto "refactor Z"`). El GATE del plan es el único punto donde siempre paras a aprobar.
36
+ - Si instalaste Engram, `cdk` **recuerda** lo que el equipo hizo antes y reanuda hitos a medias.
37
+ - Corre `ozali doctor` cuando quieras un chequeo de salud read-only.
38
+
39
+ ## La regla de oro
40
+
41
+ El histórico **crece sin tocar el repo principal**. El cerebro (`.ai/`) viaja con el código; el
42
+ registro y la memoria viven aislados y se comparten con `ozali sync`. Así el equipo acumula
43
+ conocimiento sin inflar los repos de producto.
@@ -0,0 +1,47 @@
1
+ # Seguridad del instalador (npx / pnpm / supply-chain)
2
+
3
+ ← [README](../README.md)
4
+
5
+ `ozali` se distribuye como un CLI **Node de cero dependencias y sin lifecycle scripts**
6
+ (`preinstall`/`install`/`postinstall`). Esa decisión es deliberada: **elimina de raíz** el vector
7
+ de ataque de cadena de suministro que abusaron incidentes recientes del registro npm.
8
+
9
+ ## El problema: lifecycle scripts en `npm`/`npx`
10
+
11
+ `npx <pkg>` descarga el paquete **y todo su árbol de dependencias** a un caché temporal y ejecuta
12
+ su `bin`. Por defecto, npm/npx **ejecutan los lifecycle scripts** del paquete y de **cada
13
+ dependencia transitiva** durante la instalación — *antes* de que tú corras nada. Un `postinstall`
14
+ malicioso en una dependencia popular (o comprometida) corre código arbitrario en tu máquina:
15
+ roba tokens/variables de entorno y, en los gusanos recientes, se auto-propaga.
16
+
17
+ ## Las tres palancas de mitigación
18
+
19
+ 1. **Cero dependencias + cero scripts propios.** Si el paquete no tiene dependencias ni
20
+ `postinstall`, el vector **no existe**, corras como corras. ← Es lo que hace `ozali`.
21
+ 2. **`--ignore-scripts`.** npm/npx/pnpm lo soportan; se puede fijar global en `.npmrc`
22
+ (`ignore-scripts=true`).
23
+ 3. **pnpm v10+.** Deshabilita los lifecycle scripts de las dependencias **por defecto**; hay que
24
+ aprobarlos explícitamente (`pnpm.onlyBuiltDependencies` o `pnpm approve-builds`). `pnpm dlx`
25
+ (el equivalente de `npx`) hereda ese default seguro.
26
+
27
+ ## Cómo instalar/ejecutar ozali de forma segura
28
+
29
+ | Método | Comando | Notas |
30
+ |---|---|---|
31
+ | **pnpm (recomendado)** | `pnpm dlx ozali@<versión> init` | pnpm 10 no corre postinstall de deps; pinea la versión |
32
+ | **npm con scripts off** | `npx --ignore-scripts ozali@<versión> init` | desactiva lifecycle scripts explícitamente |
33
+ | **git (máxima auditabilidad)** | `git clone <repo> && node ozali/cli/bin/ozali.mjs init` | sin registry, sin árbol de deps; el equipo lee el script antes de correrlo |
34
+
35
+ > **Siempre pinea la versión** (`@x.y.z`) en vez de `@latest`, y usa lockfile. Al publicar en npm
36
+ > se firmará con **provenance** (sigstore) para verificar origen.
37
+
38
+ ## Por qué NO un `curl … | bash` como camino primario
39
+
40
+ El `curl … | bash` estilo de otros instaladores ejecuta un script remoto **a ciegas**. Lo dejamos
41
+ solo como espejo opcional, **pineado a un tag + checksum** verificable, nunca como ruta por defecto.
42
+
43
+ ## Disciplina interna
44
+
45
+ - El harness `verify-structure.mjs` que genera `cdk` es también **cero deps, Node 16+**.
46
+ - El CLI no escribe fuera del proyecto destino sin confirmación, salvo el repo de conocimiento
47
+ que el usuario configura explícitamente en `ozali init`.
@@ -0,0 +1,66 @@
1
+ # Histórico aislado y memoria de equipo
2
+
3
+ ← [README](../README.md)
4
+
5
+ Objetivo: que el **histórico de documentación y memoria crezca sin sobrecargar el repo principal**
6
+ del proyecto, y que el conocimiento sea **compartible por el equipo**.
7
+
8
+ ## Tres capas, tres destinos
9
+
10
+ | Capa | Qué es | Dónde vive | Por qué |
11
+ |---|---|---|---|
12
+ | **Cerebro** (fuente de verdad) | `.ai/` + `AI.md` | **repo principal** (commiteado) | son las reglas; chico, crece lento, debe viajar con el código |
13
+ | **Histórico** (docs) | docs por hito (6) + por corrida (3) | **repo de conocimiento aparte** (gitignored en el principal) | esto es lo que crece sin límite |
14
+ | **Memoria** (Engram) | chunks/manifest buscables | store local de Engram + export al repo de conocimiento | Engram ya aísla por defecto; solo el export `.engram/` necesita casa |
15
+
16
+ ## Modelo recomendado: un repo de conocimiento de equipo
17
+
18
+ Un único repo `ozali-knowledge` guarda el histórico de **todos** los proyectos (Engram y las
19
+ carpetas de docs ya son multi-proyecto por naming determinista):
20
+
21
+ ```
22
+ ozali-knowledge/ (repo GitHub aparte, compartido por el equipo)
23
+ engram/ ← export de Engram (multi-proyecto: chunks + manifest)
24
+ projects/
25
+ quattro-auto-api/
26
+ runs/ ← corridas de bootstrap de ozali (3 docs c/u)
27
+ milestones/<hito>/ ← 6 docs por hito
28
+ otro-proyecto/
29
+ ...
30
+ ```
31
+
32
+ El repo principal solo gana `.ai/` (cerebro) + las skills bajo `.claude/skills/`. Su `.gitignore`
33
+ excluye `.ozali/` y `.engram/`.
34
+
35
+ ## Cómo lo cablea el CLI
36
+
37
+ - `ozali init` → detecta/clona el repo de conocimiento (default `~/.ozali/knowledge`), añade
38
+ `.ozali/` y `.engram/` al `.gitignore` del repo principal, apunta el export de Engram ahí.
39
+ - `cdk` (durante el trabajo) → escribe docs en el repo de conocimiento, no en el working tree
40
+ del repo principal.
41
+ - `ozali sync` → commit + push del repo de conocimiento; el equipo hace pull para compartir.
42
+
43
+ ## Trazabilidad (enlace código ↔ histórico)
44
+
45
+ Cada documento lleva en su encabezado: autor, rama, **commit SHA del repo principal**, proyecto y
46
+ fecha/hora (Fase 0 de `ozali`). Así, desde un commit encuentras su histórico y cada entrada del
47
+ histórico apunta a un commit exacto.
48
+
49
+ ## Opciones del enlace físico (rankeadas)
50
+
51
+ 1. **Gitignore + clon aparte gestionado por el CLI (RECOMENDADO).** Modelo mental simple, sin
52
+ dolor de submódulos. El histórico nunca pesa en el repo principal. El enlace es por SHA en el
53
+ header (no forzado por git) — aceptable.
54
+ 2. **Worktree de una rama huérfana** (`ozali-history`) en el mismo remoto, montada en `.ozali/`
55
+ (gitignored en main). Un solo remoto; el histórico nunca ensucia los diffs de main. El
56
+ object-DB crece pero el checkout queda liviano. Ideal si NO quieren un segundo repo.
57
+ 3. **Submódulo git.** Acoplamiento estricto (pin por commit) pero con fricción (clone recursivo,
58
+ detached HEAD). Solo si quieren pin forzado.
59
+ 4. **Store externo no-git** (S3/Notion/drive). Desacopla del todo pero pierde auditoría git; más
60
+ infra. No recomendado.
61
+
62
+ > Engram aporta además el `engram sync` / `engram sync --import` ya probado: lo retargeteamos al
63
+ > repo de conocimiento en lugar del repo principal.
64
+
65
+ El contrato concreto de qué se espeja a Engram, cuándo, y el algoritmo de `ozali sync` está en
66
+ [skill/references/engram-convention.md](../skill/references/engram-convention.md) (§3 y §6).
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "ozali",
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "bin": {
7
+ "ozali": "cli/bin/ozali.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test cli/test/*.test.mjs"
11
+ },
12
+ "files": [
13
+ "cli/",
14
+ "skill/",
15
+ "docs/",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "keywords": [
20
+ "ai",
21
+ "tdd",
22
+ "sdd",
23
+ "claude-code",
24
+ "opencode",
25
+ "engram",
26
+ "bootstrap",
27
+ "cli"
28
+ ],
29
+ "engines": {
30
+ "node": ">=16"
31
+ },
32
+ "license": "MIT",
33
+ "author": "Jaime Baños",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "https://github.com/jimmybathrooms/ozali.git"
37
+ },
38
+ "dependencies": {}
39
+ }