diff --git a/CHANGELOG.md b/CHANGELOG.md index d03e315..50bd544 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/) y [SemVer](htt ## [No publicado] +## [3.4.0] - 2026-10-30 + +Completa el soporte para [Pi Agent](https://github.com/earendil-works/pi): reglas por carpeta en monorepos y `/loop-task`. Para Claude Code no cambia nada. + +### Notas de actualización +- `npx -y github:lacasoft/dev-starter-kit#v3.4.0 --update --yes`. Si ya instalaste con `--pi`, la extensión de Pi se actualiza sola, salvo que la hayas editado: en ese caso queda la versión nueva como `*.kit-new`. + +### Added +- **Reglas por carpeta en Pi (monorepos).** Las reglas de `.claude/rules/` con `paths:` (un stack por carpeta) entran como en Claude Code: la primera vez que el agente lee o edita un archivo de su carpeta, la extensión anexa la regla al resultado, sin mezclar stacks. Vuelven a entrar después de compactar. `claude-rules.cjs` gana `appliesTo` (globs relativos a la raíz, incluidas las carpetas con corchetes que escapa el instalador). +- **`/loop-task` en Pi.** La extensión corre la puerta del ciclo al cerrar cada turno, como el hook `Stop` de Claude: mismo verificador, mismo tope, mismas protecciones. Pi no tiene subagentes, así que el cierre lo aprueba la persona: + - Con el verificador en verde, el agente repasa el diff y escribe el *Resumen para la persona*. + - Pi pregunta si se aprueba el cierre; si no, pide qué falta y el ciclo sigue. + - Sin interfaz (`pi -p`), el ciclo queda `awaiting-approval` hasta que la persona corra `loop.cjs approve` o `reject --reason "…"` en su terminal. + - Es una garantía más fuerte que la de Claude: allí la revisión del `reviewer` es una instrucción, y aquí es una confirmación explícita. +- `loop.cjs`: `gate --approval`, `approve` y `reject`. Sin `--approval`, la puerta de Claude se comporta igual que antes; sus pruebas no cambiaron. + +### Security +- `pre-bash` niega que el agente corra `loop.cjs approve` o `reject`: aprobar el cierre lo decide la persona, no quien hizo el trabajo. + ## [3.3.0] - 2026-10-30 Soporte opcional para [Pi Agent](https://github.com/earendil-works/pi) ≥ 1.4.0: contexto y guardrails. Para Claude Code no cambia nada. diff --git a/README.md b/README.md index 0fbb822..c9177a9 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Dentro de **cualquier proyecto** (nuevo o heredado), sin clonar nada: ```bash npx -y github:lacasoft/dev-starter-kit # interactivo npx -y github:lacasoft/dev-starter-kit --yes --all # desatendido (todo) -npx -y github:lacasoft/dev-starter-kit#v3.3.0 # fijar una versión (recomendado) +npx -y github:lacasoft/dev-starter-kit#v3.4.0 # fijar una versión (recomendado) ``` Toma siempre la última versión de `master` (o el tag indicado). Repo **público (MIT)**. @@ -257,7 +257,7 @@ git switch -c feat/migracion-billing # nunca en la rama principal | **Solo comandos de verificación** | El verificador solo admite test, lint, typecheck o build del proyecto encadenados con `&&`: el hook no ejecuta shell arbitrario. | | **No se puede hacer trampa** | El estado del ciclo no se edita (hooks `pre-edit` y `pre-bash`), y si cambia el script de `package.json` que usa el verificador, la puerta lo rechaza. | | **Línea base** | Si el verificador ya pasa antes de empezar, el ciclo no arranca: no mide lo que quieres lograr. | -| **Quien hace no aprueba** | En verde, el ciclo exige pasar el agente `reviewer` por el diff y un *Resumen para la persona* antes de cerrar. | +| **Quien hace no aprueba** | En verde, el ciclo exige pasar el agente `reviewer` por el diff y un *Resumen para la persona* antes de cerrar. En [Pi](#también-con-pi-agent), sin subagentes, el cierre lo apruebas tú. | | **Tope** | 10 turnos por defecto (máximo 50). Al agotarse, se detiene y deja el informe en `.loop/PROGRESS.md`. | El ciclo no hace commit, push ni merge: el resultado se revisa como cualquier PR. Para finales que @@ -287,8 +287,8 @@ modelos de varios proveedores, también locales. Con `--pi`, el kit añade una c las mismas skills, reglas y helpers: no hay una segunda copia que mantener. ```bash -npx -y github:lacasoft/dev-starter-kit#v3.3.0 --pi # proyecto nuevo -npx -y github:lacasoft/dev-starter-kit#v3.3.0 --update --pi --yes # proyecto que ya usa el kit +npx -y github:lacasoft/dev-starter-kit#v3.4.0 --pi # proyecto nuevo +npx -y github:lacasoft/dev-starter-kit#v3.4.0 --update --pi --yes # proyecto que ya usa el kit ``` **Requisitos:** Pi 1.4.0 o superior (paquete `@earendil-works/pi-coding-agent`, pide Node.js 22.19+), @@ -302,12 +302,20 @@ sin fijarla) y un modelo que use herramientas de forma confiable. | `CLAUDE.md` con `@imports` y reglas de `.claude/rules/` | La extensión expande los imports y añade las reglas | | Hooks `PreToolUse` y permisos de `settings.json` | La extensión aplica las mismas reglas (ver abajo) | | `/swarm`, subagentes de `.claude/agents/` | No disponibles: Pi no tiene subagentes nativos | -| `/loop-task` | Todavía no: su puerta es un hook `Stop` de Claude | +| `/loop-task` con el `reviewer` al cerrar | `/skill:loop-task`: la extensión corre la puerta al cerrar cada turno y el cierre lo apruebas tú | **Contexto.** Pi lee `CLAUDE.md`, pero no resuelve sus `@imports` ni carga `.claude/rules/`. La extensión expande `CLAUDE.project.md` y la baseline, añade las reglas que Claude carga siempre (las del stack en un -proyecto de un solo stack y las propias del equipo) y los avisos del inicio de sesión. En un monorepo, las -reglas por carpeta (`paths:`) todavía no se cargan en Pi. +proyecto de un solo stack y las propias del equipo) y los avisos del inicio de sesión. En un monorepo, +cada regla por carpeta (`paths:`) entra como en Claude: la primera vez que el agente lee o edita un +archivo de su carpeta, y otra vez después de compactar. + +**`/loop-task`.** La puerta del ciclo corre al cerrar cada turno, con el mismo verificador y el mismo +tope que en Claude. La diferencia es el cierre: Pi no tiene subagentes, así que no hay `reviewer`. +Con el verificador en verde y el resumen escrito, Pi te pregunta si apruebas el cierre; si dices que +no, le explicas qué falta y el ciclo sigue. Sin interfaz (`pi -p`), el ciclo queda esperando hasta que +corras `node .claude/helpers/loop.cjs approve` (o `reject --reason "…"`) en tu terminal; el agente no +puede correrlos. **Seguridad en Pi.** Pi no tiene sistema de permisos: sin la extensión, ejecuta cualquier herramienta sin preguntar. Con `--pi`, antes de cada herramienta, incluidas las llamadas anidadas desde codemode, la extensión aplica: @@ -418,7 +426,7 @@ recibir la copia vieja. La solución buena es la misma que exige la baseline §6 versión** — y de paso hace la instalación reproducible: ```bash -npx -y github:lacasoft/dev-starter-kit#v3.3.0 --update --yes # por tag +npx -y github:lacasoft/dev-starter-kit#v3.4.0 --update --yes # por tag npx -y github:lacasoft/dev-starter-kit# --update --yes # por commit (el SHA del tag) ``` @@ -440,4 +448,4 @@ Repo **público (MIT)** distribuido por `npx github:` → la rama **`master` deb (dry-run) y en un monorepo multi-stack, en cada PR, más un job que prueba la capa de Pi dentro de Pi 1.4.0. La lista sale de `scripts/stacks.cjs`, que la deriva de `install.js`. - **Cómo añadir** agentes, skills, stacks o externos: ver [CONTRIBUTING.md](CONTRIBUTING.md). - **Versionado**: SemVer en `package.json` + `CHANGELOG.md`, con tag anotado por versión - (`v3.3.0`). Instala siempre pineando (`#v3.3.0`): es reproducible y evita la cache de npx. + (`v3.4.0`). Instala siempre pineando (`#v3.4.0`): es reproducible y evita la cache de npx. diff --git a/install.js b/install.js index dc830cd..f2b186c 100644 --- a/install.js +++ b/install.js @@ -1345,7 +1345,8 @@ async function main() { console.log(" • Las skills se invocan como /skill: (/skill:project-context, /skill:tdd…) y el estado del kit con /status."); console.log(" • Guardrails: antes de cada herramienta se aplican las reglas del kit y las deny/ask de .claude/settings.json;"); console.log(" lo que pide confirmación se bloquea si no hay interfaz (pi -p). No es aislamiento: para eso, un contenedor o una VM."); - console.log(" • En Pi no hay subagentes: /swarm y los agentes de .claude/agents/ son solo de Claude Code."); + console.log(" • En Pi no hay subagentes: /swarm y los agentes de .claude/agents/ son solo de Claude Code. En /loop-task,"); + console.log(" el cierre lo apruebas tú (sin interfaz: node .claude/helpers/loop.cjs approve | reject)."); 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."); } diff --git a/package.json b/package.json index fc9f3c6..7d6c4b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lacasoft/dev-starter-kit", - "version": "3.3.0", + "version": "3.4.0", "private": true, "description": "Base .claude coherente (agentes, skills, helpers, orquestación multiagente nativa) instalable en cualquier proyecto lacasoft (backend/frontend/mobile/blockchain).", "bin": { diff --git a/shared/.claude/helpers/claude-permissions.cjs b/shared/.claude/helpers/claude-permissions.cjs index 1c8e459..4bd035c 100644 --- a/shared/.claude/helpers/claude-permissions.cjs +++ b/shared/.claude/helpers/claude-permissions.cjs @@ -54,6 +54,10 @@ function globToRegex(glob) { let re = ""; for (let i = 0; i < glob.length; i++) { const c = glob[i]; + if (c === "\\" && i + 1 < glob.length) { + re += glob[++i].replace(/[.+^${}()|[\]\\*?]/g, "\\$&"); // \[ y similares: el carácter literal + continue; + } if (c === "*" && glob[i + 1] === "*") { i++; if (glob[i + 1] === "/") { diff --git a/shared/.claude/helpers/claude-rules.cjs b/shared/.claude/helpers/claude-rules.cjs index 552ccec..7374bca 100644 --- a/shared/.claude/helpers/claude-rules.cjs +++ b/shared/.claude/helpers/claude-rules.cjs @@ -7,10 +7,11 @@ * * 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). + * (en monorepo, la carpeta de cada stack): `appliesTo` dice si una regla toca un archivo. */ const fs = require("fs"); const path = require("path"); +const { globToRegex } = require("./claude-permissions.cjs"); const ROOT = process.env.CLAUDE_PROJECT_DIR || path.resolve(__dirname, "..", ".."); @@ -54,7 +55,15 @@ function load(root = ROOT) { return out; } -module.exports = { parse, load }; +/** ¿Aplica la regla a este archivo? Las de `paths:` se comparan con la ruta relativa a la raíz. */ +function appliesTo(rule, file, root = ROOT) { + if (rule.always) return true; + const rel = path.relative(root, path.resolve(root, file)).split(path.sep).join("/"); + if (!rel || rel.startsWith("..")) return false; + return rule.paths.some((g) => globToRegex(g.replace(/^\.\//, "")).test(rel)); +} + +module.exports = { parse, load, appliesTo }; if (require.main === module) { const reglas = load(); diff --git a/shared/.claude/helpers/hook-handler.cjs b/shared/.claude/helpers/hook-handler.cjs index 3d05ce0..d04c9a5 100644 --- a/shared/.claude/helpers/hook-handler.cjs +++ b/shared/.claude/helpers/hook-handler.cjs @@ -130,6 +130,7 @@ const BASH_DENY = [ [/\b(?:cat|cp|scp|tar|curl|base64)\b[^\n;]*(?:~|\$HOME)\/\.(?:ssh|aws|gnupg)\b/, "lee o copia credenciales del usuario (~/.ssh, ~/.aws, ~/.gnupg)"], [/\bgit\s+(?:commit|push)\b[^\n;]*--no-verify\b/, "salta los hooks de calidad y el escáner de secretos (--no-verify)"], [/^(?!\s*node\s+\.claude\/helpers\/loop\.cjs\b).*\.claude\/loop\/state\.json/, "toca el estado del ciclo (.claude/loop/state.json), que solo escribe loop.cjs: el verificador no se negocia; usa loop.cjs status o stop"], + [/\bloop\.cjs\s+(?:approve|reject)\b/, "aprueba o rechaza el cierre del ciclo: lo decide la persona en su terminal, no el agente que hizo el trabajo"], ]; // Archivos de entorno reales (.env, .env.local, .envrc…): los ejemplos (.example, .sample, diff --git a/shared/.claude/helpers/loop.cjs b/shared/.claude/helpers/loop.cjs index 2313f81..2d40ea3 100644 --- a/shared/.claude/helpers/loop.cjs +++ b/shared/.claude/helpers/loop.cjs @@ -6,12 +6,19 @@ * node .claude/helpers/loop.cjs status * node .claude/helpers/loop.cjs stop * node .claude/helpers/loop.cjs gate ← hook Stop (lo llama Claude Code, no la persona) + * node .claude/helpers/loop.cjs gate --approval ← la puerta en Pi Agent (la llama la extensión del kit) + * node .claude/helpers/loop.cjs approve | reject [--reason "…"] ← la persona, en su terminal * * La idea: Claude no decide cuándo terminó. Al final de cada turno, el hook Stop corre el * verificador (tests, lint, build). Si falla, Claude sigue con el error a la vista; si pasa, * se exige una revisión independiente antes de cerrar; si se agota el tope de turnos, para y * deja el informe. * + * Con --approval (Pi Agent, que no tiene subagentes) la revisión independiente es de la persona: + * con el verificador en verde y el resumen escrito, el ciclo queda "awaiting-approval" y la + * puerta lo dice en { approval: {…} }. Lo cierra `approve` o lo devuelve al trabajo `reject`; + * el agente no puede correrlos (el hook pre-bash se lo niega). + * * Dos lugares, a propósito: * - .claude/loop/state.json — el contrato (verificador, tope, turnos). Solo lo escribe este helper; * Claude Code protege .claude/ de las ediciones del agente y los hooks del kit también. @@ -155,7 +162,7 @@ function status() { function stop() { const s = read(); - if (!s || !["active", "reviewing"].includes(s.status)) return console.log("No hay ciclo activo."); + if (!s || !["active", "reviewing", "awaiting-approval"].includes(s.status)) return console.log("No hay ciclo activo."); write({ ...s, status: "stopped", finishedAt: new Date().toISOString() }); appendProgress(`\n**Detenido a mano en el turno ${s.turns}.**\n`); console.log("⏹️ Ciclo detenido."); @@ -165,12 +172,47 @@ function block(reason) { process.stdout.write(JSON.stringify({ decision: "block", reason })); } +// La sección "## Resumen para la persona" de PROGRESS.md, o "" si todavía no está. Termina en el +// siguiente título o en lo que añade este helper (las líneas "- Turno N · …" y las de "**…**"). +function summary() { + let progress = ""; + try { + progress = fs.readFileSync(PROGRESS, "utf8"); + } catch (_) {} + const m = progress.match(/^## Resumen para la persona[^\n]*\n?([\s\S]*?)(?=^## |^\*\*|^- Turno \d+ · |(?![\s\S]))/m); + return m ? m[1].trim() || "(vacío)" : ""; +} + +function askApproval(s) { + process.stdout.write(JSON.stringify({ approval: { objective: s.objective, turns: s.turns, summary: summary() } })); +} + +// La persona aprueba el cierre (o lo rechaza con un motivo) desde su terminal o desde la extensión. +function approve() { + const s = read(); + if (!s || s.status !== "awaiting-approval") return fail("No hay un ciclo esperando aprobación."); + write({ ...s, status: "passed", finishedAt: new Date().toISOString() }); + appendProgress(`\n**Ciclo cerrado en ${s.turns} turnos: verificador en verde y cierre aprobado por la persona.**\n`); + console.log(`✅ Ciclo "${s.objective}" cerrado. Revisa el diff y .loop/PROGRESS.md antes de hacer merge.`); +} +function reject() { + const s = read(); + if (!s || s.status !== "awaiting-approval") return fail("No hay un ciclo esperando aprobación."); + const reason = arg("reason") || "sin motivo"; + write({ ...s, status: "active" }); + appendProgress(`\n**La persona no aprobó el cierre (turno ${s.turns}):** ${reason}\n`); + console.log(`↩️ Cierre rechazado: ${reason}. El ciclo sigue activo.`); +} + // Hook Stop. Sin ciclo activo no hace nada (y no cuesta nada). function gate() { try { fs.readFileSync(0, "utf8"); } catch (_) {} + const approval = flag("approval"); const s = read(); + // Esperando a la persona: no se trabaja ni se vuelve a correr el verificador; se repite la petición. + if (s && s.status === "awaiting-approval") return approval ? askApproval(s) : undefined; if (!s || !["active", "reviewing"].includes(s.status)) return; const turns = s.turns + 1; const changed = Object.entries(s.scripts || {}).filter(([k, v]) => scriptsFingerprint(s.check)[k] !== v).map(([k]) => k); @@ -209,6 +251,18 @@ function gate() { return; } + if (s.status === "active" && approval) { + // En verde, en Pi: sin subagentes, quien revisa es la persona. El agente deja el resumen. + write({ ...s, turns, status: "reviewing" }); + block( + `Ciclo "${s.objective}": el verificador pasa (turno ${turns}). Último paso antes de cerrar:\n` + + "1. Repasa el diff completo del ciclo (git diff contra el punto de partida) y corrige lo que encuentres.\n" + + "2. Añade al final de .loop/PROGRESS.md la sección \"## Resumen para la persona\": qué cambió, por qué, y qué debería revisar ella con atención.\n" + + "No amplíes el alcance. Al terminar, el verificador corre otra vez y la persona decide si aprueba el cierre: quien hizo el trabajo no lo aprueba." + ); + return; + } + if (s.status === "active") { // En verde. Antes de cerrar, quien revisa no puede ser quien hizo el trabajo. write({ ...s, turns, status: "reviewing" }); @@ -221,17 +275,20 @@ function gate() { return; } - let progress = ""; - try { - progress = fs.readFileSync(PROGRESS, "utf8"); - } catch (_) {} - if (!/^## Resumen para la persona/m.test(progress)) { + if (!summary()) { write({ ...s, turns }); block( - `Ciclo "${s.objective}": falta el cierre. Lanza el agente \`reviewer\` sobre el diff si no lo hiciste, y añade a .loop/PROGRESS.md la sección "## Resumen para la persona" (qué cambió, por qué y qué revisar con atención).` + approval + ? `Ciclo "${s.objective}": falta el cierre. Añade a .loop/PROGRESS.md la sección "## Resumen para la persona" (qué cambió, por qué y qué revisar con atención); después, la persona decide.` + : `Ciclo "${s.objective}": falta el cierre. Lanza el agente \`reviewer\` sobre el diff si no lo hiciste, y añade a .loop/PROGRESS.md la sección "## Resumen para la persona" (qué cambió, por qué y qué revisar con atención).` ); return; } + if (approval) { + write({ ...s, turns, status: "awaiting-approval" }); + appendProgress(`\n**Verificador en verde y resumen listo (turno ${turns}): esperando la aprobación de la persona.**\n`); + return askApproval({ ...s, turns }); + } write({ ...s, turns, status: "passed", finishedAt: stamp }); appendProgress(`\n**Ciclo cerrado en ${turns} turnos: verificador en verde y revisión hecha.**\n`); process.stdout.write( @@ -250,7 +307,9 @@ if (require.main === module) { else if (action === "status") status(); else if (action === "stop") stop(); else if (action === "gate") gate(); - else fail("Uso: loop.cjs start --check \"\" [--max-turns N] [--objective \"…\"] | status | stop | gate"); + else if (action === "approve") approve(); + else if (action === "reject") reject(); + else fail("Uso: loop.cjs start --check \"\" [--max-turns N] [--objective \"…\"] | status | stop | gate [--approval] | approve | reject [--reason \"…\"]"); } module.exports = { validateCheck }; diff --git a/shared/.claude/skills/loop-task/SKILL.md b/shared/.claude/skills/loop-task/SKILL.md index 21edfd7..f049b0f 100644 --- a/shared/.claude/skills/loop-task/SKILL.md +++ b/shared/.claude/skills/loop-task/SKILL.md @@ -8,7 +8,7 @@ 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í. +**En Pi Agent** la puerta es la extensión del kit y corre al cerrar cada turno, igual que el hook `Stop` de Claude. Lo único distinto es el cierre: Pi no tiene subagentes, así que en el paso 5 no hay `reviewer` y quien aprueba es la persona. 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. @@ -53,6 +53,15 @@ Con el verificador en verde, quien hizo el trabajo no es quien lo aprueba: Después, la persona revisa el diff y el resumen antes de hacer merge. Si no entiende un cambio, el ciclo no terminó: es deuda de comprensión. +**En Pi Agent**, el cierre es así: +1. Repasa tú el diff. +2. Escribe `## Resumen para la persona`. +3. Con el verificador en verde, Pi le pregunta a la persona si aprueba el cierre: + - **Aprueba:** el ciclo se cierra. + - **Rechaza:** dice qué falta y sigues trabajando. + +Sin interfaz (`pi -p`), el ciclo queda esperando hasta que la persona corra en su terminal `node .claude/helpers/loop.cjs approve` (o `reject --reason "…"`). Tú no puedes correr esos comandos: el hook te lo niega. + ## Variantes nativas de Claude Code - **`/goal `**: un modelo aparte evalúa la condición tras cada turno. Úsalo cuando el final necesite juicio y no un comando (por ejemplo, "se cumplen los criterios de aceptación de docs/plan.md"). Escríbelo con un estado medible, cómo se demuestra y un límite: `… o detente tras 20 turnos`. Puede convivir con este ciclo: `/goal` decide si hay que seguir y el verificador decide si está bien. - **`/loop `**: repite cada cierto tiempo; sirve para esperar algo externo (una CI, un despliegue), no para iterar sobre código. @@ -65,6 +74,7 @@ Después, la persona revisa el diff y el resumen antes de hacer merge. Si no ent | `loop.cjs start --check "…" [--max-turns N] [--objective "…"]` | Arranca el ciclo (verifica la línea base) | | `loop.cjs status` | Estado, turno actual y verificador | | `loop.cjs stop` | Detiene el ciclo a mano | +| `loop.cjs approve` / `reject --reason "…"` | Solo la persona, en Pi: aprueba el cierre o devuelve el ciclo al trabajo | ## Reglas - Nunca amplíes el alcance para "aprovechar" el ciclo. diff --git a/shared/.pi/extensions/dev-starter-kit.ts b/shared/.pi/extensions/dev-starter-kit.ts index 6843b75..09b4b79 100644 --- a/shared/.pi/extensions/dev-starter-kit.ts +++ b/shared/.pi/extensions/dev-starter-kit.ts @@ -3,15 +3,22 @@ // cubre eso con los mismos helpers del kit: // - contexto: expande los imports de CLAUDE.md (claude-imports.cjs), añade las reglas de // .claude/rules/ que Claude carga siempre, las que no tienen `paths:` (claude-rules.cjs), y los -// avisos del inicio de sesión (hook-handler.cjs session-start); +// avisos del inicio de sesión (hook-handler.cjs session-start). Las reglas con `paths:` (un +// stack por carpeta en monorepo) se anexan, como en Claude, la primera vez que se lee o edita +// un archivo de su carpeta, y otra vez tras compactar; // - guardrails: antes de cada herramienta, las mismas reglas que los hooks PreToolUse de Claude // (hook-handler.cjs) y las reglas deny/ask de .claude/settings.json (claude-permissions.cjs). // Lo que pide aprobación se confirma con la persona y, sin interfaz (pi -p), se bloquea. -// Una herramienta que el kit no conoce (MCP, otra extensión) se confirma una vez por sesión. +// Una herramienta que el kit no conoce (MCP, otra extensión) se confirma una vez por sesión; +// - /loop-task: la puerta del ciclo (loop.cjs gate --approval) corre al cerrar cada turno, como +// el hook Stop de Claude. Si el verificador falla, Pi sigue con el error a la vista; con el +// verificador en verde y el resumen escrito, el cierre lo aprueba la persona (Pi no tiene +// subagentes para la revisión independiente). Sin interfaz, el ciclo queda esperándola. // Son guardrails, no aislamiento: Pi corre con los permisos de tu usuario. Para garantías // reales, un contenedor o una VM. import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; @@ -23,6 +30,9 @@ const rules = require("../../.claude/helpers/claude-rules.cjs"); const permissions = require("../../.claude/helpers/claude-permissions.cjs"); const ROOT = fileURLToPath(new URL("../..", import.meta.url)); const HOOKS = path.join(ROOT, ".claude", "helpers", "hook-handler.cjs"); +const LOOP = path.join(ROOT, ".claude", "helpers", "loop.cjs"); +const LOOP_STATE = path.join(ROOT, ".claude", "loop", "state.json"); +const ENV = { ...process.env, CLAUDE_PROJECT_DIR: ROOT, PI_CODING_AGENT: "true" }; const KNOWN = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "powershell"]); type Input = Record; @@ -33,11 +43,23 @@ function hook(action: string, toolInput: Input, source = "startup"): { code: num const r = spawnSync("node", [HOOKS, action], { input: JSON.stringify({ tool_input: toolInput, source }), encoding: "utf8", - env: { ...process.env, CLAUDE_PROJECT_DIR: ROOT, PI_CODING_AGENT: "true" }, + env: ENV, }); return { code: r.status, stdout: r.stdout || "", stderr: r.stderr || "" }; } +// loop.cjs sin bloquear la interfaz: el verificador (tests, build) puede tardar minutos. +function loop(args: string[]): Promise { + return new Promise((resolve) => { + const p = spawn("node", [LOOP, ...args], { cwd: ROOT, env: ENV }); + let out = ""; + p.stdout.on("data", (d) => (out += d)); + p.on("close", () => resolve(out.trim())); + p.on("error", () => resolve("")); + p.stdin.end("{}"); + }); +} + // Las salidas de PreToolUse en Claude: exit 2 (bloquea con stderr), permissionDecision deny o // ask, y additionalContext. function preToolUse(action: string, toolInput: Input): Veredicto { @@ -72,6 +94,8 @@ function comoClaude(tool: string, input: Input, cwd: string): [string, Input] | export default function (pi: ExtensionAPI) { let imports = new Map(); let fijas: { path: string; content: string }[] = []; + let porCarpeta: { file: string; rel: string; paths: string[]; body: string }[] = []; + let anexadas = new Set(); let avisos = ""; let reglas = { deny: [] as string[], ask: [] as string[] }; let aprobadas = new Set(); @@ -79,7 +103,10 @@ export default function (pi: ExtensionAPI) { 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 })); + const todas = rules.load(ROOT); + fijas = todas.filter((r: { always: boolean }) => r.always).map((r: { file: string; body: string }) => ({ path: r.file, content: r.body })); + porCarpeta = todas.filter((r: { always: boolean }) => !r.always); + anexadas = new Set(); // tras compactar, las reglas por carpeta vuelven a anexarse reglas = permissions.load(ROOT); // Como el hook de Claude: los avisos solo al iniciar, no tras compactar (ya están en el resumen). avisos = inicio ? hook("session-start", {}).stdout.trim() : ""; @@ -136,13 +163,54 @@ export default function (pi: ExtensionAPI) { }); // Lo que en Claude hace PostToolUse: el contexto que pidió el hook y el formateo tras editar. + // Además, las reglas por carpeta del archivo que se acaba de leer o editar. pi.on("tool_result", async (event, ctx) => { - const extra = contexto.get(event.toolCallId); + const extra: string[] = []; + const pedido = contexto.get(event.toolCallId); contexto.delete(event.toolCallId); - if (!event.isError && (event.toolName === "write" || event.toolName === "edit")) { - const claude = comoClaude(event.toolName, event.input as Input, ctx.cwd); - if (claude) hook("post-edit", { file_path: claude[1].file_path }); + if (pedido) extra.push(`ℹ️ ${pedido}`); + const archivo = ["read", "write", "edit"].includes(event.toolName) && !event.isError && comoClaude(event.toolName, event.input as Input, ctx.cwd); + if (archivo) { + if (event.toolName !== "read") hook("post-edit", { file_path: archivo[1].file_path }); + for (const r of porCarpeta) { + if (anexadas.has(r.file) || !rules.appliesTo(r, archivo[1].file_path, ROOT)) continue; + anexadas.add(r.file); + extra.push(`📏 Regla del proyecto para ${r.paths.join(", ")} (${r.rel}); aplícala a los archivos de esa carpeta:\n\n${r.body.trim()}`); + } + } + if (extra.length) return { content: [...event.content, ...extra.map((text) => ({ type: "text" as const, text }))] }; + }); + + // La puerta de /loop-task: el hook Stop de Claude, al cerrar cada turno. + pi.on("agent_before_settle", async (event, ctx) => { + if (!fs.existsSync(LOOP_STATE)) return; + let out: Input | null = null; + try { + out = JSON.parse((await loop(["gate", "--approval"])) || "null"); + } catch (_) { + return; + } + if (!out) return; + const mensaje = (content: string, seguir: boolean) => ({ + entries: [...event.entries, { type: "custom_message" as const, customType: "loop-task", content, display: true }], + continue: seguir, + }); + if (out.decision === "block") return mensaje(out.reason, true); + if (out.systemMessage) { + if (ctx.hasUI) ctx.ui.notify(out.systemMessage, "info"); + return; + } + if (!out.approval) return; + const { objective, turns, summary } = out.approval; + if (!ctx.hasUI) { + return mensaje(`El ciclo "${objective}" pasó el verificador en ${turns} turnos y espera la aprobación de la persona: en su terminal, node .claude/helpers/loop.cjs approve (o reject --reason "…"). No sigas trabajando en el ciclo.`, false); + } + if (await ctx.ui.confirm("Cerrar el ciclo", `"${objective}" pasó el verificador en ${turns} turnos.\n\nResumen:\n${summary}\n\nRevisa el diff. ¿Apruebas el cierre?`)) { + ctx.ui.notify(await loop(["approve"]), "info"); + return; } - if (extra) return { content: [...event.content, { type: "text", text: `ℹ️ ${extra}` }] }; + const motivo = ((await ctx.ui.input("¿Qué falta para aprobarlo?", "el caso de error de…")) || "").trim() || "sin motivo"; + await loop(["reject", "--reason", motivo]); + return mensaje(`La persona no aprobó el cierre del ciclo "${objective}": ${motivo}. El ciclo sigue activo: resuélvelo, actualiza el resumen y vuelve a terminar el turno.`, true); }); } diff --git a/test/claude-rules.test.cjs b/test/claude-rules.test.cjs index 91190ea..e41f971 100644 --- a/test/claude-rules.test.cjs +++ b/test/claude-rules.test.cjs @@ -7,7 +7,7 @@ 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); +const { parse, load, appliesTo } = require(HELPER); function proyecto(files) { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-rules-")); @@ -60,3 +60,18 @@ test("sin .claude/rules/ no hay reglas; como CLI lo dice", () => { assert.match(r.stdout, /No hay reglas/); fs.rmSync(dir, { recursive: true, force: true }); }); + +test("appliesTo: las reglas con paths: solo tocan los archivos de su carpeta", () => { + const api = { always: false, paths: ["apps/api/**"] }; + assert.ok(appliesTo(api, "/repo/apps/api/src/users.service.ts", "/repo")); + assert.ok(appliesTo(api, "apps/api/main.ts", "/repo"), "también con rutas relativas"); + assert.ok(!appliesTo(api, "/repo/apps/web/app/page.tsx", "/repo")); + assert.ok(!appliesTo(api, "/otro/apps/api/x.ts", "/repo"), "fuera del proyecto, nunca"); + assert.ok(appliesTo({ always: true, paths: [] }, "/repo/cualquier.md", "/repo")); +}); + +test("appliesTo: globs de extensión y carpetas con caracteres escapados", () => { + assert.ok(appliesTo({ always: false, paths: ["**/*.ts"] }, "/r/a/b/c.ts", "/r")); + assert.ok(!appliesTo({ always: false, paths: ["**/*.ts"] }, "/r/a/b/c.tsx", "/r")); + assert.ok(appliesTo({ always: false, paths: ["apps/\\[web\\]/**"] }, "/r/apps/[web]/x.ts", "/r")); +}); diff --git a/test/loop.test.cjs b/test/loop.test.cjs index 07632d0..e9cf8c6 100644 --- a/test/loop.test.cjs +++ b/test/loop.test.cjs @@ -160,3 +160,85 @@ test("pre-bash DENIEGA tocar el estado del ciclo desde la shell", () => assert.equal(hook("pre-bash", { command: "cat .claude/loop/state.json" }), "deny")); test("pre-bash PERMITE el helper del ciclo", () => assert.equal(hook("pre-bash", { command: "node .claude/helpers/loop.cjs status" }), null)); + +// ---- modo --approval (Pi Agent): la revisión independiente es de la persona ---- +const gatePi = (dir) => { + const r = loop(dir, "gate", "--approval"); + return r.stdout.trim() ? JSON.parse(r.stdout) : null; +}; +const resumir = (dir) => fs.appendFileSync(path.join(dir, ".loop", "PROGRESS.md"), "\n## Resumen para la persona \n- arreglado el check\n"); + +test("--approval: en verde pide el resumen sin mencionar al reviewer", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + verde(dir); + const d = gatePi(dir); + assert.equal(d.decision, "block"); + assert.doesNotMatch(d.reason, /reviewer/); + assert.match(d.reason, /la persona decide si aprueba el cierre/); + limpiar(dir); +}); + +test("--approval: con el resumen, el ciclo espera a la persona y no se cierra solo", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + verde(dir); + gatePi(dir); // → reviewing + resumir(dir); + const d = gatePi(dir); + assert.equal(d.decision, undefined, "no bloquea: el agente ya terminó"); + assert.deepEqual(d.approval, { objective: state(dir).objective, turns: 2, summary: "- arreglado el check" }); + assert.equal(state(dir).status, "awaiting-approval"); + const otra = gatePi(dir); + assert.ok(otra.approval, "mientras espera, repite la petición"); + assert.equal(state(dir).turns, 2, "y no cuenta turnos"); + limpiar(dir); +}); + +test("approve cierra el ciclo; reject lo devuelve al trabajo con el motivo", () => { + const preparar = () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + verde(dir); + gatePi(dir); + resumir(dir); + gatePi(dir); + return dir; + }; + const a = preparar(); + assert.equal(loop(a, "approve").status, 0); + assert.equal(state(a).status, "passed"); + assert.match(progreso(a), /cierre aprobado por la persona/); + limpiar(a); + + const b = preparar(); + assert.equal(loop(b, "reject", "--reason", "falta el caso de error").status, 0); + assert.equal(state(b).status, "active"); + assert.match(progreso(b), /no aprobó el cierre \(turno 2\):\*\* falta el caso de error/); + limpiar(b); +}); + +test("approve y reject sin un ciclo esperando fallan", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + assert.equal(loop(dir, "approve").status, 1); + assert.equal(loop(dir, "reject").status, 1); + limpiar(dir); +}); + +test("sin --approval, un ciclo esperando a la persona no hace nada en la puerta de Claude", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + verde(dir); + gatePi(dir); + resumir(dir); + gatePi(dir); + assert.equal(gate(dir), null); + assert.equal(state(dir).status, "awaiting-approval"); + limpiar(dir); +}); + +test("pre-bash DENIEGA que el agente apruebe o rechace el cierre", () => { + assert.equal(hook("pre-bash", { command: "node .claude/helpers/loop.cjs approve" }), "deny"); + assert.equal(hook("pre-bash", { command: "node .claude/helpers/loop.cjs reject --reason x" }), "deny"); +}); diff --git a/test/pi.test.cjs b/test/pi.test.cjs index afec29a..32e844e 100644 --- a/test/pi.test.cjs +++ b/test/pi.test.cjs @@ -34,6 +34,8 @@ export default function (pi: ExtensionAPI) { } const last = context.messages[context.messages.length - 1]; if (last?.role === "toolResult") log.push({ tool: last.toolName, error: Boolean(last.isError), text: texto(last) }); + // Un mensaje de la extensión (p. ej. la puerta del ciclo) llega como mensaje de usuario. + else if (i > 0 && last?.role === "user") log.push({ tool: "mensaje", error: false, 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")); })); @@ -88,10 +90,25 @@ test("/status expande el comando kit/status de .claude/commands", { skip }, () = 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/); +test("en monorepo, cada regla entra al tocar un archivo de su carpeta, una sola vez", { skip }, () => { + const api = { tool: "read", args: { path: "apps/api/src/users.service.ts" } }; + const res = correrPi({ + install: ["--stack", "backend/nestjs:apps/api,frontend/nextjs:apps/web", "--pi"], + steps: [api, { tool: "read", args: { path: "apps/web/app/page.tsx" } }, api, { text: "ok" }], + antes: (dir) => { + for (const [f, c] of [["apps/api/src/users.service.ts", "export class UsersService {}\n"], ["apps/web/app/page.tsx", "export default function Page() { return null; }\n"]]) { + fs.mkdirSync(path.dirname(path.join(dir, f)), { recursive: true }); + fs.writeFileSync(path.join(dir, f), c); + } + }, + }); + assert.doesNotMatch(res.prompt, /# Stack: NestJS|# Stack: Next\.js/, "no entran siempre"); assert.match(res.prompt, /## 3\. Coordinación de agentes/); + assert.match(res.log[0].text, /# Stack: NestJS/); + assert.doesNotMatch(res.log[0].text, /Next\.js/, "sin contaminación entre stacks"); + assert.match(res.log[1].text, /# Stack: Next\.js/); + assert.doesNotMatch(res.log[1].text, /NestJS/); + assert.doesNotMatch(res.log[2].text, /Regla del proyecto/, "la regla ya estaba en el contexto"); limpiar(res); }); @@ -143,3 +160,45 @@ test("los guardrails del kit funcionan en Pi: hook-handler, settings.json y herr fs.rmSync(home, { recursive: true, force: true }); limpiar(res); }); + +// ---- /loop-task en Pi: la puerta corre al cerrar cada turno ---- +function cicloEnPi(steps, maxTurns = 5) { + return correrPi({ + steps, + antes: (dir) => { + const git = (...a) => spawnSync("git", a, { cwd: dir }); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "x", scripts: { test: "node check.js" }, dependencies: { "@nestjs/core": "^10" } })); + fs.writeFileSync(path.join(dir, "check.js"), "process.exit(require('fs').existsSync('ok.txt') ? 0 : 1);\n"); + git("init", "-q", "-b", "master"); + git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-qm", "init"); + git("switch", "-q", "-c", "feat/ciclo"); + const r = spawnSync("node", [".claude/helpers/loop.cjs", "start", "--check", "npm test", "--max-turns", String(maxTurns), "--objective", "crear ok.txt"], { cwd: dir, encoding: "utf8", env: { ...process.env, CLAUDE_PROJECT_DIR: dir } }); + assert.equal(r.status, 0, r.stderr); + }, + }); +} +const estadoCiclo = (res) => JSON.parse(fs.readFileSync(path.join(res.dir, ".claude", "loop", "state.json"), "utf8")); + +test("loop-task en Pi: con el verificador en rojo sigue trabajando hasta el tope", { skip }, () => { + const res = cicloEnPi([{ text: "listo" }, { text: "listo otra vez" }, { text: "fin" }], 2); + assert.ok(res.log.some((l) => l.tool === "mensaje" && /turno 1\/2: el verificador falla/.test(l.text)), "el error del verificador llega al modelo"); + assert.equal(estadoCiclo(res).status, "exhausted"); + limpiar(res); +}); + +test("loop-task en Pi: en verde pide el resumen y, sin interfaz, espera la aprobación de la persona", { skip }, () => { + const res = cicloEnPi([ + { tool: "write", args: { path: "ok.txt", content: "" } }, + { text: "listo" }, + { tool: "bash", args: { command: "printf '\\n## Resumen para la persona\\n- creé ok.txt\\n' >> .loop/PROGRESS.md" } }, + { text: "listo" }, + { text: "fin" }, + ]); + assert.ok(res.log.some((l) => l.tool === "mensaje" && /la persona decide si aprueba el cierre/.test(l.text)), "pide el resumen sin subagente"); + const s = estadoCiclo(res); + assert.equal(s.status, "awaiting-approval", "no se cierra solo"); + const aprobar = spawnSync("node", [".claude/helpers/loop.cjs", "approve"], { cwd: res.dir, encoding: "utf8", env: { ...process.env, CLAUDE_PROJECT_DIR: res.dir } }); + assert.equal(aprobar.status, 0, aprobar.stderr); + assert.equal(estadoCiclo(res).status, "passed"); + limpiar(res); +});