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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jaime Baños
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ <div align="center">
2
+
3
+ # ozali
4
+
5
+ **Bootstrap de IA por equipo — calibra tus proyectos, genera una skill de ejecución con TDD/SDD, y mantiene memoria de equipo (Engram) con histórico aislado.**
6
+
7
+ </div>
8
+
9
+ ---
10
+
11
+ ## Qué es
12
+
13
+ `ozali` lleva a cualquier repositorio existente a un flujo de desarrollo asistido por IA,
14
+ **disciplinado y trazable**, pensado para equipos. No es un chatbot: es un método.
15
+
16
+ - **Diagnostica y calibra** el proyecto: detecta stack, arquitectura, convenciones, capacidades de
17
+ testing y resuelve **Strict TDD** (test-first sí/no) contra lo que el proyecto realmente soporta.
18
+ - **Fuente de verdad** legible: `AI.md` + `.ai/` (el "Project Brain") — generado evaluando el
19
+ código real, nunca inventado.
20
+ - **Genera `cdk`**, la skill de ejecución con **8 subagentes** (owner, manager, analyzer,
21
+ orchestrator, executioners, proposer, documenter, tester), **GATE de aprobación** y plan
22
+ congelado inmutable.
23
+ - **Memoria híbrida**: documentos legibles por humanos **+** Engram buscable/acumulable, para que
24
+ la herramienta **aprenda de lo que el equipo va haciendo**.
25
+ - **Histórico aislado**: el registro que crece vive en un repo de conocimiento aparte, sin
26
+ sobrecargar el repo principal.
27
+
28
+ ## Dos piezas
29
+
30
+ 1. **Proyecto completo** (este repo): la skill `ozali`, las `references/`, el harness, el CLI y los docs.
31
+ 2. **Plug-n-play CLI**: instala y arranca el método en cualquier repo, de forma interogativa.
32
+
33
+ ## Instalación (segura por diseño)
34
+
35
+ CLI **Node de cero dependencias y sin lifecycle scripts** → sin vector de supply-chain.
36
+ Detalle en [docs/security.md](docs/security.md).
37
+
38
+ ```bash
39
+ # Recomendado (pnpm 10 deshabilita postinstall de deps por defecto)
40
+ pnpm dlx ozali@<versión> init
41
+
42
+ # npm con scripts desactivados
43
+ npx --ignore-scripts ozali@<versión> init
44
+
45
+ # Máxima auditabilidad
46
+ git clone <repo> && node ozali/cli/bin/ozali.mjs init
47
+ ```
48
+
49
+ ### Uso del CLI
50
+
51
+ ```bash
52
+ ozali init # detecta agente, instala la skill, aísla el histórico, configura Engram
53
+ ozali doctor # health-check read-only (fuente de verdad, Engram, Strict TDD, runner…)
54
+ ozali update # actualiza la skill instalada a la versión del paquete
55
+ ozali sync # lleva el histórico (docs + Engram) al repo de conocimiento de equipo
56
+ ```
57
+
58
+ Flags útiles: `--yes` (no interactivo), `--dry-run` (init sin escribir), `--agent`, `--scope`,
59
+ `--knowledge-repo`, `--import`/`--push` (sync). Modelo mental completo en
60
+ [docs/intended-usage.md](docs/intended-usage.md).
61
+
62
+ ## Agentes soportados
63
+
64
+ Claude Code y opencode (perfiles de permisos para ambos en
65
+ [skill/references/permissions-bypass.md](skill/references/permissions-bypass.md)).
66
+
67
+ ## Documentación
68
+
69
+ | Tema | Doc |
70
+ |---|---|
71
+ | Uso previsto (modelo mental) | [docs/intended-usage.md](docs/intended-usage.md) |
72
+ | Seguridad del instalador (npx/pnpm) | [docs/security.md](docs/security.md) |
73
+ | Histórico aislado y memoria de equipo | [docs/team-history.md](docs/team-history.md) |
74
+ | Skill bootstrap | [skill/SKILL.md](skill/SKILL.md) |
75
+ | Calibración de testing + TDD | [skill/references/calibration-blueprint.md](skill/references/calibration-blueprint.md) |
76
+ | Blueprint de agentes | [skill/references/agents-blueprint.md](skill/references/agents-blueprint.md) |
77
+ | Memoria híbrida (docs + Engram) | [skill/references/engram-convention.md](skill/references/engram-convention.md) |
78
+
79
+ ## Estado del roadmap
80
+
81
+ - [x] **Fase A** — Calibración + Strict TDD en el bootstrap.
82
+ - [x] **Fase B** — Memoria híbrida (docs + Engram, `state` recuperable, contrato de arranque).
83
+ - [x] **Fase C** — CLI plug-n-play (`init`/`doctor`/`update`/`sync`), Node cero-deps.
84
+ - [x] **Fase D** — Repo distribuible: tests (node:test), CI (matriz Node 16–22 + invariantes de seguridad), publicación npm con provenance.
85
+
86
+ > Siguiente paso natural: crear el repo en GitHub y publicar la primera versión (`pnpm publish` vía tag), más probar el bootstrap en un repo real del equipo.
87
+
88
+ > Linaje: `ozali` evoluciona la skill `copsis-doctor`, nutrida con conceptos de
89
+ > [gentle-ai](https://github.com/Gentleman-Programming/gentle-ai) (Engram, calibración SDD/TDD,
90
+ > distribución plug-n-play).
91
+
92
+ ## Licencia
93
+
94
+ MIT
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // ozali — CLI plug-n-play (Node, CERO dependencias, SIN lifecycle scripts).
3
+ // Seguridad: sin dependencias ni scripts de instalación. Ejecuta seguro con
4
+ // `pnpm dlx ozali` o `npx --ignore-scripts ozali`. Ver docs/security.md.
5
+ import { c, err, pkgVersion } from "../lib/util.mjs";
6
+ import { init, doctor, update, sync } from "../lib/commands.mjs";
7
+
8
+ const HELP = `
9
+ ${c.bold("ozali")} ${c.dim("v" + pkgVersion())} — bootstrap de IA por equipo (TDD/SDD + memoria Engram)
10
+
11
+ ${c.bold("Uso:")}
12
+ ozali <comando> [opciones]
13
+
14
+ ${c.bold("Comandos:")}
15
+ init Detecta el agente (Claude Code/opencode), instala la skill ozali,
16
+ aísla el histórico, configura Engram y el repo de conocimiento.
17
+ doctor Health-check read-only del proyecto (fuente de verdad, Engram, TDD…).
18
+ update Actualiza la skill ozali instalada a la versión de este paquete.
19
+ sync Sincroniza el histórico (docs + Engram) con el repo de conocimiento.
20
+
21
+ ${c.bold("Opciones comunes:")}
22
+ --yes, -y No interactivo: usa defaults.
23
+ --dry-run (init) Muestra el plan sin escribir nada.
24
+ --agent <a> (init) claude-code | opencode | both.
25
+ --scope <s> (init) project | global.
26
+ --knowledge-repo <p> (init) Ruta del repo de conocimiento.
27
+ --import (sync) Importa del repo de conocimiento a local.
28
+ --push (sync) Hace push al remoto del repo de conocimiento.
29
+ -h, --help Esta ayuda. -v, --version Versión.
30
+
31
+ ${c.bold("Instalación segura:")}
32
+ pnpm dlx ozali@<versión> init ${c.dim("# recomendado (pnpm 10: postinstall off)")}
33
+ npx --ignore-scripts ozali@<versión> init
34
+ git clone … && node cli/bin/ozali.mjs init
35
+ `;
36
+
37
+ function parseArgs(argv) {
38
+ const opts = { _: [] };
39
+ for (let i = 0; i < argv.length; i++) {
40
+ const a = argv[i];
41
+ if (a === "--yes" || a === "-y") opts.yes = true;
42
+ else if (a === "--dry-run") opts.dryRun = true;
43
+ else if (a === "--import") opts.import = true;
44
+ else if (a === "--push") opts.push = true;
45
+ else if (a === "--agent") opts.agent = argv[++i];
46
+ else if (a === "--scope") opts.scope = argv[++i];
47
+ else if (a === "--knowledge-repo") opts.knowledgeRepo = argv[++i];
48
+ else if (a === "-h" || a === "--help") opts.help = true;
49
+ else if (a === "-v" || a === "--version") opts.version = true;
50
+ else opts._.push(a);
51
+ }
52
+ return opts;
53
+ }
54
+
55
+ async function main() {
56
+ const opts = parseArgs(process.argv.slice(2));
57
+ const cmd = opts._[0];
58
+
59
+ if (opts.version) { console.log(pkgVersion()); return 0; }
60
+ if (!cmd || opts.help || cmd === "help") { console.log(HELP); return 0; }
61
+
62
+ const cwd = process.cwd();
63
+ switch (cmd) {
64
+ case "init": return await init(cwd, opts);
65
+ case "doctor": return doctor(cwd);
66
+ case "update": return update(cwd);
67
+ case "sync": return sync(cwd, opts);
68
+ default:
69
+ err(`comando desconocido "${cmd}". Usa ${c.bold("ozali --help")}.`);
70
+ return 1;
71
+ }
72
+ }
73
+
74
+ main().then((code) => process.exit(code || 0)).catch((e) => {
75
+ err(e && e.stack ? e.stack : String(e));
76
+ process.exit(1);
77
+ });
@@ -0,0 +1,235 @@
1
+ // commands.mjs — implementación de init / doctor / update / sync.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ c, ok, warn, err, info, step,
6
+ SKILL_SRC, exists, ensureDir, copyDir, readJSON, writeJSON,
7
+ ensureGitignore, tryExec, projectName, pkgVersion, DEFAULT_KNOWLEDGE,
8
+ } from "./util.mjs";
9
+ import { detectAll } from "./detect.mjs";
10
+ import { ask, confirm, select } from "./prompt.mjs";
11
+
12
+ const CONFIG_PATH = (cwd) => path.join(cwd, ".ozali", "config.json");
13
+
14
+ function skillTarget(cwd, scope) {
15
+ const base = scope === "global" ? path.join(process.env.HOME || "", ".claude") : path.join(cwd, ".claude");
16
+ return path.join(base, "skills", "ozali");
17
+ }
18
+
19
+ // ============================================================ init ===========
20
+ export async function init(cwd, opts) {
21
+ step("ozali init — bootstrap del proyecto");
22
+ const env = detectAll(cwd);
23
+
24
+ if (!env.node.ok) warn(`Node ${env.node.version} detectado; ozali y el harness piden ≥16. Continúo, pero actualiza si ves errores.`);
25
+ if (!env.git.isRepo) warn("No estás en un repo git: la trazabilidad por commit y el sync quedarán limitados.");
26
+
27
+ // Fuente de verdad
28
+ if (env.sot.found) ok(`Fuente de verdad detectada: ${c.bold(env.sot.doc)} + ${c.bold(env.sot.dir + "/")} (variante ${env.sot.variant}).`);
29
+ else info(`Sin fuente de verdad aún (se generará al correr la skill 'ozali' en tu agente).`);
30
+
31
+ // Agente
32
+ const agentDefault = env.agents.opencode.present && !env.agents.claudeCode.present ? "opencode"
33
+ : env.agents.claudeCode.present && env.agents.opencode.present ? "both" : "claude-code";
34
+ const agent = opts.agent || await select("¿Para qué agente configuro ozali?", [
35
+ { value: "claude-code", label: "Claude Code" },
36
+ { value: "opencode", label: "opencode" },
37
+ { value: "both", label: "Ambos (Claude Code + opencode)" },
38
+ ], { "claude-code": 0, opencode: 1, both: 2 }[agentDefault]);
39
+
40
+ // Scope
41
+ const scope = opts.scope || await select("¿Dónde instalo la skill?", [
42
+ { value: "project", label: `Proyecto (${c.dim(".claude/skills/ozali")})` },
43
+ { value: "global", label: `Global (${c.dim("~/.claude/skills/ozali")})` },
44
+ ], 0);
45
+
46
+ // Repo de conocimiento (histórico aislado)
47
+ const knowledgeRepo = opts.knowledgeRepo || await ask("Ruta del repo de conocimiento (histórico aislado)", DEFAULT_KNOWLEDGE);
48
+
49
+ // Engram
50
+ let memoryMode = "hybrid";
51
+ if (env.engram.available) {
52
+ ok(`Engram disponible (${env.engram.bin}). Modo de memoria: ${c.bold("hybrid")} (docs + Engram).`);
53
+ } else {
54
+ memoryMode = "docs";
55
+ warn("Engram no está instalado. Arrancamos en modo " + c.bold("docs") + " (solo documentos legibles).");
56
+ info("Para activar memoria buscable/acumulativa instala Engram: " + c.cyan("https://github.com/Gentleman-Programming/engram"));
57
+ info("Tras instalarlo, vuelve a correr " + c.bold("ozali doctor") + " — el modo subirá a hybrid automáticamente.");
58
+ }
59
+
60
+ if (opts.dryRun) { warn("--dry-run: no escribo nada. Plan mostrado arriba."); return 0; }
61
+
62
+ // --- acciones ---
63
+ step("Aplicando");
64
+ // 1) copiar skill
65
+ const target = skillTarget(cwd, scope);
66
+ ensureDir(path.dirname(target));
67
+ copyDir(SKILL_SRC, target);
68
+ ok(`Skill instalada en ${c.bold(path.relative(cwd, target) || target)}.`);
69
+
70
+ // 2) opencode: perfil base de permisos (idempotente, merge mínimo)
71
+ if (agent === "opencode" || agent === "both") {
72
+ ensureOpencodeProfile(cwd);
73
+ }
74
+
75
+ // 3) gitignore del histórico aislado
76
+ if (env.git.isRepo) {
77
+ const { added } = ensureGitignore(cwd, [".ozali/", ".engram/"]);
78
+ if (added.length) ok(`.gitignore actualizado: ${added.join(", ")} (histórico aislado del repo principal).`);
79
+ else info(".gitignore ya aislaba el histórico.");
80
+ }
81
+
82
+ // 4) repo de conocimiento
83
+ ensureDir(knowledgeRepo);
84
+ if (!exists(path.join(knowledgeRepo, ".git"))) {
85
+ if (await confirm(`¿Inicializo git en el repo de conocimiento (${knowledgeRepo})?`, true)) {
86
+ tryExec("git", ["init", "-q"], { cwd: knowledgeRepo });
87
+ ensureDir(path.join(knowledgeRepo, "projects"));
88
+ ensureDir(path.join(knowledgeRepo, "engram"));
89
+ ok("Repo de conocimiento inicializado.");
90
+ }
91
+ } else info("Repo de conocimiento ya existe.");
92
+
93
+ // 5) config local (gitignored)
94
+ const config = {
95
+ version: pkgVersion(), agent, scope, knowledgeRepo, memoryMode,
96
+ project: projectName(cwd), createdAt: new Date().toISOString(),
97
+ };
98
+ writeJSON(CONFIG_PATH(cwd), config);
99
+ ok(`Config local escrita en ${c.bold(".ozali/config.json")} (gitignored).`);
100
+
101
+ // --- siguientes pasos ---
102
+ step("Siguientes pasos");
103
+ console.log(` 1. Abre tu agente en este proyecto.`);
104
+ console.log(` 2. Escribe ${c.bold('"diagnostica el proyecto"')} o ${c.bold('"ozali"')} para arrancar el bootstrap (calibración + generación de la skill ${c.bold("cdk")}).`);
105
+ console.log(` 3. Tras trabajar, corre ${c.bold("ozali sync")} para llevar el histórico al repo de conocimiento.`);
106
+ console.log(` ${c.dim("Salud en cualquier momento:")} ${c.bold("ozali doctor")}`);
107
+ return 0;
108
+ }
109
+
110
+ function ensureOpencodeProfile(cwd) {
111
+ const p = path.join(cwd, "opencode.json");
112
+ const base = readJSON(p, {});
113
+ base["$schema"] = base["$schema"] || "https://opencode.ai/config.json";
114
+ base.permission = base.permission || {};
115
+ const perm = base.permission;
116
+ // Perfil base modo normal: lectura+comandos+fetch libres, ediciones confirman.
117
+ for (const [k, v] of Object.entries({ read: "allow", grep: "allow", glob: "allow", webfetch: "allow", external_directory: "ask", edit: "ask" })) {
118
+ if (perm[k] === undefined) perm[k] = v;
119
+ }
120
+ if (perm.bash === undefined) perm.bash = { "*": "allow", "rm -rf *": "ask", "git push *": "ask" };
121
+ writeJSON(p, base);
122
+ ok(`Perfil base de permisos de opencode en ${c.bold("opencode.json")} (lectura+comandos libres, ediciones confirman).`);
123
+ }
124
+
125
+ // =========================================================== doctor ==========
126
+ export function doctor(cwd) {
127
+ step("ozali doctor — health-check (read-only)");
128
+ const env = detectAll(cwd);
129
+ const cfg = readJSON(CONFIG_PATH(cwd));
130
+ const rows = [];
131
+ const add = (label, good, detail) => rows.push({ label, good, detail });
132
+
133
+ add("Repo git", env.git.isRepo, env.git.isRepo ? (env.git.commit ? `${env.git.branch}@${env.git.commit}` : "repo sin commits") : "no es repo git");
134
+ add("Node ≥ 16", env.node.ok, env.node.version);
135
+ add("Fuente de verdad", env.sot.found, env.sot.found ? `${env.sot.doc} + ${env.sot.dir}/` : "ausente (corre la skill 'ozali')");
136
+ add("Skill ozali instalada", env.skill.installed, env.skill.installed ? env.skill.paths.map((p) => path.relative(cwd, p) || p).join(", ") : "no instalada (ozali init)");
137
+ add("Engram", env.engram.available, env.engram.available ? env.engram.bin : "no instalado → modo docs");
138
+ add("Repo de conocimiento", !!(cfg && cfg.knowledgeRepo && exists(cfg.knowledgeRepo)), cfg ? cfg.knowledgeRepo : "sin configurar (ozali init)");
139
+
140
+ // Strict TDD (de la fuente de verdad)
141
+ const tdd = readStrictTdd(cwd, env.sot);
142
+ add("Strict TDD calibrado", tdd.found, tdd.found ? `strict_tdd: ${tdd.value}` : "sin calibrar (Fase 3.5 del bootstrap)");
143
+
144
+ // Testing signals
145
+ add("Runner de pruebas", env.testing.runners.length > 0, env.testing.runners.join(", ") || "ninguno detectado");
146
+
147
+ const pad = Math.max(...rows.map((r) => r.label.length));
148
+ console.log("");
149
+ for (const r of rows) {
150
+ const mark = r.good ? c.green("✔") : c.yellow("✖");
151
+ console.log(` ${mark} ${r.label.padEnd(pad)} ${c.dim(r.detail)}`);
152
+ }
153
+ const bad = rows.filter((r) => !r.good).length;
154
+ console.log("");
155
+ if (bad === 0) ok("Todo en orden. ozali está listo para trabajar.");
156
+ else warn(`${bad} punto(s) a atender. Revisa los ✖ de arriba.`);
157
+ return bad === 0 ? 0 : 1;
158
+ }
159
+
160
+ function readStrictTdd(cwd, sot) {
161
+ const f = path.join(cwd, sot.dir, "context", "tech-stack.md");
162
+ if (!exists(f)) return { found: false };
163
+ const txt = fs.readFileSync(f, "utf8");
164
+ const m = txt.match(/Strict\s*TDD[:*\s]+(true|false)/i);
165
+ return m ? { found: true, value: m[1].toLowerCase() } : { found: false };
166
+ }
167
+
168
+ // =========================================================== update ==========
169
+ export function update(cwd) {
170
+ step("ozali update — actualizar skill instalada");
171
+ const env = detectAll(cwd);
172
+ if (!env.skill.installed) { warn("No hay skill ozali instalada. Corre " + c.bold("ozali init") + " primero."); return 1; }
173
+ for (const p of env.skill.paths) {
174
+ copyDir(SKILL_SRC, p);
175
+ ok(`Actualizada: ${path.relative(cwd, p) || p} → v${pkgVersion()}`);
176
+ }
177
+ const cfgPath = CONFIG_PATH(cwd);
178
+ const cfg = readJSON(cfgPath);
179
+ if (cfg) { cfg.version = pkgVersion(); cfg.updatedAt = new Date().toISOString(); writeJSON(cfgPath, cfg); }
180
+ return 0;
181
+ }
182
+
183
+ // ============================================================= sync ===========
184
+ export function sync(cwd, opts) {
185
+ step(`ozali sync${opts.import ? " --import" : ""} — histórico ↔ repo de conocimiento`);
186
+ const cfg = readJSON(CONFIG_PATH(cwd));
187
+ if (!cfg || !cfg.knowledgeRepo) { warn("Sin repo de conocimiento configurado. Corre " + c.bold("ozali init") + "."); return 1; }
188
+ const kRepo = cfg.knowledgeRepo;
189
+ if (!exists(kRepo)) { err(`El repo de conocimiento no existe: ${kRepo}`); return 1; }
190
+ const project = cfg.project || projectName(cwd);
191
+ const projDir = path.join(kRepo, "projects", project);
192
+ const docsLocal = path.join(cwd, ".ozali", "docs");
193
+ const engramLocal = path.join(cwd, ".engram");
194
+
195
+ if (opts.import) {
196
+ // Repo de conocimiento → local
197
+ const srcDocs = path.join(projDir, "docs");
198
+ if (exists(srcDocs)) { copyDir(srcDocs, docsLocal); ok("Docs importados a .ozali/docs/."); }
199
+ if (cfg.memoryMode === "hybrid" && tryExec("engram", ["--version"])) {
200
+ info("Ejecutando engram sync --import…");
201
+ tryExec("engram", ["sync", "--import"], { cwd });
202
+ }
203
+ info("Import completo. Revisa .ozali/docs/.");
204
+ return 0;
205
+ }
206
+
207
+ // Local → repo de conocimiento
208
+ // 1) Engram export (si hybrid + disponible)
209
+ if (cfg.memoryMode === "hybrid" && tryExec("engram", ["--version"])) {
210
+ info("Exportando memorias con engram sync…");
211
+ tryExec("engram", ["sync"], { cwd });
212
+ if (exists(engramLocal)) { copyDir(engramLocal, path.join(kRepo, "engram", project)); ok("Export de Engram copiado al repo de conocimiento."); }
213
+ } else if (cfg.memoryMode === "hybrid") {
214
+ warn("Modo hybrid pero Engram no responde; sincronizo solo docs.");
215
+ }
216
+ // 2) Docs
217
+ if (exists(docsLocal)) { copyDir(docsLocal, path.join(projDir, "docs")); ok(`Docs copiados a projects/${project}/docs/.`); }
218
+ else info("No hay .ozali/docs/ que sincronizar todavía.");
219
+
220
+ // 3) commit (push solo si hay remoto)
221
+ if (exists(path.join(kRepo, ".git"))) {
222
+ tryExec("git", ["add", "-A"], { cwd: kRepo });
223
+ const msg = `sync(${project}): histórico ${new Date().toISOString().slice(0, 19)}`;
224
+ tryExec("git", ["commit", "-m", msg], { cwd: kRepo });
225
+ ok("Commit en el repo de conocimiento.");
226
+ const remote = tryExec("git", ["remote", "get-url", "origin"], { cwd: kRepo });
227
+ if (remote) {
228
+ if (opts.push) { tryExec("git", ["push"], { cwd: kRepo }); ok("Push realizado."); }
229
+ else info("Hay remoto configurado. Usa " + c.bold("ozali sync --push") + " para publicar al equipo.");
230
+ } else {
231
+ info("Sin remoto en el repo de conocimiento. Añade uno (git remote add origin …) para compartir con el equipo.");
232
+ }
233
+ }
234
+ return 0;
235
+ }
@@ -0,0 +1,103 @@
1
+ // detect.mjs — detección read-only del entorno del proyecto destino.
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { exists, which, gitInfo, nodeMajor, HOME } from "./util.mjs";
5
+
6
+ /** Variante de fuente de verdad: {found, doc, dir, variant} */
7
+ export function detectSourceOfTruth(cwd) {
8
+ const variants = [
9
+ { variant: "AI", doc: "AI.md", dir: ".ai" },
10
+ { variant: "IA", doc: "IA.md", dir: ".ia" },
11
+ ];
12
+ for (const v of variants) {
13
+ const docPath = path.join(cwd, v.doc);
14
+ const dirPath = path.join(cwd, v.dir);
15
+ if (exists(docPath) || exists(dirPath)) {
16
+ return { found: true, ...v, hasDoc: exists(docPath), hasDir: exists(dirPath) };
17
+ }
18
+ }
19
+ return { found: false, variant: "AI", doc: "AI.md", dir: ".ai" };
20
+ }
21
+
22
+ /** Agentes presentes: claude-code y/o opencode. */
23
+ export function detectAgents(cwd) {
24
+ const claudeProject = exists(path.join(cwd, ".claude"));
25
+ const claudeGlobal = exists(path.join(HOME, ".claude"));
26
+ const opencodeProject = exists(path.join(cwd, "opencode.json")) || exists(path.join(cwd, ".opencode"));
27
+ const opencodeGlobal = exists(path.join(HOME, ".config", "opencode"));
28
+ return {
29
+ claudeCode: { present: claudeProject || claudeGlobal, project: claudeProject, global: claudeGlobal },
30
+ opencode: { present: opencodeProject || opencodeGlobal, project: opencodeProject, global: opencodeGlobal },
31
+ };
32
+ }
33
+
34
+ /** ¿Está instalada la skill ozali? Devuelve rutas encontradas. */
35
+ export function detectInstalledSkill(cwd) {
36
+ const candidates = [
37
+ path.join(cwd, ".claude", "skills", "ozali"),
38
+ path.join(HOME, ".claude", "skills", "ozali"),
39
+ ];
40
+ const found = candidates.filter((p) => exists(path.join(p, "SKILL.md")));
41
+ return { installed: found.length > 0, paths: found };
42
+ }
43
+
44
+ /** Engram disponible (binario CLI en PATH). El MCP no se puede sondear desde aquí. */
45
+ export function detectEngram() {
46
+ const bin = which("engram");
47
+ return { available: !!bin, bin: bin || null };
48
+ }
49
+
50
+ /**
51
+ * Capacidades de testing (heurística read-only). Devuelve runner(s), comando y
52
+ * un conteo aproximado de archivos de prueba. NO resuelve strict_tdd (eso lo
53
+ * hace el bootstrap en la Fase 3.5); aquí solo damos señales.
54
+ */
55
+ export function detectTesting(cwd) {
56
+ const out = { runners: [], command: null, testFiles: 0, hints: [] };
57
+ const pkg = path.join(cwd, "package.json");
58
+ if (exists(pkg)) {
59
+ try {
60
+ const j = JSON.parse(fs.readFileSync(pkg, "utf8"));
61
+ const deps = { ...j.dependencies, ...j.devDependencies };
62
+ for (const r of ["vitest", "jest", "mocha", "@playwright/test", "cypress", "karma", "ava"]) {
63
+ if (deps[r]) out.runners.push(r);
64
+ }
65
+ if (j.scripts && j.scripts.test) out.command = "npm test";
66
+ } catch { /* ignore */ }
67
+ }
68
+ if (exists(path.join(cwd, "go.mod"))) { out.runners.push("go test"); out.command = out.command || "go test ./..."; }
69
+ if (exists(path.join(cwd, "pom.xml"))) { out.runners.push("maven/junit"); out.command = out.command || "mvn test"; }
70
+ if (exists(path.join(cwd, "pyproject.toml")) || exists(path.join(cwd, "pytest.ini"))) {
71
+ out.runners.push("pytest"); out.command = out.command || "pytest";
72
+ }
73
+ out.testFiles = countTestFiles(cwd);
74
+ if (out.runners.length === 0) out.hints.push("No se detectó runner de pruebas (strict_tdd tenderá a false).");
75
+ return out;
76
+ }
77
+
78
+ function countTestFiles(dir, depth = 0, acc = { n: 0 }) {
79
+ if (depth > 6 || acc.n > 9999) return acc.n;
80
+ let entries;
81
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc.n; }
82
+ for (const e of entries) {
83
+ if (e.name === "node_modules" || e.name === ".git" || e.name.startsWith(".") && e.isDirectory()) continue;
84
+ const full = path.join(dir, e.name);
85
+ if (e.isDirectory()) countTestFiles(full, depth + 1, acc);
86
+ else if (/\.(spec|test)\.(ts|tsx|js|jsx)$|_test\.go$|Test\.java$|^test_.*\.py$/.test(e.name)) acc.n++;
87
+ }
88
+ return acc.n;
89
+ }
90
+
91
+ /** Snapshot completo del entorno. */
92
+ export function detectAll(cwd) {
93
+ return {
94
+ cwd,
95
+ node: { major: nodeMajor(), version: process.versions.node, ok: nodeMajor() >= 16 },
96
+ git: gitInfo(cwd),
97
+ sot: detectSourceOfTruth(cwd),
98
+ agents: detectAgents(cwd),
99
+ skill: detectInstalledSkill(cwd),
100
+ engram: detectEngram(),
101
+ testing: detectTesting(cwd),
102
+ };
103
+ }
@@ -0,0 +1,51 @@
1
+ // prompt.mjs — prompts interactivos sin dependencias (node:readline callback,
2
+ // compatible con Node 16+; readline/promises no existe en 16, por eso envolvemos).
3
+ import readline from "node:readline";
4
+ import { c } from "./util.mjs";
5
+
6
+ function rl() {
7
+ return readline.createInterface({ input: process.stdin, output: process.stdout });
8
+ }
9
+
10
+ function question(query) {
11
+ return new Promise((resolve) => {
12
+ const i = rl();
13
+ i.question(query, (ans) => { i.close(); resolve(ans); });
14
+ });
15
+ }
16
+
17
+ const interactive = () => process.stdin.isTTY && process.stdout.isTTY;
18
+
19
+ /** Texto libre con valor por defecto. En no-TTY devuelve el default. */
20
+ export async function ask(message, def = "") {
21
+ if (!interactive()) return def;
22
+ const hint = def ? c.dim(` (${def})`) : "";
23
+ const ans = (await question(`${c.cyan("?")} ${message}${hint}: `)).trim();
24
+ return ans || def;
25
+ }
26
+
27
+ /** Confirmación sí/no. En no-TTY devuelve def. */
28
+ export async function confirm(message, def = true) {
29
+ if (!interactive()) return def;
30
+ const hint = def ? "Y/n" : "y/N";
31
+ const ans = (await question(`${c.cyan("?")} ${message} ${c.dim("[" + hint + "]")} `)).trim().toLowerCase();
32
+ if (!ans) return def;
33
+ return ans[0] === "y" || ans[0] === "s"; // acepta sí/yes
34
+ }
35
+
36
+ /**
37
+ * Selección de una opción. options: [{value, label}]. En no-TTY devuelve el
38
+ * primero (o el marcado por defecto).
39
+ */
40
+ export async function select(message, options, defIndex = 0) {
41
+ if (!interactive()) return options[defIndex].value;
42
+ console.log(`${c.cyan("?")} ${message}`);
43
+ options.forEach((o, i) => {
44
+ const mark = i === defIndex ? c.green("●") : c.dim("○");
45
+ console.log(` ${mark} ${c.bold(String(i + 1))}) ${o.label}`);
46
+ });
47
+ const ans = (await question(` ${c.dim("Elige [" + (defIndex + 1) + "]")}: `)).trim();
48
+ const n = ans ? parseInt(ans, 10) : defIndex + 1;
49
+ const idx = Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defIndex;
50
+ return options[idx].value;
51
+ }