diff --git a/CHANGELOG.md b/CHANGELOG.md index 673279e..c1d03b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Formato basado en [Keep a Changelog](https://keepachangelog.com/) y [SemVer](htt ## [No publicado] +## [3.2.0] - 2026-10-27 + +### Added +- **Ciclos de trabajo con verificador (loop engineering): skill `/loop-task` y helper `loop.cjs`.** Para tareas largas con un final comprobable, en vez de pedirle cada paso a Claude se define un ciclo: objetivo, verificador, tope de turnos y revisión independiente. Es la práctica que Addy Osmani llamó *loop engineering*, hecha con piezas nativas de Claude Code (hook `Stop`, subagentes, worktrees), sin nada de terceros. + - **Verificador determinista**: al final de cada turno, el hook `Stop` corre el comando del ciclo. Si falla, bloquea el cierre y le devuelve el error a Claude; Claude no decide cuándo terminó. + - **Solo comandos de verificación**: como el hook corre fuera de los permisos de Claude Code, el verificador solo admite test, lint, typecheck o build del proyecto encadenados con `&&` (sin `;`, `|`, redirecciones ni subshells). + - **Sin trampas**: el contrato vive en `.claude/loop/state.json`, que solo escribe el helper (Claude Code protege `.claude/` y los hooks `pre-edit`/`pre-bash` del kit también); 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 (`--allow-passing` para refactors que deben mantenerse en verde). + - **Quien hace no aprueba**: en verde, el ciclo exige pasar el agente `reviewer` por el diff y escribir un "Resumen para la persona" en `.loop/PROGRESS.md` antes de cerrar. + - **Tope y aislamiento**: 10 turnos por defecto (máximo 50) y solo en una rama o worktree propio, nunca en `main`/`master`/`develop`. El ciclo no hace commit, push ni merge. + - La skill explica cuándo **no** usar un ciclo y cómo combinarlo con `/goal` cuando el final requiere juicio. 32 tests nuevos (144 en total). + ## [3.1.0] - 2026-10-26 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index febb6f5..cb13fff 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ Borra el archivo de `shared/.claude/` sin más: en `--update`, el instalador ret - SemVer en `package.json` + entrada en `CHANGELOG.md`. - Como se distribuye por `npx github:`, los proyectos toman la rama por defecto: **`master` siempre debe estar verde** (CI en cada PR). Usa ramas `feat/…`, `fix/…` y PRs. -- Para fijar versiones en un proyecto concreto: `npx github:lacasoft/dev-starter-kit#v3.1.0`. +- Para fijar versiones en un proyecto concreto: `npx github:lacasoft/dev-starter-kit#v3.2.0`. ## Probar localmente diff --git a/README.md b/README.md index 088ae29..52a2e84 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,9 @@ Configuración `.claude` unificada para backend, frontend, mobile y blockchain. │ ├── CLAUDE.base.md # baseline universal (comportamiento, agentes, memoria, seguridad, calidad) │ ├── settings.json # hooks + permisos + statusLine │ ├── agents/ # 14 agentes (5 core + 9 especialistas) — ver "Agentes incluidos" -│ ├── skills/ # 18 skills (flujo + contexto + ingeniería + seguridad) — ver "Skills incluidas" +│ ├── skills/ # 19 skills (flujo + contexto + ingeniería + seguridad) — ver "Skills incluidas" │ ├── commands/ # /swarm, /kit:status -│ ├── helpers/ # hook-handler, statusline +│ ├── helpers/ # hook-handler, loop, statusline │ └── templates/ # PROJECT.template.md (definición del proyecto) ├── stacks/ # OVERLAYS: 13 stacks seleccionables + un `_common` por categoría │ ├── backend/{_common,nestjs,express,fastapi,django,php,spring,dotnet} @@ -34,7 +34,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.1.0 # fijar una versión (recomendado) +npx -y github:lacasoft/dev-starter-kit#v3.2.0 # fijar una versión (recomendado) ``` Toma siempre la última versión de `master` (o el tag indicado). Repo **público (MIT)**. @@ -121,7 +121,7 @@ Además, el kit instala por stack agentes/skills externos curados (`frontend-dev ## Skills incluidas (capa base) -**Flujo de trabajo**: `tdd` · `diagnose` · `grill-me` · `handoff` · `swarm-orchestration` · `verification` · `code-review-response` · `git-worktrees` +**Flujo de trabajo**: `tdd` · `diagnose` · `grill-me` · `handoff` · `swarm-orchestration` · `verification` · `code-review-response` · `git-worktrees` · `loop-task` **Contexto y memoria**: `project-context` (rellena `CLAUDE.project.md` a partir del repo real) · `adr` (registro de decisiones en `docs/adr/`) **Ingeniería/entrega**: `ci-cd` · `performance-profiling` · `migration` · `monorepo` · `release` · `ship-gate` · `iac` **Seguridad**: `owasp-review` (revisión contra OWASP Top 10:2025, ASVS 5.0 y CWE; LLM y agentes 2026) @@ -234,6 +234,31 @@ Las listas completas, qué buscar en cada categoría y las fuentes están en `shared/.claude/skills/owasp-review/referencia.md`. Cuando un estándar publique edición nueva, se actualiza ahí y en esta tabla. +## Ciclos de trabajo con verificador (loop engineering) + +Para tareas largas con un final comprobable (implementar un plan hasta que pasen sus tests, migrar +un módulo hasta que compile, vaciar una lista de errores de lint), en vez de pedirle cada paso a +Claude diseñas el **ciclo** que trabaja solo. Es la práctica que Addy Osmani llamó +[*loop engineering*](https://addyosmani.com/blog/loop-engineering/), implementada con piezas +nativas de Claude Code: un hook `Stop`, subagentes y worktrees. No se instala nada de terceros. + +```bash +git switch -c feat/migracion-billing # nunca en la rama principal +> /loop-task migrar src/billing al API v2 — verificador: npm run typecheck && npm test — tope: 15 turnos +``` + +| Pieza | Qué hace | +| --- | --- | +| **Verificador determinista** | Al final de cada turno, el hook `Stop` corre tu comando. Si falla, Claude sigue con el error a la vista; Claude no decide cuándo terminó. | +| **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. | +| **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 +requieren juicio y no un comando, la skill explica cómo combinarlo con `/goal` de Claude Code. + ## Orquestación multiagente (nativa) Todo con herramientas de Claude Code: el tool `Agent` para lanzar subagentes en paralelo, los @@ -336,7 +361,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.1.0 --update --yes # por tag +npx -y github:lacasoft/dev-starter-kit#v3.2.0 --update --yes # por tag npx -y github:lacasoft/dev-starter-kit# --update --yes # por commit (el SHA del tag) ``` @@ -357,4 +382,4 @@ Repo **público (MIT)** distribuido por `npx github:` → la rama **`master` deb (dry-run) y en un monorepo multi-stack, en cada PR. 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.1.0`). Instala siempre pineando (`#v3.1.0`): es reproducible y evita la cache de npx. + (`v3.2.0`). Instala siempre pineando (`#v3.2.0`): es reproducible y evita la cache de npx. diff --git a/install.js b/install.js index a1b2684..67b9bca 100644 --- a/install.js +++ b/install.js @@ -456,7 +456,7 @@ function stackRules(stacks) { // env, y el `claude-flow init` que traía sus propios hooks) y ya no lo integra; dejarlo haría que // cada sesión siguiera llamando a un runtime que nadie mantiene. Lo retirado se informa. const isKitHook = (g) => - Array.isArray(g && g.hooks) && g.hooks.length > 0 && g.hooks.every((h) => /hook-handler\.cjs|auto-memory-hook\.mjs/.test((h && h.command) || "")); + Array.isArray(g && g.hooks) && g.hooks.length > 0 && g.hooks.every((h) => /hook-handler\.cjs|auto-memory-hook\.mjs|loop\.cjs/.test((h && h.command) || "")); const RUFLO = /claude[-_]?flow|ruflo|ruv-swarm|flow-nexus/i; // Permisos que versiones anteriores del kit escribían y que ya no se usan. La unión de permisos // los arrastraría para siempre. Read(./.env.*) bloqueaba también .env.example: lo sustituyen @@ -648,7 +648,8 @@ function rufloLeftovers(kitFiles = {}) { // La memoria de agente con scope `project` (.claude/agent-memory/) se versiona a propósito: es // conocimiento del equipo. La `local` y CLAUDE.local.md son personales. .claude/memory/ es de la // memoria propia que tuvo el kit hasta la 2.x: se sigue ignorando para no versionar restos. -const GITIGNORE = [".claude/memory/", ".claude/agent-memory-local/", "CLAUDE.local.md", ".claude.backup.*", ".env", ".env.*"]; +// Ciclo en curso (skill loop-task): .claude/loop/ es el contrato y .loop/ el progreso. De trabajo, no de equipo. +const GITIGNORE = [".claude/memory/", ".claude/agent-memory-local/", ".claude/loop/", ".loop/", "CLAUDE.local.md", ".claude.backup.*", ".env", ".env.*"]; // Los archivos de ejemplo de env van versionados (la baseline los exige), pero ".env.*" los // 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"]; diff --git a/package.json b/package.json index 73f38e0..c7dce4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lacasoft/dev-starter-kit", - "version": "3.1.0", + "version": "3.2.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/CLAUDE.base.md b/shared/.claude/CLAUDE.base.md index 39f47d2..2155558 100644 --- a/shared/.claude/CLAUDE.base.md +++ b/shared/.claude/CLAUDE.base.md @@ -59,6 +59,7 @@ La orquestación multiagente usa solo herramientas nativas (tool `Agent`, agente - **Protocolo por tarea**: (1) recuperar contexto (CLAUDE.project.md, ADRs, memoria) → (2) planificar → (3) ejecutar en paralelo lo independiente → (4) verificar → (5) guardar lo duradero donde corresponde (ver §3.1). - Tras lanzar trabajo en background o subagentes: **STOP**. No hagas polling; espera el resultado. +- **Tareas largas con final verificable** (un plan, una migración, una tanda de tests rotos): ciclo con verificador determinista, tope de turnos y revisión independiente (skill `loop-task`), en una rama propia. ### 3.1 Memoria y contexto del proyecto diff --git a/shared/.claude/helpers/hook-handler.cjs b/shared/.claude/helpers/hook-handler.cjs index e39b7bf..fbc7998 100644 --- a/shared/.claude/helpers/hook-handler.cjs +++ b/shared/.claude/helpers/hook-handler.cjs @@ -129,6 +129,7 @@ const BASH_DENY = [ [new RegExp(`${C}(?:rm|mv|chown|chmod)\\s[^\\n;]*\\s/(?:etc|usr|bin|sbin|boot|lib|var|System)(?:/|\\s|$)`), "modifica rutas críticas del sistema"], [/\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"], ]; // Paquetes que un comando de instalación añadiría. null = no es una instalación; @@ -266,6 +267,9 @@ switch (action) { `Posible secreto (${hit}) en el contenido a escribir${file ? ` en ${file}` : ""}. No hardcodees secretos: usa variables de entorno / secret manager. Si es un placeholder/ejemplo, hazlo evidente (p.ej. prefijo your_/example_).` ); } + if (/(^|\/)\.claude\/loop\/state\.json$/.test(file)) { + denyPreTool("El estado del ciclo (.claude/loop/state.json) solo lo escribe loop.cjs: ni el verificador ni el contador de turnos se editan a mano. Usa `node .claude/helpers/loop.cjs status` o `stop`."); + } if (/(^|\/)\.env(\.|$)/.test(file)) { addContext(`Editando un archivo de entorno (${file}): no versiones secretos reales; usa .env.example con placeholders.`); } diff --git a/shared/.claude/helpers/loop.cjs b/shared/.claude/helpers/loop.cjs new file mode 100644 index 0000000..2313f81 --- /dev/null +++ b/shared/.claude/helpers/loop.cjs @@ -0,0 +1,256 @@ +#!/usr/bin/env node +/** + * loop.cjs — ciclo de trabajo con verificador determinista (loop engineering), zero-dep. + * + * node .claude/helpers/loop.cjs start --check "" [--max-turns 10] [--objective "…"] + * 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) + * + * 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. + * + * 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. + * - .loop/PROGRESS.md — el progreso, que Claude sí escribe (notas y resumen final). + * Ambos gitignorados. + */ +const fs = require("fs"); +const path = require("path"); +const { execSync } = require("child_process"); + +const ROOT = process.env.CLAUDE_PROJECT_DIR || path.resolve(__dirname, "..", ".."); +const DIR = path.join(ROOT, ".claude", "loop"); +const STATE = path.join(DIR, "state.json"); +const PROGRESS_DIR = path.join(ROOT, ".loop"); +const PROGRESS = path.join(PROGRESS_DIR, "PROGRESS.md"); +const DEFAULT_MAX_TURNS = 10; +const HARD_MAX_TURNS = 50; +const CHECK_TIMEOUT_MS = 10 * 60 * 1000; // el hook Stop tiene 15 min en settings.json + +// El hook ejecuta el verificador sin pasar por los permisos de Claude Code, así que solo admite +// comandos de verificación del propio proyecto: tests, lint, typecheck y build. Nada de shell +// arbitrario: sin ; | > < $ ` ni subshells; los pasos se encadenan solo con &&. +const ARGS = "(?: [\\w./:=@,+-]+)*"; +const ALLOWED_STEPS = [ + new RegExp(`^(?:npm|pnpm|yarn|bun) (?:test|run [\\w:.-]+)(?: --${ARGS})?$`), + new RegExp(`^npx --no-install (?:tsc|eslint|vitest|jest|prettier|biome|playwright)${ARGS}$`), + new RegExp(`^node --test${ARGS}$`), + new RegExp(`^(?:python3? -m )?(?:pytest|mypy|ruff check|ruff format --check)${ARGS}$`), + new RegExp(`^(?:go (?:test|vet|build)|cargo (?:test|clippy|build)|dotnet (?:test|build))${ARGS}$`), + new RegExp(`^(?:mvn|\\./mvnw)(?: -q)? (?:test|verify)${ARGS}$`), + new RegExp(`^(?:gradle|\\./gradlew) (?:test|check|build)${ARGS}$`), + new RegExp(`^(?:flutter (?:test|analyze)|dart (?:test|analyze))${ARGS}$`), + new RegExp(`^(?:forge (?:test|build)|composer (?:test|run [\\w:.-]+)|vendor/bin/(?:phpunit|phpstan|pest)(?: analyse)?)${ARGS}$`), + new RegExp(`^(?:git diff --exit-code|git diff --check)${ARGS}$`), +]; + +function validateCheck(check) { + if (!check || !check.trim()) return "falta --check: el ciclo necesita un verificador"; + if (/[;|><$`(){}\\\n]/.test(check.replace(/&&/g, ""))) return "solo se admite && entre pasos; nada de ; | > < $ ` ni subshells"; + const steps = check.split("&&").map((s) => s.trim()); + const bad = steps.filter((s) => !ALLOWED_STEPS.some((re) => re.test(s))); + if (bad.length) return `paso no permitido como verificador: ${bad.join(" · ")} (solo test, lint, typecheck o build del proyecto)`; + return null; +} + +function read() { + try { + return JSON.parse(fs.readFileSync(STATE, "utf8")); + } catch (_) { + return null; + } +} +function write(state) { + fs.mkdirSync(DIR, { recursive: true }); + fs.writeFileSync(STATE, JSON.stringify(state, null, 2) + "\n"); +} +function appendProgress(text) { + fs.mkdirSync(PROGRESS_DIR, { recursive: true }); + fs.appendFileSync(PROGRESS, text); +} + +// Corre cada paso en orden y para en el primero que falla. +function runCheck(check) { + const started = Date.now(); + for (const step of check.split("&&").map((s) => s.trim())) { + try { + execSync(step, { cwd: ROOT, stdio: "pipe", timeout: CHECK_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, CI: "1" } }); + } catch (e) { + const out = `${e.stdout || ""}${e.stderr || ""}`.trim().split("\n"); + return { ok: false, step, code: e.status ?? "timeout", tail: out.slice(-40).join("\n").slice(-4000), ms: Date.now() - started }; + } + } + return { ok: true, ms: Date.now() - started }; +} + +// Huella de los scripts de package.json que usa el verificador. Un ciclo que "pasa" porque +// alguien cambió "test" por "exit 0" no pasó: lo detecta la puerta. +function scriptsFingerprint(check) { + let scripts = {}; + try { + scripts = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).scripts || {}; + } catch (_) { + return {}; + } + const used = {}; + for (const step of check.split("&&").map((x) => x.trim())) { + const m = step.match(/^(?:npm|pnpm|yarn|bun) (?:run )?([\w:.-]+)/); + if (m && scripts[m[1]] !== undefined) used[m[1]] = scripts[m[1]]; + } + return used; +} + +function branch() { + try { + return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); + } catch (_) { + return null; + } +} + +function arg(name) { + const i = process.argv.indexOf(`--${name}`); + return i > -1 && process.argv[i + 1] !== undefined ? process.argv[i + 1] : undefined; +} +const flag = (name) => process.argv.includes(`--${name}`); + +function start() { + const check = arg("check"); + const problem = validateCheck(check); + if (problem) return fail(`❌ ${problem}.`); + const prev = read(); + if (prev && ["active", "reviewing"].includes(prev.status)) return fail(`❌ Ya hay un ciclo ${prev.status}: "${prev.objective}". Ciérralo con \`loop.cjs stop\`.`); + const current = branch(); + if (["main", "master", "develop"].includes(current)) { + return fail(`❌ Estás en ${current}. Un ciclo autónomo trabaja en una rama o worktree propio (skill git-worktrees), nunca en la rama principal.`); + } + const maxTurns = Math.min(Number(arg("max-turns")) || DEFAULT_MAX_TURNS, HARD_MAX_TURNS); + // Verificar al verificador: si ya pasa antes de empezar, no mide lo que se quiere lograr. + const baseline = runCheck(check); + if (baseline.ok && !flag("allow-passing")) { + return fail("❌ El verificador ya pasa antes de empezar: así no puede decir cuándo terminaste. Escribe primero el test que falla (skill tdd) o ajusta --check. Si es intencional (p. ej. un refactor que debe mantenerlo en verde), usa --allow-passing."); + } + const objective = arg("objective") || "(sin objetivo declarado)"; + write({ status: "active", objective, check, maxTurns, turns: 0, branch: current, scripts: scriptsFingerprint(check), startedAt: new Date().toISOString() }); + fs.mkdirSync(PROGRESS_DIR, { recursive: true }); + fs.writeFileSync( + PROGRESS, + `# Ciclo: ${objective}\n\n- Verificador: \`${check}\`\n- Tope: ${maxTurns} turnos · rama: ${current || "?"}\n- Inicio: ${new Date().toISOString()}\n\n` + + `## Línea base\n${baseline.ok ? "✓ pasa (--allow-passing)" : `✗ \`${baseline.step}\` (exit ${baseline.code})\n\n\`\`\`\n${baseline.tail}\n\`\`\``}\n\n` + + "## Notas de Claude\n\n\n## Turnos\n" + ); + console.log(`✅ Ciclo activo: "${objective}" · verificador \`${check}\` · tope ${maxTurns} turnos.`); + console.log(" Al terminar cada turno, el hook Stop corre el verificador. Progreso: .loop/PROGRESS.md"); +} + +function status() { + const s = read(); + if (!s) return console.log("No hay ciclo."); + console.log(`${s.status} · "${s.objective}" · turno ${s.turns}/${s.maxTurns} · verificador \`${s.check}\`${s.finishedAt ? ` · terminó ${s.finishedAt}` : ""}`); +} + +function stop() { + const s = read(); + if (!s || !["active", "reviewing"].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."); +} + +function block(reason) { + process.stdout.write(JSON.stringify({ decision: "block", reason })); +} + +// Hook Stop. Sin ciclo activo no hace nada (y no cuesta nada). +function gate() { + try { + fs.readFileSync(0, "utf8"); + } catch (_) {} + const s = read(); + 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); + if (changed.length) { + write({ ...s, turns }); + appendProgress(`- Turno ${turns} · ✗ el verificador cambió (scripts: ${changed.join(", ")})\n`); + block( + `Ciclo "${s.objective}": cambiaste el verificador (package.json → scripts.${changed.join(", scripts.")}). ` + + "Un verificador modificado no cuenta. Restáuralo como estaba al empezar y resuelve la causa real; si el script de verdad estaba mal, dilo, detén el ciclo (loop.cjs stop) y que la persona decida." + ); + return; + } + const result = runCheck(s.check); + const stamp = new Date().toISOString(); + appendProgress( + result.ok + ? `- Turno ${turns} · ✓ verificador en verde (${Math.round(result.ms / 1000)} s)\n` + : `- Turno ${turns} · ✗ \`${result.step}\` (exit ${result.code})\n` + ); + + if (!result.ok) { + if (turns >= s.maxTurns) { + write({ ...s, turns, status: "exhausted", finishedAt: stamp }); + appendProgress(`\n**Tope de ${s.maxTurns} turnos alcanzado sin pasar el verificador.**\n`); + process.stdout.write( + JSON.stringify({ systemMessage: `⛔ Ciclo detenido: ${s.maxTurns} turnos sin pasar \`${s.check}\`. Revisa .loop/PROGRESS.md antes de seguir.` }) + ); + return; + } + write({ ...s, turns, status: "active" }); + block( + `Ciclo "${s.objective}" — turno ${turns}/${s.maxTurns}: el verificador falla.\n` + + `Paso: ${result.step} (exit ${result.code})\n\n${result.tail}\n\n` + + "Corrige la causa (no el test, salvo que el test esté mal y lo justifiques), anota en .loop/PROGRESS.md qué intentaste y vuelve a terminar el turno." + ); + return; + } + + if (s.status === "active") { + // En verde. Antes de cerrar, quien revisa no puede ser quien hizo el trabajo. + write({ ...s, turns, status: "reviewing" }); + block( + `Ciclo "${s.objective}": el verificador pasa (turno ${turns}). Último paso antes de cerrar:\n` + + "1. Lanza el agente `reviewer` sobre el diff completo del ciclo (git diff contra el punto de partida) y corrige lo CRITICAL o HIGH.\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." + ); + return; + } + + let progress = ""; + try { + progress = fs.readFileSync(PROGRESS, "utf8"); + } catch (_) {} + if (!/^## Resumen para la persona/m.test(progress)) { + 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).` + ); + return; + } + 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( + JSON.stringify({ systemMessage: `✅ Ciclo "${s.objective}" cerrado en ${turns} turnos. Revisa el diff y .loop/PROGRESS.md antes de hacer merge.` }) + ); +} + +function fail(msg) { + console.error(msg); + process.exitCode = 1; +} + +if (require.main === module) { + const action = process.argv[2]; + if (action === "start") start(); + 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"); +} + +module.exports = { validateCheck }; diff --git a/shared/.claude/settings.json b/shared/.claude/settings.json index ccd296b..bbb6954 100644 --- a/shared/.claude/settings.json +++ b/shared/.claude/settings.json @@ -29,6 +29,13 @@ ] } ], + "Stop": [ + { + "hooks": [ + { "type": "command", "command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/loop.cjs\" gate'", "timeout": 900 } + ] + } + ], "SessionStart": [ { "hooks": [ diff --git a/shared/.claude/skills/loop-task/SKILL.md b/shared/.claude/skills/loop-task/SKILL.md new file mode 100644 index 0000000..1e05c5e --- /dev/null +++ b/shared/.claude/skills/loop-task/SKILL.md @@ -0,0 +1,68 @@ +--- +name: loop-task +description: Úsalo para delegar una tarea larga con un final verificable (implementar un plan hasta que pasen sus tests, migrar un módulo hasta que compile, vaciar una lista de fallos de lint o de tests) a un ciclo que trabaja solo, turno tras turno, y que solo termina cuando un verificador determinista pasa, con tope de turnos y revisión independiente al final. +argument-hint: " — verificador: [— tope: N turnos]" +--- + +# /loop-task — ciclo de trabajo con verificador (loop engineering) + +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. + +## 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. + +## 1. El contrato +Antes de arrancar, fija con la persona (si no te lo dio en el argumento): +- **Objetivo** en una frase, con el alcance ("solo `src/billing/`"). +- **Verificador**: el comando que demuestra que terminaste. Solo test, lint, typecheck o build del proyecto, encadenados con `&&` (por ejemplo `npm run typecheck && npm test`). El helper rechaza cualquier otra cosa. +- **Tope de turnos**: 10 por defecto, como máximo 50. Cada turno consume uso de tu cuenta: empieza bajo y súbelo si hace falta. +- **Qué no se toca**: archivos o comportamientos que deben quedar como están. + +Si no hay verificador, **créalo primero**: el test que falla (skill `tdd`). Un ciclo sin verificador es un ciclo que puede declarar éxito sin haberlo logrado. + +## 2. Aislamiento +Trabaja en una **rama o worktree propio** (skill `git-worktrees`), nunca en `main`/`master`/`develop`: el helper se niega a arrancar ahí. Lo que haga el ciclo se revisa como un PR, no se mezcla con trabajo en curso. + +## 3. Arrancar + +```bash +node .claude/helpers/loop.cjs start --objective "" --check "" --max-turns 10 +``` + +El helper corre el verificador una vez como **línea base**. Si ya pasa, se niega a arrancar: no mide lo que quieres lograr (para un refactor que debe mantenerse en verde, `--allow-passing`). Crea `.loop/PROGRESS.md`, la columna vertebral del ciclo, que tú escribes. El contrato (verificador, tope, turnos) queda en `.claude/loop/state.json`, que solo escribe el helper. + +## 4. Cada turno +- Haz un avance concreto y verificable; no intentes todo de golpe. +- Anota en `PROGRESS.md` (sección *Notas de Claude*) qué intentaste, qué descartaste y por qué. El helper añade solo el resultado del verificador de cada turno. +- Termina el turno. El **hook Stop** corre el verificador: + - **falla** → te devuelve el error y sigues. Arregla la causa, no el test; si el test está mal, dilo y justifícalo. + - **pasa** → pide el cierre (paso 5). + - **tope alcanzado** → el ciclo se detiene y la persona decide. +- El verificador, el contador de turnos y los scripts de `package.json` que usa el verificador **no se tocan**: los hooks bloquean editar el estado del ciclo, y si cambias el script del verificador la puerta lo rechaza. + +## 5. Cerrar +Con el verificador en verde, quien hizo el trabajo no es quien lo aprueba: +1. Lanza el agente **`reviewer`** sobre el diff completo del ciclo y corrige lo CRITICAL o HIGH. +2. Añade a `PROGRESS.md` la sección **`## Resumen para la persona`**: qué cambió, por qué, qué decisiones tomaste y qué debería revisar ella con atención. Sin esa sección la puerta no cierra el ciclo. +3. El verificador corre una última vez y el ciclo queda cerrado. + +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. + +## 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. +- Para que los turnos corran sin pedir permiso en cada herramienta, el modo **auto** o **acceptEdits**; los hooks de seguridad del kit siguen activos igual. + +## Comandos del helper + +| Comando | Para qué | +|---|---| +| `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 | + +## Reglas +- Nunca amplíes el alcance para "aprovechar" el ciclo. +- Si el verificador falla por algo ajeno a la tarea (entorno, red, dependencia), detén el ciclo y dilo: no lo "arregles" desactivando tests. +- El ciclo no hace commit, push ni merge: eso lo decide la persona. diff --git a/test/loop.test.cjs b/test/loop.test.cjs new file mode 100644 index 0000000..07632d0 --- /dev/null +++ b/test/loop.test.cjs @@ -0,0 +1,162 @@ +// Tests del ciclo con verificador (loop.cjs) sobre un repo temporal real. +// El verificador del proyecto de prueba es `npm test` → `node check.js`, que pasa solo si existe +// ok.txt: así cada test pone el verificador en verde o en rojo a voluntad. +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 LOOP = path.join(__dirname, "..", "shared", ".claude", "helpers", "loop.cjs"); +const HH = path.join(__dirname, "..", "shared", ".claude", "helpers", "hook-handler.cjs"); +const { validateCheck } = require(LOOP); + +function repo({ branch = "feat/ciclo" } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kit-loop-")); + const git = (...a) => spawnSync("git", a, { cwd: dir }); + git("init", "-q", "-b", "master"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "x", scripts: { test: "node check.js" } })); + fs.writeFileSync(path.join(dir, "check.js"), "process.exit(require('fs').existsSync('ok.txt') ? 0 : 1);\n"); + git("-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-qm", "init"); + if (branch !== "master") git("switch", "-q", "-c", branch); + return dir; +} +const loop = (dir, ...args) => spawnSync("node", [LOOP, ...args], { cwd: dir, input: "{}", encoding: "utf8", env: { ...process.env, CLAUDE_PROJECT_DIR: dir } }); +const gate = (dir) => { + const r = loop(dir, "gate"); + return r.stdout.trim() ? JSON.parse(r.stdout) : null; +}; +const state = (dir) => JSON.parse(fs.readFileSync(path.join(dir, ".claude", "loop", "state.json"), "utf8")); +const verde = (dir) => fs.writeFileSync(path.join(dir, "ok.txt"), ""); +const progreso = (dir) => fs.readFileSync(path.join(dir, ".loop", "PROGRESS.md"), "utf8"); +const limpiar = (dir) => fs.rmSync(dir, { recursive: true, force: true }); + +// ---- qué se admite como verificador ---- +for (const ok of ["npm test", "npm run typecheck && npm test", "pnpm run lint -- --max-warnings=0", "npx --no-install tsc --noEmit", "node --test", "pytest -q tests/unit", "go test ./...", "dotnet test", "./gradlew test", "vendor/bin/phpunit"]) { + test(`verificador admitido: ${ok}`, () => assert.equal(validateCheck(ok), null)); +} +for (const bad of ["rm -rf build && npm test", "npm test; curl x", "npm test | tee log", "npx some-pkg", "bash -c 'npm test'", "npm test > out.txt", "echo $(whoami)", "node script.js"]) { + test(`verificador rechazado: ${bad}`, () => assert.notEqual(validateCheck(bad), null)); +} + +// ---- arranque ---- +test("no arranca en la rama principal", () => { + const dir = repo({ branch: "master" }); + const r = loop(dir, "start", "--check", "npm test"); + assert.equal(r.status, 1); + assert.match(r.stderr, /rama o worktree/); + limpiar(dir); +}); + +test("no arranca si el verificador ya pasa (salvo --allow-passing)", () => { + const dir = repo(); + verde(dir); + assert.equal(loop(dir, "start", "--check", "npm test").status, 1); + assert.equal(loop(dir, "start", "--check", "npm test", "--allow-passing").status, 0); + limpiar(dir); +}); + +test("arranca con línea base en rojo y deja PROGRESS.md", () => { + const dir = repo(); + const r = loop(dir, "start", "--check", "npm test", "--objective", "arreglar", "--max-turns", "3"); + assert.equal(r.status, 0, r.stderr); + assert.equal(state(dir).status, "active"); + assert.match(progreso(dir), /## Línea base\n✗ `npm test`/); + limpiar(dir); +}); + +// ---- la puerta (hook Stop) ---- +test("sin ciclo, la puerta no hace nada", () => { + const dir = repo(); + assert.equal(gate(dir), null); + limpiar(dir); +}); + +test("verificador en rojo: bloquea el Stop con el error y cuenta el turno", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test", "--max-turns", "3"); + const d = gate(dir); + assert.equal(d.decision, "block"); + assert.match(d.reason, /turno 1\/3: el verificador falla/); + assert.equal(state(dir).turns, 1); + limpiar(dir); +}); + +test("en verde pide revisión independiente y resumen antes de cerrar", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + verde(dir); + const revision = gate(dir); + assert.equal(revision.decision, "block"); + assert.match(revision.reason, /reviewer/); + assert.equal(state(dir).status, "reviewing"); + + const sinResumen = gate(dir); + assert.equal(sinResumen.decision, "block", "sin el resumen no cierra"); + assert.match(sinResumen.reason, /Resumen para la persona/); + + fs.appendFileSync(path.join(dir, ".loop", "PROGRESS.md"), "\n## Resumen para la persona\n- arreglado\n"); + const cierre = gate(dir); + assert.equal(cierre.decision, undefined, "ya no bloquea"); + assert.match(cierre.systemMessage, /cerrado/); + assert.equal(state(dir).status, "passed"); + limpiar(dir); +}); + +test("si en la revisión se rompe algo, vuelve a trabajar", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + verde(dir); + gate(dir); // → reviewing + fs.rmSync(path.join(dir, "ok.txt")); + assert.equal(gate(dir).decision, "block"); + assert.equal(state(dir).status, "active"); + limpiar(dir); +}); + +test("al llegar al tope se detiene y avisa, sin seguir bloqueando", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test", "--max-turns", "2"); + assert.equal(gate(dir).decision, "block"); + const fin = gate(dir); + assert.equal(fin.decision, undefined); + assert.match(fin.systemMessage, /2 turnos sin pasar/); + assert.equal(state(dir).status, "exhausted"); + assert.equal(gate(dir), null, "un ciclo agotado ya no actúa"); + limpiar(dir); +}); + +test("cambiar el script del verificador no cuenta como pasar", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "x", scripts: { test: "exit 0" } })); + const d = gate(dir); + assert.equal(d.decision, "block"); + assert.match(d.reason, /cambiaste el verificador/); + assert.equal(state(dir).status, "active"); + limpiar(dir); +}); + +test("stop lo detiene a mano", () => { + const dir = repo(); + loop(dir, "start", "--check", "npm test"); + loop(dir, "stop"); + assert.equal(state(dir).status, "stopped"); + assert.equal(gate(dir), null); + limpiar(dir); +}); + +// ---- el estado del ciclo no se edita a mano ---- +const hook = (action, toolInput) => { + const r = spawnSync("node", [HH, action], { input: JSON.stringify({ tool_input: toolInput }), encoding: "utf8" }); + return r.stdout.trim() ? JSON.parse(r.stdout).hookSpecificOutput.permissionDecision : null; +}; +test("pre-edit DENIEGA editar .claude/loop/state.json", () => + assert.equal(hook("pre-edit", { file_path: "/p/.claude/loop/state.json", content: '{"turns":0}' }), "deny")); +test("pre-edit PERMITE escribir en PROGRESS.md", () => + assert.equal(hook("pre-edit", { file_path: "/p/.loop/PROGRESS.md", content: "nota" }), null)); +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));