From 277b475f7b555a662dbecbc12aa8d9fe433e1752 Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:00:02 +0100 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20helpers=20que=20replican=20c=C3=B3m?= =?UTF-8?q?o=20Claude=20Code=20carga=20CLAUDE.md=20y=20.claude/rules/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit claude-imports.cjs (el mismo de system-design-kit) expande los @imports de CLAUDE.md con las reglas de Claude Code y nunca incluye .env reales ni ~/.ssh o ~/.aws. claude-rules.cjs lee .claude/rules/ y distingue las reglas que aplican siempre de las que tienen paths:. Zero-dep; los usa la capa de Pi Agent. --- shared/.claude/helpers/claude-imports.cjs | 92 ++++++++++++++++ shared/.claude/helpers/claude-rules.cjs | 63 +++++++++++ test/claude-imports.test.cjs | 122 ++++++++++++++++++++++ test/claude-rules.test.cjs | 62 +++++++++++ 4 files changed, 339 insertions(+) create mode 100644 shared/.claude/helpers/claude-imports.cjs create mode 100644 shared/.claude/helpers/claude-rules.cjs create mode 100644 test/claude-imports.test.cjs create mode 100644 test/claude-rules.test.cjs diff --git a/shared/.claude/helpers/claude-imports.cjs b/shared/.claude/helpers/claude-imports.cjs new file mode 100644 index 0000000..c57aa42 --- /dev/null +++ b/shared/.claude/helpers/claude-imports.cjs @@ -0,0 +1,92 @@ +#!/usr/bin/env node +/** + * claude-imports.cjs — expande los @imports de CLAUDE.md como lo hace Claude Code, para agentes + * que leen CLAUDE.md pero no los resuelven (Pi Agent). Zero-dep; lo usa .pi/extensions/. + * + * node .claude/helpers/claude-imports.cjs [archivo] imprime el archivo con sus imports expandidos + * + * Reglas de Claude Code: `@ruta` al inicio de línea o tras un espacio, fuera de bloques y spans de + * código; la ruta es relativa al archivo que la contiene (o absoluta, o ~/…); hasta 5 saltos y sin + * ciclos. Un import que no existe queda como texto. Además, nunca expande archivos de entorno + * reales (.env, .env.local…; los .example sí) ni nada de ~/.ssh o ~/.aws. + */ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const MAX_DEPTH = 5; +const TOKEN = /(^|\s)@([^\s`'"<>()[\]{}]+)/g; + +function resolveTarget(ref, fromFile, home) { + // "Ver @notas.md." → la puntuación final no es parte de la ruta, salvo que el archivo exista así. + for (const r of [ref, ref.replace(/[.,;:!?]+$/, "")]) { + const p = r.startsWith("~/") ? path.join(home, r.slice(2)) : path.resolve(path.dirname(fromFile), r); + try { + if (fs.statSync(p).isFile()) return fs.realpathSync(p); + } catch (_) {} + } + return null; +} + +function blocked(p, home) { + const base = path.basename(p); + if (/^\.env(rc|\..+)?$/.test(base) && !/\.(example|sample|template)$/.test(base)) return true; + return [".ssh", ".aws"].some((d) => p.startsWith(path.join(home, d) + path.sep)); +} + +/** Devuelve `text` (el contenido de `file`) con sus imports expandidos en el lugar donde aparecen. */ +function expand(file, text, opts = {}, depth = 0, seen = new Set()) { + const home = opts.home || os.homedir(); + const self = fs.existsSync(file) ? fs.realpathSync(file) : path.resolve(file); + const chain = new Set(seen).add(self); + const included = new Set(); // un archivo importado dos veces en el mismo texto entra una sola vez + const out = []; + let fence = null; + for (const line of text.split("\n")) { + const f = line.match(/^\s*(```|~~~)/); + if (f) { + if (!fence) fence = f[1]; + else if (f[1] === fence) fence = null; + out.push(line); + continue; + } + if (fence || depth >= MAX_DEPTH) { + out.push(line); + continue; + } + // Los spans de código se tapan con espacios: así sus @ no cuentan y las posiciones se conservan. + const bare = line.replace(/`[^`]*`/g, (m) => " ".repeat(m.length)); + const found = []; + for (const m of bare.matchAll(TOKEN)) { + const target = resolveTarget(m[2], file, home); + if (target && !chain.has(target) && !blocked(target, home)) found.push({ ref: m[2], target }); + } + if (!found.length) { + out.push(line); + continue; + } + // Una línea que solo trae imports se sustituye; si además tiene texto, se conserva y los + // archivos van debajo. + const rest = found.reduce((s, { ref }) => s.split(`@${ref}`).join(""), bare).trim(); + if (rest) out.push(line); + for (const { ref, target } of found) { + if (included.has(target)) continue; + included.add(target); + const body = expand(target, fs.readFileSync(target, "utf8"), opts, depth + 1, chain).replace(/\s+$/, ""); + // Marcadores sin "@": una segunda pasada no los confunde con imports. + out.push(``, body, ``); + } + } + return out.join("\n"); +} + +module.exports = { expand, MAX_DEPTH }; + +if (require.main === module) { + const file = path.resolve(process.argv[2] || "CLAUDE.md"); + if (!fs.existsSync(file)) { + console.error(`No existe ${file}.`); + process.exit(1); + } + process.stdout.write(expand(file, fs.readFileSync(file, "utf8")) + "\n"); +} diff --git a/shared/.claude/helpers/claude-rules.cjs b/shared/.claude/helpers/claude-rules.cjs new file mode 100644 index 0000000..552ccec --- /dev/null +++ b/shared/.claude/helpers/claude-rules.cjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +/** + * claude-rules.cjs — las reglas de .claude/rules/ que Claude Code carga por su cuenta, para agentes + * que no las leen (Pi Agent). Zero-dep; lo usa .pi/extensions/. + * + * node .claude/helpers/claude-rules.cjs lista las reglas y cuándo aplica cada una + * + * Como en Claude Code: una regla sin `paths:` en su frontmatter se carga siempre (proyecto de un + * stack, reglas propias del equipo); una con `paths:` solo al trabajar en archivos que coinciden + * (en monorepo, la carpeta de cada stack). + */ +const fs = require("fs"); +const path = require("path"); + +const ROOT = process.env.CLAUDE_PROJECT_DIR || path.resolve(__dirname, "..", ".."); + +const unquote = (s) => s.trim().replace(/^["']|["']$/g, ""); + +/** Separa el frontmatter de una regla: sus `paths` (lista YAML, en línea o con guiones) y el cuerpo. */ +function parse(text) { + const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/); + if (!m) return { paths: [], body: text }; + const paths = []; + let enPaths = false; + for (const line of m[1].split(/\r?\n/)) { + const key = line.match(/^([\w-]+):\s*(.*)$/); + if (key) { + enPaths = key[1] === "paths"; + const v = key[2].trim(); + if (enPaths && v) paths.push(...v.replace(/^\[|\]$/g, "").split(",").map(unquote).filter(Boolean)); + continue; + } + const item = enPaths && line.match(/^\s*-\s*(.+?)\s*$/); + if (item) paths.push(unquote(item[1])); + } + return { paths, body: text.slice(m[0].length) }; +} + +/** Todas las reglas `.md` de .claude/rules/ (recursivo), en orden estable. */ +function load(root = ROOT) { + const out = []; + const walk = (dir) => { + for (const e of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const p = path.join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".md")) { + const { paths, body } = parse(fs.readFileSync(p, "utf8")); + out.push({ file: p, rel: path.relative(root, p).split(path.sep).join("/"), always: !paths.length, paths, body }); + } + } + }; + const dir = path.join(root, ".claude", "rules"); + if (fs.existsSync(dir)) walk(dir); + return out; +} + +module.exports = { parse, load }; + +if (require.main === module) { + const reglas = load(); + if (!reglas.length) console.log("No hay reglas en .claude/rules/."); + for (const r of reglas) console.log(`${r.rel}: ${r.always ? "siempre" : `al trabajar en ${r.paths.join(", ")}`}`); +} diff --git a/test/claude-imports.test.cjs b/test/claude-imports.test.cjs new file mode 100644 index 0000000..2ba8eec --- /dev/null +++ b/test/claude-imports.test.cjs @@ -0,0 +1,122 @@ +// Tests del helper que expande los @imports de CLAUDE.md para Pi Agent, sobre carpetas temporales. +const { test } = require("node:test"); +const assert = require("node:assert"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const HELPER = path.join(__dirname, "..", "shared", ".claude", "helpers", "claude-imports.cjs"); +const { expand, MAX_DEPTH } = require(HELPER); + +// Crea los archivos indicados en una carpeta temporal y devuelve la carpeta. +function proyecto(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-imports-")); + for (const [rel, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + } + return dir; +} +const expandir = (dir, file = "CLAUDE.md", opts = {}) => expand(path.join(dir, file), fs.readFileSync(path.join(dir, file), "utf8"), { home: path.join(dir, "home"), ...opts }); +const limpiar = (dir) => fs.rmSync(dir, { recursive: true, force: true }); + +test("una línea que solo es un import se sustituye por el archivo", () => { + const dir = proyecto({ "CLAUDE.md": "# Proyecto\n@.claude/reglas.md\n@design.md\nfin\n", ".claude/reglas.md": "regla 1\n", "design.md": "# Diseño\n" }); + const out = expandir(dir); + assert.doesNotMatch(out, /^@/m, "no quedan líneas de import"); + assert.match(out, /\nregla 1\n/); + assert.match(out, /# Diseño/); + assert.ok(out.indexOf("regla 1") < out.indexOf("# Diseño") && out.indexOf("# Diseño") < out.indexOf("fin"), "conserva el orden"); + limpiar(dir); +}); + +test("un import dentro de una frase conserva la frase y pone el archivo debajo", () => { + const dir = proyecto({ "CLAUDE.md": "Lee @notas.md antes de empezar.\n", "notas.md": "nota\n" }); + const out = expandir(dir); + assert.match(out, /^Lee @notas\.md antes de empezar\.\n\nnota/); + limpiar(dir); +}); + +test("la puntuación final no forma parte de la ruta", () => { + const dir = proyecto({ "CLAUDE.md": "Ver @notas.md.\n", "notas.md": "nota\n" }); + assert.match(expandir(dir), /nota/); + limpiar(dir); +}); + +test("la ruta es relativa al archivo que contiene el import", () => { + const dir = proyecto({ "CLAUDE.md": "@docs/a.md\n", "docs/a.md": "@b.md\n", "docs/b.md": "soy b\n", "b.md": "b equivocado\n" }); + const out = expandir(dir); + assert.match(out, /soy b/); + assert.doesNotMatch(out, /b equivocado/); + limpiar(dir); +}); + +test("un ciclo no se expande dos veces", () => { + const dir = proyecto({ "CLAUDE.md": "@a.md\n", "a.md": "en a\n@b.md\n", "b.md": "en b\n@a.md\n" }); + const out = expandir(dir); + assert.equal(out.split("en a").length - 1, 1); + assert.match(out, /en b\n@a\.md/, "el import circular queda como texto"); + limpiar(dir); +}); + +test(`llega como máximo a ${MAX_DEPTH} saltos`, () => { + const files = { "CLAUDE.md": "@n1.md\n" }; + for (let i = 1; i <= 7; i++) files[`n${i}.md`] = `nivel ${i}\n@n${i + 1}.md\n`; + const dir = proyecto(files); + const out = expandir(dir); + assert.match(out, new RegExp(`nivel ${MAX_DEPTH}\\n@n${MAX_DEPTH + 1}\\.md`)); + assert.doesNotMatch(out, new RegExp(`nivel ${MAX_DEPTH + 1}`)); + limpiar(dir); +}); + +test("ignora los @ dentro de bloques y spans de código", () => { + const dir = proyecto({ "CLAUDE.md": "```\n@notas.md\n```\nUsa `@notas.md` así.\n", "notas.md": "NO\n" }); + assert.doesNotMatch(expandir(dir), /NO/); + limpiar(dir); +}); + +test("un import que no existe y los correos quedan como texto", () => { + const dir = proyecto({ "CLAUDE.md": "@falta.md\nescribe a ana@ejemplo.com\n" }); + const raw = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"); + assert.equal(expandir(dir), raw); + limpiar(dir); +}); + +test("nunca expande archivos de entorno reales ni ~/.ssh; los .example sí", () => { + const dir = proyecto({ + "CLAUDE.md": "@.env\n@.env.local\n@.env.example\n@~/.ssh/id_rsa\n", + ".env": "SECRETO=1\n", + ".env.local": "SECRETO=2\n", + ".env.example": "SECRETO=\n", + "home/.ssh/id_rsa": "LLAVE\n", + }); + const out = expandir(dir); + assert.doesNotMatch(out, /SECRETO=[12]|LLAVE/); + assert.match(out, /\nSECRETO=\n/); + assert.match(out, /^@\.env$/m, "el import bloqueado queda como texto"); + limpiar(dir); +}); + +test("un archivo importado dos veces entra una sola vez", () => { + const dir = proyecto({ "CLAUDE.md": "@notas.md\ntexto\n@notas.md\n", "notas.md": "nota\n" }); + const out = expandir(dir); + assert.equal(out.split("nota\n").length - 1, 1); + assert.doesNotMatch(out, /^@notas\.md$/m); + limpiar(dir); +}); + +test("sin imports devuelve el texto igual (idempotente)", () => { + const dir = proyecto({ "CLAUDE.md": "@design.md\n", "design.md": "# Diseño\n" }); + const una = expandir(dir); + assert.equal(expand(path.join(dir, "CLAUDE.md"), una, { home: path.join(dir, "home") }), una); + limpiar(dir); +}); + +test("como CLI imprime el CLAUDE.md expandido", () => { + const dir = proyecto({ "CLAUDE.md": "@design.md\n", "design.md": "# Diseño\n" }); + const r = spawnSync("node", [HELPER], { cwd: dir, encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /# Diseño/); + limpiar(dir); +}); diff --git a/test/claude-rules.test.cjs b/test/claude-rules.test.cjs new file mode 100644 index 0000000..91190ea --- /dev/null +++ b/test/claude-rules.test.cjs @@ -0,0 +1,62 @@ +// Tests del helper que replica cómo Claude Code carga .claude/rules/ (lo usa la capa de Pi). +const { test } = require("node:test"); +const assert = require("node:assert"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const HELPER = path.join(__dirname, "..", "shared", ".claude", "helpers", "claude-rules.cjs"); +const { parse, load } = require(HELPER); + +function proyecto(files) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-rules-")); + for (const [rel, body] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + } + return dir; +} + +test("una regla sin frontmatter aplica siempre y conserva su cuerpo", () => { + assert.deepEqual(parse("# Regla\ntexto\n"), { paths: [], body: "# Regla\ntexto\n" }); +}); + +test("lee paths como lista con guiones, entre comillas o no", () => { + const r = parse('---\npaths:\n - "apps/api/**"\n - apps/worker/**\n---\n# Backend\n'); + assert.deepEqual(r.paths, ["apps/api/**", "apps/worker/**"]); + assert.equal(r.body, "# Backend\n"); +}); + +test("lee paths en línea, sueltos o como arreglo", () => { + assert.deepEqual(parse('---\npaths: "src/**/*.ts"\n---\nx').paths, ["src/**/*.ts"]); + assert.deepEqual(parse('---\npaths: ["a/**", "b/**"]\ndescription: otra\n---\nx').paths, ["a/**", "b/**"]); +}); + +test("otras claves del frontmatter no cuentan como paths", () => { + assert.deepEqual(parse("---\ndescription: algo\ntags:\n - x\n---\ncuerpo").paths, []); +}); + +test("load recorre .claude/rules/ completo, en orden, y marca cuáles aplican siempre", () => { + const dir = proyecto({ + ".claude/rules/kit/backend.md": '---\npaths:\n - "apps/api/**"\n---\n# Backend\n', + ".claude/rules/kit/frontend.md": "# Frontend siempre\n", + ".claude/rules/equipo.md": "# Nuestra regla\n", + ".claude/rules/notas.txt": "no es regla", + }); + const reglas = load(dir); + assert.deepEqual(reglas.map((r) => [r.rel, r.always]), [ + [".claude/rules/equipo.md", true], + [".claude/rules/kit/backend.md", false], + [".claude/rules/kit/frontend.md", true], + ]); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("sin .claude/rules/ no hay reglas; como CLI lo dice", () => { + const dir = proyecto({ "README.md": "x" }); + assert.deepEqual(load(dir), []); + const r = spawnSync("node", [HELPER], { encoding: "utf8", env: { ...process.env, CLAUDE_PROJECT_DIR: dir } }); + assert.match(r.stdout, /No hay reglas/); + fs.rmSync(dir, { recursive: true, force: true }); +}); From bddfb1404d90cfe106d1f28efee21ea4c156e687 Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:00:02 +0100 Subject: [PATCH 2/4] feat: capa opcional para Pi Agent con --pi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aditiva: sin --pi el instalador hace lo mismo que antes. Con --pi instala .pi/extensions/dev-starter-kit.ts (expande los imports de CLAUDE.md, añade las reglas sin paths: y los avisos de inicio, con el indicador de capa activa) y fusiona .pi/settings.json para cargar las skills y /status de .claude/ sin copiarlos. El manifiesto registra la capa y --update la recuerda. Dentro de Pi el aviso de inicio sugiere /skill:project-context. Nuevo job de CI que prueba la capa dentro de Pi 1.4.0 con su proveedor simulado. --- .github/workflows/ci.yml | 15 ++++ README.md | 5 +- install.js | 78 +++++++++++++++++-- scripts/validate.cjs | 14 ++++ shared/.claude/helpers/hook-handler.cjs | 4 +- shared/.pi/extensions/dev-starter-kit.ts | 68 ++++++++++++++++ test/hook-handler.test.cjs | 7 +- test/install-update.test.cjs | 70 +++++++++++++++++ test/pi.test.cjs | 98 ++++++++++++++++++++++++ 9 files changed, 346 insertions(+), 13 deletions(-) create mode 100644 shared/.pi/extensions/dev-starter-kit.ts create mode 100644 test/pi.test.cjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e7e2e3..5d31edf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,3 +39,18 @@ jobs: --stack backend/nestjs:apps/api,frontend/nextjs:apps/web,mobile/flutter:apps/mobile >/dev/null ) rm -rf "$tmp" echo "✅ instalador OK en monorepo" + + pi: + # La capa de Pi dentro de Pi real, con su proveedor simulado: sin modelo ni API key. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 # Pi 1.4.0 pide Node >= 22.19 + - name: Instalar Pi 1.4.0 (versión fija, sin scripts) + run: npm install --prefix "$RUNNER_TEMP/pi" --no-save --save-exact --ignore-scripts --no-audit --no-fund @earendil-works/pi-coding-agent@1.4.0 + - name: Integración con Pi + env: + PI_BIN: ${{ runner.temp }}/pi/node_modules/.bin/pi + run: node --test test/pi.test.cjs diff --git a/README.md b/README.md index 52a2e84..a20dfa4 100644 --- a/README.md +++ b/README.md @@ -16,15 +16,16 @@ Configuración `.claude` unificada para backend, frontend, mobile y blockchain. │ ├── agents/ # 14 agentes (5 core + 9 especialistas) — ver "Agentes incluidos" │ ├── skills/ # 19 skills (flujo + contexto + ingeniería + seguridad) — ver "Skills incluidas" │ ├── commands/ # /swarm, /kit:status -│ ├── helpers/ # hook-handler, loop, statusline +│ ├── helpers/ # claude-imports, claude-rules, hook-handler, loop, statusline │ └── templates/ # PROJECT.template.md (definición del proyecto) +├── shared/.pi/extensions/ # capa opcional para Pi Agent (--pi): imports de CLAUDE.md, reglas y avisos ├── stacks/ # OVERLAYS: 13 stacks seleccionables + un `_common` por categoría │ ├── backend/{_common,nestjs,express,fastapi,django,php,spring,dotnet} │ ├── frontend/{_common,angular,react,nextjs} │ ├── mobile/{_common,react-native,flutter} │ └── blockchain/solidity ├── scripts/ # validate.cjs (integridad del kit) · stacks.cjs (fuente única) -└── test/ # tests del hook-handler y de --update (node:test, zero-dep) +└── test/ # tests de helpers, instalador y capa de Pi (node:test, zero-dep) ``` ## Uso diff --git a/install.js b/install.js index 67b9bca..cec959f 100644 --- a/install.js +++ b/install.js @@ -12,6 +12,8 @@ * 6. (opcional) Instala agentes/skills externos curados (claude-code-templates) y deps npm por stack. * 7. Indica los plugins de Claude Code a añadir (shared + por-stack). * 8. Imprime un resumen del proyecto (stack, tests, calidad, entrega, git, entorno) y sus huecos. + * 9. Con --pi, añade la capa para Pi Agent (shared/.pi → .pi) y asegura que .pi/settings.json + * cargue las skills de .claude/ (sin copiarlas). * * Flags: * --stack [:dir][,…] fuerza el stack (si no, autodetecta). En monorepo, uno por carpeta: @@ -19,6 +21,7 @@ * --yes, -y no interactivo: usa el default de cada confirmación * --all acepta TODO (externos + deps). Combina con --yes para desatendido total * --no-external no instala componentes externos (claude-code-templates / npm) + * --pi añade la capa para Pi Agent; --update la recuerda (manifiesto) * --update, --force sobrescribe la capa base con la última versión (conserva memoria y CLAUDE.project.md) * --dry-run simula: imprime qué haría, no escribe ni ejecuta * --help, -h muestra esta ayuda @@ -38,6 +41,7 @@ const FLAGS = { // Retirado junto con Ruflo (claude-flow). Se acepta para no romper scripts, pero no hace nada. noFlowLegacy: argv.includes("--no-flow"), noExternal: argv.includes("--no-external"), + pi: argv.includes("--pi"), update: argv.includes("--update") || argv.includes("--force"), dryRun: argv.includes("--dry-run"), help: argv.includes("--help") || argv.includes("-h"), @@ -60,6 +64,8 @@ Uso: node install.js [opciones] --yes, -y no interactivo: usa el default de cada confirmación (conservador) --all acepta TODO (externos + deps). Usa "--yes --all" para desatendido total --no-external no instala componentes externos + --pi añade la capa para Pi Agent (.pi/): usa las mismas skills y expande los + imports de CLAUDE.md. --update la recuerda. --update, --force actualiza la capa base sin perder nada tuyo: solo reescribe los archivos que siguen igual que como los dejó el kit (según .claude/.kit-manifest.json). Lo que hayas editado se conserva y la versión nueva queda como *.kit-new. @@ -654,15 +660,40 @@ const GITIGNORE = [".claude/memory/", ".claude/agent-memory-local/", ".claude/lo // captura. Git aplica la última regla que casa, así que las negaciones van SIEMPRE después. const GITIGNORE_KEEP = ["!.env.example", "!.env.sample", "!.env.template"]; +// ---------- .pi/settings.json (con --pi) ---------- +// Es de la persona (tema, modelo, paquetes…): el kit solo se asegura de que Pi cargue las skills +// de .claude/ y el comando /status (de .claude/commands/kit/status.md), sin copiarlos. /swarm no +// se carga: depende de los subagentes de Claude Code. Las rutas de ese archivo son relativas a .pi/. +const PI_RESOURCES = { skills: ["../.claude/skills"], prompts: ["../.claude/commands/kit/status.md"] }; +function mergePiSettings() { + const p = path.join(CWD, ".pi", "settings.json"); + const exists = fs.existsSync(p); + const cur = exists ? readJSON(p) : {}; + const keys = Object.keys(PI_RESOURCES); + if (!cur || typeof cur !== "object" || Array.isArray(cur) || keys.some((k) => cur[k] !== undefined && !Array.isArray(cur[k]))) { + console.log(` ⚠️ .pi/settings.json no tiene el formato esperado: no se toca. Agrega a mano ${keys.map((k) => `"${k}": ${JSON.stringify(PI_RESOURCES[k])}`).join(" y ")}.`); + return; + } + const next = { ...cur }; + for (const k of keys) next[k] = [...(cur[k] || []), ...PI_RESOURCES[k].filter((r) => !(cur[k] || []).includes(r))]; + if (JSON.stringify(next) === JSON.stringify(cur)) return; + const dry = FLAGS.dryRun ? "(dry-run) " : ""; + if (!FLAGS.dryRun) { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify(next, null, 2) + "\n"); + } + console.log(` ${dry}${exists ? "🔀" : "➕"} .pi/settings.json (skills y /status de .claude/)`); +} + // Asegura que .gitignore cubra memoria/runtime/backups/secretos (idempotente). -function ensureGitignore() { +function ensureGitignore(withPi = false) { const gi = path.join(CWD, ".gitignore"); const cur = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : ""; const have = new Set(cur.split(/\r?\n/).map((l) => l.trim())); // `npm init` no crea .gitignore y el propio kit instala devDeps: sin esto, el primer // `git add .` mete node_modules/ al repo. const node = fs.existsSync(path.join(CWD, "package.json")) && !have.has("node_modules") && !have.has("/node_modules") ? ["node_modules/"] : []; - const add = [...node, ...GITIGNORE].filter((n) => !have.has(n)); + const add = [...node, ...GITIGNORE, ...(withPi ? [".pi.backup.*"] : [])].filter((n) => !have.has(n)); // Si acabamos de añadir ".env.*", reemitimos las negaciones aunque ya existan más arriba: // colocadas antes del patrón no harían nada, y la línea duplicada es inocua. const keep = GITIGNORE_KEEP.filter((n) => !have.has(n) || add.includes(".env.*")); @@ -1163,11 +1194,18 @@ async function main() { } stacks = r.stacks; } + // --pi se recuerda en el manifiesto: un --update posterior no la pierde. + const withPi = FLAGS.pi || Boolean(prevManifest && prevManifest.pi); + const piDir = path.join(CWD, ".pi"); + // backup - if (fs.existsSync(claudeDir) && !FLAGS.dryRun) { - const bk = `.claude.backup.${Date.now()}`; - fs.cpSync(claudeDir, path.join(CWD, bk), { recursive: true }); - console.log(`\n📦 Backup: ${bk}`); + const stamp = Date.now(); + for (const dir of withPi ? [claudeDir, piDir] : [claudeDir]) { + if (fs.existsSync(dir) && !FLAGS.dryRun) { + const bk = `${path.basename(dir)}.backup.${stamp}`; + fs.cpSync(dir, path.join(CWD, bk), { recursive: true }); + console.log(`\n📦 Backup: ${bk}`); + } } if (FLAGS.noFlowLegacy) { @@ -1196,15 +1234,30 @@ async function main() { for (const r of stackRules(stacks)) applyFile(r.rel, r.buf, path.join(claudeDir, r.rel), layer); const remembered = retireFiles(layer, claudeDir); reportLayer(layer); + + // 1b) capa de Pi Agent: mismas reglas de manifiesto, en su propia sección. + const piLayer = { + prev: (prevManifest && prevManifest.pi && prevManifest.pi.files) || {}, + next: {}, + added: 0, updated: 0, same: 0, kept: 0, userOwned: 0, merged: false, conflicts: [], retiredSettings: [], + retired: 0, retiredKept: 0, + }; + if (withPi) { + console.log("\n🧩 Capa de Pi Agent (shared/.pi)..."); + applyLayer(path.join(KIT, "shared", ".pi"), piDir, piLayer); + mergePiSettings(); + reportLayer(piLayer); + } if (!FLAGS.dryRun) { - fs.writeFileSync(manifestPath, JSON.stringify({ version: VERSION, stacks, files: { ...remembered, ...layer.next } }, null, 2) + "\n"); + const m = { version: VERSION, stacks, files: { ...remembered, ...layer.next }, ...(withPi ? { pi: { files: piLayer.next } } : {}) }; + fs.writeFileSync(manifestPath, JSON.stringify(m, null, 2) + "\n"); } // 2) Definición del proyecto + CLAUDE.md raíz (añade/actualiza nuestro bloque; respeta lo demás) console.log("\n📝 Definición del proyecto y CLAUDE.md..."); const hasProject = await scaffoldProject(); composeClaudeMd(hasProject); - ensureGitignore(); + ensureGitignore(withPi); await scaffoldEnvExample(); // 3) restos de Ruflo (solo lectura) @@ -1285,6 +1338,15 @@ async function main() { console.log(` • CLAUDE.md compuesto (proyecto + baseline) y reglas del stack en .claude/rules/kit/: ${describeStacks(stacks)}`); console.log(" • Memoria: la nativa de Claude Code + la de agente en .claude/agent-memory/ (versionada); .gitignore actualizado"); console.log(" • Siguiente paso: abre Claude Code y corre /project-context para rellenar CLAUDE.project.md"); + if (withPi) { + console.log("\n🧩 Pi Agent (capa .pi/):"); + console.log(" • Abre pi en el proyecto y acepta su confianza: sin ella Pi no carga .pi/ (ni las skills, ni los"); + console.log(' imports de CLAUDE.md, ni las reglas). Con la capa activa verás "🧰 dev-kit" abajo.'); + console.log(" • Las skills se invocan como /skill: (/skill:project-context, /skill:tdd…) y el estado del kit con /status."); + console.log(" • En Pi no hay subagentes: /swarm y los agentes de .claude/agents/ son solo de Claude Code."); + console.log(" • Pi envía telemetría anónima de instalación por defecto: PI_TELEMETRY=0 la apaga."); + console.log(" • Instala ripgrep y fd con el gestor de paquetes de tu sistema: si faltan, Pi descarga la última versión sin fijarla."); + } try { summarizeProject(stacks); diff --git a/scripts/validate.cjs b/scripts/validate.cjs index 7725e05..d6428ff 100644 --- a/scripts/validate.cjs +++ b/scripts/validate.cjs @@ -8,6 +8,7 @@ * - Sin entidades HTML sin escapar (< / >) en agentes/skills * - JSON válido: components.json, shared/.claude/settings.json * - Sintaxis JS: install.js, helpers, este script + * - Capa de Pi: la extensión carga helpers de .claude/ que existen * - Overlays de stack presentes y no vacíos; plantilla de proyecto presente */ const fs = require("fs"); @@ -128,6 +129,18 @@ if (declIds.size === 0) errors.push("install.js: no se pudo extraer el objeto ST for (const id of declIds) if (!fsIds.has(id)) errors.push(`STACKS["${id}"] (install.js) sin overlay stacks/${id}/.claude/CLAUDE.md`); for (const id of fsIds) if (!declIds.has(id)) errors.push(`overlay stacks/${id} sin entrada en STACKS de install.js (no se detectaría)`); +// --- capa de Pi Agent --- +// La extensión carga helpers de .claude/ por ruta relativa: si uno se renombra o se borra, Pi +// fallaría al cargarla en cada proyecto con --pi. +for (const ext of walk(path.join(ROOT, "shared/.pi/extensions"), (p) => p.endsWith(".ts"))) { + const refs = [...fs.readFileSync(ext, "utf8").matchAll(/\.\.\/\.\.\/\.claude\/helpers\/([\w.-]+)/g)].map((m) => m[1]); + if (!refs.length) errors.push(`${rel(ext)}: no carga ningún helper de .claude/`); + for (const h of refs) { + if (!fs.existsSync(path.join(ROOT, "shared/.claude/helpers", h))) errors.push(`${rel(ext)}: carga .claude/helpers/${h}, que no existe`); + else ok.push(`Pi: ${path.basename(ext)} → ${h}`); + } +} + // --- es-MX: peninsularismos prohibidos (agentes, skills, overlays, comandos, plantillas, helpers; ver CONTRIBUTING.md) --- // Excluye CLAUDE.base.md y CONTRIBUTING.md a propósito: ahí vive el glosario que cita estos términos. const ESMX = /\b(coste|costes|montar|montamos|montas|ordenador|ordenadores|fichero|ficheros|vale|pillas|pillamos|pill[eé])\b/i; @@ -138,6 +151,7 @@ const esmxFiles = [ ...walk(path.join(ROOT, "shared/.claude/commands"), (p) => p.endsWith(".md")), ...walk(path.join(ROOT, "shared/templates"), (p) => p.endsWith(".md")), ...walk(path.join(ROOT, "shared/.claude/helpers"), (p) => /\.(cjs|mjs|js)$/.test(p)), + ...walk(path.join(ROOT, "shared/.pi"), (p) => p.endsWith(".ts")), path.join(ROOT, "install.js"), // es el texto que más lee el usuario: sale por consola en cada instalación ]; for (const f of esmxFiles) { diff --git a/shared/.claude/helpers/hook-handler.cjs b/shared/.claude/helpers/hook-handler.cjs index fbc7998..684abeb 100644 --- a/shared/.claude/helpers/hook-handler.cjs +++ b/shared/.claude/helpers/hook-handler.cjs @@ -313,8 +313,10 @@ switch (action) { // línea cada uno. Tras una compactación no se repiten (ya estaban en el contexto resumido). if (payload.source === "compact") break; const avisos = []; + // Dentro de Pi Agent (que marca a sus procesos con PI_CODING_AGENT=true) las skills son /skill:. + const skill = (n) => (process.env.PI_CODING_AGENT === "true" ? `/skill:${n}` : `/${n}`); if (projectDefinitionIsEmpty(path.join(ROOT, "CLAUDE.project.md"))) { - avisos.push("CLAUDE.project.md falta o sigue siendo la plantilla: sin él solo conoces reglas genéricas. Propón correr /project-context para rellenarlo con el contexto real del repo."); + avisos.push(`CLAUDE.project.md falta o sigue siendo la plantilla: sin él solo conoces reglas genéricas. Propón correr ${skill("project-context")} para rellenarlo con el contexto real del repo.`); } const pendientes = findKitNew(path.join(ROOT, ".claude")); if (pendientes.length) { diff --git a/shared/.pi/extensions/dev-starter-kit.ts b/shared/.pi/extensions/dev-starter-kit.ts new file mode 100644 index 0000000..3287ff7 --- /dev/null +++ b/shared/.pi/extensions/dev-starter-kit.ts @@ -0,0 +1,68 @@ +// Dev Starter Kit para Pi Agent. Pi lee CLAUDE.md, pero no resuelve sus @imports, no carga +// .claude/rules/ ni corre los hooks de Claude Code. Esta extensión cubre la base con los mismos +// helpers del kit: +// - expande los imports de CLAUDE.md (claude-imports.cjs): CLAUDE.project.md y la baseline; +// - añade las reglas de .claude/rules/ que Claude carga siempre, las que no tienen `paths:` +// (claude-rules.cjs); las de un monorepo, por carpeta, todavía no; +// - pone en el contexto los avisos del inicio de sesión (hook-handler.cjs session-start). +// Todo se calcula al iniciar, recargar o compactar la sesión y se reutiliza entre turnos, igual +// que Claude no relee CLAUDE.md a cada mensaje. +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const require = createRequire(import.meta.url); +const { expand } = require("../../.claude/helpers/claude-imports.cjs"); +const rules = require("../../.claude/helpers/claude-rules.cjs"); +const ROOT = fileURLToPath(new URL("../..", import.meta.url)); +const HOOKS = path.join(ROOT, ".claude", "helpers", "hook-handler.cjs"); + +function avisosDeInicio(): string { + const r = spawnSync("node", [HOOKS, "session-start"], { + input: JSON.stringify({ source: "startup" }), + encoding: "utf8", + env: { ...process.env, CLAUDE_PROJECT_DIR: ROOT, PI_CODING_AGENT: "true" }, + }); + return (r.stdout || "").trim(); +} + +export default function (pi: ExtensionAPI) { + let imports = new Map(); + let fijas: { path: string; content: string }[] = []; + let avisos = ""; + + const refrescar = (inicio: boolean) => { + imports = new Map(); + fijas = rules.load(ROOT).filter((r: { always: boolean }) => r.always).map((r: { file: string; body: string }) => ({ path: r.file, content: r.body })); + // Como el hook de Claude: los avisos solo al iniciar, no tras compactar (ya están en el resumen). + avisos = inicio ? avisosDeInicio() : ""; + }; + + pi.on("session_start", async (_event, ctx) => { + refrescar(true); + // Si la persona no ve este indicador, la capa del kit no está activa (proyecto sin confianza + // o Pi corriendo con --no-extensions). + if (ctx.hasUI) ctx.ui.setStatus("dev-starter-kit", "🧰 dev-kit"); + }); + + pi.on("session_compact", async () => refrescar(false)); + + pi.on("before_agent_start", async (event) => { + const opts = event.systemPromptOptions; + const files = (opts.contextFiles ??= []); + for (const file of files) { + if (path.basename(file.path).toLowerCase() !== "claude.md") continue; + let hit = imports.get(file.path); + if (hit && file.content === hit.expanded) continue; + if (!hit || hit.raw !== file.content) { + hit = { raw: file.content, expanded: expand(file.path, file.content) }; + imports.set(file.path, hit); + } + file.content = hit.expanded; + } + for (const regla of fijas) if (!files.some((f) => f.path === regla.path)) files.push(regla); + if (opts.sections && avisos) opts.sections["dev-starter-kit"] = avisos; + }); +} diff --git a/test/hook-handler.test.cjs b/test/hook-handler.test.cjs index 744083d..cce0262 100644 --- a/test/hook-handler.test.cjs +++ b/test/hook-handler.test.cjs @@ -128,7 +128,8 @@ test(".env emite additionalContext sin bloquear", () => { // ---- session-start: avisos accionables al abrir sesión ---- const fs = require("node:fs"); const os = require("node:os"); -function sessionStart(files, payload = { source: "startup" }) { +// `env` permite simular que corre dentro de Pi (PI_CODING_AGENT=true); por defecto, Claude Code. +function sessionStart(files, payload = { source: "startup" }, env = {}) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-session-")); for (const [nombre, contenido] of Object.entries(files)) { fs.mkdirSync(path.dirname(path.join(dir, nombre)), { recursive: true }); @@ -137,7 +138,7 @@ function sessionStart(files, payload = { source: "startup" }) { const r = spawnSync("node", [HH, "session-start"], { input: JSON.stringify(payload), encoding: "utf8", - env: { ...process.env, CLAUDE_PROJECT_DIR: dir }, + env: { ...(({ PI_CODING_AGENT, ...base }) => base)(process.env), CLAUDE_PROJECT_DIR: dir, ...env }, }); fs.rmSync(dir, { recursive: true, force: true }); return { code: r.status, out: r.stdout || "" }; @@ -153,6 +154,8 @@ test("session-start calla si CLAUDE.project.md está relleno", () => assert.equal(sessionStart({ "CLAUDE.project.md": RELLENO }).out, "")); test("session-start no repite avisos tras una compactación", () => assert.equal(sessionStart({}, { source: "compact" }).out, "")); +test("session-start dentro de Pi sugiere /skill:project-context", () => + assert.match(sessionStart({}, { source: "startup" }, { PI_CODING_AGENT: "true" }).out, /\/skill:project-context/)); test("session-start avisa de *.kit-new pendientes", () => assert.match(sessionStart({ "CLAUDE.project.md": RELLENO, ".claude/agents/coder.md.kit-new": "x" }).out, /kit-new/)); diff --git a/test/install-update.test.cjs b/test/install-update.test.cjs index 01b7af3..f5c7914 100644 --- a/test/install-update.test.cjs +++ b/test/install-update.test.cjs @@ -442,3 +442,73 @@ test("--update nunca toca la memoria ni CLAUDE.project.md", () => { assert.equal(fs.readFileSync(path.join(dir, "CLAUDE.project.md"), "utf8"), "# contexto real del proyecto"); fs.rmSync(dir, { recursive: true, force: true }); }); + +// ---- capa de Pi Agent (--pi) ---- +const manifiestoDe = (dir) => leerJSON(path.join(dir, ".claude", ".kit-manifest.json")); +const PI_SETTINGS = { skills: ["../.claude/skills"], prompts: ["../.claude/commands/kit/status.md"] }; + +test("sin --pi no crea .pi/ ni lo registra en el manifiesto", () => { + const dir = proyecto(); + assert.ok(!fs.existsSync(path.join(dir, ".pi"))); + assert.equal(manifiestoDe(dir).pi, undefined); + assert.doesNotMatch(fs.readFileSync(path.join(dir, ".gitignore"), "utf8"), /\.pi\.backup/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("--pi instala la extensión, los helpers que usa y .pi/settings.json con skills y /status", () => { + const dir = proyecto(); + const out = instalar(dir, ["--pi"]); + assert.ok(fs.existsSync(path.join(dir, ".pi/extensions/dev-starter-kit.ts"))); + for (const h of ["claude-imports.cjs", "claude-rules.cjs"]) assert.ok(fs.existsSync(path.join(dir, ".claude/helpers", h)), h); + assert.deepEqual(leerJSON(path.join(dir, ".pi/settings.json")), PI_SETTINGS); + assert.ok(manifiestoDe(dir).pi.files["extensions/dev-starter-kit.ts"]); + assert.match(out, /PI_TELEMETRY=0/); + assert.match(fs.readFileSync(path.join(dir, ".gitignore"), "utf8"), /^\.pi\.backup\.\*$/m); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("--update recuerda --pi y respeta la extensión editada", () => { + const dir = proyecto(); + instalar(dir, ["--pi"]); + fs.rmSync(path.join(dir, ".pi/settings.json")); + instalar(dir, ["--update"]); + assert.ok(fs.existsSync(path.join(dir, ".pi/settings.json")), "sin --pi, --update vuelve a asegurar la capa"); + const ext = path.join(dir, ".pi/extensions/dev-starter-kit.ts"); + fs.appendFileSync(ext, "\n// mi ajuste\n"); + instalar(dir, ["--update"]); + assert.match(fs.readFileSync(ext, "utf8"), /mi ajuste/); + assert.ok(manifiestoDe(dir).pi); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("--pi fusiona un .pi/settings.json existente sin duplicar", () => { + const dir = proyecto(); + fs.mkdirSync(path.join(dir, ".pi")); + fs.writeFileSync(path.join(dir, ".pi/settings.json"), JSON.stringify({ theme: "dark", skills: ["../mis-skills"] })); + instalar(dir, ["--pi"]); + instalar(dir, ["--pi", "--update"]); + assert.deepEqual(leerJSON(path.join(dir, ".pi/settings.json")), { + theme: "dark", + skills: ["../mis-skills", "../.claude/skills"], + prompts: ["../.claude/commands/kit/status.md"], + }); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("--pi no toca un .pi/settings.json que no es un objeto JSON", () => { + const dir = proyecto(); + fs.mkdirSync(path.join(dir, ".pi")); + fs.writeFileSync(path.join(dir, ".pi/settings.json"), "{ esto no es json"); + const out = instalar(dir, ["--pi"]); + assert.equal(fs.readFileSync(path.join(dir, ".pi/settings.json"), "utf8"), "{ esto no es json"); + assert.match(out, /no se toca/); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("--dry-run --pi no escribe nada", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-update-")); + fs.writeFileSync(path.join(dir, "package.json"), '{"name":"x","dependencies":{"@nestjs/core":"^10"}}'); + instalar(dir, ["--dry-run", "--pi"]); + assert.deepEqual(fs.readdirSync(dir), ["package.json"]); + fs.rmSync(dir, { recursive: true, force: true }); +}); diff --git a/test/pi.test.cjs b/test/pi.test.cjs new file mode 100644 index 0000000..ab89b72 --- /dev/null +++ b/test/pi.test.cjs @@ -0,0 +1,98 @@ +// Integración con Pi Agent real, sin modelo: el proveedor simulado de Pi ejecuta pasos guionizados +// y el arnés guarda el prompt de sistema, el primer mensaje, las herramientas activas y el +// resultado de cada llamada. Corre solo si PI_BIN apunta al binario de Pi (la CI instala 1.4.0). +const { test } = require("node:test"); +const assert = require("node:assert"); +const { spawnSync } = require("node:child_process"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const PI = process.env.PI_BIN; +const skip = PI ? false : "define PI_BIN con la ruta al binario de Pi para correr estas pruebas"; +const INSTALL = path.join(__dirname, "..", "install.js"); +// Arnés que Pi carga con -e. Va como texto porque Node trata los .ts de test/ como pruebas. +// HARNESS_STEPS: [{tool, args} | {text}]. Guarda system.txt, user.txt, tools.json y log.json en HARNESS_OUT. +const HARNESS = ` +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { fauxAssistantMessage, fauxProvider, fauxText, fauxToolCall, getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +export default function (pi: ExtensionAPI) { + const out = process.env.HARNESS_OUT ?? "."; + const steps = JSON.parse(process.env.HARNESS_STEPS ?? '[{"text":"ok"}]'); + const log: unknown[] = []; + const texto = (m: any) => (typeof m.content === "string" ? m.content : m.content.map((c: any) => c.text ?? "").join("")); + const faux = fauxProvider(); + faux.setResponses(steps.map((step: any, i: number) => (context: any) => { + if (i === 0) { + fs.writeFileSync(path.join(out, "system.txt"), getCurrentSystemPrompt(context.messages)); + fs.writeFileSync(path.join(out, "user.txt"), texto(context.messages.find((m: any) => m.role === "user"))); + fs.writeFileSync(path.join(out, "tools.json"), JSON.stringify(getCurrentTools(context.messages).map((t: any) => t.name))); + } + const last = context.messages[context.messages.length - 1]; + if (last?.role === "toolResult") log.push({ tool: last.toolName, error: Boolean(last.isError), text: texto(last) }); + fs.writeFileSync(path.join(out, "log.json"), JSON.stringify(log)); + return fauxAssistantMessage(step.tool ? fauxToolCall(step.tool, step.args ?? {}) : fauxText(step.text ?? "ok")); + })); + pi.registerProvider(faux.provider); +} +`; + +// Instala el kit en un proyecto NestJS temporal y corre Pi una vez con los pasos dados. +function correrPi({ install = ["--stack", "backend/nestjs", "--pi"], prompt = "hola", piArgs = [], steps = [{ text: "ok" }], antes } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-pi-")); + const agente = path.join(os.tmpdir(), `kit-pi-agente-${path.basename(dir)}`); + fs.mkdirSync(agente); + // Configuración de Pi aislada: confía en la carpeta de prueba y no envía telemetría. + fs.writeFileSync(path.join(agente, "settings.json"), JSON.stringify({ defaultProjectTrust: "always", enableInstallTelemetry: false })); + fs.writeFileSync(path.join(agente, "harness.ts"), HARNESS); + fs.writeFileSync(path.join(dir, "package.json"), '{"name":"x","dependencies":{"@nestjs/core":"^10"}}'); + const inst = spawnSync("node", [INSTALL, "--yes", "--no-external", ...install], { cwd: dir, input: "", encoding: "utf8" }); + assert.equal(inst.status, 0, inst.stdout + inst.stderr); + if (antes) antes(dir); + const r = spawnSync(PI, ["-e", path.join(agente, "harness.ts"), "--provider", "faux", "--model", "faux-1", "--no-session", ...piArgs, "-p", prompt], { + cwd: dir, + input: "", + encoding: "utf8", + timeout: 60000, + env: { ...process.env, PI_CODING_AGENT_DIR: agente, HARNESS_OUT: agente, HARNESS_STEPS: JSON.stringify(steps), PI_OFFLINE: "1", PI_SKIP_VERSION_CHECK: "1", PI_TELEMETRY: "0" }, + }); + assert.equal(r.status, 0, r.stdout + r.stderr); + const leer = (f) => fs.readFileSync(path.join(agente, f), "utf8"); + const res = { prompt: leer("system.txt"), user: leer("user.txt"), tools: JSON.parse(leer("tools.json")), log: JSON.parse(leer("log.json")), dir }; + fs.rmSync(agente, { recursive: true, force: true }); + return res; +} +const limpiar = (res) => fs.rmSync(res.dir, { recursive: true, force: true }); + +test("con --pi, Pi ve la baseline, el proyecto y las reglas del stack, y carga las skills", { skip }, () => { + const res = correrPi(); + assert.match(res.prompt, /## 3\. Coordinación de agentes/, "contenido de .claude/CLAUDE.base.md"); + assert.match(res.prompt, /# Stack: NestJS/, "regla del stack sin paths: entra siempre"); + assert.doesNotMatch(res.prompt, /^@(CLAUDE\.project|\.claude\/CLAUDE\.base)\.md$/m, "no quedan líneas de import"); + for (const s of ["project-context", "tdd", "owasp-review", "loop-task"]) assert.match(res.prompt, new RegExp(`${s}`)); + assert.match(res.prompt, /[\s\S]*\/skill:project-context[\s\S]*<\/dev-starter-kit>/, "avisos de inicio con sintaxis de Pi"); + limpiar(res); +}); + +test("/status expande el comando kit/status de .claude/commands", { skip }, () => { + const res = correrPi({ prompt: "/status" }); + assert.match(res.user, /Reporta de forma concisa/); + limpiar(res); +}); + +test("en monorepo, las reglas con paths: no entran siempre (todavía no se cargan por carpeta)", { skip }, () => { + const res = correrPi({ install: ["--stack", "backend/nestjs:apps/api,frontend/nextjs:apps/web", "--pi"] }); + assert.doesNotMatch(res.prompt, /# Stack: NestJS|# Stack: Next\.js/); + assert.match(res.prompt, /## 3\. Coordinación de agentes/); + limpiar(res); +}); + +test("sin --pi, Pi ve las líneas @ sin expandir y no carga las skills", { skip }, () => { + const res = correrPi({ install: ["--stack", "backend/nestjs"] }); + assert.match(res.prompt, /^@\.claude\/CLAUDE\.base\.md$/m); + assert.doesNotMatch(res.prompt, /tdd<\/name>/); + limpiar(res); +}); From 242f7f206a4859fb3e6dad47756e8ac94e20997b Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:00:03 +0100 Subject: [PATCH 3/4] fix: skills y baseline sin supuestos exclusivos de Claude Code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las 9 skills que nombran archivos del proyecto aclaran que sus rutas parten de la raíz (Pi dice que son relativas a la carpeta de la skill). swarm-orchestration y la sección 3 de la baseline dicen que los subagentes son solo de Claude Code; loop-task avisa que en Pi todavía no tiene su puerta. --- shared/.claude/CLAUDE.base.md | 1 + shared/.claude/skills/adr/SKILL.md | 2 ++ shared/.claude/skills/ci-cd/SKILL.md | 2 ++ shared/.claude/skills/git-worktrees/SKILL.md | 2 ++ shared/.claude/skills/handoff/SKILL.md | 2 ++ shared/.claude/skills/loop-task/SKILL.md | 4 ++++ shared/.claude/skills/monorepo/SKILL.md | 2 ++ shared/.claude/skills/owasp-review/SKILL.md | 2 ++ shared/.claude/skills/project-context/SKILL.md | 2 ++ shared/.claude/skills/ship-gate/SKILL.md | 2 ++ shared/.claude/skills/swarm-orchestration/SKILL.md | 2 ++ 11 files changed, 23 insertions(+) diff --git a/shared/.claude/CLAUDE.base.md b/shared/.claude/CLAUDE.base.md index 2155558..4c69c0b 100644 --- a/shared/.claude/CLAUDE.base.md +++ b/shared/.claude/CLAUDE.base.md @@ -42,6 +42,7 @@ Nunca en la raíz. En `.gitignore`: `/docs/*` con `!/docs/adr/` después (no `/d ## 3. Coordinación de agentes (nativa de Claude Code) La orquestación multiagente usa solo herramientas nativas (tool `Agent`, agentes de `.claude/agents/`, worktrees). Sin runtimes de orquestación de terceros. +En Pi Agent esta sección no aplica: Pi no tiene subagentes nativos, así que ahí el trabajo va en secuencia en la misma sesión. - **Agentes core** (ver `.claude/agents/`): - `planner` → antes de toda feature grande: descompone, identifica riesgos, secuencia. diff --git a/shared/.claude/skills/adr/SKILL.md b/shared/.claude/skills/adr/SKILL.md index f8d67ab..8ad0e09 100644 --- a/shared/.claude/skills/adr/SKILL.md +++ b/shared/.claude/skills/adr/SKILL.md @@ -8,6 +8,8 @@ argument-hint: "[título de la decisión]" Eres quien cuida la memoria de decisiones del proyecto. Un ADR captura **por qué** se eligió algo cuando se eligió, con las alternativas y sus consecuencias, para que nadie tenga que reconstruirlo leyendo código ni volver a discutirlo sin datos nuevos. Viven en `docs/adr/` y **se versionan** (la baseline los exceptúa del `/docs` ignorado): son historia del proyecto. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Cuándo sí y cuándo no - **Sí**: decisiones caras de revertir, que afectan a varios módulos o equipos, que eligen entre alternativas razonables, o que imponen una restricción (seguridad, compliance, costo). - **No**: detalles de implementación locales, preferencias de estilo (eso va a lint o a `CLAUDE.project.md`), o lo que ya decide el framework. diff --git a/shared/.claude/skills/ci-cd/SKILL.md b/shared/.claude/skills/ci-cd/SKILL.md index acfc565..b423896 100644 --- a/shared/.claude/skills/ci-cd/SKILL.md +++ b/shared/.claude/skills/ci-cd/SKILL.md @@ -7,6 +7,8 @@ description: Úsalo al configurar CI/CD en un proyecto nuevo, refactorizar pipel Eres un ingeniero de plataforma senior. Generas pipelines a partir de **señales reales del repo** (lockfiles, manifiestos, frameworks), no de adivinanzas. Tu objetivo: feedback rápido, checks repetibles y despliegues seguros y reversibles. Un pipeline que tarda 30 min o que es flaky no se usa. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Detección primero Inspecciona el repo para inferir lenguaje/runtime/gestor (package.json+lock, pyproject, go.mod, Cargo.toml, composer.json, pubspec.yaml, foundry.toml, Dockerfile, turbo.json/nx.json). El pipeline se deriva de lo que **hay**. diff --git a/shared/.claude/skills/git-worktrees/SKILL.md b/shared/.claude/skills/git-worktrees/SKILL.md index 36a94a3..f9b676f 100644 --- a/shared/.claude/skills/git-worktrees/SKILL.md +++ b/shared/.claude/skills/git-worktrees/SKILL.md @@ -7,6 +7,8 @@ description: Úsalo al empezar trabajo (una feature, un plan de implementación, Aísla el trabajo en un workspace propio para no tocar la rama actual. **Orden de preferencia: detecta aislamiento existente → usa la herramienta nativa → recurre a `git worktree` solo si no hay nativa.** Nunca pelees con el harness creando estado que no puede ver. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Paso 0 — ¿ya estás aislado? Antes de crear nada, compruébalo: diff --git a/shared/.claude/skills/handoff/SKILL.md b/shared/.claude/skills/handoff/SKILL.md index ef81d94..f07c89e 100644 --- a/shared/.claude/skills/handoff/SKILL.md +++ b/shared/.claude/skills/handoff/SKILL.md @@ -7,6 +7,8 @@ description: Traspaso experto de contexto — comprime el estado del trabajo en Eres un experto en transferencia de conocimiento y gestión de contexto. Sabes que el valor de un handoff no está en contarlo todo, sino en capturar **exactamente lo que el siguiente no puede reconstruir leyendo el código**: las decisiones, el por qué, el estado en vuelo y las trampas. Escribes para alguien competente que llega frío: ni le insultas explicando lo obvio, ni le dejas adivinar lo crítico. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Qué capturar (y qué omitir) Captura lo **no derivable del repo**; omite lo que un buen ingeniero deduce leyendo el código. diff --git a/shared/.claude/skills/loop-task/SKILL.md b/shared/.claude/skills/loop-task/SKILL.md index 1e05c5e..21edfd7 100644 --- a/shared/.claude/skills/loop-task/SKILL.md +++ b/shared/.claude/skills/loop-task/SKILL.md @@ -8,6 +8,10 @@ argument-hint: " — verificador: [— tope: N turnos]" En vez de pedirle a Claude cada paso, diseñas el **ciclo** que trabaja por ti: un objetivo, un verificador que el agente **no puede falsear**, un tope y una revisión independiente al final. Claude no decide cuándo terminó: lo decide el verificador. El ciclo aporta velocidad, pero no sustituye a la persona: ella revisa el resultado antes de hacer merge. +**Solo en Claude Code por ahora:** la puerta del ciclo es un hook `Stop` de Claude. En Pi Agent esta skill todavía no tiene su puerta: no la uses ahí. + +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Cuándo sí y cuándo no - **Sí**: el final se puede comprobar con un comando (tests, typecheck, lint, build) y el camino tiene muchos pasos parecidos: implementar un plan ya decidido, migrar llamadas a un API nuevo, arreglar una tanda de tests rotos, subir la cobertura de un módulo. - **No**: exploración o diseño ("¿cómo debería…?"), decisiones de producto o de arquitectura, tareas sin verificador posible, nada que toque producción, datos compartidos o despliegues. Tampoco tareas de un solo paso: un ciclo no compensa su costo. diff --git a/shared/.claude/skills/monorepo/SKILL.md b/shared/.claude/skills/monorepo/SKILL.md index f97dd68..4dbc3a5 100644 --- a/shared/.claude/skills/monorepo/SKILL.md +++ b/shared/.claude/skills/monorepo/SKILL.md @@ -7,6 +7,8 @@ description: Úsalo al configurar un monorepo, optimizar el CI de un workspace g Eres un experto en sistemas de build de monorepos. Sabes que el valor de un monorepo (compartir código, cambios atómicos cross-package) se pierde si cada cambio reconstruye y testea todo. Tu trabajo: que solo se construya y pruebe **lo afectado**, con caché agresiva y límites de paquete claros. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Herramientas y cuándo - **Turborepo**: pipelines (`turbo.json`) con `dependsOn`, caché local y remota, `--filter` por paquete afectado. Ideal con pnpm. - **Nx**: grafo de dependencias, `nx affected`, generadores, caché distribuida; más potente y más opinado. diff --git a/shared/.claude/skills/owasp-review/SKILL.md b/shared/.claude/skills/owasp-review/SKILL.md index 4d15f4e..37d284e 100644 --- a/shared/.claude/skills/owasp-review/SKILL.md +++ b/shared/.claude/skills/owasp-review/SKILL.md @@ -7,6 +7,8 @@ description: Úsalo al revisar o diseñar código con impacto de seguridad (aute Eres un ingeniero de AppSec que revisa con estándares públicos en la mano, no con intuición. Cada hallazgo sale con su **CWE**, su categoría del **OWASP Top 10:2025** y, si aplica, el requisito de **ASVS 5.0** que incumple: así cualquiera puede verificarlo, priorizarlo y rastrearlo. Distingues lo confirmado (trazaste el dato desde la entrada hasta el sink) de lo sospechado, y lo dices. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill, salvo `referencia.md`, que está junto a este archivo. + Las listas completas (las 10 categorías con qué buscar, los 17 capítulos de ASVS, LLM y agentes, MASVS, API) están en [referencia.md](referencia.md). Léela cuando llegues al paso que la necesita. ## 1. Alcance y nivel diff --git a/shared/.claude/skills/project-context/SKILL.md b/shared/.claude/skills/project-context/SKILL.md index 9b94961..a0a205e 100644 --- a/shared/.claude/skills/project-context/SKILL.md +++ b/shared/.claude/skills/project-context/SKILL.md @@ -8,6 +8,8 @@ argument-hint: "[área o paquete a profundizar | ruta a design.md]" Eres quien hace el onboarding de un agente en un repo que no conoce. Sabes que el mayor salto de calidad no viene de más reglas genéricas, sino de que el agente sepa **cómo funciona este proyecto en concreto**: con qué se construye, cómo se prueba, dónde vive cada cosa y qué no se puede romper. Tu entrega es `CLAUDE.project.md`, que el `CLAUDE.md` raíz importa en cada sesión. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill, salvo `desde-design.md`, que está junto a este archivo. + ## Principios - **Verificado o marcado**: lo que escribes sale de un archivo que leíste o de un comando que corriste. Lo que no puedes confirmar va como pregunta, no como afirmación. - **Solo lo que no se deduce rápido**: nada de listar cada carpeta ni repetir lo que dice el código. Lo que un ingeniero nuevo tardaría una mañana en averiguar. diff --git a/shared/.claude/skills/ship-gate/SKILL.md b/shared/.claude/skills/ship-gate/SKILL.md index a32b079..58857f2 100644 --- a/shared/.claude/skills/ship-gate/SKILL.md +++ b/shared/.claude/skills/ship-gate/SKILL.md @@ -7,6 +7,8 @@ description: Úsalo antes de desplegar a producción — "¿estoy listo para shi Eres un revisor de release implacable. Antes de que algo salga a producción, escaneas el repo por categorías de riesgo y emites **pass / fail / manual** por cada una. Si hay algo crítico en rojo, **bloqueas el despliegue** y dices exactamente qué falta. Stack-agnóstico. +Las rutas que nombra esta skill (`CLAUDE.project.md`, `docs/adr/`, `.claude/…`) son relativas a la raíz del proyecto, donde está `CLAUDE.md`; no a la carpeta de la skill. + ## Comportamiento de intercepción Cuando detectes intención de desplegar ("deploy", "push to prod", "go live", "release"), ejecuta el gate primero. No des el OK hasta que **todos los CRITICAL** pasen. diff --git a/shared/.claude/skills/swarm-orchestration/SKILL.md b/shared/.claude/skills/swarm-orchestration/SKILL.md index 94a53d0..6d09d77 100644 --- a/shared/.claude/skills/swarm-orchestration/SKILL.md +++ b/shared/.claude/skills/swarm-orchestration/SKILL.md @@ -7,6 +7,8 @@ description: Úsalo al escalar más allá de un solo agente — features grandes Eres un experto en sistemas distribuidos y coordinación multiagente. Tratas a un equipo de agentes como un sistema concurrente: hay que descomponer para paralelizar, definir contratos claros entre tareas, evitar coordinación innecesaria (es cara) y **verificar de forma independiente** lo producido. Sabes que más agentes no es mejor: el overhead de coordinación crece y, pasado un punto, resta. Escalas el equipo a la tarea, no al revés. +**Solo en Claude Code:** depende del tool `Agent` y de los subagentes de `.claude/agents/`. Pi Agent no tiene subagentes nativos: ahí haz el trabajo en secuencia, en la misma sesión. + Todo con herramientas nativas de Claude Code: el tool `Agent` para lanzar subagentes, los agentes de `.claude/agents/`, worktrees para aislar escrituras y la memoria de agente para lo que deba sobrevivir a la sesión. Sin runtimes de orquestación de terceros (ver `components.json` → `discarded`). ## Cuándo orquestar (y cuándo no) From 2baac342741816bfd9d4dc977fdd9fc9c4acda68 Mon Sep 17 00:00:00 2001 From: lacasoft Date: Fri, 30 Oct 2026 00:00:03 +0100 Subject: [PATCH 4/4] docs: changelog de la base para Pi Agent --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c1d03b8..93d1f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/) y [SemVer](htt ## [No publicado] +### Added +- **Base para Pi Agent (`--pi`)**: primera parte del soporte para [Pi Agent](https://github.com/earendil-works/pi) ≥ 1.4.0, el mismo enfoque que system-design-kit 1.2.0. Es una capa aditiva: sin `--pi` el instalador hace exactamente lo de antes. Con `--pi`: + - `.pi/settings.json` carga las skills desde `.claude/skills` y el comando `/status` desde `.claude/commands/kit/status.md`, sin copias. `/swarm` no se carga, porque depende de los subagentes de Claude Code. Si el archivo ya existe se fusiona, y si no es JSON válido no se toca. + - `.pi/extensions/dev-starter-kit.ts`: + - Expande los `@imports` de `CLAUDE.md` (`CLAUDE.project.md` y la baseline), que Pi lee pero no resuelve. + - Añade las reglas de `.claude/rules/` que Claude Code carga siempre, las que no tienen `paths:`: las del stack en un proyecto de un solo stack y las propias del equipo. En monorepo, las reglas por carpeta todavía no se cargan en Pi. + - Pone en el contexto los avisos del inicio de sesión. + - Muestra `🧰 dev-kit` en la barra de estado cuando la capa está activa. + - Helpers nuevos, zero-dep: `claude-imports.cjs`, el mismo de system-design-kit (reglas de Claude Code; nunca incluye `.env` reales ni `~/.ssh` o `~/.aws`), y `claude-rules.cjs`, que lee `.claude/rules/` como Claude Code (con o sin `paths:`). + - El manifiesto registra la capa, así que `--update` la recuerda. Al terminar, el instalador explica la confianza del proyecto, `PI_TELEMETRY=0`, por qué conviene instalar ripgrep y fd, y que en Pi no hay subagentes. +- CI: job que prueba la capa dentro de Pi 1.4.0 real con su proveedor simulado, sin modelo ni API key. + +### Changed +- Dentro de Pi (`PI_CODING_AGENT=true`), el aviso de inicio de `hook-handler.cjs` sugiere `/skill:project-context`. +- Las 9 skills que nombran archivos del proyecto aclaran que sus rutas son relativas a la raíz del proyecto. Pi le dice al modelo que las referencias de una skill son relativas a la carpeta de la skill, y en las pruebas un modelo pequeño buscaba los archivos ahí. +- `swarm-orchestration` y la sección 3 de la baseline dicen que los subagentes son solo de Claude Code. `loop-task` avisa que en Pi todavía no tiene su puerta. + ## [3.2.0] - 2026-10-27 ### Added