From d826e5dae4bf16282d0490c0dc6d7584b3f08913 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:22:36 +0000 Subject: [PATCH 01/15] fix: planificar no aprueba sin revisiones ni con un APROBADO incoherente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Una task cuya revisión individual falló se descartaba con un aviso y el veredicto final salía solo de la auditoría, así que el plan podía aprobarse sin que nadie la revisara. Ahora cada task sin revisión se nombra en los huecos y fuerza al menos NECESITA_AJUSTES. Un APROBADO con criterios sin cobertura, huecos o incoherencias se contradice: se normaliza a NECESITA_AJUSTES y se conserva el veredicto original y la causa. Las dudas sin evidencia del auditor van a un campo avisos que no bloquea. La segunda pasada volvía a revisar todas las tasks sin sus hallazgos previos. Ahora solo revisa las no listas, las que no tuvieron revisión y las que la corrección tocó, calculadas por hash del contenido de cada task y de la spec antes y después de corregir, sobre las mismas rutas resueltas una sola vez dentro del proyecto. Cada una recibe sus hallazgos previos; las demás conservan su revisión y la auditoría cruzada se repite completa. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .claude/workflows/lib/auditoria.js | 126 +++++++++++++++- .claude/workflows/planificar.js | 96 ++++++++++-- tests/planificar-auditoria.test.js | 227 +++++++++++++++++++++++++++++ 3 files changed, 432 insertions(+), 17 deletions(-) diff --git a/.claude/workflows/lib/auditoria.js b/.claude/workflows/lib/auditoria.js index 0f3ce77..370c17d 100644 --- a/.claude/workflows/lib/auditoria.js +++ b/.claude/workflows/lib/auditoria.js @@ -8,9 +8,10 @@ // reglas sin llamar al modelo. // // El presupuesto de pasadas es una simple constante en el workflow, no un motor de -// convergencia: como mucho una segunda pasada sobre las tasks corregidas. Aqui solo +// convergencia: como mucho una segunda pasada sobre las tasks pendientes. Aqui solo // viven las decisiones puras (adverso o no, hace falta otra pasada, cual hallazgo es -// el mas severo, como reflejar contratos rotos), separadas del runtime de workflows. +// el mas severo, como reflejar contratos rotos y revisiones ausentes, qué tasks se +// vuelven a revisar), separadas del runtime de workflows. const APROBADO = 'APROBADO'; const NECESITA_AJUSTES = 'NECESITA_AJUSTES'; @@ -68,6 +69,121 @@ function incorporarContratosRotos(audit, contratosRotos) { return Object.assign({}, base, { veredicto, incoherencias }); } +function esObjeto(valor) { + return !!valor && typeof valor === 'object' && !Array.isArray(valor); +} + +// Una task sin revisión individual (el revisor cayó o no devolvió nada) no puede +// acabar en un plan aprobado: nadie la ha mirado. Cada task sin revisión se nombra +// en los huecos y fuerza como mínimo NECESITA_AJUSTES. Una auditoría ausente (null) +// se devuelve tal cual: el llamador ya la trata como ERROR, que tampoco aprueba. +function incorporarRevisionesAusentes(audit, tasksSinRevision) { + if (!tasksSinRevision || tasksSinRevision.length === 0) return audit; + if (!esObjeto(audit)) return audit; + + const huecos = Array.isArray(audit.huecos) ? audit.huecos.slice() : []; + for (const ruta of tasksSinRevision) { + const linea = 'Task sin revisión individual: ' + ruta; + if (!huecos.includes(linea)) huecos.push(linea); + } + const veredicto = audit.veredicto === NECESITA_REPLANTEAMIENTO ? NECESITA_REPLANTEAMIENTO : NECESITA_AJUSTES; + return Object.assign({}, audit, { veredicto, huecos }); +} + +function noVacia(lista) { + return Array.isArray(lista) && lista.length > 0; +} + +// Un APROBADO que convive con criterios sin cobertura, huecos o incoherencias se +// contradice a sí mismo: se normaliza a NECESITA_AJUSTES. El veredicto del auditor +// se conserva en veredicto_original y la causa en causa_normalizacion, para que se +// vea que la rebaja es mecánica y no del auditor. Cualquier otro veredicto, o un +// APROBADO coherente, pasa intacto. +function normalizarAprobado(audit) { + if (!esObjeto(audit) || audit.veredicto !== APROBADO) return audit; + + const abiertas = []; + if (noVacia(audit.cobertura && audit.cobertura.sin_cobertura)) abiertas.push('cobertura.sin_cobertura'); + if (noVacia(audit.huecos)) abiertas.push('huecos'); + if (noVacia(audit.incoherencias)) abiertas.push('incoherencias'); + if (abiertas.length === 0) return audit; + + return Object.assign({}, audit, { + veredicto: NECESITA_AJUSTES, + veredicto_original: APROBADO, + causa_normalizacion: 'veredicto contradictorio: APROBADO con ' + abiertas.join(', ') + ' no vacíos', + }); +} + +// Qué tasks tocó la corrección entre pasadas. No se fía del autoinforme del agente +// corrector: compara el hash del contenido de cada task doc (y de la spec) antes y +// después. Cada lado es { spec: hash|null, tasks: { [ruta]: hash|null } }, con null +// para un documento ilegible. Fallo seguro, nunca menos: si la spec cambió o no se +// pudo leer, todas cuentan como tocadas; una task ilegible en cualquiera de los dos +// momentos, o ausente de uno de ellos, también. +function tasksTocadas(hashesAntes, hashesDespues) { + const antes = (hashesAntes && hashesAntes.tasks) || {}; + const despues = (hashesDespues && hashesDespues.tasks) || {}; + const rutas = Array.from(new Set(Object.keys(antes).concat(Object.keys(despues)))); + + const specAntes = hashesAntes ? hashesAntes.spec : null; + const specDespues = hashesDespues ? hashesDespues.spec : null; + if (!specAntes || !specDespues || specAntes !== specDespues) return rutas; + + return rutas.filter(function (ruta) { + const a = antes[ruta]; + const d = despues[ruta]; + return !a || !d || a !== d; + }); +} + +// Una ruta de plan solo se hashea si es relativa y no sale del proyecto con '..'. +// Una ruta absoluta o que escapa no se resuelve: su huella es null y cuenta como +// tocada (fallo seguro). Recibe el módulo path para seguir siendo pura. +function rutaPlanAdmisible(ruta, path) { + if (typeof ruta !== 'string' || ruta.trim() === '') return false; + if (path.isAbsolute(ruta) || path.win32.isAbsolute(ruta)) return false; + const normalizada = path.posix.normalize(ruta.replace(/\\/g, '/')); + return normalizada !== '..' && !normalizada.startsWith('../'); +} + +// Huella { spec, tasks } a partir de rutas YA resueltas una sola vez (absolutas, o +// null si no eran admisibles). Las dos huellas, antes y después de la corrección, +// hashean las mismas rutas absolutas: si un documento desaparece, su hash es null +// aunque exista una copia con la misma ruta relativa en un directorio antecesor. +// hashDe(rutaAbsoluta) devuelve el hash o lanza; un fallo de lectura da null. +function huellaDocumentos(rutasResueltas, hashDe) { + function hash(absoluta) { + if (!absoluta) return null; + try { + return hashDe(absoluta) || null; + } catch (e) { + return null; + } + } + const origen = (rutasResueltas && rutasResueltas.tasks) || {}; + const tasks = {}; + for (const ruta of Object.keys(origen)) tasks[ruta] = hash(origen[ruta]); + return { spec: hash(rutasResueltas && rutasResueltas.spec), tasks }; +} + +// Índices de taskList que la segunda pasada vuelve a revisar: las que no salieron +// LISTO_PARA_IMPLEMENTAR, las que no tuvieron revisión y las que la corrección tocó. +// reviewsPrevias está alineado con taskList (null donde la revisión falló). Una task +// LISTO no tocada conserva su revisión y no se repite. +function tasksARevisar(taskList, reviewsPrevias, tocadas) { + const previas = Array.isArray(reviewsPrevias) ? reviewsPrevias : []; + const tocadasSet = new Set(tocadas || []); + const indices = []; + (taskList || []).forEach(function (task, i) { + const previa = previas[i]; + if (!previa || previa.veredicto !== 'LISTO_PARA_IMPLEMENTAR' || tocadasSet.has(task && task.path)) { + indices.push(i); + } + }); + return indices; +} + module.exports = { APROBADO, NECESITA_AJUSTES, @@ -77,4 +193,10 @@ module.exports = { necesitaOtraPasada, auditoriaMasSevera, incorporarContratosRotos, + incorporarRevisionesAusentes, + normalizarAprobado, + tasksTocadas, + tasksARevisar, + rutaPlanAdmisible, + huellaDocumentos, }; diff --git a/.claude/workflows/planificar.js b/.claude/workflows/planificar.js index d11dd5b..8b7bc86 100644 --- a/.claude/workflows/planificar.js +++ b/.claude/workflows/planificar.js @@ -84,6 +84,9 @@ const AUDIT_SCHEMA = { huecos: { type: 'array', items: { type: 'string' } }, incoherencias: { type: 'array', items: { type: 'string' } }, dependencias_problematicas: { type: 'array', items: { type: 'string' } }, + // Dudas sin evidencia (WARNING): se informan pero no bloquean. Opcional, para que + // una duda no tenga que meterse en huecos o incoherencias, que sí bloquean. + avisos: { type: 'array', items: { type: 'string' } }, resumen: { type: 'string' } }, required: ['veredicto', 'cobertura', 'overlaps', 'huecos', 'incoherencias', 'dependencias_problematicas', 'resumen'] @@ -282,18 +285,36 @@ const taskPaths = taskList.map(function(t) { return t.path }).join(', ') // El fan-out parallel() de la revision (Fase 4) se conserva intacto: es el // paralelismo de auditoria por task. La pasada se ejecuta hasta dos veces (ver // MAX_REVISION_PASSES); solo la primera marca las fases, la segunda solo registra. -async function pasadaRevisionAuditoria(numeroPasada) { - if (numeroPasada === 1) phase('Revision') - else log('Segunda pasada: re-revision paralela de las tasks corregidas') +// +// La primera pasada revisa todas las tasks. La segunda recibe las revisiones de la +// primera (alineadas con taskList, null donde fallaron) y los índices a revisar de +// nuevo: solo esos se relanzan, con sus hallazgos previos en el prompt; el resto +// conserva su revisión. La auditoría cruzada se repite siempre completa. +function hallazgosPreviosTexto(previa) { + if (!previa) return '' + const problemas = (previa.problemas || []).length ? previa.problemas.join('\n- ') : 'ninguno' + const ajustes = (previa.ajustes_propuestos || []).length ? previa.ajustes_propuestos.join('\n- ') : 'ninguno' + return '\nRevisión previa de esta task (veredicto ' + previa.veredicto + '). Comprueba si cada ' + + 'problema quedó resuelto en el documento actual y busca los que la corrección haya introducido.\n' + + 'Problemas previos:\n- ' + problemas + '\nAjustes propuestos previos:\n- ' + ajustes + '\n' +} - const reviews = await parallel( - taskList.map((task, i) => () => - agent(` +async function pasadaRevisionAuditoria(numeroPasada, reviewsPrevias, indicesARevisar) { + const indices = indicesARevisar || taskList.map(function(_, i) { return i }) + if (numeroPasada === 1) phase('Revision') + else log('Segunda pasada: re-revision paralela de ' + indices.length + ' de ' + taskList.length + + ' tasks (no listas, sin revision o tocadas por la correccion)') + + const nuevas = indices.length === 0 ? [] : await parallel( + indices.map((i) => () => { + const task = taskList[i] + const previa = reviewsPrevias ? reviewsPrevias[i] : null + return agent(` Lee ai_docs/dev_templates/revisar_tarea.md y sigue TODOS sus pasos sin saltar ninguno. Lee la task en: ${task.path} Lee la spec madre en: ${specPath} Lee ai_docs/core/ para contexto del proyecto. - +${hallazgosPreviosTexto(previa)} POSTURA OBLIGATORIA: - Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. - Asume que hay errores hasta demostrar lo contrario. @@ -316,12 +337,19 @@ Retorna tu veredicto estructurado con problemas y ajustes concretos. phase: 'Revision', schema: REVIEW_SCHEMA }) - ) + }) ) + // Revisiones alineadas con taskList: las conservadas de la pasada previa y, encima, + // las nuevas. Una re-revisión que falla deja null (la previa ya no vale para un + // documento que se volvió a revisar por algo): cuenta como task sin revisión. + const reviews = reviewsPrevias ? reviewsPrevias.slice() : taskList.map(function() { return null }) + indices.forEach(function(i, k) { reviews[i] = nuevas[k] || null }) + const validReviews = reviews.filter(Boolean) - if (validReviews.length < taskList.length) { - log('ADVERTENCIA: ' + (taskList.length - validReviews.length) + ' de ' + taskList.length + ' revisiones fallaron') + const tasksSinRevision = taskList.filter(function(_, i) { return !reviews[i] }).map(function(t) { return t.path }) + if (tasksSinRevision.length) { + log('ADVERTENCIA: ' + tasksSinRevision.length + ' de ' + taskList.length + ' tasks sin revision: ' + tasksSinRevision.join(', ')) } const listos = validReviews.filter(r => r.veredicto === 'LISTO_PARA_IMPLEMENTAR').length const ajustes = validReviews.filter(r => r.veredicto === 'NECESITA_AJUSTES').length @@ -381,13 +409,46 @@ Ejecuta todas las verificaciones: Si hay criterios de aceptacion sin cobertura, el veredicto es NECESITA_AJUSTES minimo. Si hay contratos producer/consumer rotos, el veredicto es NECESITA_AJUSTES minimo. Si hay problemas estructurales graves, el veredicto es NECESITA_REPLANTEAMIENTO. +Las dudas sin evidencia (WARNING) van en avisos: se informan y no bloquean. En huecos, +incoherencias y cobertura.sin_cobertura solo hallazgos con evidencia: cualquiera de ellos +impide un APROBADO. Retorna tu veredicto estructurado. `, { label: 'auditoria-cruzada-p' + numeroPasada, phase: 'Auditoria', schema: AUDIT_SCHEMA }) // Un contrato roto no puede quedar aprobado aunque el auditor no lo reflejara: - // se incorpora como incoherencia y fuerza un veredicto adverso. - return { audit: aud.incorporarContratosRotos(auditResult, contratosRotos), reviews: validReviews } + // se incorpora como incoherencia y fuerza un veredicto adverso. Lo mismo un + // APROBADO que se contradice con sus propias listas de hallazgos, y una task que + // se quedó sin revisión: ninguno de los dos aprueba el plan. + let audit = aud.incorporarContratosRotos(auditResult, contratosRotos) + audit = aud.normalizarAprobado(audit) + audit = aud.incorporarRevisionesAusentes(audit, tasksSinRevision) + return { audit: audit, reviews: reviews } +} + +// Huella del plan en disco: hash del contenido de la spec y de cada task doc. Se toma +// antes y después de corregirTasks para saber, sin fiarse del agente, qué cambió. Un +// documento ilegible da null, que tasksTocadas trata como tocado. Las rutas se +// resuelven UNA vez, antes de corregir; la huella posterior hashea esas mismas rutas +// absolutas sin volver a resolverlas, para que un documento borrado no se confunda +// con una copia homónima en un directorio antecesor. Una ruta absoluta o que sale +// del proyecto con '..' no se resuelve: queda null y cuenta como tocada. +async function resolverRutasPlan() { + const path = await import('node:path') + async function resolver(ruta) { + return aud.rutaPlanAdmisible(ruta, path) ? await resolverEnProyecto(ruta) : null + } + const tasks = {} + for (const t of taskList) tasks[t.path] = await resolver(t.path) + return { spec: await resolver(specPath), tasks: tasks } +} + +async function huellaPlan(rutasPlan) { + const crypto = await import('node:crypto') + const fs = await import('node:fs') + return aud.huellaDocumentos(rutasPlan, function(absoluta) { + return crypto.createHash('sha256').update(fs.readFileSync(absoluta)).digest('hex') + }) } // Corrige las tasks segun los hallazgos de la auditoria adversa, en el sitio, para @@ -419,20 +480,25 @@ REGLAS: // Presupuesto de pasadas: MAX_REVISION_PASSES, una simple constante. Primera pasada // siempre; si su veredicto es adverso y queda presupuesto, se corrigen las tasks y se -// hace UNA segunda pasada sobre el plan corregido. No hay tercera. +// hace UNA segunda pasada sobre el plan corregido. No hay tercera. La segunda solo +// re-revisa lo pendiente (no listas, sin revisión, tocadas por la corrección). let pasadas = 1 let resultado = await pasadaRevisionAuditoria(pasadas) let auditPrevia = null if (aud.necesitaOtraPasada(resultado.audit ? resultado.audit.veredicto : 'ERROR', pasadas, MAX_REVISION_PASSES)) { + const rutasPlan = await resolverRutasPlan() + const huellaAntes = await huellaPlan(rutasPlan) await corregirTasks(resultado.audit, pasadas) + const tocadas = aud.tasksTocadas(huellaAntes, await huellaPlan(rutasPlan)) + const indicesARevisar = aud.tasksARevisar(taskList, resultado.reviews, tocadas) auditPrevia = resultado.audit pasadas = 2 - resultado = await pasadaRevisionAuditoria(pasadas) + resultado = await pasadaRevisionAuditoria(pasadas, resultado.reviews, indicesARevisar) } const auditFinal = resultado.audit -const validReviews = resultado.reviews +const validReviews = resultado.reviews.filter(Boolean) const veredicto = auditFinal ? auditFinal.veredicto : 'ERROR' log('Auditoria final tras ' + pasadas + ' pasada' + (pasadas > 1 ? 's' : '') + ': ' + veredicto) diff --git a/tests/planificar-auditoria.test.js b/tests/planificar-auditoria.test.js index 97c2d42..e5e36e0 100644 --- a/tests/planificar-auditoria.test.js +++ b/tests/planificar-auditoria.test.js @@ -13,6 +13,13 @@ const test = require('node:test'); const assert = require('node:assert'); const aud = require('../.claude/workflows/lib/auditoria'); const orq = require('../.claude/workflows/lib/orquestacion'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const crypto = require('crypto'); + +const WORKFLOW = fs.readFileSync( + path.resolve(__dirname, '..', '.claude', 'workflows', 'planificar.js'), 'utf8'); const { APROBADO, @@ -138,3 +145,223 @@ test('cadena: un consumidor sin productor detectado por verificarContratos aflor assert.strictEqual(auditFinal.veredicto, NECESITA_AJUSTES); assert.ok(auditFinal.incoherencias.some(i => i.includes('ApiUsuario'))); }); + +// ── Revisiones ausentes: una task sin revisar no se aprueba ─────────────────── + +const { + incorporarRevisionesAusentes, + normalizarAprobado, + tasksTocadas, + tasksARevisar, +} = aud; + +/** Las tasks sin revisión, por índice, como las calcula el workflow. */ +function sinRevision(taskList, reviews) { + return taskList.filter((_, i) => !reviews[i]).map(t => t.path); +} + +const TASKS = [{ path: 't1.md' }, { path: 't2.md' }, { path: 't3.md' }]; + +function revision(veredicto, problemas) { + return { veredicto, problemas: problemas || [], ajustes_propuestos: [] }; +} + +test('incorporarRevisionesAusentes: 1 de 3 revisiones null y auditoría APROBADO -> NECESITA_AJUSTES con la task nombrada', () => { + const reviews = [revision('LISTO_PARA_IMPLEMENTAR'), null, revision('LISTO_PARA_IMPLEMENTAR')]; + const despues = incorporarRevisionesAusentes(auditoria(APROBADO), sinRevision(TASKS, reviews)); + + assert.strictEqual(despues.veredicto, NECESITA_AJUSTES); + assert.ok(despues.huecos.some(h => h.includes('t2.md')), 'la task sin revisión debe aparecer en los huecos'); + assert.ok(!despues.huecos.some(h => h.includes('t1.md') || h.includes('t3.md'))); +}); + +test('incorporarRevisionesAusentes: todas las revisiones null -> veredicto adverso con todas nombradas', () => { + const despues = incorporarRevisionesAusentes(auditoria(APROBADO), sinRevision(TASKS, [null, null, null])); + assert.ok(esVeredictoAdverso(despues.veredicto)); + for (const t of TASKS) assert.ok(despues.huecos.some(h => h.includes(t.path)), t.path); +}); + +test('incorporarRevisionesAusentes: con todas las revisiones, la auditoría pasa intacta', () => { + const original = auditoria(APROBADO); + assert.strictEqual(incorporarRevisionesAusentes(original, []), original); +}); + +test('incorporarRevisionesAusentes: nunca rebaja un replanteamiento', () => { + const despues = incorporarRevisionesAusentes(auditoria(NECESITA_REPLANTEAMIENTO), ['t1.md']); + assert.strictEqual(despues.veredicto, NECESITA_REPLANTEAMIENTO); +}); + +test('incorporarRevisionesAusentes: una auditoría ausente sigue ausente (el workflow la trata como ERROR)', () => { + assert.strictEqual(incorporarRevisionesAusentes(null, ['t1.md']), null); +}); + +// ── APROBADO incoherente: se normaliza conservando el original ──────────────── + +test('normalizarAprobado: APROBADO con huecos no vacío -> NECESITA_AJUSTES con veredicto_original', () => { + const despues = normalizarAprobado(auditoria(APROBADO, { huecos: ['falta la migración'] })); + assert.strictEqual(despues.veredicto, NECESITA_AJUSTES); + assert.strictEqual(despues.veredicto_original, APROBADO); + assert.match(despues.causa_normalizacion, /veredicto contradictorio/); +}); + +test('normalizarAprobado: APROBADO con criterios sin cobertura o incoherencias también se normaliza', () => { + const sinCobertura = normalizarAprobado(auditoria(APROBADO, { + cobertura: { cubiertos: 1, total: 2, sin_cobertura: ['CA-2'] }, + })); + assert.strictEqual(sinCobertura.veredicto, NECESITA_AJUSTES); + + const incoherente = normalizarAprobado(auditoria(APROBADO, { incoherencias: ['t1 contradice la spec'] })); + assert.strictEqual(incoherente.veredicto, NECESITA_AJUSTES); +}); + +test('normalizarAprobado: APROBADO con las tres listas vacías y todas las revisiones queda intacto', () => { + const original = auditoria(APROBADO); + const reviews = TASKS.map(() => revision('LISTO_PARA_IMPLEMENTAR')); + const despues = incorporarRevisionesAusentes(normalizarAprobado(original), sinRevision(TASKS, reviews)); + assert.strictEqual(despues, original); + assert.strictEqual(despues.veredicto, APROBADO); + assert.strictEqual(despues.veredicto_original, undefined); +}); + +test('normalizarAprobado: NECESITA_REPLANTEAMIENTO nunca se rebaja', () => { + const original = auditoria(NECESITA_REPLANTEAMIENTO, { huecos: ['h'] }); + assert.strictEqual(normalizarAprobado(original), original); +}); + +// ── Segunda pasada: solo lo pendiente ───────────────────────────────────────── + +function huella(spec, tasks) { + return { spec, tasks }; +} + +test('tasksTocadas: marca una task LISTO cuyo hash cambió aunque nadie lo declarase', () => { + const antes = huella('s', { 't1.md': 'a1', 't2.md': 'a2', 't3.md': 'a3' }); + const despues = huella('s', { 't1.md': 'b1', 't2.md': 'a2', 't3.md': 'a3' }); + assert.deepStrictEqual(tasksTocadas(antes, despues), ['t1.md']); +}); + +test('tasksTocadas: si cambia el hash de la spec, todas cuentan como tocadas', () => { + const tasks = { 't1.md': 'a1', 't2.md': 'a2', 't3.md': 'a3' }; + assert.deepStrictEqual(tasksTocadas(huella('s1', tasks), huella('s2', tasks)).sort(), ['t1.md', 't2.md', 't3.md']); +}); + +test('tasksTocadas: un documento ilegible antes o después cuenta como tocado (nunca menos)', () => { + const antes = huella('s', { 't1.md': 'a1', 't2.md': 'a2', 't3.md': null }); + const despues = huella('s', { 't1.md': null, 't2.md': 'a2', 't3.md': null }); + assert.deepStrictEqual(tasksTocadas(antes, despues).sort(), ['t1.md', 't3.md']); + + const tasks = { 't1.md': 'a1' }; + assert.deepStrictEqual(tasksTocadas(huella(null, tasks), huella(null, tasks)), ['t1.md']); +}); + +test('tasksTocadas: sin cambios no hay tocadas', () => { + const tasks = { 't1.md': 'a1', 't2.md': 'a2' }; + assert.deepStrictEqual(tasksTocadas(huella('s', tasks), huella('s', Object.assign({}, tasks))), []); +}); + +test('tasksARevisar: devuelve solo las no LISTO, las sin revisión y las tocadas', () => { + const tasks = TASKS.concat([{ path: 't4.md' }]); + const previas = [ + revision('LISTO_PARA_IMPLEMENTAR'), + revision('NECESITA_AJUSTES', ['p']), + null, + revision('LISTO_PARA_IMPLEMENTAR'), + ]; + assert.deepStrictEqual(tasksARevisar(tasks, previas, ['t4.md']), [1, 2, 3]); +}); + +test('tasksARevisar: una task LISTO no tocada no vuelve a revisarse', () => { + const previas = TASKS.map(() => revision('LISTO_PARA_IMPLEMENTAR')); + assert.deepStrictEqual(tasksARevisar(TASKS, previas, []), []); +}); + +test('tasksARevisar: con todas las revisiones null, todas van a la segunda pasada', () => { + assert.deepStrictEqual(tasksARevisar(TASKS, [null, null, null], []), [0, 1, 2]); +}); + +test('planificar.js: la segunda pasada lleva los hallazgos previos de cada task revisada de nuevo', () => { + assert.match(WORKFLOW, /hallazgosPreviosTexto\(previa\)/); + assert.match(WORKFLOW, /previa\.problemas/); + assert.match(WORKFLOW, /tasksARevisar\(taskList, resultado\.reviews, tocadas\)/); + assert.match(WORKFLOW, /tasksTocadas\(huellaAntes, await huellaPlan\(rutasPlan\)\)/); + // Las rutas se resuelven una vez y ambas huellas hashean las mismas absolutas. + assert.match(WORKFLOW, /const rutasPlan = await resolverRutasPlan\(\)\n\s*const huellaAntes = await huellaPlan\(rutasPlan\)/); +}); + +test('planificar.js: la normalización se compone en orden contratos -> APROBADO incoherente -> revisiones ausentes', () => { + const contratos = WORKFLOW.indexOf('aud.incorporarContratosRotos(auditResult'); + const aprobado = WORKFLOW.indexOf('audit = aud.normalizarAprobado(audit)'); + const ausentes = WORKFLOW.indexOf('audit = aud.incorporarRevisionesAusentes(audit, tasksSinRevision)'); + assert.ok(contratos > 0 && aprobado > contratos && ausentes > aprobado, + 'orden esperado: incorporarContratosRotos, normalizarAprobado, incorporarRevisionesAusentes'); +}); + +test('planificar.js: el auditor tiene un campo opcional avisos para las dudas sin evidencia', () => { + assert.match(WORKFLOW, /avisos: \{ type: 'array', items: \{ type: 'string' \} \}/); + assert.doesNotMatch(WORKFLOW, /required: \[[^\]]*'avisos'/, 'avisos debe ser opcional'); + assert.match(WORKFLOW, /dudas sin evidencia \(WARNING\) van en avisos: se informan y no bloquean/); +}); + +test('normalizarAprobado: APROBADO con avisos y el resto vacío sigue APROBADO; con huecos no', () => { + const soloAvisos = auditoria(APROBADO, { avisos: ['duda sin evidencia sobre t2'] }); + assert.strictEqual(normalizarAprobado(soloAvisos), soloAvisos); + assert.strictEqual(normalizarAprobado(soloAvisos).veredicto, APROBADO); + + const conHuecos = normalizarAprobado(auditoria(APROBADO, { avisos: ['duda'], huecos: ['falta la task de migración'] })); + assert.strictEqual(conHuecos.veredicto, NECESITA_AJUSTES); +}); + +// ── Huella del plan: rutas resueltas una vez ────────────────────────────────── + +const { rutaPlanAdmisible, huellaDocumentos } = aud; + +function sha(absoluta) { + return crypto.createHash('sha256').update(fs.readFileSync(absoluta)).digest('hex'); +} + +test('rutaPlanAdmisible: rechaza absolutas y rutas que salen del proyecto con ..', () => { + for (const mala of ['/etc/passwd', 'C:\\x\\t.md', '../fuera.md', 'ai_docs/../../fuera.md', '..', '', null]) { + assert.strictEqual(rutaPlanAdmisible(mala, path), false, String(mala)); + } + for (const buena of ['ai_docs/tasks/001_a.md', 'ai_docs/tasks/../tasks/001_a.md', 'ai_docs\\tasks\\001_a.md']) { + assert.strictEqual(rutaPlanAdmisible(buena, path), true, buena); + } +}); + +test('huellaDocumentos: un doc borrado tras corregir da null (tocado) aunque haya una copia en un antecesor', () => { + const raiz = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sdd-huella-'))); + try { + const sub = path.join(raiz, 'sub'); + fs.mkdirSync(path.join(sub, 'tasks'), { recursive: true }); + fs.mkdirSync(path.join(raiz, 'tasks'), { recursive: true }); + fs.writeFileSync(path.join(sub, 'spec.md'), 'spec'); + fs.writeFileSync(path.join(sub, 'tasks', 't1.md'), 'uno'); + fs.writeFileSync(path.join(sub, 'tasks', 't2.md'), 'dos'); + // Copia homónima en el antecesor: re-resolver desde sub tras el borrado la encontraría. + fs.writeFileSync(path.join(raiz, 'tasks', 't1.md'), 'uno'); + + const rutas = { + spec: path.join(sub, 'spec.md'), + tasks: { 'tasks/t1.md': path.join(sub, 'tasks', 't1.md'), 'tasks/t2.md': path.join(sub, 'tasks', 't2.md') }, + }; + const antes = huellaDocumentos(rutas, sha); + assert.ok(antes.tasks['tasks/t1.md'] && antes.tasks['tasks/t2.md'] && antes.spec); + + fs.unlinkSync(path.join(sub, 'tasks', 't1.md')); + const despues = huellaDocumentos(rutas, sha); + + // (+) el borrado cuenta como tocado aunque la copia del antecesor tenga el mismo contenido. + assert.strictEqual(despues.tasks['tasks/t1.md'], null); + // (−) el doc intacto conserva su hash y no cuenta como tocado. + assert.strictEqual(despues.tasks['tasks/t2.md'], antes.tasks['tasks/t2.md']); + assert.deepStrictEqual(tasksTocadas(antes, despues), ['tasks/t1.md']); + } finally { + fs.rmSync(raiz, { recursive: true, force: true }); + } +}); + +test('huellaDocumentos: una ruta no admitida (null) da huella null y cuenta como tocada', () => { + const huella = huellaDocumentos({ spec: null, tasks: { 't1.md': null } }, () => { throw new Error('no debe leerse'); }); + assert.deepStrictEqual(huella, { spec: null, tasks: { 't1.md': null } }); + assert.deepStrictEqual(tasksTocadas(huella, huella), ['t1.md']); +}); From 01a5c6dc7c9498919c545bef44b5195f90aab192 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:18:59 +0000 Subject: [PATCH 02/15] fix: marcar como CommonJS workflows/lib y scripts en destinos ESM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit En un proyecto con "type": "module" en su package.json raíz, Node trata como ESM los .js de .claude/workflows/lib y de scripts, que son CommonJS, y los workflows fallan con "require is not defined" al cargarlos con import(). Un package.json con "type": "commonjs" en cada directorio fija su ámbito, como ya hacía hooks/. El banco de instalación prueba ahora un destino ESM: carga cada módulo de lib con import(), ejecuta cada hook cableado y comprueba que sin el marcador la carga falla. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .claude/workflows/lib/package.json | 1 + scripts/package.json | 1 + tests/install-native.test.js | 64 ++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 .claude/workflows/lib/package.json create mode 100644 scripts/package.json diff --git a/.claude/workflows/lib/package.json b/.claude/workflows/lib/package.json new file mode 100644 index 0000000..a3c15a7 --- /dev/null +++ b/.claude/workflows/lib/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..a3c15a7 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/tests/install-native.test.js b/tests/install-native.test.js index e649d66..d16f5f4 100644 --- a/tests/install-native.test.js +++ b/tests/install-native.test.js @@ -24,6 +24,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const { spawnSync } = require('node:child_process'); +const { pathToFileURL } = require('node:url'); const RAIZ = path.join(__dirname, '..'); const CLI = path.join(RAIZ, 'bin', 'cli.js'); @@ -120,6 +121,69 @@ for (const [backend, contexto] of Object.entries(CONTEXTO_POR_BACKEND)) { }); } +// Un proyecto destino con `"type": "module"` en su package.json raíz hace que +// Node trate como ESM cualquier `.js` sin un package.json más cercano. Los +// módulos de `.claude/workflows/lib/` son CommonJS y los workflows los cargan +// con `import()`, así que solo funcionan si su marcador de ámbito viaja con la +// instalación. Los hooks cableados se ejecutan como lo hace Claude Code. +test('la via del CLI de claude funciona en un destino ESM', async (t) => { + const destino = fs.mkdtempSync(path.join(os.tmpdir(), 'install-claude-esm-')); + t.after(() => fs.rmSync(destino, { recursive: true, force: true })); + fs.writeFileSync(path.join(destino, 'package.json'), JSON.stringify({ name: 'destino-esm', type: 'module' })); + + const instalacion = spawnSync(process.execPath, [CLI, 'install', '--backend', 'claude'], { + cwd: destino, + encoding: 'utf8', + }); + assert.strictEqual(instalacion.status, 0, `install --backend claude falló: ${instalacion.stderr}`); + assert.strictEqual(leerJson(path.join(destino, 'package.json')).type, 'module', 'el instalador cambió el tipo del destino'); + + const lib = path.join(destino, '.claude', 'workflows', 'lib'); + for (const marcador of [lib, path.join(destino, 'scripts')]) { + assert.deepStrictEqual(leerJson(path.join(marcador, 'package.json')), { type: 'commonjs' }, `falta el marcador de ${marcador}`); + } + + const modulos = fs.readdirSync(lib).filter(f => f.endsWith('.js')); + assert.ok(modulos.includes('orquestacion.js'), 'falta orquestacion.js en el destino'); + for (const modulo of modulos) { + const cargado = await import(pathToFileURL(path.join(lib, modulo)).href); + assert.ok(Object.keys(cargado.default ?? {}).length > 0, `${modulo} no exporta nada al cargarse como CommonJS`); + } + + // Control negativo: la misma biblioteca sin su marcador falla como en un + // destino ESM real. Va en una copia porque Node cachea, dentro del proceso, + // la resolución del package.json de cada directorio ya visitado. + const sinMarcador = path.join(destino, '.claude', 'workflows', 'lib_sin_marcador'); + fs.cpSync(lib, sinMarcador, { recursive: true }); + fs.rmSync(path.join(sinMarcador, 'package.json')); + await assert.rejects( + import(pathToFileURL(path.join(sinMarcador, 'orquestacion.js')).href), + /require is not defined/, + ); + + const settings = leerJson(path.join(destino, '.claude', 'settings.json')); + const scripts = new Set(); + for (const grupos of Object.values(settings.hooks)) { + for (const { hooks } of grupos) { + for (const { command } of hooks) { + const ruta = /^node "\$CLAUDE_PROJECT_DIR\/(.+)"$/.exec(command); + assert.ok(ruta, `orden de hook con forma no reconocida: ${command}`); + scripts.add(ruta[1]); + } + } + } + assert.ok(scripts.size >= 3, `settings.json cablea ${scripts.size} hooks`); + for (const script of scripts) { + const resultado = spawnSync(process.execPath, [path.join(destino, script)], { + cwd: destino, + input: '{}', + encoding: 'utf8', + env: { ...process.env, CLAUDE_PROJECT_DIR: destino }, + }); + assert.strictEqual(resultado.status, 0, `${script} terminó con ${resultado.status} en un destino ESM: ${resultado.stderr}`); + } +}); + test('la via nativa de Gemini entrega el mismo archivo de contexto que la via del CLI', (t) => { const manifiesto = leerJson(path.join(RAIZ, 'gemini-extension.json')); const contexto = manifiesto.contextFileName; From c17bbb9168f5cfceb937968f674764e861c61cb5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:21:13 +0000 Subject: [PATCH 03/15] =?UTF-8?q?fix:=20update=20no=20trata=20como=20edici?= =?UTF-8?q?=C3=B3n=20el=20CRLF=20ni=20un=20fichero=20convergido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Con core.autocrlf=true todo lo instalado queda en CRLF y el hash crudo hacía que update lo diera entero por editado y dejara de actualizarlo. El hash de los ficheros de texto normaliza ahora CRLF a LF; un fichero sin CRLF conserva su hash, así que los sidecars existentes siguen valiendo, y un binario se sigue hasheando en crudo. Un fichero que el usuario ya dejó igual a la versión nueva se informa como convergido y refresca su hash en lugar de repetirse como cambio local en cada update (con --dry-run solo se anuncia). Si .claude/settings.json o hooks/config.json se saltan por edición local, update avisa de los hooks (evento, matcher, comando) y de las claves de la plantilla que faltan, con la entrada exacta a añadir o la opción --reset-protected. Solo avisa: no fusiona el JSON del usuario. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- bin/cli.js | 180 ++++++++++++++++++++++++++++++++++---- tests/cli.test.js | 214 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 379 insertions(+), 15 deletions(-) diff --git a/bin/cli.js b/bin/cli.js index 5724073..cdc485a 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -132,6 +132,8 @@ Actualiza el framework instalado en el directorio actual: copia las rutas del ba Si se omite --backend, se muestra un menu interactivo para elegir (requiere terminal; en pipe/CI, falla con mensaje claro). --skip omite componentes opcionales (asesor, auditar, bugfix, cleanup, testing, pr); solo aplica al backend claude. --reset-protected sobrescribe cualquier archivo del framework con ediciones locales detectadas (no solo los archivos nucleares como CLAUDE.md o hooks/config.json) y refresca el sidecar de hashes al final. +Un cambio solo de fin de línea (CRLF frente a LF) no cuenta como edición local: el archivo se actualiza igual. Un archivo que ya coincide con la versión nueva se informa como convergido y se actualiza su hash. +Si .claude/settings.json o hooks/config.json se saltan por cambios locales, update avisa de los hooks y claves de la versión nueva que les faltan, con la entrada exacta a añadir. --dry-run muestra que ficheros se copiarian/saltarian sin escribir nada en disco. --force si hay archivos no protegidos que se sobrescribirian, salta la confirmacion (util en CI/pipe). No afecta a los archivos protegidos con ediciones locales: para esos sigue haciendo falta --reset-protected.`); } @@ -287,10 +289,28 @@ function advertirSiGeminiLayoutAntiguo(backend) { ); } -/** SHA-256 hexadecimal del contenido de un archivo, o null si no existe. */ +/** + * SHA-256 hexadecimal del contenido de un archivo, o null si no existe. + * + * Un fichero de texto (sin ningún byte 0) se hashea con cada par `\r\n` + * sustituido por `\n`: un checkout con `core.autocrlf=true` deja en CRLF todo + * lo instalado y, con el hash de los bytes crudos, `update` lo tomaría entero + * por editado localmente. Un `\r` aislado no se toca. Un fichero sin `\r\n` + * produce el mismo hash que antes, así que los sidecars existentes siguen + * valiendo. Un fichero con algún byte 0 se trata como binario y se hashea en + * crudo. + */ function hashFile(rutaAbsoluta) { if (!fs.existsSync(rutaAbsoluta)) return null; - return crypto.createHash('sha256').update(fs.readFileSync(rutaAbsoluta)).digest('hex'); + return hashContenido(fs.readFileSync(rutaAbsoluta)); +} + +/** Hash de `hashFile` sobre un buffer ya leído. */ +function hashContenido(buffer) { + const normalizado = buffer.includes(0) + ? buffer + : Buffer.from(buffer.toString('latin1').replace(/\r\n/g, '\n'), 'latin1'); + return crypto.createHash('sha256').update(normalizado).digest('hex'); } /** @@ -492,8 +512,13 @@ async function confirmarColisiones(colisiones) { * anterior a su introduccion), compara contra el hash del archivo de origen: * si coinciden, no hay edicion local (siembra la entrada en el sidecar); * si difieren -- o el origen ya no existe -- se protege por defecto. + * + * Si el destino difiere del hash instalado pero ya es igual al origen nuevo + * (el usuario lo dejó como la versión que se va a instalar), no es una + * edición: se anota en `convergidas` y se copia, de modo que el sidecar se + * refresca y deja de señalarse en cada `update`. */ -function editadaLocalmente(rutaRelativa, hashesInstalados) { +function editadaLocalmente(rutaRelativa, hashesInstalados, convergidas = []) { const hashActual = hashFile(path.join(DEST, rutaRelativa)); if (hashActual === null) return false; if (hashesInstalados[rutaRelativa] === undefined) { @@ -502,7 +527,12 @@ function editadaLocalmente(rutaRelativa, hashesInstalados) { hashesInstalados[rutaRelativa] = hashActual; return false; } - return hashesInstalados[rutaRelativa] !== hashActual; + if (hashesInstalados[rutaRelativa] === hashActual) return false; + if (hashFile(origenDe(rutaRelativa)) === hashActual) { + convergidas.push(rutaRelativa); + return false; + } + return true; } /** @@ -516,7 +546,7 @@ function editadaLocalmente(rutaRelativa, hashesInstalados) { * reporta por consola lo que habria pasado y se retorna el mismo resultado * que produciria el run real. */ -function copiarRuta(ruta, hashesInstalados, saltadasPorEdicion, resetProtected, dryRun, generalizado) { +function copiarRuta(ruta, hashesInstalados, saltadasPorEdicion, resetProtected, dryRun, generalizado, convergidas = []) { const origen = origenDe(ruta); if (!fs.existsSync(origen)) { return { ruta, copiada: false }; @@ -526,7 +556,7 @@ function copiarRuta(ruta, hashesInstalados, saltadasPorEdicion, resetProtected, const candidatas = generalizado ? archivosDe(ruta) : protegidasEn(ruta); const editadas = resetProtected ? [] - : candidatas.filter(archivo => editadaLocalmente(archivo, hashesInstalados)); + : candidatas.filter(archivo => editadaLocalmente(archivo, hashesInstalados, convergidas)); editadas.forEach(archivo => saltadasPorEdicion.push(archivo)); const esDirectorio = fs.statSync(origen).isDirectory(); @@ -783,6 +813,7 @@ function copiarRutasFramework(backend, skip, opciones = {}) { const hashesInstalados = loadInstalledHashes(DEST); const saltadasPorEdicion = []; + const convergidas = []; if (!opciones.dryRun) crearLockfileInstalacion(backend); @@ -802,7 +833,9 @@ function copiarRutasFramework(backend, skip, opciones = {}) { for (const ruta of rutas) { try { resultados.push( - copiarRuta(ruta, hashesInstalados, saltadasPorEdicion, opciones.resetProtected, opciones.dryRun, opciones.generalizado), + copiarRuta( + ruta, hashesInstalados, saltadasPorEdicion, opciones.resetProtected, opciones.dryRun, opciones.generalizado, convergidas, + ), ); } catch (err) { fallidas.push({ ruta, error: err.message }); @@ -825,7 +858,15 @@ function copiarRutasFramework(backend, skip, opciones = {}) { if (fallidas.length) reportarRutasFallidas(fallidas); - return { copiadas, saltadas, creados, saltadasPorEdicion, fallidas, dirsFallidos }; + // Solo cuenta como convergido lo que de verdad se copio: una ruta fallida + // no refresco su hash en el sidecar. + const convergidasCopiadas = convergidas.filter( + archivo => copiadas.some(ruta => archivo === ruta || archivo.startsWith(`${ruta}/`)), + ); + + return { + copiadas, saltadas, creados, saltadasPorEdicion, convergidas: convergidasCopiadas, fallidas, dirsFallidos, + }; } finally { // El lockfile se borra siempre al terminar, con exito o con fallo: dejarlo // huerfano tras un fallo bloquearia el siguiente intento legitimo con el @@ -880,6 +921,108 @@ function reportarArchivosProtegidos(saltadasPorEdicion) { saltadasPorEdicion.forEach(ruta => console.log(` - ${ruta}`)); } +/** Archivos que el usuario ya había dejado iguales a la versión nueva: no son ediciones locales. */ +function reportarConvergidas(convergidas, dryRun) { + if (!convergidas.length) return; + const sidecar = dryRun ? 'su hash se refrescaría sin --dry-run' : 'su hash se ha refrescado'; + console.log(`Archivos convergidos (ya coincidían con la versión nueva; ${sidecar}):`); + convergidas.forEach(ruta => console.log(` - ${ruta}`)); +} + +// Ficheros protegidos cuyo cableado se compara con la plantilla cuando +// `update` los salta por edición local. +const SETTINGS_CLAUDE = '.claude/settings.json'; +const CONFIG_HOOKS = 'hooks/config.json'; + +/** + * Diferencia de cableado entre la plantilla y el destino de un fichero + * protegido, sobre los objetos JSON ya parseados. Pura: no lee ni escribe. + * + * - `.claude/settings.json`: cada hook de la plantilla, identificado por + * evento, matcher y comando, que no está en el destino. Devuelve + * `{ evento, matcher, hook }` con el objeto `hook` de la plantilla tal cual. + * - `hooks/config.json`: cada clave de primer nivel de la plantilla ausente + * en el destino, sin las claves `_comment*`, que solo documentan. Devuelve + * `{ clave, valor }`. + * + * Una estructura inesperada (no objeto, `hooks` ausente) cuenta como vacía. + */ +function diferenciaCableado(ruta, plantilla, destino) { + const esObjeto = valor => valor !== null && typeof valor === 'object' && !Array.isArray(valor); + if (ruta === CONFIG_HOOKS) { + if (!esObjeto(plantilla)) return []; + const claves = esObjeto(destino) ? destino : {}; + return Object.keys(plantilla) + .filter(clave => !clave.startsWith('_comment') && !Object.prototype.hasOwnProperty.call(claves, clave)) + .map(clave => ({ clave, valor: plantilla[clave] })); + } + if (ruta !== SETTINGS_CLAUDE) return []; + + const hooksDe = config => (esObjeto(config) && esObjeto(config.hooks) ? config.hooks : {}); + const gruposDe = (hooks, evento) => (Array.isArray(hooks[evento]) ? hooks[evento].filter(esObjeto) : []); + const matcherDe = grupo => (typeof grupo.matcher === 'string' ? grupo.matcher : ''); + const comandosDe = grupo => (Array.isArray(grupo.hooks) + ? grupo.hooks.filter(hook => esObjeto(hook) && typeof hook.command === 'string') + : []); + const claveDe = (grupo, hook) => `${matcherDe(grupo)}\n${hook.command}`; + + const hooksPlantilla = hooksDe(plantilla); + const hooksDestino = hooksDe(destino); + const ausentes = []; + for (const evento of Object.keys(hooksPlantilla)) { + const presentes = new Set( + gruposDe(hooksDestino, evento).flatMap(grupo => comandosDe(grupo).map(hook => claveDe(grupo, hook))), + ); + for (const grupo of gruposDe(hooksPlantilla, evento)) { + for (const hook of comandosDe(grupo)) { + if (presentes.has(claveDe(grupo, hook))) continue; + ausentes.push({ evento, matcher: matcherDe(grupo), hook }); + } + } + } + return ausentes; +} + +/** + * Para `.claude/settings.json` y `hooks/config.json` saltados por edición + * local, avisa de los hooks y claves de la plantilla que quedan sin cablear + * en el destino. Solo avisa: no fusiona ni escribe nada. Un JSON ilegible en + * la plantilla o en el destino se reduce a un aviso genérico. + */ +function reportarCableadoAusente(saltadasPorEdicion) { + for (const ruta of [SETTINGS_CLAUDE, CONFIG_HOOKS]) { + if (!saltadasPorEdicion.includes(ruta)) continue; + let ausentes; + try { + const plantilla = JSON.parse(fs.readFileSync(origenDe(ruta), 'utf8')); + const destino = JSON.parse(fs.readFileSync(path.join(DEST, ruta), 'utf8')); + ausentes = diferenciaCableado(ruta, plantilla, destino); + } catch (err) { + console.log( + `Aviso: no se pudo comparar ${ruta} con la plantilla (${err.message}); ` + + 'revisa a mano si le faltan hooks o claves de la versión nueva.', + ); + continue; + } + if (!ausentes.length) continue; + + if (ruta === SETTINGS_CLAUDE) { + console.log(`Hooks de la plantilla sin cablear en ${ruta} (tiene cambios locales y no se actualizó):`); + for (const { evento, matcher, hook } of ausentes) { + const entrada = matcher ? { matcher, hooks: [hook] } : { hooks: [hook] }; + console.log(` - evento ${evento}, matcher ${matcher ? `"${matcher}"` : '(sin matcher)'}, comando: ${hook.command}`); + console.log(` añade a hooks.${evento}: ${JSON.stringify(entrada)}`); + } + } else { + console.log(`Claves de la plantilla ausentes en ${ruta} (tiene cambios locales y no se actualizó):`); + for (const { clave, valor } of ausentes) { + console.log(` - ${clave}: añade ${JSON.stringify({ [clave]: valor })}`); + } + } + console.log(` O ejecuta update con --reset-protected para sustituir ${ruta} por la plantilla (descarta tus cambios en él).`); + } +} + /** * Lee el modelo de sesion declarado en .claude/settings.json de PACKAGE_ROOT * (el paquete de origen que la instalacion acaba de copiar, no el destino). @@ -935,7 +1078,7 @@ function reportarInstalacion(copiadas, saltadas, creados, saltadasPorEdicion, sc } /** Reporta el resultado de update. Mismo criterio que reportarInstalacion: `huboFallos` decide el mensaje final. */ -function reportarActualizacion(copiadas, saltadas, saltadasPorEdicion, version, scriptsTestMergeado, huboFallos) { +function reportarActualizacion(copiadas, saltadas, saltadasPorEdicion, convergidas, version, scriptsTestMergeado, huboFallos, dryRun) { if (copiadas.length) { console.log('Rutas actualizadas:'); copiadas.forEach(ruta => console.log(` - ${ruta}`)); @@ -945,6 +1088,8 @@ function reportarActualizacion(copiadas, saltadas, saltadasPorEdicion, version, saltadas.forEach(ruta => console.log(` - ${ruta}`)); } reportarArchivosProtegidos(saltadasPorEdicion); + reportarCableadoAusente(saltadasPorEdicion); + reportarConvergidas(convergidas, dryRun); if (scriptsTestMergeado) { console.log('Anadido scripts.test a package.json para correr los tests de los hooks.'); } @@ -985,7 +1130,7 @@ async function cmdUpdate(args) { advertirSiBackendEquivocado(backend); advertirSiGeminiLayoutAntiguo(backend); await ejecutarPreflight(backend, skip, dryRun, force); - const { copiadas, saltadas, saltadasPorEdicion, fallidas, dirsFallidos } = copiarRutasFramework(backend, skip, { + const { copiadas, saltadas, saltadasPorEdicion, convergidas, fallidas, dirsFallidos } = copiarRutasFramework(backend, skip, { crearDirsUsuario: false, resetProtected, dryRun, @@ -995,7 +1140,7 @@ async function cmdUpdate(args) { const version = obtenerVersion(); if (!dryRun) sincronizarMarcadores(backend, copiadas, version); const huboFallos = fallidas.length > 0 || dirsFallidos.length > 0; - reportarActualizacion(copiadas, saltadas, saltadasPorEdicion, version, scriptsTestMergeado, huboFallos); + reportarActualizacion(copiadas, saltadas, saltadasPorEdicion, convergidas, version, scriptsTestMergeado, huboFallos, dryRun); escribirEcosistema(backend, dryRun); if (huboFallos) process.exit(1); } @@ -1050,7 +1195,12 @@ async function main() { process.exit(1); } -main().catch(err => { - process.stderr.write(`Error inesperado: ${err.message}\n`); - process.exit(1); -}); +if (require.main === module) { + main().catch(err => { + process.stderr.write(`Error inesperado: ${err.message}\n`); + process.exit(1); + }); +} + +// Funciones puras expuestas para los tests en proceso. +module.exports = { hashContenido, diferenciaCableado }; diff --git a/tests/cli.test.js b/tests/cli.test.js index acf5df9..d444493 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -2406,3 +2406,217 @@ test('con .claude/settings.json invalido en el paquete de origen, la nota se omi assert.doesNotMatch(stderr, /Error inesperado/); assert.doesNotMatch(stdout, /modelo de sesion \(tier capaz/); }); + +// --- Fin de línea, ficheros convergidos y cableado sin actualizar --------- +// +// El hash del sidecar normaliza CRLF a LF en los ficheros de texto: con +// `core.autocrlf=true` todo lo instalado queda en CRLF y, con el hash crudo, +// `update` lo daría entero por editado. Un fichero que el usuario ya dejó igual +// a la versión nueva tampoco es una edición. Y si `.claude/settings.json` o +// `hooks/config.json` se saltan por edición local, `update` avisa de lo que la +// plantilla trae y el destino no tiene. + +const { hashContenido, diferenciaCableado } = require(CLI); + +function sha256Crudo(buffer) { + return crypto.createHash('sha256').update(buffer).digest('hex'); +} + +test('hashContenido: un texto en LF da el mismo hash que el hash crudo (los sidecars existentes siguen valiendo)', () => { + const texto = Buffer.from('linea 1\nlinea 2\n'); + assert.strictEqual(hashContenido(texto), sha256Crudo(texto)); + assert.strictEqual(hashContenido(Buffer.alloc(0)), sha256Crudo(Buffer.alloc(0))); +}); + +test('hashContenido: CRLF se normaliza a LF; un \\r aislado y el resto del EOL mixto no', () => { + assert.strictEqual(hashContenido(Buffer.from('a\r\nb\r\n')), hashContenido(Buffer.from('a\nb\n'))); + assert.strictEqual(hashContenido(Buffer.from('a\r\nb\rc\n')), sha256Crudo(Buffer.from('a\nb\rc\n'))); + assert.notStrictEqual(hashContenido(Buffer.from('a\rb')), hashContenido(Buffer.from('a\nb'))); +}); + +test('hashContenido: un fichero con algún byte 0 se hashea en crudo, sin normalizar CRLF', () => { + const binario = Buffer.from([0x50, 0x0d, 0x0a, 0x00, 0x01]); + const sinCr = Buffer.from([0x50, 0x0a, 0x00, 0x01]); + assert.strictEqual(hashContenido(binario), sha256Crudo(binario)); + assert.notStrictEqual(hashContenido(binario), hashContenido(sinCr)); +}); + +test('update: CRLF sin otra edición se actualiza, una edición real en CRLF se preserva y un fichero convergido refresca el sidecar', () => { + const v1 = { a: 'uno\ndos\n', b: 'tres\ncuatro\n', c: 'cinco\n' }; + const v2 = { a: 'uno\ndos\nnuevo\n', b: 'tres\ncuatro\nnuevo\n', c: 'cinco\nnuevo\n' }; + const paquete = crearPaqueteFixture( + { common: ['hooks'], claude: [] }, + { 'hooks/a.js': v2.a, 'hooks/b.js': v2.b, 'hooks/c.js': v2.c }, + ); + const proyecto = dirTemporal(); + // Estado tras un install de la versión anterior: sidecar con el hash de v1. + escribirArchivo(proyecto, '.sdd-installed-hashes.json', JSON.stringify({ + 'hooks/a.js': sha256Crudo(Buffer.from(v1.a)), + 'hooks/b.js': sha256Crudo(Buffer.from(v1.b)), + 'hooks/c.js': sha256Crudo(Buffer.from(v1.c)), + })); + // a: el checkout lo dejó en CRLF, sin otro cambio. + escribirArchivo(proyecto, 'hooks/a.js', v1.a.replace(/\n/g, '\r\n')); + // b: edición real del usuario, guardada en CRLF. + const editadoB = 'tres\ncuatro\nmio\n'.replace(/\n/g, '\r\n'); + escribirArchivo(proyecto, 'hooks/b.js', editadoB); + // c: el usuario ya lo dejó igual a la versión nueva. + escribirArchivo(proyecto, 'hooks/c.js', v2.c); + + const { codigo, stdout } = ejecutar(['update', '--backend', 'claude', '--force'], { + cwd: proyecto, + env: { SDD_FRAMEWORK_ROOT: paquete }, + }); + + assert.strictEqual(codigo, 0); + assert.strictEqual(fs.readFileSync(path.join(proyecto, 'hooks', 'a.js'), 'utf8'), v2.a, + 'un cambio solo de fin de línea no debe proteger el fichero'); + assert.strictEqual(fs.readFileSync(path.join(proyecto, 'hooks', 'b.js'), 'utf8'), editadoB, + 'una edición real guardada en CRLF debe preservarse'); + const protegidos = stdout.split('Archivos con cambios locales')[1] || ''; + assert.match(protegidos, /hooks\/b\.js/); + assert.doesNotMatch(stdout, /- hooks\/a\.js/); + assert.match(stdout, /convergidos[^\n]*\n {2}- hooks\/c\.js/); + assert.doesNotMatch(protegidos.split('convergidos')[0], /hooks\/c\.js/); + const sidecar = JSON.parse(fs.readFileSync(path.join(proyecto, '.sdd-installed-hashes.json'), 'utf8')); + assert.strictEqual(sidecar['hooks/c.js'], sha256Crudo(Buffer.from(v2.c)), 'el sidecar del convergido debe refrescarse'); + assert.strictEqual(sidecar['hooks/a.js'], sha256Crudo(Buffer.from(v2.a))); + assert.strictEqual(sidecar['hooks/b.js'], sha256Crudo(Buffer.from(v1.b)), 'el editado conserva su hash previo'); +}); + +test('diferenciaCableado: settings.json nombra el hook ausente y el de matcher cambiado; nada si están todos', () => { + const hook = comando => ({ type: 'command', command: comando }); + const plantilla = { + hooks: { + SessionStart: [{ hooks: [hook('node inicio.js')] }], + PreToolUse: [ + { matcher: 'Write|Edit|NotebookEdit', hooks: [hook('node guard.js')] }, + { matcher: 'Bash', hooks: [hook('node commit.js'), hook('node nuevo.js')] }, + ], + }, + }; + const destino = { + model: 'otro', + hooks: { + SessionStart: [{ hooks: [hook('node inicio.js')] }], + PreToolUse: [ + { matcher: 'Write|Edit', hooks: [hook('node guard.js')] }, + { matcher: 'Bash', hooks: [hook('node commit.js'), hook('node propio.js')] }, + ], + }, + }; + assert.deepStrictEqual(diferenciaCableado('.claude/settings.json', plantilla, destino), [ + { evento: 'PreToolUse', matcher: 'Write|Edit|NotebookEdit', hook: hook('node guard.js') }, + { evento: 'PreToolUse', matcher: 'Bash', hook: hook('node nuevo.js') }, + ]); + assert.deepStrictEqual(diferenciaCableado('.claude/settings.json', plantilla, plantilla), []); + assert.strictEqual(diferenciaCableado('.claude/settings.json', plantilla, { hooks: 'roto' }).length, 4); +}); + +test('diferenciaCableado: hooks/config.json nombra las claves de primer nivel ausentes, sin las _comment', () => { + const plantilla = { _comment: 'x', a: { enabled: true }, _comment_b: 'y', b: { n: 1 } }; + assert.deepStrictEqual( + diferenciaCableado('hooks/config.json', plantilla, { a: { enabled: false } }), + [{ clave: 'b', valor: { n: 1 } }], + ); + assert.deepStrictEqual(diferenciaCableado('hooks/config.json', plantilla, { a: 1, b: 2 }), []); +}); + +/** Proyecto con settings.json y config.json instalados (v1) y luego editados por el usuario. */ +function proyectoConProtegidosEditados(paquete, settingsDestino, configDestino) { + const proyecto = dirTemporal(); + escribirArchivo(proyecto, '.sdd-installed-hashes.json', JSON.stringify({ + '.claude/settings.json': sha256Crudo(Buffer.from('{"version":1}')), + 'hooks/config.json': sha256Crudo(Buffer.from('{"version":1}')), + })); + escribirArchivo(proyecto, '.claude/settings.json', settingsDestino); + escribirArchivo(proyecto, 'hooks/config.json', configDestino); + return proyecto; +} + +const SETTINGS_PLANTILLA = JSON.stringify({ + hooks: { + PreToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'node hooks/commit.js' }] }, + { matcher: 'Read|Write', hooks: [{ type: 'command', command: 'node hooks/nuevo.js' }] }, + ], + }, +}); +const CONFIG_PLANTILLA = JSON.stringify({ existente: { enabled: true }, seccion_nueva: { enabled: true } }); + +test('update avisa de los hooks y claves de la plantilla ausentes en settings.json y config.json editados', () => { + const paquete = crearPaqueteFixture( + { common: ['hooks'], claude: ['.claude/settings.json'] }, + { '.claude/settings.json': SETTINGS_PLANTILLA, 'hooks/config.json': CONFIG_PLANTILLA }, + ); + const settingsUsuario = JSON.stringify({ + model: 'propio', + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'node hooks/commit.js' }] }] }, + }); + const configUsuario = JSON.stringify({ existente: { enabled: false } }); + const proyecto = proyectoConProtegidosEditados(paquete, settingsUsuario, configUsuario); + + const { codigo, stdout } = ejecutar(['update', '--backend', 'claude', '--force'], { + cwd: proyecto, + env: { SDD_FRAMEWORK_ROOT: paquete }, + }); + + assert.strictEqual(codigo, 0); + assert.match(stdout, /evento PreToolUse, matcher "Read\|Write", comando: node hooks\/nuevo\.js/); + assert.match(stdout, /hooks\.PreToolUse: \{"matcher":"Read\|Write","hooks":\[\{"type":"command","command":"node hooks\/nuevo\.js"\}\]\}/); + assert.doesNotMatch(stdout, /comando: node hooks\/commit\.js/); + assert.match(stdout, /--reset-protected/); + assert.match(stdout, /- seccion_nueva: /); + assert.doesNotMatch(stdout, /- existente: /); + assert.strictEqual(fs.readFileSync(path.join(proyecto, '.claude', 'settings.json'), 'utf8'), settingsUsuario); + assert.strictEqual(fs.readFileSync(path.join(proyecto, 'hooks', 'config.json'), 'utf8'), configUsuario); +}); + +test('update con settings.json editado que conserva todos los hooks y JSON de config.json inválido: sin aviso de cableado ni excepción', () => { + const paquete = crearPaqueteFixture( + { common: ['hooks'], claude: ['.claude/settings.json'] }, + { '.claude/settings.json': SETTINGS_PLANTILLA, 'hooks/config.json': CONFIG_PLANTILLA }, + ); + const settingsUsuario = JSON.stringify({ ...JSON.parse(SETTINGS_PLANTILLA), model: 'propio' }); + const configUsuario = '{ esto no es json'; + const proyecto = proyectoConProtegidosEditados(paquete, settingsUsuario, configUsuario); + + const { codigo, stdout, stderr } = ejecutar(['update', '--backend', 'claude', '--force'], { + cwd: proyecto, + env: { SDD_FRAMEWORK_ROOT: paquete }, + }); + + assert.strictEqual(codigo, 0); + assert.doesNotMatch(stderr, /Error inesperado/); + assert.doesNotMatch(stdout, /Hooks de la plantilla sin cablear/); + assert.match(stdout, /no se pudo comparar hooks\/config\.json con la plantilla/); + assert.strictEqual(fs.readFileSync(path.join(proyecto, '.claude', 'settings.json'), 'utf8'), settingsUsuario); + assert.strictEqual(fs.readFileSync(path.join(proyecto, 'hooks', 'config.json'), 'utf8'), configUsuario); +}); + +test('update --dry-run con un fichero convergido: lo informa en condicional y no escribe el sidecar', () => { + const paquete = crearPaqueteFixture({ common: ['hooks'], claude: [] }, { 'hooks/c.js': 'cinco\nnuevo\n' }); + const proyecto = dirTemporal(); + const sidecarPrevio = JSON.stringify({ 'hooks/c.js': sha256Crudo(Buffer.from('cinco\n')) }); + escribirArchivo(proyecto, '.sdd-installed-hashes.json', sidecarPrevio); + escribirArchivo(proyecto, 'hooks/c.js', 'cinco\nnuevo\n'); + + const { codigo, stdout } = ejecutar(['update', '--backend', 'claude', '--dry-run'], { + cwd: proyecto, + env: { SDD_FRAMEWORK_ROOT: paquete }, + }); + + assert.strictEqual(codigo, 0); + assert.match(stdout, /convergidos \(ya coincidían con la versión nueva; su hash se refrescaría sin --dry-run\):\n {2}- hooks\/c\.js/); + assert.doesNotMatch(stdout, /su hash se ha refrescado/); + assert.strictEqual(fs.readFileSync(path.join(proyecto, '.sdd-installed-hashes.json'), 'utf8'), sidecarPrevio); +}); + +test('diferenciaCableado: un hook sin command en la plantilla no se informa como ausente', () => { + const plantilla = { + hooks: { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command' }, { type: 'command', command: 'node a.js' }] }] }, + }; + assert.deepStrictEqual(diferenciaCableado('.claude/settings.json', plantilla, { hooks: {} }), [ + { evento: 'PreToolUse', matcher: 'Bash', hook: { type: 'command', command: 'node a.js' } }, + ]); +}); From 7c8c42713a1209a37745e4c21f3b8dab0ceeb427 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:20:57 +0000 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20la=20revisi=C3=B3n=20bloquea=20sol?= =?UTF-8?q?o=20lo=20material=20y=20el=20revisor=20reporta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La prosa empujaba a convertir la duda en bloqueo («mejor un falso positivo») y un bloqueo dudoso cuesta la task entera. La plantilla de revisión adversarial fija ahora una sola definición de BLOQUEANTE (defecto material con evidencia, incluido todo fallo de seguridad sea cual sea su severidad percibida); la duda sin evidencia va como WARNING con lo que la resolvería y se intenta refutar antes de bloquear, sin relajar la búsqueda. Distingue modo task e integración, define la re-revisión, amplía la búsqueda (llamadas, coste, mecanismos de verificación) y abre la salida con el veredicto. El Paso 4bis deja de ordenar al revisor, que no tiene escritura, crear tasks de convergencia: las reporta y las crea quien escribe. La auditoría de spec bloquea también por contrato roto o dependencia circular y manda las dudas a avisos. El agente revisor aplica los pasos pertinentes al riesgo y justifica los omitidos. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .agents/plugins/sdd/agents/revisor.md | 32 +- .claude/agents/revisor.md | 20 +- .codex/agents/revisor.toml | 32 +- .gemini/agents/revisor.md | 20 +- ai_docs/dev_templates/auditar_spec.md | 10 +- ai_docs/dev_templates/revision_adversarial.md | 292 +++++++++++------- docs-src/agents/revisor.md | 32 +- 7 files changed, 255 insertions(+), 183 deletions(-) diff --git a/.agents/plugins/sdd/agents/revisor.md b/.agents/plugins/sdd/agents/revisor.md index 9ea8e12..b855966 100644 --- a/.agents/plugins/sdd/agents/revisor.md +++ b/.agents/plugins/sdd/agents/revisor.md @@ -1,34 +1,38 @@ --- name: revisor -description: "Revisa tasks antes de implementar y hace la revision adversarial del codigo entregado. Esceptico por diseno." +description: "Despáchalo para revisar una task antes de implementarla, auditar una spec con sus tasks o hacer la revisión adversarial del código entregado. Reporta hallazgos y veredicto; no escribe." tools: [view_file, grep_search, run_command] --- # Revisor -Revision esceptica en dos momentos: antes de implementar (el plan) y despues de implementar (el -codigo). No escribe codigo. +Revisión escéptica en dos momentos: antes de implementar (el plan) y después de implementar (el +código). No escribe código ni ficheros. -## Cuando actuas +## Cuándo actúas -- Se crea o modifica un documento de task (revision pre-implementacion). +- Se crea o modifica un documento de task (revisión pre-implementación). - Se audita la coherencia entre una spec y sus tasks. -- Se completa la implementacion de las tasks de una spec (revision adversarial). +- Se completa la implementación de una task o de las tasks de una spec (revisión adversarial). -No actuas para crear specs (eso es del planificador) ni para implementar (eso es del implementador). +No actúas para crear specs (eso es del planificador) ni para implementar (eso es del implementador). ## Proceso -- Revision de una task: sigue `ai_docs/dev_templates/revisar_tarea.md`. -- Auditoria de spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md`. -- Revision adversarial del codigo: sigue `ai_docs/dev_templates/revision_adversarial.md`. +- Revisión de una task: sigue `ai_docs/dev_templates/revisar_tarea.md`. +- Auditoría de spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md`. +- Revisión adversarial del código: sigue `ai_docs/dev_templates/revision_adversarial.md`. -Lee la plantilla correspondiente y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Lee la plantilla correspondiente y aplica los pasos pertinentes al riesgo del cambio; declara los +omitidos con su motivo. Cuando juzgues si los tests acreditan el cambio, lee +`ai_docs/dev_templates/testing_basico.md`. ## Postura -Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asume que hay errores hasta -demostrar lo contrario y valida con evidencia (lee el codigo, ejecuta los tests). Si la evidencia -es ambigua, reporta el hallazgo: mejor un falso positivo que un fallo en produccion. +Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo está bien: busca con todo el escepticismo +y valida con evidencia (lee el código, ejecuta los tests). Bloquea solo lo material, según el +criterio de bloqueo de la plantilla; una duda sin evidencia se reporta como WARNING con lo que la +resolvería. Reportas: las tasks y correcciones que deriven de tus hallazgos las escribe quien tiene +escritura. Tu veredicto es el gate: hasta que lo emites, la entrega no avanza. diff --git a/.claude/agents/revisor.md b/.claude/agents/revisor.md index 3e7fe40..cd6a95c 100644 --- a/.claude/agents/revisor.md +++ b/.claude/agents/revisor.md @@ -1,6 +1,6 @@ --- name: revisor -description: "Revisa tasks pre-implementacion y hace revision adversarial post-implementacion. Esceptico por diseno." +description: "Despáchalo para revisar una task antes de implementarla, auditar una spec con sus tasks o hacer la revisión adversarial de un diff ya escrito. Reporta hallazgos y veredicto; no escribe." model: inherit tools: - Read @@ -9,24 +9,24 @@ tools: # Revisor -> Revision esceptica en dos momentos: antes de implementar (tasks) y despues de implementar (codigo). +> Revisión escéptica en dos momentos: antes de implementar (tasks) y después de implementar (código). -## Cuando activarse +## Cuándo activarse -- Se crea o modifica un documento de task en ai_docs/tasks/ (revision pre-impl) -- Se completa la implementacion de todas las tasks de una spec (revision adversarial) +- Se crea o modifica un documento de task en ai_docs/tasks/ (revisión pre-implementación) +- Se completa la implementación de una task o de todas las tasks de una spec (revisión adversarial) - Se invoca /auditar o /revision **No activarse** para crear specs (eso es del planificador) ni para implementar (eso es del implementador). ## Proceso -- Revision de task individual: sigue `ai_docs/dev_templates/revisar_tarea.md` -- Auditoria spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md` -- Revision adversarial post-impl: sigue `ai_docs/dev_templates/revision_adversarial.md` +- Revisión de task individual: sigue `ai_docs/dev_templates/revisar_tarea.md` +- Auditoría spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md` +- Revisión adversarial post-implementación: sigue `ai_docs/dev_templates/revision_adversarial.md` -Lee la plantilla correspondiente y ejecuta todos sus pasos. No condenses ni saltes pasos. +Lee la plantilla correspondiente y aplica los pasos pertinentes al riesgo del cambio; declara los omitidos con su motivo. Cuando juzgues si los tests acreditan el cambio, lee `ai_docs/dev_templates/testing_basico.md`. ## Postura -Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asume que hay errores hasta demostrar lo contrario. Si la evidencia es ambigua, reporta el hallazgo — mejor un falso positivo que un bug en produccion. +Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo está bien: busca con todo el escepticismo y valida con evidencia (lee el código, ejecuta los tests). Bloquea solo lo material, según el criterio de bloqueo de la plantilla; una duda sin evidencia se reporta como WARNING con lo que la resolvería. Reportas: las tasks y correcciones que deriven de tus hallazgos las escribe quien tiene escritura. diff --git a/.codex/agents/revisor.toml b/.codex/agents/revisor.toml index 1cb92b6..f499d28 100644 --- a/.codex/agents/revisor.toml +++ b/.codex/agents/revisor.toml @@ -1,34 +1,38 @@ name = "revisor" -description = "Revisa tasks antes de implementar y hace la revision adversarial del codigo entregado. Esceptico por diseno." +description = "Despáchalo para revisar una task antes de implementarla, auditar una spec con sus tasks o hacer la revisión adversarial del código entregado. Reporta hallazgos y veredicto; no escribe." sandbox_mode = "read-only" developer_instructions = """ # Revisor -Revision esceptica en dos momentos: antes de implementar (el plan) y despues de implementar (el -codigo). No escribe codigo. +Revisión escéptica en dos momentos: antes de implementar (el plan) y después de implementar (el +código). No escribe código ni ficheros. -## Cuando actuas +## Cuándo actúas -- Se crea o modifica un documento de task (revision pre-implementacion). +- Se crea o modifica un documento de task (revisión pre-implementación). - Se audita la coherencia entre una spec y sus tasks. -- Se completa la implementacion de las tasks de una spec (revision adversarial). +- Se completa la implementación de una task o de las tasks de una spec (revisión adversarial). -No actuas para crear specs (eso es del planificador) ni para implementar (eso es del implementador). +No actúas para crear specs (eso es del planificador) ni para implementar (eso es del implementador). ## Proceso -- Revision de una task: sigue `ai_docs/dev_templates/revisar_tarea.md`. -- Auditoria de spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md`. -- Revision adversarial del codigo: sigue `ai_docs/dev_templates/revision_adversarial.md`. +- Revisión de una task: sigue `ai_docs/dev_templates/revisar_tarea.md`. +- Auditoría de spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md`. +- Revisión adversarial del código: sigue `ai_docs/dev_templates/revision_adversarial.md`. -Lee la plantilla correspondiente y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Lee la plantilla correspondiente y aplica los pasos pertinentes al riesgo del cambio; declara los +omitidos con su motivo. Cuando juzgues si los tests acreditan el cambio, lee +`ai_docs/dev_templates/testing_basico.md`. ## Postura -Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asume que hay errores hasta -demostrar lo contrario y valida con evidencia (lee el codigo, ejecuta los tests). Si la evidencia -es ambigua, reporta el hallazgo: mejor un falso positivo que un fallo en produccion. +Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo está bien: busca con todo el escepticismo +y valida con evidencia (lee el código, ejecuta los tests). Bloquea solo lo material, según el +criterio de bloqueo de la plantilla; una duda sin evidencia se reporta como WARNING con lo que la +resolvería. Reportas: las tasks y correcciones que deriven de tus hallazgos las escribe quien tiene +escritura. Tu veredicto es el gate: hasta que lo emites, la entrega no avanza. """ diff --git a/.gemini/agents/revisor.md b/.gemini/agents/revisor.md index 814b7bf..61da8ac 100644 --- a/.gemini/agents/revisor.md +++ b/.gemini/agents/revisor.md @@ -1,30 +1,30 @@ --- name: revisor -description: "Revisa tasks pre-implementacion y hace revision adversarial post-implementacion. Esceptico por diseno." +description: "Despáchalo para revisar una task antes de implementarla, auditar una spec con sus tasks o hacer la revisión adversarial de un diff ya escrito. Reporta hallazgos y veredicto; no escribe." model: gemini-2.5-pro tools: [read_file, run_command, glob, grep_search] --- # Revisor -> Revision esceptica en dos momentos: antes de implementar (tasks) y despues de implementar (codigo). +> Revisión escéptica en dos momentos: antes de implementar (tasks) y después de implementar (código). -## Cuando activarse +## Cuándo activarse -- Se crea o modifica un documento de task en ai_docs/tasks/ (revision pre-impl) -- Se completa la implementacion de todas las tasks de una spec (revision adversarial) +- Se crea o modifica un documento de task en ai_docs/tasks/ (revisión pre-implementación) +- Se completa la implementación de una task o de todas las tasks de una spec (revisión adversarial) - Se invoca /auditar o /revision **No activarse** para crear specs (eso es del planificador) ni para implementar (eso es del implementador). ## Proceso -- Revision de task individual: sigue `ai_docs/dev_templates/revisar_tarea.md` -- Auditoria spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md` -- Revision adversarial post-impl: sigue `ai_docs/dev_templates/revision_adversarial.md` +- Revisión de task individual: sigue `ai_docs/dev_templates/revisar_tarea.md` +- Auditoría spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md` +- Revisión adversarial post-implementación: sigue `ai_docs/dev_templates/revision_adversarial.md` -Lee la plantilla correspondiente y ejecuta todos sus pasos. No condenses ni saltes pasos. +Lee la plantilla correspondiente y aplica los pasos pertinentes al riesgo del cambio; declara los omitidos con su motivo. Cuando juzgues si los tests acreditan el cambio, lee `ai_docs/dev_templates/testing_basico.md`. ## Postura -Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asume que hay errores hasta demostrar lo contrario. Si la evidencia es ambigua, reporta el hallazgo — mejor un falso positivo que un bug en produccion. +Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo está bien: busca con todo el escepticismo y valida con evidencia (lee el código, ejecuta los tests). Bloquea solo lo material, según el criterio de bloqueo de la plantilla; una duda sin evidencia se reporta como WARNING con lo que la resolvería. Reportas: las tasks y correcciones que deriven de tus hallazgos las escribe quien tiene escritura. diff --git a/ai_docs/dev_templates/auditar_spec.md b/ai_docs/dev_templates/auditar_spec.md index bd381f6..b11da2b 100644 --- a/ai_docs/dev_templates/auditar_spec.md +++ b/ai_docs/dev_templates/auditar_spec.md @@ -13,7 +13,7 @@ Estas instrucciones son OBLIGATORIAS. Debes seguirlas en todo momento: -1. **MUESTRA antes de ejecutar** — Presenta el resultado completo de la auditoria al usuario ANTES de emitir el veredicto final. +1. **VEREDICTO primero** — La salida abre con el veredicto y el problema más grave; después, el resultado completo de la auditoría. 2. **EXPLICA el porque** — Para cada problema encontrado, explica POR QUE es un problema y QUE consecuencia tiene. 3. **SUGIERE mejoras** — Si detectas formas de mejorar la cobertura o reducir riesgos, PROPONLAS. 4. **VERIFICA despues** — Si el usuario corrige problemas, vuelve a ejecutar las verificaciones afectadas. @@ -23,7 +23,7 @@ Estas instrucciones son OBLIGATORIAS. Debes seguirlas en todo momento: **Instrucciones adicionales para esta plantilla:** - Esta plantilla es de AUDITORIA. NO modifica nada. Solo detecta problemas y emite un veredicto. - El rol del auditor es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asumir que hay huecos hasta demostrar lo contrario. -- NUNCA emitas un veredicto APROBADO si hay problemas pendientes. Es preferible un falso positivo que un falso negativo. +- Un problema impide el veredicto APROBADO solo si es material y tiene evidencia: criterio sin cobertura, contradicción entre spec y tasks, contrato roto, dependencia circular, o hueco que impide implementar o verificar una task. Una duda sin evidencia va como WARNING, con lo que la resolvería, en el campo de avisos del resultado (`avisos`), no entre los huecos ni las incoherencias. Antes de bloquear, intenta refutar el hallazgo. La búsqueda no se relaja: lo que cambia es el umbral de bloqueo. --- @@ -134,6 +134,8 @@ Para cada task con dependencias declaradas: ## Paso 7: Presentar resultado ``` +**Veredicto:** [APROBADO / NECESITA_AJUSTES / NECESITA_REPLANTEAMIENTO] — [problema más grave en una frase, o "sin problemas bloqueantes"] + ## Resultado de la auditoria **Spec:** [Titulo] @@ -162,9 +164,7 @@ Para cada task con dependencias declaradas: - [N] dependencias verificadas: [N] correctas, [N] con problemas - Dependencias no declaradas encontradas: [lista o "Ninguna"] -### Veredicto - -[APROBADO / NECESITA_AJUSTES / NECESITA_REPLANTEAMIENTO] +### Acciones según el veredicto **Si NECESITA_AJUSTES:** - [Ajuste 1: que corregir y en que task] diff --git a/ai_docs/dev_templates/revision_adversarial.md b/ai_docs/dev_templates/revision_adversarial.md index 209a16a..9d2d820 100644 --- a/ai_docs/dev_templates/revision_adversarial.md +++ b/ai_docs/dev_templates/revision_adversarial.md @@ -1,155 +1,204 @@ -# Plantilla de Revision Adversarial Post-Implementacion +# Plantilla de Revisión Adversarial Post-Implementación -> **Cuando usar:** Despues de implementar una task, ANTES de commitearla — el gate primario es por task. Tambien sirve como revision de integracion opcional sobre varias tasks que interactuan, antes de mergear, y como cierre de spec completa (verificando convergencia entre el resultado real de todas las tasks y la spec original). -> **Como usar:** Copia este documento completo y pegalo en tu asistente de IA junto con la task (o la spec y las tasks) y el diff a revisar. +> **Cuándo usar:** después de implementar una task, ANTES de commitearla — el gate primario es por task (modo task). También sirve como revisión de integración sobre varias tasks que interactúan, antes de mergear, y como cierre de spec completa, verificando la convergencia entre el resultado real de todas las tasks y la spec original (modo integración). +> **Cómo usar:** copia este documento completo y pégalo en tu asistente de IA junto con la task (o la spec y las tasks) y el diff a revisar. > **Veredicto:** emite uno de `APROBADA`, `NECESITA_CORRECCIONES` o `RECHAZADA`. --- ## Instrucciones para el Asistente de IA -> **Modo de uso — declaralo antes de empezar:** -> - **Via workflow** (`/implementar-spec`, `/revision`): NO hagas preguntas. Analiza el diff que se te entrega y emite el veredicto directamente. El PUNTO DE ESPERA de abajo NO aplica. -> - **Uso manual** (copy-paste al asistente): si falta contexto critico, pregunta antes de emitir el veredicto; respeta el PUNTO DE ESPERA hasta la respuesta del usuario. +> **Modo de uso — decláralo antes de empezar:** +> - **Vía workflow** (`/implementar-spec`, `/revision`): NO hagas preguntas. Analiza el diff que se te entrega y emite el veredicto directamente. El PUNTO DE ESPERA de abajo NO aplica. +> - **Uso manual** (copy-paste al asistente): si falta contexto crítico, pregunta antes de emitir el veredicto; respeta el PUNTO DE ESPERA hasta la respuesta del usuario. Estas instrucciones son OBLIGATORIAS. Debes seguirlas en todo momento: -1. **MUESTRA antes de ejecutar** — Presenta todos los hallazgos al usuario ANTES de emitir el veredicto. -2. **EXPLICA el porque** — Para cada problema encontrado, explica POR QUE es un problema, QUE consecuencia tiene y COMO corregirlo. -3. **SUGIERE mejoras** — Si detectas oportunidades de mejora mas alla de los problemas, mencionalas por separado. -4. **VERIFICA despues** — Si el usuario aplica correcciones, vuelve a verificar los puntos afectados. -5. **ESCALA cuando corresponda** — Si los problemas son graves (perdida de datos, vulnerabilidad de seguridad), DETENTE inmediatamente y comunica la severidad. -6. **NUNCA asumas que la primera solucion es la mejor** — Si propones una correccion, presenta alternativas cuando no sea trivial. +1. **VEREDICTO primero** — El informe abre con el veredicto y el peor hallazgo; después, el detalle de cada hallazgo. +2. **EXPLICA el porqué** — Para cada problema encontrado, explica POR QUÉ es un problema, QUÉ consecuencia tiene y CÓMO corregirlo. +3. **SUGIERE mejoras** — Si detectas oportunidades de mejora más allá de los problemas, menciónalas por separado: no cambian el veredicto. +4. **VERIFICA después** — Si se aplican correcciones, haz la re-revisión descrita abajo. +5. **ESCALA cuando corresponda** — Si los problemas son graves (pérdida de datos, vulnerabilidad de seguridad), DETENTE inmediatamente y comunica la severidad. +6. **NUNCA asumas que la primera solución es la mejor** — Si propones una corrección, presenta alternativas cuando no sea trivial. **Instrucciones adicionales para esta plantilla:** -- Tu rol es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asume que hay bugs hasta demostrar lo contrario. -- Revisa TODA la implementacion, no solo la ultima task. Los problemas de integracion entre tasks son los mas peligrosos. -- NUNCA emitas veredicto APROBADA si tienes dudas. Es preferible NECESITA_CORRECCIONES que dejar pasar un problema. -- Esta plantilla NO modifica codigo. Solo detecta problemas y emite un veredicto. +- Tu rol es ENCONTRAR PROBLEMAS, no confirmar que todo está bien. Asume que hay bugs hasta demostrar lo contrario. +- Esta plantilla NO modifica código ni escribe ficheros. Solo detecta problemas, los reporta y emite un veredicto. + +### Criterio de bloqueo + +BLOQUEANTE es un defecto material con evidencia: un criterio de aceptación de la task incumplido, un bug reproducible, un fallo de seguridad (sea cual sea su severidad percibida), una regresión, un contrato roto o una garantía declarada que el cambio no da. Solo un BLOQUEANTE cambia el veredicto. + +- **WARNING:** defecto real que no llega a lo anterior. No cambia el veredicto. +- **Preferencia:** estilo o alternativa equivalente. Se menciona aparte y no cambia el veredicto. +- **Duda sin evidencia:** va como WARNING, con lo que la resolvería (la prueba que habría que ejecutar, el fichero que habría que leer o el dato que falta). +- **Refuta antes de bloquear:** antes de emitir un BLOQUEANTE, intenta refutarlo: busca el test, la guarda o el llamador que lo invalidaría y reproduce el fallo cuando se pueda. Si la refutación prospera, el hallazgo se retira; si deja duda, baja a WARNING. +- **La búsqueda no se relaja:** la intensidad con que buscas problemas no baja; lo que cambia es el umbral de bloqueo. +- **El informe de quien hizo el trabajo no es evidencia:** la revisión se pronuncia sobre el diff y sobre la salida real (tests ejecutados, comandos, ficheros leídos), no sobre lo que el implementador dice haber hecho. +- **WARNING aceptado sin corregir:** sale con dueño: una task que lo recoge o una decisión explícita del usuario. + +### Modo de revisión + +Declara el modo antes del Paso 1: + +| Modo | Entrada | Pasos | Qué bloquea | +|------|---------|-------|-------------| +| **Task** | El diff, la task y sus criterios de aceptación | 1 (acotado a la task), 3, 4 sobre los criterios de la task y 5 sobre las restricciones que la task declare | Solo los criterios de esta task y los defectos del diff. Los criterios de otras tasks nunca son BLOQUEANTE aquí; si el diff rompe algo ya entregado, es una regresión | +| **Integración** | La spec completa, todas sus tasks y el código | Todos, incluidos el 2 y el 4bis | Los criterios y restricciones de la spec completa y los problemas entre tasks | + +Los pasos que no aplican al modo se declaran omitidos con su motivo en el resultado; no se rellenan con contenido supuesto. + +### Re-revisión + +Si revisas tras una corrección: + +- Pronúnciate sobre cada hallazgo previo: **resuelto** o **persistente**, con la evidencia que lo acredita. +- Revisa el delta de la corrección con el mismo criterio que la revisión inicial. +- Un hallazgo recibido de otra pasada (otra revisión, una búsqueda previa) se adjudica: se confirma, se rebaja o se refuta con motivo. Nunca se descarta en silencio. --- -## Paso 1: Cargar contexto completo +## Paso 1: Cargar contexto Antes de revisar, carga y verifica: -1. **Spec original** — leer completa, especialmente criterios de aceptacion y restricciones -2. **Todas las tasks** — leer cada documento de task, incluyendo criterios de exito y edge cases -3. **Codigo implementado** — revisar TODOS los archivos modificados o creados por todas las tasks +- **Modo task:** la task completa (criterios de éxito y edge cases) y el diff con todos los ficheros que modifica o crea. +- **Modo integración:** + 1. **Spec original** — leer completa, especialmente criterios de aceptación y restricciones. + 2. **Todas las tasks** — leer cada documento de task, incluyendo criterios de éxito y edge cases. + 3. **Código implementado** — revisar TODOS los ficheros modificados o creados por todas las tasks. ``` ## Contexto cargado -**Spec:** [Titulo] — [N] criterios de aceptacion, [N] restricciones -**Tasks completadas:** [N] de [N] total -**Archivos afectados:** [N] archivos en [N] tasks +**Modo:** [task / integración] +**Spec:** [Título] — [N] criterios de aceptación, [N] restricciones +**Task revisada:** [NNN] (modo task) / **Tasks completadas:** [N] de [N] total (modo integración) +**Archivos afectados:** [N] -**Tasks pendientes:** [Lista, o "Ninguna — todas completadas"] +**Tasks pendientes (solo modo integración):** [Lista, o "Ninguna — todas completadas"] ``` -**Si hay tasks pendientes:** DETENTE. La revision adversarial se hace cuando TODAS las tasks estan completadas. +**Solo en modo integración — si hay tasks pendientes:** DETENTE. La revisión de integración se hace cuando TODAS las tasks están completadas. En modo task, las tasks pendientes no detienen la revisión. --- -## Paso 2: Verificar integracion entre tasks +## Paso 2: Verificar integración entre tasks -Este es el paso mas critico. Los problemas de integracion son los que se escapan en revisiones por task individual. +> Aplicable solo en modo integración. En modo task se declara omitido. + +Los problemas de integración son los que se escapan en las revisiones por task individual. ### 2.1 Interfaces entre tasks -Para cada par de tasks que interactuan (una produce algo que la otra consume): +Para cada par de tasks que interactúan (una produce algo que la otra consume): -| Task productora | Task consumidora | Interfaz | Compatible? | +| Task productora | Task consumidora | Interfaz | ¿Compatible? | |-----------------|-----------------|----------|-------------| -| Task NNN (crea API) | Task NNN (consume API) | Formato de respuesta, codigos de error | SI / NO — detallar | -| Task NNN (crea modelo) | Task NNN (usa modelo) | Campos, tipos, validaciones | SI / NO — detallar | +| Task NNN (crea API) | Task NNN (consume API) | Formato de respuesta, códigos de error | SÍ / NO — detallar | +| Task NNN (crea modelo) | Task NNN (usa modelo) | Campos, tipos, validaciones | SÍ / NO — detallar | ### 2.2 Conflictos de merge -Si multiples tasks tocaron archivos compartidos, verificar: +Si varias tasks tocaron ficheros compartidos, verificar: -- Archivos tocados por mas de una task: hay conflictos? -- Imports anadidos por tasks diferentes: hay duplicados o conflictos? -- Configuraciones modificadas por mas de una task: son coherentes? +- Ficheros tocados por más de una task: ¿hay conflictos? +- Imports añadidos por tasks diferentes: ¿hay duplicados o conflictos? +- Configuraciones modificadas por más de una task: ¿son coherentes? ### 2.3 Estado global Verificar que las tasks no dejan el sistema en estado inconsistente: -- Migraciones de base de datos: se pueden ejecutar en orden sin error? -- Variables de entorno nuevas: estan documentadas y tienen valores por defecto? -- Dependencias nuevas: son compatibles entre si y con las existentes? +- Migraciones de base de datos: ¿se pueden ejecutar en orden sin error? +- Variables de entorno nuevas: ¿están documentadas y tienen valores por defecto? +- Dependencias nuevas: ¿son compatibles entre sí y con las existentes? --- ## Paso 3: Buscar problemas activamente -Revisar TODA la implementacion buscando cada categoria: +Revisa todo lo que entra en el modo buscando cada categoría. Agrupa los hallazgos por causa raíz: varios síntomas de un mismo defecto son un solo hallazgo, con todas sus ubicaciones. ### 3.1 Regresiones -| Verificacion | Estado | +| Verificación | Estado | |-------------|--------| -| Tests existentes siguen pasando | SI / NO | -| Funcionalidad existente no afectada no se rompio | SI / NO | -| APIs publicas mantienen su contrato (no breaking changes sin documentar) | SI / NO | +| Tests existentes siguen pasando | SÍ / NO | +| Funcionalidad existente no afectada no se rompió | SÍ / NO | +| APIs públicas mantienen su contrato (no breaking changes sin documentar) | SÍ / NO | ### 3.2 Edge cases no cubiertos Para cada task, revisar los edge cases documentados y buscar los no documentados: -| Edge case | Task | Cubierto por test? | Manejado en codigo? | +| Edge case | Task | ¿Cubierto por test? | ¿Manejado en código? | |-----------|------|-------------------|-------------------| -| [Caso 1] | NNN | SI / NO | SI / NO | -| [Caso 2] | NNN | SI / NO | SI / NO | +| [Caso 1] | NNN | SÍ / NO | SÍ / NO | +| [Caso 2] | NNN | SÍ / NO | SÍ / NO | **Edge cases comunes a verificar siempre:** -- Inputs vacios, null, undefined -- Listas vacias +- Inputs vacíos, null, undefined +- Listas vacías - Valores negativos o cero cuando se esperan positivos - Strings extremadamente largos - Concurrencia (dos usuarios haciendo lo mismo al mismo tiempo) -- Fallo a mitad de operacion (que queda en que estado?) -- Permisos (usuario sin autorizacion intenta la operacion) -- **Completitud CRUD:** si la task crea un recurso, verificar que tambien existen las operaciones de lectura, edicion y eliminacion (o que estan planificadas en otra task). No dejar entidades con solo CREATE. -- **Basicos transversales:** ruta inexistente retorna 404 (no 500 ni pagina en blanco). Healthcheck/status endpoint existe si hay deployment. Imagenes o assets con fallback si la fuente falla. -- **Higiene de contenedores/ORM (si aplica):** Dockerfile no corre como root (`USER` declarado). `.dockerignore` excluye `node_modules/`, `.git/`, `.env`. Errores de ORM (Prisma, Drizzle, SQLAlchemy) no se exponen crudos al usuario — se capturan y retornan mensaje generico. +- Fallo a mitad de operación (¿qué queda en qué estado?) +- Permisos (usuario sin autorización intenta la operación) +- **Completitud CRUD:** si la task crea un recurso, verificar que también existen las operaciones de lectura, edición y eliminación (o que están planificadas en otra task). No dejar entidades con solo CREATE. +- **Básicos transversales:** ruta inexistente retorna 404 (no 500 ni página en blanco). Healthcheck/status endpoint existe si hay deployment. Imágenes o assets con fallback si la fuente falla. +- **Higiene de contenedores/ORM (si aplica):** Dockerfile no corre como root (`USER` declarado). `.dockerignore` excluye `node_modules/`, `.git/`, `.env`. Errores de ORM (Prisma, Drizzle, SQLAlchemy) no se exponen crudos al usuario — se capturan y retornan mensaje genérico. -### 3.3 Codigo muerto y restos de desarrollo +### 3.3 Código muerto y restos de desarrollo - Funciones creadas que nadie llama - Imports sin usar -- Codigo comentado que no deberia estar en produccion +- Código comentado que no debería estar en producción - Logs de debug (console.log, print, var_dump) - TODO/FIXME sin ticket asociado ### 3.4 Seguridad -| Verificacion | Estado | +| Verificación | Estado | |-------------|--------| | No hay secretos hardcoded (API keys, passwords, tokens) | OK / FALLA | | Inputs de usuario validados antes de usar | OK / FALLA | -| Queries a base de datos parametrizadas (sin concatenacion de strings) | OK / FALLA / N/A | -| Rutas protegidas con autenticacion/autorizacion | OK / FALLA / N/A | +| Queries a base de datos parametrizadas (sin concatenación de strings) | OK / FALLA / N/A | +| Rutas protegidas con autenticación/autorización | OK / FALLA / N/A | | Datos sensibles no expuestos en logs o respuestas API | OK / FALLA | ### 3.5 Consistencia -- Estilo de codigo consistente con el resto del proyecto +- Estilo de código consistente con el resto del proyecto - Nombres siguen las convenciones del proyecto -- Estructura de archivos sigue el patron existente +- Estructura de ficheros sigue el patrón existente - Manejo de errores consistente con el resto del proyecto +### 3.6 Llamadas nuevas o cambiadas + +En cada llamada nueva o cambiada, lee la declaración de la función llamada: firma, valores de retorno, errores que lanza y efectos laterales. No supongas el contrato por el nombre. + +### 3.7 Coste en ejecución + +- Proceso, red o I/O dentro de un bucle +- Lectura repetida del mismo fichero o recurso +- Colección que crece sin límite (caché, cola, lista acumulada) + +### 3.8 Mecanismos de verificación y superficie no evaluable + +- Un mecanismo de verificación nuevo (test, hook, gate, job de CI) que no declara en qué punto del ciclo corre ni cuánto cuesta es WARNING. +- Un cambio de interfaz perceptual (diseño, maquetación, interacción visual) sin verificación en el viewport real se declara **no evaluado**; no se aprueba ni se bloquea por suposición. + --- -## Paso 4: Verificar criterios de aceptacion de la spec +## Paso 4: Verificar criterios de aceptación -Para CADA criterio de aceptacion de la spec, verificar que se cumple: +En modo task, los criterios de la task; en modo integración, los de la spec. Para CADA criterio, verificar que se cumple: -| # | Criterio | Cumplido? | Evidencia | +| # | Criterio | ¿Cumplido? | Evidencia | |---|----------|-----------|-----------| -| 1 | [Criterio de la spec] | SI / NO / PARCIAL | [Test que lo verifica / Como se comprobo] | -| 2 | [Criterio de la spec] | SI / NO / PARCIAL | [Test que lo verifica / Como se comprobo] | +| 1 | [Criterio] | SÍ / NO / PARCIAL | [Test que lo verifica / Cómo se comprobó] | +| 2 | [Criterio] | SÍ / NO / PARCIAL | [Test que lo verifica / Cómo se comprobó] | **Si hay criterios NO cumplidos o PARCIALES:** listarlos como hallazgo BLOQUEANTE. @@ -157,72 +206,87 @@ Para CADA criterio de aceptacion de la spec, verificar que se cumple: ### Paso 4bis: Verificar convergencia de cierre de spec -> Aplicable solo en modo integracion/spec completa (no en revisiones de task individual). +> Aplicable solo en modo integración/spec completa. En modo task se declara omitido. -**Modos de invocacion:** -- **Integrado** (comportamiento por defecto): parte de la revision completa de esta plantilla, ejecutado despues del Paso 4 dentro del mismo pase. -- **Standalone**: invocable en solitario, sin ejecutar los Pasos 1-3 ni el Paso 5, desde `/implementar-spec` al cerrar todas las tasks de una spec. Solo requiere la spec y el estado final de sus tasks (Paso 1.2-1.3 de este documento, aplicados de forma acotada). Util para re-verificar convergencia sin repetir toda la revision de codigo. +**Modos de invocación:** +- **Integrado** (comportamiento por defecto): parte de la revisión completa de esta plantilla, ejecutado después del Paso 4 dentro del mismo pase. +- **Standalone**: invocable en solitario, sin ejecutar los Pasos 1-3 ni el Paso 5, desde `/implementar-spec` al cerrar todas las tasks de una spec. Solo requiere la spec y el estado final de sus tasks (Paso 1, puntos 2 y 3, aplicados de forma acotada). Útil para re-verificar convergencia sin repetir toda la revisión de código. Antes de emitir el veredicto final de la spec: 1. **Reconciliar resultado real por task.** Para cada task del roadmap, verificar su estado final (`COMPLETADA`, `FALLIDA`, `PARCIAL`). Una task `FALLIDA` o `PARCIAL` implica que sus criterios asignados carecen de cobertura, aunque el plan los asignara — el veredicto de la spec DEBE reflejarlo. -2. **Recuperar la tabla criterio-task.** Usar la tabla de asignacion del Paso 2 de la spec (la que mapea cada criterio de exito a la task responsable). Si la spec no tiene esa tabla (formato antiguo), reconstruirla a partir de los criterios de exito y el roadmap de tasks. Confirmar que cada criterio tiene al menos una task `COMPLETADA` que lo cubre. Criterio sin task completada = criterio SIN COBERTURA. -3. **Barrido de "No incluye".** Verificar contra el codigo final que las exclusiones declaradas en la spec (seccion "No incluye" / "Fuera de alcance") realmente NO se implementaron. Implementacion no solicitada es tan grave como omision. Si la spec no declara exclusiones, este sub-paso se salta. +2. **Recuperar la tabla criterio-task.** Usar la tabla de asignación del Paso 2 de la spec (la que mapea cada criterio de éxito a la task responsable). Si la spec no tiene esa tabla (formato antiguo), reconstruirla a partir de los criterios de éxito y el roadmap de tasks. Confirmar que cada criterio tiene al menos una task `COMPLETADA` que lo cubre. Criterio sin task completada = criterio SIN COBERTURA. +3. **Barrido de "No incluye".** Verificar contra el código final que las exclusiones declaradas en la spec (sección "No incluye" / "Fuera de alcance") realmente NO se implementaron. Implementación no solicitada es tan grave como omisión. Si la spec no declara exclusiones, este sub-paso se salta. -**Schema de hallazgo.** Cada gap detectado en los sub-pasos 1-3 se registra con estos 6 campos: +**Schema de hallazgo.** Cada brecha detectada en los sub-pasos 1-3 se reporta con estos 6 campos: -| Campo | Descripcion | +| Campo | Descripción | |-------|-------------| | `id` | Identificador corto, secuencial dentro del pase (ej. `CONV-001`) | -| `source_ref` | Referencia a la seccion de la spec o criterio de aceptacion afectado | +| `source_ref` | Referencia a la sección de la spec o criterio de aceptación afectado | | `gap_type` | `missing` (no implementado) \| `partial` (implementado a medias) \| `contradicts` (contradice la spec) \| `unrequested` (implementado sin pedirse, viola "No incluye") | | `severidad` | `BLOQUEANTE` \| `WARNING` | -| `evidencia` | Descripcion concreta del gap, con referencia a archivo:linea cuando aplique | -| `correccion_sugerida` | Accion concreta para cerrar el gap | +| `evidencia` | Descripción concreta de la brecha, con referencia a archivo:línea cuando aplique | +| `correccion_sugerida` | Acción concreta para cerrar la brecha | + +**Resultado.** El revisor reporta; no escribe ficheros: -**Generacion de artefactos.** El resultado depende de la severidad de los hallazgos: +- **>=1 hallazgo BLOQUEANTE:** emitir veredicto `DIVERGE` con cada hallazgo completo según el schema. +- **Solo WARNINGs o cero hallazgos:** emitir veredicto `CONVERGIDA` con el conteo de criterios verificados. -- **>=1 hallazgo BLOQUEANTE:** por cada uno, crear una task doc `NNN_convergencia_.md` en `ai_docs/tasks/`, numerada con `scripts/next-task-number.sh` (ver su cabecera para el uso). La task doc lleva `Estado: PENDIENTE`, referencia a la spec madre, el hallazgo completo con su schema, y criterios de exito derivados del gap. Emitir veredicto `DIVERGE` con la lista de tasks generadas. - - Si `next-task-number.sh` no esta disponible (sin permisos de ejecucion o inexistente en el proyecto destino): reportar el error explicitamente y listar los hallazgos igualmente. NUNCA silenciar el diagnostico ni omitir hallazgos por falta de la herramienta de numeracion. -- **Solo WARNINGs o cero hallazgos:** no se escribe ningun fichero (ni se invoca `next-task-number.sh`). Emitir veredicto `CONVERGIDA` con el conteo de criterios verificados. +**Tasks de convergencia.** Las crea quien tiene escritura a partir del informe: en `/revision`, la sesión principal; en el workflow, el paso que el propio workflow dedica a ello. Por cada hallazgo BLOQUEANTE se crea una task `NNN_convergencia_.md` en `ai_docs/tasks/`, numerada con `scripts/next-task-number.sh` (ver su cabecera para el uso), con `Estado: PENDIENTE`, referencia a la spec madre, el hallazgo completo con su schema y criterios de éxito derivados de la brecha. Si `next-task-number.sh` no está disponible (sin permisos de ejecución o inexistente en el proyecto destino), se reporta el error explícitamente y se listan los hallazgos igualmente: el diagnóstico nunca se silencia ni se omiten hallazgos por falta de la herramienta de numeración. Con solo WARNINGs o sin hallazgos no se escribe ningún fichero. -El modo standalone es **append-only por construccion**: solo crea ficheros nuevos (`NNN_convergencia_*.md`). Nunca reescribe la spec, tasks existentes ni codigo — ni en modo standalone ni en modo integrado. +La creación es **append-only por construcción**: solo crea ficheros nuevos (`NNN_convergencia_*.md`). Nunca reescribe la spec, tasks existentes ni código, ni en modo standalone ni en modo integrado. --- -## Paso 5: Verificar restricciones de la spec +## Paso 5: Verificar restricciones -Para CADA restriccion de la spec, verificar que se respeta: +En modo task, las restricciones que la task declare; en modo integración, las de la spec. Para CADA restricción, verificar que se respeta: -| Restriccion | Respetada? | Evidencia | +| Restricción | ¿Respetada? | Evidencia | |-------------|-----------|-----------| -| [Restriccion 1] | SI / NO | [Como se verifico] | -| [Restriccion 2] | SI / NO | [Como se verifico] | +| [Restricción 1] | SÍ / NO | [Cómo se verificó] | +| [Restricción 2] | SÍ / NO | [Cómo se verificó] | --- ## Paso 6: Presentar veredicto ``` -## Resultado de la revision adversarial +**Veredicto:** [APROBADA / NECESITA_CORRECCIONES / RECHAZADA] — [peor hallazgo en una frase, o "sin hallazgos BLOQUEANTES"] + +## Resultado de la revisión adversarial -**Spec:** [Titulo] +**Modo:** [task / integración] +**Spec:** [Título] **Tasks revisadas:** [N] **Archivos revisados:** [N] +**Pasos omitidos:** [paso — motivo, o "Ninguno"] + +### Hallazgos previos (solo en re-revisión) + +| # | Hallazgo | Estado | Evidencia | +|---|----------|--------|-----------| +| 1 | [Hallazgo de la pasada anterior] | Resuelto / Persistente / Refutado | [Cómo se comprobó] | ### Hallazgos BLOQUEANTES (corregir antes de mergear) -| # | Tipo | Descripcion | Archivo:linea | Correccion sugerida | +| # | Tipo | Descripción | Archivo:línea | Corrección sugerida | |---|------|-------------|---------------|-------------------| -| 1 | [Regresion/Seguridad/Criterio no cumplido/etc.] | [Descripcion] | [ubicacion] | [Como corregir] | +| 1 | [Regresión/Seguridad/Criterio no cumplido/etc.] | [Descripción] | [ubicación] | [Cómo corregir] | -### Hallazgos WARNING (deberian corregirse) +### Hallazgos WARNING (no cambian el veredicto) -| # | Tipo | Descripcion | Archivo:linea | Correccion sugerida | -|---|------|-------------|---------------|-------------------| -| 1 | [Edge case/Codigo muerto/Consistencia/etc.] | [Descripcion] | [ubicacion] | [Como corregir] | +| # | Tipo | Descripción | Archivo:línea | Corrección sugerida o qué lo resolvería | Dueño si se acepta | +|---|------|-------------|---------------|----------------------------------------|--------------------| +| 1 | [Edge case/Código muerto/Duda/etc.] | [Descripción] | [ubicación] | [Cómo corregir] | [Task o decisión del usuario] | -### Criterios de aceptacion +### No evaluado + +- [Superficie que no se pudo verificar y por qué, o "Nada"] + +### Criterios de aceptación - [N] de [N] cumplidos - No cumplidos: [lista o "Todos cumplidos"] @@ -232,40 +296,36 @@ Para CADA restriccion de la spec, verificar que se respeta: - [N] de [N] respetadas - No respetadas: [lista o "Todas respetadas"] -### Integracion entre tasks - -- [Resumen de problemas de integracion encontrados, o "Sin problemas de integracion"] +### Integración entre tasks (solo modo integración) -### Veredicto +- [Resumen de problemas de integración encontrados, o "Sin problemas de integración"] -[APROBADA / NECESITA_CORRECCIONES / RECHAZADA] +### Significado del veredicto -- `APROBADA`: sin hallazgos BLOQUEANTES. En el flujo por task, habilita el commit. -- `NECESITA_CORRECCIONES`: hay hallazgos corregibles con una pasada acotada (1-2 archivos, sin cambio de alcance). -- `RECHAZADA`: hallazgos graves o cambio de alcance; la task no debe commitearse tal cual. +- `APROBADA`: sin hallazgos BLOQUEANTES; los WARNING no la impiden. En el flujo por task, habilita el commit. +- `NECESITA_CORRECCIONES`: hay hallazgos BLOQUEANTES corregibles con una pasada acotada (1-2 archivos, sin cambio de alcance). +- `RECHAZADA`: hallazgos BLOQUEANTES graves o cambio de alcance; la task no debe commitearse tal cual. **Si NECESITA_CORRECCIONES o RECHAZADA:** -- Para correcciones menores (1-2 archivos, sin cambio de alcance): aplicar directamente usando `implementar.md` y re-ejecutar la revision sobre los puntos afectados -- Para correcciones mayores (3+ archivos o cambio de alcance): crear nueva task via `tareas.md` referenciando los hallazgos, implementar, y re-ejecutar la revision completa -- No es necesario repetir la revision completa si solo se corrigen puntos especificos +- Para correcciones menores (1-2 archivos, sin cambio de alcance): aplicar directamente usando `implementar.md` y hacer la re-revisión sobre los hallazgos previos y el delta +- Para correcciones mayores (3+ archivos o cambio de alcance): crear nueva task vía `tareas.md` referenciando los hallazgos, implementar y re-ejecutar la revisión completa -> **Nota sobre el workflow mecanizado** (`/implementar-spec` u otro flujo automatizado equivalente): actualmente solo ejecuta UNA pasada de correccion, sin distinguir menor de mayor. Si esa pasada no deja la task en `APROBADA`, el workflow la marca `FALLIDA` y descarta los cambios — no crea una task nueva automaticamente. La distincion menor/mayor de arriba aplica al flujo MANUAL (humano o asistente operando fuera del workflow), donde si corresponde crear la task nueva via `tareas.md`. Si un futuro workflow implementa la rama "mayor" automaticamente, actualiza esta nota. +> **Nota sobre el workflow mecanizado** (`/implementar-spec` u otro flujo automatizado equivalente): el número de rondas de corrección y el destino del trabajo que no alcanza `APROBADA` los fija el workflow. La distinción menor/mayor de arriba aplica al flujo MANUAL (humano o asistente operando fuera del workflow). ``` -**PUNTO DE ESPERA (revision manual/standalone):** +**PUNTO DE ESPERA (revisión manual/standalone):** - Si revisas manualmente, DETENTE y espera la respuesta del usuario -- Si el veredicto es NECESITA_CORRECCIONES o RECHAZADA, no continues hasta aplicar los cambios -- Si se aplican correcciones, vuelve a verificar SOLO los puntos afectados +- Si el veredicto es NECESITA_CORRECCIONES o RECHAZADA, no continúes hasta aplicar los cambios +- Si se aplican correcciones, haz la re-revisión: hallazgos previos y delta - Si el veredicto es APROBADA, procede al commit (o crea la PR con `revision_pr.md`) --- ## Reglas inquebrantables -1. **NUNCA emitas APROBADA si hay hallazgos BLOQUEANTES** — sin excepciones -2. **NUNCA modifiques codigo** — esta plantilla es solo de revision -3. **Revisa a fondo lo que se te da** — el diff de una task en la revision por task, o toda la integracion cuando revises varias tasks juntas: los problemas de integracion son los mas peligrosos +1. **NUNCA emitas APROBADA con un hallazgo BLOQUEANTE abierto** — sin excepciones +2. **NUNCA modifiques código ni escribas ficheros** — esta plantilla solo revisa y reporta +3. **Revisa a fondo lo que el modo te da** — el diff de una task en modo task, o toda la integración en modo integración 4. **Tu rol es encontrar problemas** — asumir que hay bugs hasta demostrar lo contrario -5. **Criterios de aceptacion no cumplidos son BLOQUEANTES** — no se pueden ignorar -6. **En una revision de integracion, si faltan tasks por completar**, DETENTE — es prematuro -7. **Vulnerabilidades de seguridad son siempre BLOQUEANTES** — sin importar la severidad percibida +5. **Bloquea solo lo material** — aplica el criterio de bloqueo de las instrucciones; una duda sin evidencia es WARNING con lo que la resolvería +6. **En modo integración, si faltan tasks por completar**, DETENTE — es prematuro diff --git a/docs-src/agents/revisor.md b/docs-src/agents/revisor.md index 28dfc50..7b79baa 100644 --- a/docs-src/agents/revisor.md +++ b/docs-src/agents/revisor.md @@ -5,34 +5,38 @@ declaran aqui: el nombre viene del fichero fuente y el modelo de scripts/model-policy.json. --> --- -description: "Revisa tasks antes de implementar y hace la revision adversarial del codigo entregado. Esceptico por diseno." +description: "Despáchalo para revisar una task antes de implementarla, auditar una spec con sus tasks o hacer la revisión adversarial del código entregado. Reporta hallazgos y veredicto; no escribe." --- # Revisor -Revision esceptica en dos momentos: antes de implementar (el plan) y despues de implementar (el -codigo). No escribe codigo. +Revisión escéptica en dos momentos: antes de implementar (el plan) y después de implementar (el +código). No escribe código ni ficheros. -## Cuando actuas +## Cuándo actúas -- Se crea o modifica un documento de task (revision pre-implementacion). +- Se crea o modifica un documento de task (revisión pre-implementación). - Se audita la coherencia entre una spec y sus tasks. -- Se completa la implementacion de las tasks de una spec (revision adversarial). +- Se completa la implementación de una task o de las tasks de una spec (revisión adversarial). -No actuas para crear specs (eso es del planificador) ni para implementar (eso es del implementador). +No actúas para crear specs (eso es del planificador) ni para implementar (eso es del implementador). ## Proceso -- Revision de una task: sigue `ai_docs/dev_templates/revisar_tarea.md`. -- Auditoria de spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md`. -- Revision adversarial del codigo: sigue `ai_docs/dev_templates/revision_adversarial.md`. +- Revisión de una task: sigue `ai_docs/dev_templates/revisar_tarea.md`. +- Auditoría de spec + tasks: sigue `ai_docs/dev_templates/auditar_spec.md`. +- Revisión adversarial del código: sigue `ai_docs/dev_templates/revision_adversarial.md`. -Lee la plantilla correspondiente y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Lee la plantilla correspondiente y aplica los pasos pertinentes al riesgo del cambio; declara los +omitidos con su motivo. Cuando juzgues si los tests acreditan el cambio, lee +`ai_docs/dev_templates/testing_basico.md`. ## Postura -Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo esta bien. Asume que hay errores hasta -demostrar lo contrario y valida con evidencia (lee el codigo, ejecuta los tests). Si la evidencia -es ambigua, reporta el hallazgo: mejor un falso positivo que un fallo en produccion. +Tu trabajo es ENCONTRAR PROBLEMAS, no confirmar que todo está bien: busca con todo el escepticismo +y valida con evidencia (lee el código, ejecuta los tests). Bloquea solo lo material, según el +criterio de bloqueo de la plantilla; una duda sin evidencia se reporta como WARNING con lo que la +resolvería. Reportas: las tasks y correcciones que deriven de tus hallazgos las escribe quien tiene +escritura. Tu veredicto es el gate: hasta que lo emites, la entrega no avanza. From 27c22e91a39ab67140f3640b2afa38b4869451d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:18:33 +0000 Subject: [PATCH 05/15] test: validar el frontmatter de skills, comandos y agentes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Solo se comprobaba el frontmatter de una skill y el compilador solo exigía description. El test nuevo recorre en proceso skills, fragmentos de docs-src, agentes y comandos, y comprueba name contra la carpeta o el fichero, description en una línea de 240 caracteres como máximo y sin duplicados, ausencia de model en skills, campos admitidos por tipo y que ningún campo exclusivo de Claude Code llegue a .agents/skills/. Cada regla lleva un fixture negativo en línea. CONTRIBUTING.md añade la guía de autoría: fórmula de la description, cuándo usar disable-model-invocation, campos por tipo y los tokens que leen los hooks, que se escriben en ASCII. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- CONTRIBUTING.md | 31 ++++ tests/frontmatter.test.js | 315 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 tests/frontmatter.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b136eb..75b4511 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -114,6 +114,37 @@ Una skill no es el comando con otro nombre, y el transform lo tiene en cuenta: El modelo de un artefacto generado nunca sale del fichero fuente: lo fija `scripts/model-policy.json`. +### Autoría de skills, comandos y agentes + +**La `description` de una skill** es lo único que el modelo lee para decidir si la activa. Se escribe con tres piezas, en este orden: + +1. La condición observable que la dispara: un estado del trabajo que el modelo puede ver (hay un fallo reproducible, se ha creado un documento de task, la implementación de una spec ha terminado), no una lista de frases del usuario. +2. Qué hace la skill, en una frase. +3. La frontera con la skill vecina cuando dos pueden confundirse: qué casos no son suyos y a cuál van. + +Sin frases literales entrecomilladas: atan la activación a unas palabras concretas y dejan fuera las equivalentes. Una sola línea, 240 caracteres como máximo y distinta de la de cualquier otra skill. + +**`disable-model-invocation: true`** solo en una skill cuya ejecución tiene un efecto externo que no debe dispararse sin que el usuario lo pida (publicar, abrir una PR, escribir fuera del repositorio). Una skill que solo lee o propone no lo lleva. + +**Campos admitidos por tipo.** Un campo fuera de esta tabla es un error, no un aviso: + +| Tipo | Campos | +|---|---| +| Skill (`.claude/skills//SKILL.md`) | `name` (igual a la carpeta), `description`, `argument-hint`, `disable-model-invocation`. Nunca `model`: el modelo lo fija la política | +| Fragmento de skill (`docs-src/skills/.md` con frontmatter) | Los de una skill, con `name` igual al nombre del fichero, más `source-body` | +| Comando (`.claude/commands/.md`) | `description`, `argument-hint` | +| Agente (`.claude/agents/.md`) | `name` (igual al nombre del fichero), `description`, `model`, `tools` | + +Si una capacidad nueva necesita otro campo, se amplía la lista en el test con su motivo; la regla no se desactiva. + +**Los tokens que leen los hooks se escriben en ASCII**, aunque la prosa que los rodea lleve acentos: `Estado`, `APROBADA`, `Archivos afectados`, `Spec madre`, `CREAR`, `MODIFICAR`. `sdd-pipeline-guard.js` las busca tal cual en specs y tasks (los parsers viven en `hooks/sdd-plan-state.js` y `hooks/sdd-task-files.js`); con una variante acentuada o reformulada el hook deja de reconocer el documento. + +```bash +node --test tests/frontmatter.test.js # valida el frontmatter de skills, fragmentos, comandos y agentes +``` + +El test comprueba además que ningún campo exclusivo de Claude Code (los que `skill-to-backend` retira, como `argument-hint`) aparece en `.agents/skills/`. + ## Paridad entre CLIs El framework se distribuye para Claude Code, Gemini CLI, Codex y Antigravity. Muchos artefactos existen por duplicado: diff --git a/tests/frontmatter.test.js b/tests/frontmatter.test.js new file mode 100644 index 0000000..f34231b --- /dev/null +++ b/tests/frontmatter.test.js @@ -0,0 +1,315 @@ +'use strict'; + +// Valida el frontmatter de los artefactos que escribe una persona: skills, +// fragmentos de skill en `docs-src/skills/`, agentes y comandos. Las reglas +// viven como funciones puras sobre documentos en memoria; el árbol real se +// recorre una vez y cada regla tiene además un fixture en línea que la pone en +// rojo, para que un detector que nunca dispara no pase por verde. +// +// Si una skill futura necesita un campo nuevo, se amplía la lista de campos +// permitidos aquí, con su motivo; la regla no se desactiva. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { parsearFrontmatter } = require('../scripts/transforms/frontmatter'); +const { transformarSkill } = require('../scripts/transforms/skill-to-backend'); + +const RAIZ = path.join(__dirname, '..'); + +const LONGITUD_MAXIMA_DESCRIPTION = 240; + +// Campos admitidos por tipo de artefacto. +// skill: los que entiende Claude Code en una skill. +// fragmento: los de una skill más `source-body`, que solo leen los +// fragmentos de `docs-src/skills/` (lo consume el transform +// `command-to-skill` y nunca llega a la salida). +// comando, agente: los que leen el compilador y Claude Code. +const CAMPOS_SKILL = ['name', 'description', 'argument-hint', 'disable-model-invocation']; +const CAMPOS_PERMITIDOS = { + skill: new Set(CAMPOS_SKILL), + fragmento: new Set([...CAMPOS_SKILL, 'source-body']), + comando: new Set(['description', 'argument-hint']), + agente: new Set(['name', 'description', 'model', 'tools']), +}; + +const TIPOS_SKILL = new Set(['skill', 'fragmento']); + +/** Texto de un escalar YAML sin comillas dobles ni simples envolventes. */ +function sinComillas(crudo) { + const recortado = String(crudo).trim(); + if (recortado.length >= 2 && /^(["']).*\1$/.test(recortado)) return recortado.slice(1, -1); + return recortado; +} + +/** + * Documento a validar: `ruta` para los mensajes, `tipo` (skill, fragmento, + * comando o agente), `nombreEsperado` (carpeta de la skill, nombre del fichero + * del agente o del fragmento; `null` en comandos) y el `contenido` del fichero. + */ +function documento(ruta, tipo, nombreEsperado, contenido) { + return { ruta, tipo, nombreEsperado, contenido }; +} + +/** Regla 1: frontmatter parseable. Devuelve `{ campos }` o `{ error }`. */ +function parsear(doc) { + try { + return { campos: parsearFrontmatter(doc.contenido, doc.ruta).campos }; + } catch (error) { + return { error: error.message }; + } +} + +function campo(campos, clave) { + return campos.find(c => c.clave === clave); +} + +/** Regla 2: skills y fragmentos con `name` igual a la carpeta o fichero; agentes, igual al fichero. */ +function reglaName(doc, campos) { + if (doc.nombreEsperado === null) return []; + const declarado = campo(campos, 'name'); + const valor = declarado ? sinComillas(declarado.valor) : ''; + if (valor !== doc.nombreEsperado) { + return [`${doc.ruta}: name "${valor}" no coincide con "${doc.nombreEsperado}"`]; + } + return []; +} + +/** Regla 3: `description` presente, en una sola línea y con longitud acotada. */ +function reglaDescription(doc, campos) { + const declarado = campo(campos, 'description'); + if (!declarado || sinComillas(declarado.valor) === '') return [`${doc.ruta}: falta description`]; + if (declarado.extra.length > 0 || /^[>|]/.test(declarado.valor.trim())) { + return [`${doc.ruta}: description ocupa más de una línea`]; + } + const longitud = sinComillas(declarado.valor).length; + if (longitud > LONGITUD_MAXIMA_DESCRIPTION) { + return [`${doc.ruta}: description de ${longitud} caracteres (máximo ${LONGITUD_MAXIMA_DESCRIPTION})`]; + } + return []; +} + +/** Regla 5: una skill no declara `model`; el modelo lo fija la política, no la fuente. */ +function reglaSinModelo(doc, campos) { + if (!TIPOS_SKILL.has(doc.tipo)) return []; + return campo(campos, 'model') ? [`${doc.ruta}: una skill no declara model`] : []; +} + +/** Regla 6: solo los campos permitidos para el tipo del documento. */ +function reglaCamposPermitidos(doc, campos) { + const permitidos = CAMPOS_PERMITIDOS[doc.tipo]; + return campos + .filter(c => !permitidos.has(c.clave)) + .map(c => `${doc.ruta}: campo "${c.clave}" no permitido en ${doc.tipo === 'skill' ? 'una' : 'un'} ${doc.tipo}`); +} + +/** Errores de un documento: si no parsea, solo ese; si parsea, los de cada regla. */ +function erroresDeDocumento(doc) { + const { campos, error } = parsear(doc); + if (error) return [error]; + return [ + ...reglaName(doc, campos), + ...reglaDescription(doc, campos), + ...reglaSinModelo(doc, campos), + ...reglaCamposPermitidos(doc, campos), + ]; +} + +/** Regla 4: ninguna description repetida entre skills (fuentes y fragmentos). */ +function erroresDeDuplicados(docs) { + const vistas = new Map(); + const errores = []; + for (const doc of docs.filter(d => TIPOS_SKILL.has(d.tipo))) { + const { campos } = parsear(doc); + const declarado = campos && campo(campos, 'description'); + if (!declarado) continue; + const clave = sinComillas(declarado.valor).toLowerCase(); + if (vistas.has(clave)) { + errores.push(`${doc.ruta}: description duplicada con ${vistas.get(clave)}`); + } else { + vistas.set(clave, doc.ruta); + } + } + return errores; +} + +/** + * Campos que el transform `skill-to-backend` retira al generar para un backend + * que no es Claude Code. Se deriva del productor, no de una copia de su lista: + * se le pasa una skill con todos los campos admitidos y se ve cuáles no salen. + */ +function camposSoloClaude() { + const entrada = ['---', ...CAMPOS_SKILL.map(c => `${c}: "x"`), '---', '', 'cuerpo', ''].join('\n'); + const salida = transformarSkill(entrada, { output: { backend: 'antigravity' }, source: 'sonda' }, {}); + const conservados = new Set(parsearFrontmatter(salida, 'sonda').campos.map(c => c.clave)); + return CAMPOS_SKILL.filter(c => !conservados.has(c)); +} + +/** Regla 7: ninguna salida de `.agents/skills/` arrastra un campo solo-Claude. */ +function erroresDeSalida(salidas, soloClaude) { + const errores = []; + for (const { ruta, contenido } of salidas) { + let campos; + try { + campos = parsearFrontmatter(contenido, ruta).campos; + } catch (error) { + errores.push(error.message); + continue; + } + for (const c of campos) { + if (soloClaude.includes(c.clave)) errores.push(`${ruta}: campo solo-Claude "${c.clave}" en una salida`); + } + } + return errores; +} + +// --- Árbol real --------------------------------------------------------------- + +function leer(rel) { + return fs.readFileSync(path.join(RAIZ, rel), 'utf8'); +} + +function entradas(rel) { + const abs = path.join(RAIZ, rel); + return fs.existsSync(abs) ? fs.readdirSync(abs, { withFileTypes: true }) : []; +} + +/** `true` si el fichero abre un bloque de frontmatter (los fragmentos sin él son solo cuerpo). */ +function tieneFrontmatter(contenido) { + try { + parsearFrontmatter(contenido, 'sonda'); + return true; + } catch (error) { + return !error.message.startsWith('MISSING_FRONTMATTER'); + } +} + +function documentosDelArbol() { + const docs = []; + + for (const e of entradas('.claude/skills').filter(e => e.isDirectory())) { + const rel = `.claude/skills/${e.name}/SKILL.md`; + docs.push(documento(rel, 'skill', e.name, leer(rel))); + } + for (const e of entradas('docs-src/skills').filter(e => e.isFile() && e.name.endsWith('.md'))) { + const rel = `docs-src/skills/${e.name}`; + const contenido = leer(rel); + if (tieneFrontmatter(contenido)) docs.push(documento(rel, 'fragmento', path.basename(e.name, '.md'), contenido)); + } + for (const e of entradas('.claude/agents').filter(e => e.isFile() && e.name.endsWith('.md'))) { + const rel = `.claude/agents/${e.name}`; + docs.push(documento(rel, 'agente', path.basename(e.name, '.md'), leer(rel))); + } + for (const e of entradas('.claude/commands').filter(e => e.isFile() && e.name.endsWith('.md'))) { + const rel = `.claude/commands/${e.name}`; + docs.push(documento(rel, 'comando', null, leer(rel))); + } + return docs; +} + +function salidasDelArbol() { + return entradas('.agents/skills') + .filter(e => e.isDirectory()) + .map(e => `.agents/skills/${e.name}/SKILL.md`) + .filter(rel => fs.existsSync(path.join(RAIZ, rel))) + .map(rel => ({ ruta: rel, contenido: leer(rel) })); +} + +const DOCUMENTOS = documentosDelArbol(); +const SALIDAS = salidasDelArbol(); + +test('el árbol real cubre los cuatro tipos de artefacto', () => { + const tipos = new Set(DOCUMENTOS.map(d => d.tipo)); + assert.deepStrictEqual([...tipos].sort(), ['agente', 'comando', 'fragmento', 'skill']); + assert.ok(SALIDAS.length > 0, 'no se encontró ninguna salida en .agents/skills/'); +}); + +test('el frontmatter de skills, fragmentos, agentes y comandos cumple las reglas', () => { + const docs = DOCUMENTOS; + const errores = [...docs.flatMap(erroresDeDocumento), ...erroresDeDuplicados(docs)]; + assert.deepStrictEqual(errores, []); +}); + +test('ninguna salida de .agents/skills/ lleva un campo solo-Claude', () => { + const soloClaude = camposSoloClaude(); + assert.ok(soloClaude.length > 0, 'el transform no retira ningún campo: la sonda ya no mide nada'); + assert.deepStrictEqual(erroresDeSalida(SALIDAS, soloClaude), []); +}); + +// --- Fixtures negativos: cada regla dispara ------------------------------------ + +function fm(líneas, cuerpo = 'cuerpo') { + return ['---', ...líneas, '---', '', cuerpo, ''].join('\n'); +} + +const SKILL_VALIDA = ['name: demo', 'description: "Se activa ante X. Hace Y."']; + +test('fixture válido: una skill correcta no da errores', () => { + assert.deepStrictEqual(erroresDeDocumento(documento('s', 'skill', 'demo', fm(SKILL_VALIDA))), []); +}); + +test('regla 1: frontmatter sin cerrar o con línea ilegible se rechaza', () => { + const sinCierre = erroresDeDocumento(documento('s', 'skill', 'demo', '---\nname: demo\n')); + assert.match(sinCierre.join('\n'), /PARSE_ERROR/); + const ilegible = erroresDeDocumento(documento('s', 'skill', 'demo', fm(['name: demo', 'esto no es un campo']))); + assert.match(ilegible.join('\n'), /PARSE_ERROR/); + const ausente = erroresDeDocumento(documento('a', 'agente', 'demo', 'sin frontmatter\n')); + assert.match(ausente.join('\n'), /MISSING_FRONTMATTER/); +}); + +test('regla 2: name distinto de la carpeta de la skill o del fichero del agente se rechaza', () => { + const skill = erroresDeDocumento(documento('s', 'skill', 'otra', fm(SKILL_VALIDA))); + assert.match(skill.join('\n'), /name "demo" no coincide con "otra"/); + const agente = erroresDeDocumento(documento('a', 'agente', 'revisor', fm(['name: revisora', 'description: "d"']))); + assert.match(agente.join('\n'), /no coincide con "revisor"/); +}); + +test('regla 3: description ausente, multilínea o demasiado larga se rechaza', () => { + const ausente = erroresDeDocumento(documento('s', 'skill', 'demo', fm(['name: demo']))); + assert.match(ausente.join('\n'), /falta description/); + const plegada = erroresDeDocumento(documento('s', 'skill', 'demo', fm(['name: demo', 'description: >', ' línea uno', ' línea dos']))); + assert.match(plegada.join('\n'), /más de una línea/); + const larga = erroresDeDocumento(documento('s', 'skill', 'demo', fm(['name: demo', `description: "${'a'.repeat(241)}"`]))); + assert.match(larga.join('\n'), /241 caracteres/); + const limite = erroresDeDocumento(documento('s', 'skill', 'demo', fm(['name: demo', `description: "${'a'.repeat(240)}"`]))); + assert.deepStrictEqual(limite, []); +}); + +test('regla 4: dos skills con la misma description se rechazan', () => { + const docs = [ + documento('a', 'skill', 'demo', fm(SKILL_VALIDA)), + documento('b', 'fragmento', 'demo', fm(SKILL_VALIDA)), + ]; + assert.match(erroresDeDuplicados(docs).join('\n'), /b: description duplicada con a/); +}); + +test('regla 5: una skill con model se rechaza', () => { + const errores = erroresDeDocumento(documento('s', 'skill', 'demo', fm([...SKILL_VALIDA, 'model: sonnet']))); + assert.match(errores.join('\n'), /una skill no declara model/); +}); + +test('regla 6: un campo fuera de la lista del tipo se rechaza', () => { + const comando = erroresDeDocumento(documento('c', 'comando', null, fm(['description: "d"', 'name: demo']))); + assert.match(comando.join('\n'), /campo "name" no permitido en un comando/); + const agente = erroresDeDocumento(documento('a', 'agente', 'demo', fm(['name: demo', 'description: "d"', 'color: red']))); + assert.match(agente.join('\n'), /campo "color" no permitido en un agente/); +}); + +test('regla 6: source-body se rechaza en una skill y se admite en un fragmento de docs-src', () => { + const conCuerpo = [...SKILL_VALIDA, 'source-body: omit']; + const enSkill = erroresDeDocumento(documento('.claude/skills/demo/SKILL.md', 'skill', 'demo', fm(conCuerpo))); + assert.match(enSkill.join('\n'), /campo "source-body" no permitido en una skill/); + const enFragmento = erroresDeDocumento(documento('docs-src/skills/demo.md', 'fragmento', 'demo', fm(conCuerpo))); + assert.deepStrictEqual(enFragmento, []); +}); + +test('regla 7: una salida con un campo solo-Claude se rechaza', () => { + const soloClaude = camposSoloClaude(); + assert.ok(soloClaude.includes('argument-hint'), 'argument-hint debería ser solo-Claude'); + const salida = { ruta: '.agents/skills/demo/SKILL.md', contenido: fm([...SKILL_VALIDA, 'argument-hint: "[x]"']) }; + assert.match(erroresDeSalida([salida], soloClaude).join('\n'), /campo solo-Claude "argument-hint"/); + const limpia = { ruta: '.agents/skills/demo/SKILL.md', contenido: fm(SKILL_VALIDA) }; + assert.deepStrictEqual(erroresDeSalida([limpia], soloClaude), []); +}); From 0ffc6e43ea34985c288125feb4898406b58a3e08 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:22:36 +0000 Subject: [PATCH 06/15] fix: avisar si falla el directorio de provenance y tests sin reloj MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readPayload admite setTimeout/clearTimeout inyectables (por defecto los globales) y sus tests afirman qué decidió la carrera y que el temporizador se limpia, no cuánto tardó: con carga, la cota de reloj daba rojos espurios. Se retira la forma numérica, que nadie usaba. El registro de sesiones no escribible ya no se omite como root: el fallo se provoca con un destino que es un directorio (EISDIR) o con un padre que es un fichero (ENOTDIR), en vez de con permisos, que root ignora. Este segundo caso destapó que ensureDir callaba el error y el hook no avisaba; ahora lo avisa por stderr y sigue saliendo con 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- hooks/sdd-hook-utils.js | 11 ++++++- hooks/sdd-session-start.js | 7 +++- hooks/tests/sdd-hook-utils.test.js | 47 ++++++++++++++++++++------- hooks/tests/sdd-session-start.test.js | 36 ++++++++++++-------- 4 files changed, 75 insertions(+), 26 deletions(-) diff --git a/hooks/sdd-hook-utils.js b/hooks/sdd-hook-utils.js index b380907..d05a6ce 100644 --- a/hooks/sdd-hook-utils.js +++ b/hooks/sdd-hook-utils.js @@ -41,8 +41,17 @@ function checkNodeVersion() { // anomalo), leer hasta EOF cuelga el proceso indefinidamente. La carrera // contra el timeout garantiza que el hook siempre resuelve: si stdin no // llega a tiempo, se degrada a null igual que un payload invalido. -async function readPayload(timeoutMs) { +// +// `opciones` es opcional: `{ timeoutMs, setTimeout, clearTimeout }`. Los temporizadores +// inyectables permiten a los tests afirmar qué decidió la carrera sin medir el reloj; por +// defecto son los globales. +async function readPayload(opciones) { checkNodeVersion(); + const { + timeoutMs, + setTimeout = global.setTimeout, + clearTimeout = global.clearTimeout, + } = opciones || {}; let timer; const timeout = new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs || STDIN_TIMEOUT_MS, null); diff --git a/hooks/sdd-session-start.js b/hooks/sdd-session-start.js index 7e47c44..410e71b 100644 --- a/hooks/sdd-session-start.js +++ b/hooks/sdd-session-start.js @@ -223,7 +223,12 @@ function ensureDir(dir) { try { fs.mkdirSync(dir, { recursive: true }); return true; - } catch { + } catch (err) { + try { + fs.writeSync(2, '[SDD] no se pudo crear el directorio de provenance en ' + dir + ': ' + err.message + '\n'); + } catch { + // stderr roto: nada más que hacer. + } return false; } } diff --git a/hooks/tests/sdd-hook-utils.test.js b/hooks/tests/sdd-hook-utils.test.js index 9117428..b48e903 100644 --- a/hooks/tests/sdd-hook-utils.test.js +++ b/hooks/tests/sdd-hook-utils.test.js @@ -100,28 +100,53 @@ test('stdin llega completo y valido -> devuelve el objeto parseado', async () => } }); -test('stdin nunca cierra -> resuelve a null tras el timeout, en tiempo acotado', async () => { +// Temporizador falso para readPayload(): registra lo programado y lo limpiado, y solo dispara +// cuando el test lo pide. Así el test afirma qué decidió la carrera, no cuánto tardó. +function temporizadorFalso() { + const estado = { programado: null, limpiado: false, disparado: false }; + const handle = { unref() { estado.unref = true; return handle; } }; + return { + estado, + setTimeout(fn, ms, ...args) { + estado.programado = { fn, ms, args }; + return handle; + }, + clearTimeout(h) { + if (h === handle) estado.limpiado = true; + }, + disparar() { + estado.disparado = true; + estado.programado.fn(...estado.programado.args); + }, + }; +} + +test('stdin nunca cierra -> el temporizador decide la carrera y resuelve a null', async () => { stubStdin(hangingStdin()); + const reloj = temporizadorFalso(); try { - const start = Date.now(); - const data = await readPayload(50); // timeout corto para no ralentizar la suite - const elapsed = Date.now() - start; - assert.strictEqual(data, null); - assert.ok(elapsed < 2000, `readPayload() debe resolver en tiempo acotado, tardo ${elapsed}ms`); + const lectura = readPayload({ timeoutMs: 50, setTimeout: reloj.setTimeout, clearTimeout: reloj.clearTimeout }); + assert.ok(reloj.estado.programado, 'readPayload() debe programar su temporizador'); + assert.strictEqual(reloj.estado.programado.ms, 50); + assert.ok(reloj.estado.unref, 'el temporizador debe soltar el bucle de eventos con unref()'); + reloj.disparar(); + assert.strictEqual(await lectura, null); + assert.ok(reloj.estado.limpiado, 'el temporizador debe limpiarse al resolver'); } finally { restoreStdin(); clearHangingStdin(); } }); -test('stdin llega con JSON invalido -> resuelve a null sin esperar al timeout', async () => { +test('stdin llega con JSON inválido -> resuelve a null sin que salte el temporizador, y lo limpia', async () => { stubStdin(closingStdin(['no es json {'])); + const reloj = temporizadorFalso(); try { - const start = Date.now(); - const data = await readPayload(5000); - const elapsed = Date.now() - start; + const data = await readPayload({ timeoutMs: 5000, setTimeout: reloj.setTimeout, clearTimeout: reloj.clearTimeout }); assert.strictEqual(data, null); - assert.ok(elapsed < 1000, `JSON invalido no debe esperar al timeout, tardo ${elapsed}ms`); + assert.ok(reloj.estado.programado, 'readPayload() debe programar su temporizador'); + assert.strictEqual(reloj.estado.disparado, false, 'JSON inválido no debe depender del temporizador'); + assert.ok(reloj.estado.limpiado, 'el temporizador debe limpiarse al resolver'); } finally { restoreStdin(); } diff --git a/hooks/tests/sdd-session-start.test.js b/hooks/tests/sdd-session-start.test.js index 7b7a5b5..2fda48a 100644 --- a/hooks/tests/sdd-session-start.test.js +++ b/hooks/tests/sdd-session-start.test.js @@ -117,21 +117,31 @@ test('payload SessionStart malformado (JSON invalido) -> linea con campos inferi assert.strictEqual(linea.cwd, process.cwd()); }); -test('provenance.jsonl de solo lectura -> avisa por stderr y exit 0, no bloquea la sesion', { skip: process.getuid && process.getuid() === 0 }, () => { +// El fallo de escritura se provoca sin depender de permisos, que root ignora: el destino es +// un directorio existente, así que el append falla con EISDIR para cualquier uid. +test('provenance.jsonl no escribible -> avisa por stderr y exit 0, no bloquea la sesion', () => { const e = entorno(); - fs.mkdirSync(path.dirname(e.provenanceFile), { recursive: true }); - fs.writeFileSync(e.provenanceFile, ''); - fs.chmodSync(e.provenanceFile, 0o444); - fs.chmodSync(path.dirname(e.provenanceFile), 0o555); + fs.mkdirSync(e.provenanceFile, { recursive: true }); - try { - const r = runHook(HOOK, payload(), e.env); - assert.strictEqual(r.code, 0); - assert.match(r.stderr, /no se pudo escribir/); - } finally { - fs.chmodSync(path.dirname(e.provenanceFile), 0o755); - fs.chmodSync(e.provenanceFile, 0o644); - } + const r = runHook(HOOK, payload(), e.env); + assert.strictEqual(r.code, 0); + assert.match(r.stderr, /no se pudo escribir provenance/); + assert.match(r.stderr, /EISDIR/); + assert.ok(fs.statSync(e.provenanceFile).isDirectory(), 'el destino no debe sustituirse'); +}); + +// Si el directorio de provenance no se puede crear porque un ancestro es un fichero regular, +// el hook avisa igual (ENOTDIR para cualquier uid) en vez de salir en silencio. +test('directorio de provenance imposible de crear -> avisa por stderr y exit 0', () => { + const e = entorno(); + const ancestro = path.dirname(path.dirname(e.provenanceFile)); + fs.writeFileSync(ancestro, ''); + + const r = runHook(HOOK, payload(), e.env); + assert.strictEqual(r.code, 0); + assert.match(r.stderr, /no se pudo crear el directorio de provenance/); + assert.match(r.stderr, /ENOTDIR/); + assert.ok(fs.statSync(ancestro).isFile(), 'el fichero ancestro no debe sustituirse'); }); test('sin session_id en el payload, genera uno via randomUUID', () => { From 4cb8b1590efbd0bf2d152e2ccb9361390fa283c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:18:20 +0000 Subject: [PATCH 07/15] =?UTF-8?q?fix:=20read-before-edit=20no=20avisa=20al?= =?UTF-8?q?=20editar=20lo=20que=20el=20agente=20escribi=C3=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un Write que creaba un fichero salía por «no existe» sin registrar la ruta, así que el Edit posterior del mismo agente disparaba el aviso: un falso positivo que enseña a ignorar el hook. Ahora una escritura completa (Write y sus equivalentes por backend) registra la ruta como conocida en la sesión. Sobrescribir un fichero existente sin leerlo sigue avisando. En backends que no entregan lecturas no se registra, para no activar avisos sobre lecturas que el hook nunca ve. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- hooks/sdd-read-before-edit.js | 39 +++++++++++++++---- hooks/tests/sdd-read-before-edit.test.js | 48 ++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/hooks/sdd-read-before-edit.js b/hooks/sdd-read-before-edit.js index 0a0c5dd..e2ed5dc 100644 --- a/hooks/sdd-read-before-edit.js +++ b/hooks/sdd-read-before-edit.js @@ -12,6 +12,8 @@ * En cada lectura registra la ruta en sdd-read-tracker.js (fichero por sesion). * En cada escritura consulta si esa ruta consta leida. El estado vive fuera del * proceso porque lectura y escritura son invocaciones distintas del hook. + * Una escritura completa (Write y equivalentes) también registra su ruta: el + * agente conoce lo que él mismo escribió, y editarlo después no avisa. * * DEGRADACION SEGURA (nunca falso positivo, nunca bloqueo): * - Archivo nuevo (no existe en disco): no habia nada que leer -> no avisa. @@ -61,6 +63,15 @@ const WRITE_TOOLS = new Set([ 'create_file', ]); +// Escrituras que fijan el contenido entero del fichero (crear o sobrescribir), frente a las +// parciales (Edit, replace_*), que modifican un contenido que el agente puede no conocer. +const FULL_WRITE_TOOLS = new Set([ + 'Write', // Claude Code + 'write_file', // Gemini CLI + 'write_to_file', // Antigravity CLI + 'create_file', +]); + // Firma estable (ver hooks/gate-signatures.json). Este hook es advisory-only: nunca deniega, asi // que no existe una firma _BLOCK para el, solo esta _ADVISORY. const ADVISORY_FIRMA = '[SDD_READ_EDIT_ADVISORY] '; @@ -103,19 +114,31 @@ async function main() { // Archivo nuevo, o ruta que no se puede situar en el proyecto: no habia nada que leer. La ruta // se ancla a la raiz del proyecto (resolveRepoPath, en sdd-plan-state.js) porque una comprobada // contra el directorio equivocado responde "no existe" en silencio y calla el aviso entero. - const resolved = resolveRepoPath(filePath, data.cwd); - if (!resolved || !fs.existsSync(resolved)) process.exit(0); - - // resolveRepoPath es idempotente sobre una absoluta: se reutiliza la ya situada en vez de + // resolveRepoPath es idempotente sobre una absoluta: hasRead reutiliza la ya situada en vez de // volver a buscar la raiz del repositorio. - if (hasRead(sessionId, resolved)) process.exit(0); + const resolved = resolveRepoPath(filePath, data.cwd); + const exists = !!resolved && fs.existsSync(resolved); + const known = exists && hasRead(sessionId, resolved); - // Sin lectura de esta ruta. Solo se avisa si consta que el backend entrega - // lecturas: Claude Code siempre; los demas solo si ya hubo alguna lectura. + // Solo se avisa si consta que el backend entrega lecturas: Claude Code siempre; los demas solo + // si ya hubo alguna lectura. Se evalúa antes de registrar la escritura de abajo, que también + // cuenta para hasAnyRead. const backendDeliversReads = READ_RELIABLE_WRITE_TOOLS.has(call.name) || hasAnyRead(sessionId); - if (!backendDeliversReads) process.exit(0); + // Una escritura completa deja el contenido en manos del agente: la ruta pasa a ser conocida + // en la sesión, y el Edit posterior sobre lo que él mismo escribió no avisa. Se registra antes + // de las salidas tempranas porque crear un fichero es justo el caso que sale por «no existe». + // Solo cuando el backend entrega lecturas: en otro, el registro activaría hasAnyRead y con él + // avisos sobre ficheros cuya lectura el hook nunca llega a ver. + if (FULL_WRITE_TOOLS.has(call.name) && backendDeliversReads) { + trackRead(sessionId, filePath, data.cwd); + } + + if (!exists || known || !backendDeliversReads) process.exit(0); + + // El aviso se decide con el estado previo a esta escritura: sobrescribir un fichero existente + // sin leerlo sigue avisando, aunque la ruta quede ya registrada para el siguiente Edit. warn(advisory(filePath), call); } diff --git a/hooks/tests/sdd-read-before-edit.test.js b/hooks/tests/sdd-read-before-edit.test.js index 9512d10..e905c20 100644 --- a/hooks/tests/sdd-read-before-edit.test.js +++ b/hooks/tests/sdd-read-before-edit.test.js @@ -150,3 +150,51 @@ test('la lectura persiste en el rastreador por sesion, no en el mensaje de commi const rutas = fs.readFileSync(tracker, 'utf8').trim().split('\n').map((linea) => JSON.parse(linea)); assert.ok(rutas.includes(path.resolve(e.existente)), 'la ruta leida consta en el rastreador'); }); + +const claudeEdit = (file, session) => ({ tool_name: 'Edit', tool_input: { file_path: file }, session_id: session }); + +test('Claude Code: Write que crea un fichero y Edit posterior del mismo -> silencio', () => { + const e = entorno(CONFIG_ON); + const nuevo = path.join(e.dir, 'creado.js'); + + const escritura = runHook(HOOK, claudeWrite(nuevo, SESSION), e.env); + assert.strictEqual(escritura.decision, null); + // El hook corre antes de la herramienta: el fichero lo crea el Write real, no el hook. + writeFile(nuevo, 'contenido escrito por el agente'); + + const r = runHook(HOOK, claudeEdit(nuevo, SESSION), e.env); + assert.strictEqual(r.code, 0); + assert.strictEqual(r.decision, null, 'el agente conoce lo que él mismo escribió'); +}); + +test('Claude Code: Edit de un fichero existente nunca leído ni escrito -> avisa', () => { + const e = entorno(CONFIG_ON); + const r = runHook(HOOK, claudeEdit(e.existente, SESSION), e.env); + + assert.strictEqual(r.decision.decision, 'warn'); + assert.match(r.decision.reason, /sin haberlo leido/); +}); + +test('Claude Code: Write sobre un existente no leído avisa; el Edit siguiente ya no', () => { + const e = entorno(CONFIG_ON); + + const escritura = runHook(HOOK, claudeWrite(e.existente, SESSION), e.env); + assert.strictEqual(escritura.decision.decision, 'warn', 'sobrescribir sin leer sigue avisando'); + + const r = runHook(HOOK, claudeEdit(e.existente, SESSION), e.env); + assert.strictEqual(r.decision, null, 'tras sobrescribirlo, el contenido es del agente'); +}); + +test('backend sin lecturas registradas: una escritura completa no registra la ruta ni activa el aviso', () => { + // Si la escritura se registrara, hasAnyRead pasaría a verdadero y el hook avisaría sobre + // ficheros cuya lectura este backend nunca le entrega. + const e = entorno(CONFIG_ON); + const nuevo = path.join(e.dir, 'creado.js'); + runHook(HOOK, agyWrite(nuevo, SESSION), e.env); + + const tracker = path.join(e.dir, 'sdd-reads-' + SESSION + '.jsonl'); + assert.strictEqual(fs.existsSync(tracker), false); + + const r = runHook(HOOK, agyWrite(e.existente, SESSION), e.env); + assert.strictEqual(r.decision, null); +}); From 8f63b5d3079d1f590eeb37bfd6ae606e36714f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:17:45 +0000 Subject: [PATCH 08/15] update: premisas, control negativo y registro de decisiones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las tasks no exigían evidencia de sus premisas ni el sentido contrario de un criterio de ausencia, y las decisiones de la planificación técnica se recogían sin estado, de modo que una decisión nueva podía contradecir una vigente en silencio. tareas.md añade «Premisas verificadas», el control positivo y negativo y criterios condicionales de coste de verificación y de acceso a datos; revisar_tarea.md los comprueba y pone el veredicto primero; 02_planificacion_tecnica.md numera las decisiones como D y las registra con estado VIGENTE / SUSTITUIDA / DESCARTADA. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .../02_planificacion_tecnica.md | 20 ++++++++++++++---- ai_docs/dev_templates/revisar_tarea.md | 14 ++++++++++--- ai_docs/dev_templates/tareas.md | 21 +++++++++++++++++-- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/ai_docs/core_templates/02_planificacion_tecnica.md b/ai_docs/core_templates/02_planificacion_tecnica.md index 6881cd8..714fbf9 100644 --- a/ai_docs/core_templates/02_planificacion_tecnica.md +++ b/ai_docs/core_templates/02_planificacion_tecnica.md @@ -118,12 +118,14 @@ Tabla: [nombre — el existente si ya estaba en el schema, o uno nuevo] Identificar las 2-4 decisiones de datos mas importantes. No enterrarlas en detalles — presentarlas como decisiones claras: ``` -Decision 1: [Nombre del problema] +D: [Nombre del problema] - Situacion: [Estado actual vs lo que necesitas] - Opciones: [Opcion A] vs [Opcion B] - Recomendacion: [Opcion X] porque [razon concreta] ``` +Cada decisión aprobada se registra como fila `VIGENTE` en «Registro de decisiones» del documento final, con su porqué y las alternativas descartadas. + **Temas tipicos que requieren decision:** - Modelo de permisos (roles en tabla de usuarios vs tabla separada de roles) - Modelo de billing (datos en tu BD vs delegar al proveedor de pagos) @@ -246,7 +248,7 @@ C) La planificacion esta completa ## Generar documento final -Si `ai_docs/core/planificacion_tecnica.md` ya existe, leerlo primero. Usarlo como base a actualizar: preservar decisiones y secciones que sigan vigentes, anadir lo nuevo, senalar conflictos entre lo existente y lo nuevo en vez de mezclarlos en silencio. Si no existe, generar desde cero. +Si `ai_docs/core/planificacion_tecnica.md` ya existe, leerlo primero. Usarlo como base a actualizar: preservar decisiones y secciones que sigan vigentes, anadir lo nuevo, senalar conflictos entre lo existente y lo nuevo en vez de mezclarlos en silencio. Una decisión nueva que contradice una `VIGENTE` no la borra ni la reescribe: la fila antigua pasa a `SUSTITUIDA por D` y la nueva entra como fila propia. Si no existe, generar desde cero. Solo despues de la aprobacion, generar con este formato. El documento final DEBE incluir al final una seccion `## Registro de revision` con el bloque de autocritica generado (los puntos identificados, incluso si el usuario no los refino) y la eleccion textual del usuario en cada ronda (ej. `Usuario eligio: C — la planificacion esta completa`). Si hubo varias rondas de refinamiento, listarlas todas en orden, no solo la ultima. @@ -283,7 +285,7 @@ Solo despues de la aprobacion, generar con este formato. El documento final DEBE ## Modelo de Datos ### Decisiones Clave -- [Decision 1]: [opcion elegida y por que] +- D: [resumen de la decisión de datos; estado, porqué y alternativas en su fila de la tabla de decisiones] ### Tablas - **[nombre_tabla]**: [proposito] — Campos clave: [lista] @@ -306,7 +308,7 @@ Solo despues de la aprobacion, generar con este formato. El documento final DEBE [Diagrama Mermaid o texto] ### Seguridad -- [Decisiones de seguridad] +- D: [resumen de la decisión de seguridad; estado, porqué y alternativas en su fila de «Registro de decisiones»] ### Deployment - [Estrategia de despliegue] @@ -319,6 +321,16 @@ Solo despues de la aprobacion, generar con este formato. El documento final DEBE --- +## Registro de decisiones + +Una fila por decisión tomada en cualquier fase (datos, arquitectura, seguridad, deployment). El estado es `VIGENTE`, `SUSTITUIDA por D` o `DESCARTADA`. Las filas no se borran: una decisión nueva que contradice una vigente entra como fila nueva y la antigua pasa a `SUSTITUIDA por` esa fila. + +| ID | Decisión | Estado | Porqué | Alternativas descartadas | +|----|----------|--------|--------|--------------------------| +| D1 | [opción elegida] | VIGENTE | [razón concreta] | [opción B: por qué no] | + +--- + ## Registro de revision - **Autocritica:** [Bloque de autocritica generado] - **Usuario eligio:** [Eleccion textual de cada ronda, en orden] diff --git a/ai_docs/dev_templates/revisar_tarea.md b/ai_docs/dev_templates/revisar_tarea.md index a7ad24d..f3cc398 100644 --- a/ai_docs/dev_templates/revisar_tarea.md +++ b/ai_docs/dev_templates/revisar_tarea.md @@ -37,6 +37,10 @@ Antes de revisar la task, verificar que es coherente con la spec aprobada: | El objetivo de la task contribuye a un criterio de aceptacion de la spec | SI / NO | | La task no implementa nada excluido en "No incluye" de la spec | SI / NO | | Los archivos afectados son coherentes con el alcance de la spec | SI / NO | +| Cada premisa de «Premisas verificadas» trae `archivo:línea` (o comando) que la acredita, o se marca «no verificada» con su motivo | SI / NO | +| Cada criterio de ausencia de «Criterios de exito» trae su control positivo y su control negativo | SI / NO | + +Comprueba por tu cuenta al menos una premisa citada: abre el `archivo:línea` y confirma que dice lo que la task afirma. Una premisa desmentida por el código invalida el plan que se apoya en ella. **Si alguna verificacion falla:** DETENTE y comunica el problema. La task puede estar desalineada con la spec. @@ -138,10 +142,15 @@ Presenta al usuario un resumen de la revision: ``` ## Resultado de la revision +### Veredicto +[LISTO_PARA_IMPLEMENTAR / NECESITA_AJUSTES / NECESITA_REPLANTEAMIENTO] + **Task:** [Titulo de la task] **Spec madre:** [Titulo de la spec] **Coherencia con spec:** [OK / Desalineada — detallar] +**Premisas verificadas:** [OK / Hay N premisas sin evidencia o desmentidas — detallar] +**Criterios de ausencia:** [OK / Hay N sin control positivo y negativo — detallar] **Alcance:** [OK / Necesita ajustes — detallar] **Dependencias:** [OK / Faltan pre-requisitos — detallar] **Edge cases:** [OK / Hay N casos no contemplados — detallar] @@ -152,11 +161,10 @@ Presenta al usuario un resumen de la revision: ### Ajustes propuestos al documento de task - [Ajuste 1 — que cambiar y por que] - [Ajuste 2 — que cambiar y por que] - -### Veredicto -[LISTO_PARA_IMPLEMENTAR / NECESITA_AJUSTES / NECESITA_REPLANTEAMIENTO] ``` +El veredicto va primero: quien lee el resultado decide con la primera línea y solo baja al detalle si lo necesita. + **PUNTO DE ESPERA (uso manual):** - DETENTE aqui y espera la respuesta del usuario - Si el veredicto es "NECESITA_REPLANTEAMIENTO", NO continues hasta que el usuario decida como proceder diff --git a/ai_docs/dev_templates/tareas.md b/ai_docs/dev_templates/tareas.md index ef5939e..45c283c 100644 --- a/ai_docs/dev_templates/tareas.md +++ b/ai_docs/dev_templates/tareas.md @@ -79,6 +79,16 @@ Para cada task, usa este formato: <1-2 frases: que se implementa y por que. Referencia a la spec, no repitas el contexto completo.> +## Premisas verificadas + +Cada hecho del código o del entorno del que depende el plan, con su evidencia: + +- — `ruta/archivo.ext:línea` (o el comando que la acredita) +- — no verificada: + +Una premisa sin evidencia se marca «no verificada» con su motivo; nunca se da por +cierta en silencio. + ## Archivos afectados | Archivo | Accion | Descripcion del cambio | @@ -130,6 +140,11 @@ Minimo 3, todos verificables (test, comando o inspeccion concreta): - [ ] - [ ] +Un criterio de ausencia («no aparece», «0 líneas», «no se bloquea») se acredita +también con un control positivo y otro negativo: el mismo comando detecta el caso +cuando existe (por ejemplo, revirtiendo el cambio de forma temporal) y deja de +detectarlo tras el cambio. + ## Casos limite minimos Minimo 3: @@ -146,13 +161,15 @@ Minimo 3: - [ ] Sin dead/legacy code. - [ ] DRY/KISS/early returns aplicados. - [ ] TDD reutilizando infra existente. +- [ ] Si la task añade o cambia un test, un hook o un job de CI: punto del ciclo en que corre, quién espera su resultado y coste medido (tiempo, procesos lanzados). +- [ ] Si la task toca acceso a datos: volumen esperado, índice para cada filtro nuevo, plan de ejecución de la consulta y secuencia add → backfill → drop si cambia el esquema. ``` **Reglas para cada task:** - El titulo debe ser accionable: "Crear endpoint de registro" no "Registro" - Minimo 3 casos limite por task si involucra logica de negocio - El tamano estimado debe ser realista. Si supera 400 lineas, divide la task -- Cada task aprobada genera al menos un commit; las tasks fallidas (revision adversarial no superada) se descartan sin commit +- Cada task aprobada genera al menos un commit; las tasks fallidas (revision adversarial no superada) no se commitean --- @@ -232,6 +249,6 @@ El siguiente paso es revisar cada task individualmente con `revisar_tarea.md`. 3. **Cada task tiene un alcance acotado** — si depende de otra, la dependencia debe estar documentada 4. **NUNCA repitas el contexto completo de la spec** en cada task — referencia la spec madre 5. **Si dos tasks tocan el mismo archivo**, una depende de la otra — documentar el orden -6. **Cada task aprobada genera al menos un commit** — las tasks fallidas (revision adversarial no superada) se descartan sin commit; si una task aprobada es tan pequena que no justifica commit, fusionarla con otra +6. **Cada task aprobada genera al menos un commit** — las tasks fallidas (revision adversarial no superada) no se commitean; si una task aprobada es tan pequena que no justifica commit, fusionarla con otra 7. **Minimo 3 casos limite** por task que involucre logica de negocio 8. **`**Actualizado:**` se refresca en TODA edicion posterior a la creacion** — no solo al crear el archivo. `**Creado:**` es inmutable: fijalo una vez y no lo vuelvas a escribir From 71fcf380a893c7565726847605e4b745599f82f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:17:53 +0000 Subject: [PATCH 09/15] =?UTF-8?q?update:=20plantillas=20de=20implementaci?= =?UTF-8?q?=C3=B3n=20y=20de=20tests=20sin=20reloj=20ni=20notas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La plantilla de implementación reporta los hallazgos fuera de alcance en vez de anotarlos y solo admite tests dirigidos cuando quien lanza la task declara que ejecuta la suite completa como gate antes de commitear; si no lo declara, se ejecuta la suite completa. Pide parar los procesos propios, releer antes de reintentar, declarar el coste de cada verificación nueva y verificar la interfaz en el viewport real, amplía el checklist de datos y lista las desviaciones en el informe. testing_basico sustituye la cota de 50ms por tests independientes del reloj y añade dos antipatrones; limpieza_de_codigo convierte los umbrales de líneas en señal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- ai_docs/dev_templates/implementar.md | 38 ++++++++++++++++----- ai_docs/dev_templates/limpieza_de_codigo.md | 4 +-- ai_docs/dev_templates/testing_basico.md | 4 ++- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/ai_docs/dev_templates/implementar.md b/ai_docs/dev_templates/implementar.md index f464470..e99bb69 100644 --- a/ai_docs/dev_templates/implementar.md +++ b/ai_docs/dev_templates/implementar.md @@ -22,7 +22,7 @@ Estas instrucciones son OBLIGATORIAS. Debes seguirlas en todo momento: **Instrucciones adicionales para esta plantilla:** - SOLO implementa lo que dice el documento de task. Nada mas, nada menos. -- Si descubres un problema fuera del alcance de la task (bug, deuda tecnica, mejora), ANOTALO para otra task. No lo corrijas ahora. +- Si descubres un problema fuera del alcance de la task (bug, deuda técnica, mejora), no lo corrijas ahora: **repórtalo** (ver 3.3). Reportar no es dejar una nota en el código ni en un documento sin dueño. - Si necesitas modificar un archivo que no esta en la lista de "Archivos afectados" de la task, DETENTE y consulta al usuario. - Los tests se escriben DURANTE la implementacion, no despues. Para funcionalidad nueva, el protocolo RED-GREEN es OBLIGATORIO (ver Paso 4). @@ -91,13 +91,22 @@ Sigue el plan de implementacion de la task. Para cada archivo: - **Reutilizar antes que duplicar** — si ya existe logica similar, extenderla - **Seguir convenciones del proyecto** — no imponer un estilo diferente - **Nombres descriptivos** — variables, funciones y archivos con nombres que expliquen su proposito +- **Edición rechazada** — si una edición falla o se rechaza, relee el fichero antes de reintentarla: reintentar sin releer da por supuesto que el fichero no cambió +- **Procesos propios** — todo proceso que lances (servidor, watcher, proceso en segundo plano) se detiene antes de terminar y compruebas que terminó. Para esperar a algo, espera a su señal o a que el comando termine; nunca con `sleep` ni con bucles de sondeo +- **Tests, hooks o jobs de CI nuevos o cambiados** — declara en qué punto del ciclo corren (commit, push, CI), quién espera su resultado y su coste medido (tiempo, procesos lanzados), no supuesto +- **Cambio de interfaz perceptual** (diseño, maquetación, interacción) — verifícalo en el viewport real de la aplicación en ejecución; si no puedes, decláralo «no evaluado» en el informe, no lo des por bueno ### 3.3 Si descubres algo fuera de alcance -No lo corrijas. Anotalo: +No lo corrijas. **Repórtalo** a quien puede darle dueño: + +- **Vía workflow:** en el campo de hallazgos fuera de alcance que pida tu resultado (p. ej. `hallazgos_fuera_alcance`). +- **Uso manual:** al usuario, en el informe de entrega (Paso 6). + +No lo dejes como `TODO`, comentario ni nota en un documento: una nota sin dueño no la atiende nadie. ``` -## Hallazgos fuera de alcance (para tasks futuras) +## Hallazgos fuera de alcance - [Archivo:linea] — [Descripcion del problema encontrado] - [Archivo:linea] — [Descripcion del problema encontrado] @@ -123,7 +132,7 @@ Para toda funcionalidad nueva y toda correccion de bug, RED-GREEN es obligatorio 1. **RED** — Escribir el test ANTES del codigo. Ejecutarlo y verificar que FALLA sin tu cambio. Un test que pasa sin el cambio no prueba nada: mata al mutante o no es un test de regresion. Si no lo ves fallar, no lo has probado. 2. **GREEN** — Implementar el codigo minimo que hace pasar el test. Ejecutar. Debe PASAR. -3. **Verificar** — Ejecutar TODOS los tests del proyecto para confirmar que nada se rompio. +3. **Verificar** — Confirmar que nada se rompió. Si quien te lanzó declara que ejecuta la suite completa como gate antes de commitear, basta con los tests dirigidos al cambio (los del módulo tocado y sus consumidores); si no lo declara, ejecuta la suite completa. **Kill-the-mutant:** el criterio de un test de regresion util es que falle al revertir el fix. Si el test sigue verde con el codigo viejo, esta midiendo otra cosa; reescribelo hasta que el RED sea real. @@ -133,9 +142,9 @@ El workflow `/implementar-spec` re-ejecuta la suite del proyecto como gate mecan Sugiere configurarlo con `core_templates/04_setup_testing.md`. Si el usuario decide no configurarlo, documentar como se verifico manualmente. -### Checklist de migraciones (solo si la task toca schema o migraciones) +### Checklist de datos (solo si la task toca schema, migraciones o consultas sobre datos persistidos) -Si la task NO toca migraciones ni schema, este checklist no aplica. +Si la task NO toca schema, migraciones ni consultas, este checklist no aplica. | Verificacion | Estado | |-------------|--------| @@ -143,6 +152,10 @@ Si la task NO toca migraciones ni schema, este checklist no aplica. | Si hay cambio de tipo o rename de columna, existe un paso de backfill | SI / NO / N/A | | La migracion se probo contra datos no vacios (no solo contra una base de datos limpia) | SI / NO / N/A | | La migracion es reversible o tiene documentado por que no lo es | SI / NO / N/A | +| Se conoce el volumen de la tabla afectada y la operación es viable con ese volumen (bloqueos, duración, lotes) | SI / NO / N/A | +| Todo filtro o join nuevo tiene un índice que lo cubre | SI / NO / N/A | +| Se revisó el plan de ejecución de las consultas nuevas o cambiadas | SI / NO / N/A | +| Un cambio incompatible sigue la secuencia add → backfill → drop, no un cambio destructivo en un solo paso | SI / NO / N/A | --- @@ -153,8 +166,9 @@ Antes de dar la implementacion por terminada: 1. **Linting** — ejecutar el linter del proyecto. Cero errores nuevos 2. **Verificacion de tipos** — si el proyecto usa tipos (TypeScript, mypy), ejecutar. Cero errores nuevos 3. **Tests nuevos** — todos pasan -4. **Tests existentes** — todos siguen pasando +4. **Tests existentes** — todos siguen pasando (suite completa, salvo que quien te lanzó declare que la ejecuta como gate antes de commitear: entonces, tests dirigidos al cambio; ver Paso 4) 5. **Build** — si aplica, verificar que el proyecto compila +6. **Marcadores sin dueño** — `grep -rniE "todo|fixme|pendiente"` sobre los ficheros tocados: cada coincidencia que hayas introducido se resuelve o se reporta como hallazgo fuera de alcance ``` ## Resultado de validaciones @@ -199,6 +213,12 @@ Presenta un resumen al usuario: - [ ] [Criterio 1 de la task — cumplido SI/NO] - [ ] [Criterio 2 de la task — cumplido SI/NO] +### Desviaciones + +- Ficheros tocados fuera de "Archivos afectados": [lista o "Ninguno"] +- Contratos cambiados (interfaces, formatos, comportamiento que consumen otros): [lista o "Ninguno"] +- Criterios de éxito no cumplidos o no evaluados: [lista con motivo o "Ninguno"] + ### Hallazgos fuera de alcance - [Lista de problemas encontrados que no se corrigieron, o "Ninguno"] @@ -219,6 +239,6 @@ El cambio esta listo para: 2. **NUNCA modifiques archivos no listados** en la task sin consultar al usuario 3. **Los tests se escriben DURANTE la implementacion**, no despues 4. **Si algo falla en las validaciones**, corregir antes de continuar -5. **Hallazgos fuera de alcance se anotan**, no se corrigen -6. **NUNCA hagas "mejoras de paso"** que no estan en la task — anotar para task futura +5. **Hallazgos fuera de alcance se reportan**, no se corrigen +6. **NUNCA hagas "mejoras de paso"** que no estan en la task — repórtalas como hallazgo fuera de alcance 7. **Si el alcance real supera la estimacion**, DETENTE y comunica al usuario diff --git a/ai_docs/dev_templates/limpieza_de_codigo.md b/ai_docs/dev_templates/limpieza_de_codigo.md index d7fd1e0..b79fda1 100644 --- a/ai_docs/dev_templates/limpieza_de_codigo.md +++ b/ai_docs/dev_templates/limpieza_de_codigo.md @@ -215,8 +215,8 @@ Los comentarios deben explicar POR QUE, nunca QUE. Si necesitas un comentario pa | Code smell | Solucion tipica | |------------|----------------| -| Funcion de mas de 50 lineas | Dividir en funciones mas pequenas con nombres descriptivos | -| Archivo de mas de 300 lineas | Extraer modulos/componentes | +| Función de más de 50 líneas (señal, no regla) | Inspecciona si mezcla responsabilidades; divide en funciones con nombres descriptivos solo si separa responsabilidades distintas o hace el código más fácil de leer y probar | +| Archivo de más de 300 líneas (señal, no regla) | Inspecciona si agrupa responsabilidades sin relación; extrae módulos o componentes solo si esas partes cambian por motivos distintos | | Parametro que se pasa 3+ niveles | Usar contexto, inyeccion de dependencias o composicion | | Multiples `if/else` anidados (3+ niveles) | Early returns o patron strategy | | Codigo duplicado en 3+ lugares | Extraer a funcion compartida | diff --git a/ai_docs/dev_templates/testing_basico.md b/ai_docs/dev_templates/testing_basico.md index 50f8ade..457e3e3 100644 --- a/ai_docs/dev_templates/testing_basico.md +++ b/ai_docs/dev_templates/testing_basico.md @@ -134,7 +134,7 @@ No pierdas tiempo con tests que no aportan valor: - **Una verificacion por test:** No mezclar multiples comprobaciones - **Datos minimos:** Solo los datos necesarios para el test, nada mas - **Independientes:** Cada test funciona solo, sin depender de otros ni del orden de ejecucion -- **Rapidos:** Un test unitario no deberia tardar mas de 50ms +- **Sin dependencia del reloj:** el resultado no depende del tiempo ni de la carga de la máquina: inyecta el reloj o el temporizador. Un rojo por contención no se tapa ampliando un plazo ### Parametrizacion — evitar tests duplicados @@ -223,6 +223,8 @@ Antes de dar el fix por cerrado: | Test sin verificacion (`render()` y ya) | No comprueba nada | Anadir `expect(...)` con valor concreto | | Verificacion generica (`toBeTruthy()`) | No dice que se espera | `toEqual(valorExacto)` | | `sleep()` o esperas fijas | Test lento y fragil | Usar `waitFor()` o async/await | +| Cota de tiempo de pared (`expect(duracion).toBeLessThan(50)`) | El resultado depende de la carga de la máquina: rojo intermitente por contención | Inyectar el reloj o el temporizador y afirmar sobre el comportamiento, no sobre la duración | +| Un subproceso por caso cuando se puede importar el módulo | Cada arranque de intérprete suma segundos: la suite pasa de segundos a minutos | Importar el módulo y llamarlo en proceso; el subproceso queda para uno o dos casos de la interfaz de línea de comandos | | Copiar test cambiando 1 valor | Codigo duplicado | Usar parametrizacion (`test.each` / `parametrize`) | | Nombre vago (`"deberia funcionar"`) | No dice que prueba | `"devuelve 404 cuando el usuario no existe"` | | Test que depende de otro test | Si uno falla, todos fallan | Cada test se prepara sus propios datos | From c4b5364cfd2d0a171df81f5ef155d7ac87ae0fdb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:22:57 +0000 Subject: [PATCH 10/15] =?UTF-8?q?fix:=20session-analyzer=20cuenta=20cada?= =?UTF-8?q?=20respuesta=20una=20vez=20y=20tarifa=20la=20cach=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code escribe varias líneas por message.id durante el streaming, con el uso acumulándose hasta la última, y el analizador las sumaba todas: en transcripciones reales inflaba la entrada y la caché unas 3 veces y la salida unas 7. Ahora cuenta cada message.id una vez con su última línea, también entre transcripciones si se comparte un Set de ids (una sesión reanudada repite mensajes), agrega /subagents/*.jsonl y aplica las tarifas de entrada, salida, escritura en caché (5 min y 1 h) y lectura de caché de los modelos vigentes, copiadas de la página oficial de precios con su URL y fecha, incluido el precio por tramos de Haiku 5.5 calculado por petición. Un modelo sin tarifa sigue en unpricedModels. La skill auditar-sesion describe el nuevo alcance. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .claude/skills/auditar-sesion/SKILL.md | 6 +- .claude/workflows/lib/session-analyzer.js | 232 +++++++++++++++++----- hooks/tests/session-analyzer.test.js | 187 ++++++++++++++++- 3 files changed, 366 insertions(+), 59 deletions(-) diff --git a/.claude/skills/auditar-sesion/SKILL.md b/.claude/skills/auditar-sesion/SKILL.md index fc6c23a..211ab5c 100644 --- a/.claude/skills/auditar-sesion/SKILL.md +++ b/.claude/skills/auditar-sesion/SKILL.md @@ -1,6 +1,6 @@ --- name: auditar-sesion -description: "Se activa cuando el usuario pide metricas de la sesion, coste, duracion, cache hit rate o friccion de hooks. Analiza las transcripciones del proyecto actual." +description: "Cuando se piden métricas de las sesiones de Claude Code del proyecto (coste, duración, cache hit rate o fricción de hooks): analiza las transcripciones JSONL locales, subagentes incluidos." --- Solo funciona en Claude Code: depende del formato nativo de transcripcion JSONL que este backend escribe en `~/.claude/projects/`. Ningun otro backend genera ese formato. @@ -8,7 +8,7 @@ Solo funciona en Claude Code: depende del formato nativo de transcripcion JSONL 1. Localiza el directorio de transcripciones del proyecto actual: `~/.claude/projects//` (la ruta absoluta de tu proyecto con las barras `/` sustituidas por guiones `-`). Si no existe o esta vacio, informa "no se encontraron transcripciones" y detente. 2. Verifica que `.claude/workflows/lib/session-analyzer.js` existe antes de importarlo. Si falta, informa que la instalacion esta incompleta y detente. 3. Si hay mas de 50 transcripciones, ofrece filtrar por fecha o por las N mas recientes antes de analizarlas todas. -4. Para cada transcripcion seleccionada, usa `require('.claude/workflows/lib/session-analyzer.js')` y llama a `parseTranscript(ruta)` seguido de `computeMetrics(resultado)`. Si `parseTranscript` lanza para una transcripcion concreta, saltala y sigue con las demas. +4. Para cada transcripcion seleccionada, usa `require('.claude/workflows/lib/session-analyzer.js')`, crea un único `const vistos = new Set()` para todo el análisis y llama a `parseTranscript(ruta, { seenMessageIds: vistos })` seguido de `computeMetrics(resultado)`; el `Set` compartido evita contar dos veces los mensajes que una sesión reanudada repite de la anterior. Si `parseTranscript` lanza para una transcripcion concreta, saltala y sigue con las demas. 5. Presenta un resumen: cuantas transcripciones se analizaron frente a cuantas se saltaron (por fallo o por lineas malformadas), y para el conjunto analizado: coste total, duracion, cache hit rate y friccion por hook (nombre del hook, total de eventos, desglose por codigo). Si algun `computeMetrics` devolvio `unpricedModels` no vacio, antepon al coste un aviso "coste parcial: modelo X sin tarifa conocida" (uno por cada modelo distinto acumulado entre todas las transcripciones analizadas) — el total mostrado NO incluye los tokens de esos modelos, no lo presentes como si fuera el coste completo. -Alcance de las cifras: la libreria agrega cada transcripcion entera y no desglosa por turno ni por subagente. El coste sale de las tarifas de input y de output de cada modelo conocido; los tokens de cache cuentan para el cache hit rate pero no llevan tarifa propia, asi que el coste es una aproximacion util para comparar sesiones entre si, no una factura. +Alcance de las cifras: la librería agrega cada transcripción entera, sumando las de sus subagentes (`/subagents/*.jsonl`), y no desglosa por turno ni por subagente. Cada respuesta del modelo cuenta una vez aunque la transcripción la repita en varias líneas; entre transcripciones, solo si se les pasa el mismo `Set` en `seenMessageIds`. El coste se calcula mensaje a mensaje con la tarifa estándar de entrada, salida, escritura en caché (5 minutos y 1 hora) y lectura de caché de cada modelo conocido, con el tramo por longitud de prompt de cada petición cuando el modelo lo tiene, copiada de la página oficial de precios con la fecha de consulta en el comentario de `PRICING_USD_PER_MTOK`; no aplica recargos (inferencia restringida a EE. UU., modo rápido) ni descuentos de batch. Es una estimación para comparar sesiones, no una factura. diff --git a/.claude/workflows/lib/session-analyzer.js b/.claude/workflows/lib/session-analyzer.js index 78329cd..5f7e9a4 100644 --- a/.claude/workflows/lib/session-analyzer.js +++ b/.claude/workflows/lib/session-analyzer.js @@ -3,9 +3,11 @@ // Analisis de transcripciones nativas de Claude Code (JSONL en ~/.claude/projects/*). // // El formato es una linea = un evento. Dos formas de evento importan aqui: -// - `{ type: 'assistant', message: { model, usage: { input_tokens, output_tokens, -// cache_creation_input_tokens, cache_read_input_tokens } }, timestamp }`: el uso -// de tokens por turno, ya troceado por modelo por el propio backend. +// - `{ type: 'assistant', message: { id, model, usage: { input_tokens, output_tokens, +// cache_creation_input_tokens, cache_read_input_tokens, cache_creation } }, timestamp }`: +// el uso de tokens de una respuesta. Durante el streaming Claude Code escribe varias +// líneas con el mismo `message.id` (una por bloque de contenido); `output_tokens` crece +// de una a otra y la última trae el total: se cuenta una vez por `message.id`, con la última. // - `{ type: 'attachment', attachment: { type: 'hook_error'|'hook_success', command, // content, durationMs, exitCode, hookEvent, hookName, stderr, stdout, toolUseID } }`: // como Claude Code registra la ejecucion de un hook en la transcripcion. `hookName` @@ -15,23 +17,65 @@ // corto que un guard emite via warn()/deny() (ver sdd-hook-utils.js) no llega como // campo top-level: viaja embebido como JSON dentro de `attachment.stdout`. // +// Los subagentes de una sesión escriben su propia transcripción junto a la principal: +// `.jsonl` y `/subagents/*.jsonl`. parseTranscript las agrega. +// // Sin dependencias npm: solo `fs` y `path`, para poder copiarse a cualquier proyecto // que tenga Node sin arrastrar nada mas. const fs = require('fs'); const path = require('path'); -// $/MTok, tokens de input y de output. No incluye tarifas de cache (creacion/lectura -// tienen precio propio en la API real); el coste que calcula esta libreria es una -// aproximacion basada solo en input/output, suficiente para comparar sesiones entre si. +// USD por millón de tokens, por categoría de la API: entrada base, salida, escritura en +// caché de 5 minutos, escritura en caché de 1 hora y lectura de caché (aciertos y refrescos). +// Fuente: https://platform.claude.com/docs/en/about-claude/pricing (tabla "Model pricing"), +// consultada el 2026-10-13. Solo modelos no retirados en esa fecha. +// +// claude-haiku-5-5 tiene dos tramos según la longitud del prompt de cada petición: entrada, +// escritura y lectura de caché sumadas; por encima de `promptLargo.umbral` (100.000 tokens) +// toda la petición paga las tarifas de `promptLargo`. Por eso el coste se calcula mensaje a +// mensaje. Los modelos retirados quedan fuera de la tabla, y por tanto en `unpricedModels`. +// No se aplican los recargos de inferencia restringida a EE. UU. (`inference_geo`) ni de +// modo rápido, ni el descuento de batch: el coste es el de la tarifa estándar. const PRICING_USD_PER_MTOK = { - 'claude-opus-4-6': { input: 5, output: 25 }, - 'claude-opus-4-7': { input: 5, output: 25 }, - 'claude-opus-4-8': { input: 5, output: 25 }, - 'claude-sonnet-4-6': { input: 3, output: 15 }, - 'claude-sonnet-5': { input: 2, output: 10 }, + 'claude-fable-5-1': { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 }, + 'claude-mythos-5-1': { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 0.25 }, + 'claude-fable-5': { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 1 }, + 'claude-mythos-5': { input: 10, output: 50, cacheWrite5m: 12.5, cacheWrite1h: 20, cacheRead: 1 }, + 'claude-opus-5-5': { input: 4, output: 20, cacheWrite5m: 5, cacheWrite1h: 8, cacheRead: 0.2 }, + 'claude-opus-5': { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 }, + 'claude-opus-4-8': { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 }, + 'claude-opus-4-7': { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 }, + 'claude-opus-4-6': { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 }, + 'claude-opus-4-5': { input: 5, output: 25, cacheWrite5m: 6.25, cacheWrite1h: 10, cacheRead: 0.5 }, + 'claude-sonnet-5-5': { input: 2, output: 10, cacheWrite5m: 2.5, cacheWrite1h: 4, cacheRead: 0.1 }, + 'claude-sonnet-5': { input: 2, output: 10, cacheWrite5m: 2.5, cacheWrite1h: 4, cacheRead: 0.2 }, + 'claude-sonnet-4-6': { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 }, + 'claude-sonnet-4-5': { input: 3, output: 15, cacheWrite5m: 3.75, cacheWrite1h: 6, cacheRead: 0.3 }, + 'claude-haiku-5-5': { + input: 0.1, output: 0.5, cacheWrite5m: 0.125, cacheWrite1h: 0.2, cacheRead: 0.01, + promptLargo: { umbral: 100000, input: 0.5, output: 2.5, cacheWrite5m: 0.625, cacheWrite1h: 1, cacheRead: 0.05 }, + }, + 'claude-haiku-4-5': { input: 1, output: 5, cacheWrite5m: 1.25, cacheWrite1h: 2, cacheRead: 0.1 }, }; +// Un identificador con fecha (`claude-sonnet-4-5-20250929`) tiene la tarifa de su alias. +function tarifaDe(model) { + return PRICING_USD_PER_MTOK[model] || PRICING_USD_PER_MTOK[String(model).replace(/-\d{8}$/, '')] || null; +} + +// Coste en USD de un uso con las cinco categorías de la tarifa. Con tramos, el umbral se +// compara con el prompt de ese uso, que debe ser el de una sola petición. +function costeDeUso(price, t) { + const prompt = t.inputTokens + t.cacheCreationTokens + t.cacheReadTokens; + const p = price.promptLargo && prompt > price.promptLargo.umbral ? price.promptLargo : price; + return (t.inputTokens * p.input + + t.outputTokens * p.output + + (t.cacheCreationTokens - t.cacheCreation1hTokens) * p.cacheWrite5m + + t.cacheCreation1hTokens * p.cacheWrite1h + + t.cacheReadTokens * p.cacheRead) / 1e6; +} + const HOOK_ATTACHMENT_RE = /hook_(error|success)/; const HOOK_SCRIPT_RE = /\.(js|mjs|cjs|py|sh)$/i; @@ -111,69 +155,140 @@ function parseHookStdout(stdout) { } /** - * Recorre un transcript JSONL y acumula uso de tokens por modelo, el rango temporal - * de la sesion y los adjuntos de hook (friccion). Lineas vacias o no-JSON se saltan - * con un contador, sin abortar el resto del archivo. + * Transcripciones de los subagentes de una sesión: `/subagents/*.jsonl`, junto al + * fichero `.jsonl`. Sin ese directorio, ninguna. Orden alfabético para que el + * resultado no dependa del orden en que el sistema de ficheros lista las entradas. + */ +function subagentTranscriptsOf(jsonlPath) { + const dir = path.join(path.dirname(jsonlPath), path.basename(jsonlPath, '.jsonl'), 'subagents'); + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + return entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .map((entry) => path.join(dir, entry.name)) + .sort(); +} + +/** + * Recorre un transcript JSONL, y los de sus subagentes, y acumula uso de tokens por modelo, + * el rango temporal de la sesión y los adjuntos de hook (fricción). Líneas vacías o no-JSON + * se saltan con un contador, sin abortar el resto del archivo. + * + * El uso se cuenta una vez por `message.id`, con la última línea que lo trae: Claude Code + * escribe varias líneas por respuesta en streaming, con `output_tokens` creciente y el total + * en la última, y sumarlas todas multiplica los tokens. Una línea `assistant` sin + * `message.id` cuenta por sí sola. + * + * Una sesión reanudada repite en su transcripción nueva mensajes de la anterior. Para + * analizar varias sin contarlos dos veces se pasa el mismo `Set` en + * `options.seenMessageIds` a cada llamada: un `message.id` que ya esté en él no se cuenta, + * y los de esta transcripción se añaden. Sin él, la deduplicación vale solo dentro de una. + * + * Cada modelo acumula además `costUsd`, calculado mensaje a mensaje (los tramos por + * longitud de prompt se deciden por petición), o `null` si el modelo no tiene tarifa. * * Lanza si `jsonlPath` no existe, con el path en el mensaje: un ENOENT generico no * dice cual de N transcripciones en un batch fallo. */ -function parseTranscript(jsonlPath) { +function parseTranscript(jsonlPath, options = {}) { + const seenMessageIds = options.seenMessageIds || null; if (!fs.existsSync(jsonlPath)) { throw new Error(`session-analyzer: transcripcion no encontrada: ${jsonlPath}`); } - const usageByModel = {}; + const usageByMessage = new Map(); const hookEvents = []; let firstTimestamp = null; let lastTimestamp = null; let malformedLines = 0; + let anonymous = 0; - const raw = fs.readFileSync(jsonlPath, 'utf8'); + const subagentFiles = subagentTranscriptsOf(jsonlPath); - for (const line of raw.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; + for (const file of [jsonlPath].concat(subagentFiles)) { + const raw = fs.readFileSync(file, 'utf8'); - let event; - try { - event = JSON.parse(trimmed); - } catch { - malformedLines += 1; - continue; - } + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; - if (event.timestamp) { - const ts = Date.parse(event.timestamp); - if (!Number.isNaN(ts)) { - if (firstTimestamp === null || ts < firstTimestamp) firstTimestamp = ts; - if (lastTimestamp === null || ts > lastTimestamp) lastTimestamp = ts; + let event; + try { + event = JSON.parse(trimmed); + } catch { + malformedLines += 1; + continue; + } + + if (event.timestamp) { + const ts = Date.parse(event.timestamp); + if (!Number.isNaN(ts)) { + if (firstTimestamp === null || ts < firstTimestamp) firstTimestamp = ts; + if (lastTimestamp === null || ts > lastTimestamp) lastTimestamp = ts; + } } - } - if (event.type === 'assistant' && event.message && event.message.usage) { - const model = event.message.model || 'unknown'; - const usage = event.message.usage; - if (!usageByModel[model]) { - usageByModel[model] = { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0 }; + if (event.type === 'assistant' && event.message && event.message.usage) { + anonymous += 1; + const id = event.message.id || null; + const key = id ? `id:${id}` : `sin-id:${anonymous}`; + usageByMessage.set(key, { id, model: event.message.model || 'unknown', usage: event.message.usage }); + continue; + } + + if (event.type === 'attachment' && event.attachment && HOOK_ATTACHMENT_RE.test(event.attachment.type || '')) { + const { code } = parseHookStdout(event.attachment.stdout); + hookEvents.push({ + hookName: hookIdentifierFromCommand(event.attachment.command), + code, + }); } - usageByModel[model].inputTokens += usage.input_tokens || 0; - usageByModel[model].outputTokens += usage.output_tokens || 0; - usageByModel[model].cacheCreationTokens += usage.cache_creation_input_tokens || 0; - usageByModel[model].cacheReadTokens += usage.cache_read_input_tokens || 0; - continue; } + } - if (event.type === 'attachment' && event.attachment && HOOK_ATTACHMENT_RE.test(event.attachment.type || '')) { - const { code } = parseHookStdout(event.attachment.stdout); - hookEvents.push({ - hookName: hookIdentifierFromCommand(event.attachment.command), - code, - }); + const usageByModel = {}; + for (const { id, model, usage } of usageByMessage.values()) { + if (id && seenMessageIds) { + if (seenMessageIds.has(id)) continue; + seenMessageIds.add(id); } + if (!usageByModel[model]) { + usageByModel[model] = { + inputTokens: 0, + outputTokens: 0, + cacheCreationTokens: 0, + cacheCreation1hTokens: 0, + cacheReadTokens: 0, + costUsd: tarifaDe(model) ? 0 : null, + }; + } + const tokens = usageByModel[model]; + const creation = usage.cache_creation_input_tokens || 0; + // El desglose por duración (`cache_creation.ephemeral_1h_input_tokens`) solo existe en + // transcripciones recientes; sin él, toda la escritura se tarifica como de 5 minutos. + const message = { + inputTokens: usage.input_tokens || 0, + outputTokens: usage.output_tokens || 0, + cacheCreationTokens: creation, + cacheCreation1hTokens: Math.min(creation, (usage.cache_creation && usage.cache_creation.ephemeral_1h_input_tokens) || 0), + cacheReadTokens: usage.cache_read_input_tokens || 0, + }; + for (const campo of Object.keys(message)) tokens[campo] += message[campo]; + if (tokens.costUsd !== null) tokens.costUsd += costeDeUso(tarifaDe(model), message); } - return { usageByModel, firstTimestamp, lastTimestamp, hookEvents, malformedLines }; + return { + usageByModel, + firstTimestamp, + lastTimestamp, + hookEvents, + malformedLines, + subagentTranscripts: subagentFiles.length, + }; } /** @@ -181,12 +296,16 @@ function parseTranscript(jsonlPath) { * tasa de acierto de cache y friccion por hook (agrupada por `hookName` y, si el hook * la emitio, por `code`). * + * El coste es el que `parseTranscript` acumuló mensaje a mensaje con las cinco categorías + * de la tarifa (entrada, salida, escritura en caché de 5 minutos y de 1 hora, y lectura + * de caché). Un objeto sin `costUsd` se tarifica sobre sus totales. + * * Una transcripcion vacia o sin hooks nunca lanza: cae en los valores por defecto * (0/null/{}) para que un consumidor pueda iterar sesiones heterogeneas sin try/catch * por cada una. * * `unpricedModels` recoge los nombres de modelo distintos vistos en la transcripcion - * que no estan en `PRICING_USD_PER_MTOK`: sus tokens SI se suman a `totalInputTokens` + * que no tienen tarifa en `PRICING_USD_PER_MTOK`: sus tokens SI se suman a `totalInputTokens` * etc., pero contribuyen $0 a `cost` porque no hay tarifa conocida. Sin esta lista, * `cost` se presentaria como si fuera siempre el total completo aunque en realidad sea * parcial para cualquier modelo no reconocido. @@ -200,9 +319,14 @@ function computeMetrics(parsed) { for (const model of Object.keys(parsed.usageByModel)) { const tokens = parsed.usageByModel[model]; - const price = PRICING_USD_PER_MTOK[model]; - if (!price) unpricedModels.push(model); - cost += (tokens.inputTokens / 1e6) * (price ? price.input : 0) + (tokens.outputTokens / 1e6) * (price ? price.output : 0); + const price = tarifaDe(model); + if (price) { + cost += typeof tokens.costUsd === 'number' + ? tokens.costUsd + : costeDeUso(price, { cacheCreation1hTokens: 0, ...tokens }); + } else { + unpricedModels.push(model); + } totalInputTokens += tokens.inputTokens; totalCacheReadTokens += tokens.cacheReadTokens; totalCacheCreationTokens += tokens.cacheCreationTokens; diff --git a/hooks/tests/session-analyzer.test.js b/hooks/tests/session-analyzer.test.js index 7ee71f9..6754174 100644 --- a/hooks/tests/session-analyzer.test.js +++ b/hooks/tests/session-analyzer.test.js @@ -21,8 +21,9 @@ if (!motorDeWorkflowsInstalado()) { function ejecutarTests() { const { parseTranscript, computeMetrics, hookIdentifierFromCommand, rutaNativa, PRICING_USD_PER_MTOK } = require('../../.claude/workflows/lib/session-analyzer'); -function assistantEvent(timestamp, model, usage) { - return JSON.stringify({ type: 'assistant', timestamp, message: { model, usage } }); +function assistantEvent(timestamp, model, usage, id) { + const message = id ? { id, model, usage } : { model, usage }; + return JSON.stringify({ type: 'assistant', timestamp, message }); } // Forma REAL observada en transcripciones nativas de Claude Code para adjuntos de @@ -226,6 +227,188 @@ test('todos los modelos con tarifa conocida: unpricedModels vacio', () => { assert.deepStrictEqual(metrics.unpricedModels, []); }); +// ── Una respuesta, una cuenta ──────────────────────────────────────────────── +// Durante el streaming Claude Code escribe una línea `assistant` por bloque de contenido, +// todas con el mismo `message.id` y el `usage` de la misma respuesta. Sumarlas multiplica +// los tokens por el número de bloques. + +test('tres líneas con el mismo message.id cuentan una vez', () => { + const dir = tempDir('session-analyzer-'); + const usage = { input_tokens: 10, output_tokens: 100, cache_read_input_tokens: 1000, cache_creation_input_tokens: 500 }; + const file = writeFile(path.join(dir, 'streaming.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', usage, 'msg_01'), + assistantEvent('2026-08-02T10:00:01.000Z', 'claude-sonnet-5', usage, 'msg_01'), + assistantEvent('2026-08-02T10:00:02.000Z', 'claude-sonnet-5', usage, 'msg_01'), + ].join('\n')); + + const tokens = parseTranscript(file).usageByModel['claude-sonnet-5']; + + assert.deepStrictEqual( + [tokens.inputTokens, tokens.outputTokens, tokens.cacheReadTokens, tokens.cacheCreationTokens], + [10, 100, 1000, 500]); +}); + +test('de las líneas de un mismo message.id manda la última; sin id, cada línea cuenta', () => { + const dir = tempDir('session-analyzer-'); + const file = writeFile(path.join(dir, 'ultima.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 1 }, 'msg_01'), + assistantEvent('2026-08-02T10:00:01.000Z', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 7 }, 'msg_01'), + assistantEvent('2026-08-02T10:00:02.000Z', 'claude-sonnet-5', { input_tokens: 3, output_tokens: 2 }, 'msg_02'), + assistantEvent('2026-08-02T10:00:03.000Z', 'claude-sonnet-5', { input_tokens: 1, output_tokens: 1 }), + assistantEvent('2026-08-02T10:00:04.000Z', 'claude-sonnet-5', { input_tokens: 1, output_tokens: 1 }), + ].join('\n')); + + const tokens = parseTranscript(file).usageByModel['claude-sonnet-5']; + + assert.strictEqual(tokens.inputTokens, 10 + 3 + 1 + 1); + assert.strictEqual(tokens.outputTokens, 7 + 2 + 1 + 1); +}); + +// ── Subagentes ─────────────────────────────────────────────────────────────── + +test('el uso de /subagents/*.jsonl se agrega al de la sesión', () => { + const dir = tempDir('session-analyzer-'); + const file = writeFile(path.join(dir, 'sesion.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 5 }, 'msg_principal'), + ].join('\n')); + writeFile(path.join(dir, 'sesion', 'subagents', 'agent-a.jsonl'), [ + assistantEvent('2026-08-02T10:01:00.000Z', 'claude-sonnet-5', { input_tokens: 20, output_tokens: 7 }, 'msg_sub_a'), + assistantEvent('2026-08-02T10:01:01.000Z', 'claude-sonnet-5', { input_tokens: 20, output_tokens: 7 }, 'msg_sub_a'), + realHookAttachment('2026-08-02T10:01:02.000Z', 'hook_error', { + hookEvent: 'PreToolUse', + matcher: '*', + command: 'node "/proj/hooks/sdd-turn-budget.js"', + stdout: JSON.stringify({ decision: 'warn', code: 'TURN_BUDGET_WARN' }) + '\n', + }), + ].join('\n')); + writeFile(path.join(dir, 'sesion', 'subagents', 'agent-b.jsonl'), [ + assistantEvent('2026-08-02T10:03:00.000Z', 'claude-haiku-4-5', { input_tokens: 100, output_tokens: 50 }, 'msg_sub_b'), + ].join('\n')); + // Otra sesión del mismo directorio no se mezcla. + writeFile(path.join(dir, 'otra', 'subagents', 'agent-c.jsonl'), [ + assistantEvent('2026-08-02T11:00:00.000Z', 'claude-sonnet-5', { input_tokens: 9999, output_tokens: 9999 }, 'msg_otra'), + ].join('\n')); + + const parsed = parseTranscript(file); + const metrics = computeMetrics(parsed); + + assert.strictEqual(parsed.subagentTranscripts, 2); + assert.strictEqual(parsed.usageByModel['claude-sonnet-5'].inputTokens, 30); + assert.strictEqual(parsed.usageByModel['claude-sonnet-5'].outputTokens, 12); + assert.strictEqual(parsed.usageByModel['claude-haiku-4-5'].inputTokens, 100); + assert.strictEqual(metrics.duration, 3 * 60 * 1000); + assert.deepStrictEqual(metrics.frictionByHook, { + 'sdd-turn-budget.js': { total: 1, byCode: { TURN_BUDGET_WARN: 1 } }, + }); +}); + +test('sin directorio de subagentes: solo la transcripción principal', () => { + const dir = tempDir('session-analyzer-'); + const file = writeFile(path.join(dir, 'sola.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 5 }, 'msg_01'), + ].join('\n')); + + const parsed = parseTranscript(file); + assert.strictEqual(parsed.subagentTranscripts, 0); + assert.strictEqual(parsed.usageByModel['claude-sonnet-5'].inputTokens, 10); +}); + +// ── Tarifas de caché ───────────────────────────────────────────────────────── + +function cercano(real, esperado) { + assert.ok(Math.abs(real - esperado) < 1e-9, `${real} != ${esperado}`); +} + +test('escritura (5 min y 1 h) y lectura de caché tienen precio', () => { + const dir = tempDir('session-analyzer-'); + const price = PRICING_USD_PER_MTOK['claude-sonnet-5']; + const file = writeFile(path.join(dir, 'cache.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 3000000, + cache_creation: { ephemeral_5m_input_tokens: 1000000, ephemeral_1h_input_tokens: 2000000 }, + cache_read_input_tokens: 1000000, + }, 'msg_01'), + ].join('\n')); + + const metrics = computeMetrics(parseTranscript(file)); + + for (const campo of ['cacheWrite5m', 'cacheWrite1h', 'cacheRead']) { + assert.ok(price[campo] > 0, campo); + } + cercano(metrics.cost, price.cacheWrite5m + 2 * price.cacheWrite1h + price.cacheRead); +}); + +test('escritura en caché sin desglose por duración: se tarifica como de 5 minutos', () => { + const dir = tempDir('session-analyzer-'); + const price = PRICING_USD_PER_MTOK['claude-sonnet-5']; + const file = writeFile(path.join(dir, 'cache-antigua.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { cache_creation_input_tokens: 1000000 }, 'msg_01'), + ].join('\n')); + + cercano(computeMetrics(parseTranscript(file)).cost, price.cacheWrite5m); +}); + +test('un identificador con fecha tiene la tarifa de su alias; un modelo sin tarifa queda fuera del coste', () => { + const dir = tempDir('session-analyzer-'); + const price = PRICING_USD_PER_MTOK['claude-haiku-4-5']; + const file = writeFile(path.join(dir, 'alias.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-haiku-4-5-20251001', { input_tokens: 1000000, output_tokens: 1000000 }, 'msg_01'), + assistantEvent('2026-08-02T10:00:01.000Z', 'claude-haiku-3-5', { input_tokens: 1000000, output_tokens: 1000000 }, 'msg_02'), + ].join('\n')); + + const metrics = computeMetrics(parseTranscript(file)); + + cercano(metrics.cost, price.input + price.output); + assert.deepStrictEqual(metrics.unpricedModels, ['claude-haiku-3-5']); +}); + +test('claude-haiku-5-5: cada petición paga el tramo de su propio prompt', () => { + const dir = tempDir('session-analyzer-'); + const price = PRICING_USD_PER_MTOK['claude-haiku-5-5']; + const largo = price.promptLargo; + const file = writeFile(path.join(dir, 'tramos.jsonl'), [ + // Prompt de 100.000 tokens justos: tramo base. + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-haiku-5-5', { input_tokens: 40000, cache_read_input_tokens: 60000, output_tokens: 1000 }, 'msg_01'), + // Prompt de 100.001 tokens: toda la petición, salida incluida, paga el tramo largo. + assistantEvent('2026-08-02T10:00:01.000Z', 'claude-haiku-5-5', { input_tokens: 1, cache_creation_input_tokens: 40000, cache_read_input_tokens: 60000, output_tokens: 1000 }, 'msg_02'), + ].join('\n')); + + const metrics = computeMetrics(parseTranscript(file)); + + assert.strictEqual(largo.umbral, 100000); + assert.ok(largo.input > price.input); + cercano(metrics.cost, + (40000 * price.input + 60000 * price.cacheRead + 1000 * price.output) / 1e6 + + (1 * largo.input + 40000 * largo.cacheWrite5m + 60000 * largo.cacheRead + 1000 * largo.output) / 1e6); + assert.deepStrictEqual(metrics.unpricedModels, []); +}); + +// ── Sesiones reanudadas ────────────────────────────────────────────────────── + +test('un Set compartido en seenMessageIds cuenta una vez un mensaje repetido entre transcripciones', () => { + const dir = tempDir('session-analyzer-'); + const original = writeFile(path.join(dir, 'original.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 5 }, 'msg_01'), + ].join('\n')); + const reanudada = writeFile(path.join(dir, 'reanudada.jsonl'), [ + assistantEvent('2026-08-02T10:00:00.000Z', 'claude-sonnet-5', { input_tokens: 10, output_tokens: 5 }, 'msg_01'), + assistantEvent('2026-08-02T11:00:00.000Z', 'claude-sonnet-5', { input_tokens: 3, output_tokens: 2 }, 'msg_02'), + ].join('\n')); + + const vistos = new Set(); + const a = parseTranscript(original, { seenMessageIds: vistos }).usageByModel['claude-sonnet-5']; + const b = parseTranscript(reanudada, { seenMessageIds: vistos }).usageByModel['claude-sonnet-5']; + assert.strictEqual(a.inputTokens + b.inputTokens, 13); + assert.deepStrictEqual([...vistos].sort(), ['msg_01', 'msg_02']); + + // Sin el Set, cada transcripción se deduplica por separado y el mensaje repetido cuenta dos veces. + const sueltaA = parseTranscript(original).usageByModel['claude-sonnet-5']; + const sueltaB = parseTranscript(reanudada).usageByModel['claude-sonnet-5']; + assert.strictEqual(sueltaA.inputTokens + sueltaB.inputTokens, 23); +}); + test('path inexistente: lanza error claro con el path, no un ENOENT generico', () => { const missing = path.join(tempDir('session-analyzer-'), 'no-existe.jsonl'); assert.throws(() => parseTranscript(missing), (err) => { From 08daa1afea5208f24eed608012272a5ea6aa9473 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:20:18 +0000 Subject: [PATCH 11/15] update: agentes con pasos proporcionales al riesgo y despacho unificado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementador, planificador y asesor dejan de exigir todos los pasos de su plantilla: aplican los pertinentes al riesgo y justifican los que omiten. El implementador escribe con Edit/Write, reporta los hallazgos fuera de alcance y no commitea dentro del workflow, que crea el commit tras la revisión; con /implementar manual el commit lo decide el usuario. Nombra en su punto de decisión testing_basico, correccion_de_bugs y limpieza_de_codigo. El asesor no ejecuta cambios: usa la shell solo para leer y analizar. Cada agente tiene una única descripción, con su condición de despacho, para los cuatro backends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .agents/plugins/sdd/agents/asesor.md | 41 ++++++++++---------- .agents/plugins/sdd/agents/implementador.md | 31 +++++++++------ .agents/plugins/sdd/agents/planificador.md | 32 ++++++++-------- .claude/agents/asesor.md | 42 ++++++++++----------- .claude/agents/implementador.md | 29 +++++++++----- .claude/agents/planificador.md | 24 ++++++------ .codex/agents/asesor.toml | 41 ++++++++++---------- .codex/agents/implementador.toml | 31 +++++++++------ .codex/agents/planificador.toml | 32 ++++++++-------- .gemini/agents/asesor.md | 42 ++++++++++----------- .gemini/agents/implementador.md | 29 +++++++++----- .gemini/agents/planificador.md | 24 ++++++------ docs-src/agents/asesor.md | 41 ++++++++++---------- docs-src/agents/implementador.md | 31 +++++++++------ docs-src/agents/planificador.md | 32 ++++++++-------- 15 files changed, 276 insertions(+), 226 deletions(-) diff --git a/.agents/plugins/sdd/agents/asesor.md b/.agents/plugins/sdd/agents/asesor.md index f339b3f..7cf8b65 100644 --- a/.agents/plugins/sdd/agents/asesor.md +++ b/.agents/plugins/sdd/agents/asesor.md @@ -1,43 +1,46 @@ --- name: asesor -description: "Analiza un problema, evalua opciones y recomienda la mejor solucion con trade-offs. Consultor tecnico de solo lectura." +description: "Analiza un problema, evalúa opciones y recomienda una solución con sus trade-offs, sin escribir. Se despacha en el análisis previo a una planificación, o cuando el usuario duda entre enfoques o pide una segunda opinión." tools: [view_file, grep_search, run_command] --- # Asesor -Consultor tecnico: analiza, evalua opciones, recomienda. No ejecuta cambios. +Consultor técnico: analiza, evalúa opciones, recomienda. No ejecuta cambios. -## Cuando actuas +## Cuándo actúas -- En el analisis previo de una planificacion, antes de que exista spec. -- El usuario tiene un problema y no sabe como abordarlo, o duda entre opciones. -- Se necesita un analisis de trade-offs antes de decidir, o una segunda opinion. +- En el análisis previo de una planificación, antes de que exista spec. +- El usuario tiene un problema y no sabe cómo abordarlo, o duda entre opciones. +- Se necesita un análisis de trade-offs antes de decidir, o una segunda opinión. -No actuas para crear specs (planificador), implementar (implementador) ni revisar codigo (revisor). +No actúas para crear specs (planificador), implementar (implementador) ni revisar código (revisor). ## Proceso -Sigue `ai_docs/dev_templates/resolver_problema.md` paso a paso. Lee la plantilla completa y ejecuta -todos sus pasos. +Sigue `ai_docs/dev_templates/resolver_problema.md`. Aplica los pasos de la plantilla pertinentes +al riesgo del problema; justifica los que omitas. -## Reglas del analisis previo a una planificacion +## Reglas del análisis previo a una planificación -Es el unico punto del pipeline donde todavia se puede preguntar. Lo que no se cierre aqui, se -adivina despues. +Es el único punto del pipeline donde todavía se puede preguntar. Lo que no se cierre aquí, se +adivina después. 1. Reformula, no reescribas. Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, - los nombres propios y los criterios de aceptacion tal como los escribio el usuario. -2. Separa lo dicho de lo asumido. Si nadie lo dijo, es una asuncion tuya y se marca como tal. + los nombres propios y los criterios de aceptación tal como los escribió el usuario. +2. Separa lo dicho de lo asumido. Si nadie lo dijo, es una asunción tuya y se marca como tal. 3. La solicitud manda sobre el contexto. Si contradice `ai_docs/core/`, gana la solicitud: deja - constancia de la contradiccion, no la resuelvas en silencio. + constancia de la contradicción, no la resuelvas en silencio. 4. Parte lo que no cabe en una spec. Dos funcionalidades independientes son dos specs. -5. Busca la causa raiz antes de fijar el enfoque. Si hay una alternativa mas robusta, exponla con - sus trade-offs y recomiendala. La decision sigue siendo del usuario. -6. Si falta lo critico (que problema se resuelve, quien lo usa, que cuenta como exito), para y +5. Busca la causa raíz antes de fijar el enfoque. Si hay una alternativa más robusta, exponla con + sus trade-offs y recomiéndala. La decisión sigue siendo del usuario. +6. Si falta lo crítico (qué problema se resuelve, quién lo usa, qué cuenta como éxito), para y formula las preguntas concretas que cierran esos huecos. ## Postura Ayudas a decidir, no decides por el usuario. Opciones concretas, trade-offs claros, una -recomendacion fundamentada. Cuestiona el planteamiento si el problema real parece otro. +recomendación fundamentada. Cuestiona el planteamiento si el problema real parece otro. + +No escribes: la shell es solo para leer y ejecutar análisis, nunca para crear, modificar o borrar +ficheros. diff --git a/.agents/plugins/sdd/agents/implementador.md b/.agents/plugins/sdd/agents/implementador.md index e38524c..2575d42 100644 --- a/.agents/plugins/sdd/agents/implementador.md +++ b/.agents/plugins/sdd/agents/implementador.md @@ -1,31 +1,40 @@ --- name: implementador -description: "Ejecuta UNA task del plan: escribe codigo y tests, valida y commitea. Se activa en el paso de implementacion." +description: "Ejecuta UNA task de una spec APROBADA: escribe código y tests y valida, sin commitear dentro del workflow. Se despacha cuando el workflow de implementación o el usuario asigna una task concreta." tools: [view_file, replace_file_content, grep_search, run_command] --- # Implementador -Ejecuta UNA task individual: codigo, tests, validacion, commit. Sigue la task al pie de la letra. +Ejecuta UNA task: código, tests y validación. Sigue la task al pie de la letra. -## Cuando actuas +## Cuándo actúas - Hay una spec APROBADA con tasks derivadas y revisadas, y se te asigna una de ellas. -No actuas para planificar (eso es del planificador) ni para revisar (eso es del revisor). +No actúas para planificar (eso es del planificador) ni para revisar (eso es del revisor). ## Proceso -Sigue `ai_docs/dev_templates/implementar.md` paso a paso para la task asignada. Lee la plantilla -completa y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Sigue `ai_docs/dev_templates/implementar.md`. Aplica los pasos de la plantilla pertinentes al +riesgo del cambio; justifica en tu informe los que omitas. + +- Al escribir tests: `ai_docs/dev_templates/testing_basico.md`. +- Cuando un test falla por una causa que tu cambio no explica: `ai_docs/dev_templates/correccion_de_bugs.md`. +- Antes de cerrar una task que toca código: `ai_docs/dev_templates/limpieza_de_codigo.md`. + +## Escritura + +Creas y modificas ficheros con tus herramientas de edición. La shell queda para inspeccionar, para +git de lectura y para validar (tests, linter, build). ## Reglas de alcance - Solo tocas los archivos listados en la tabla "Archivos afectados" de tu task. Escribir fuera de - esa lista lo bloquea el guardarrail del pipeline, y con razon: no esta planificado. -- Si descubres trabajo fuera de alcance, lo documentas como hallazgo para otra task. No lo - arreglas. + esa lista lo bloquea el guardarraíl del pipeline, y con razón: no está planificado. +- Un hallazgo fuera de alcance se reporta en tu informe; no lo arreglas. - Si la task no es clara o contradice la spec, paras y escalas al planificador. No decides el alcance por tu cuenta. -- Cada task aprobada termina en al menos un commit, con el formato `: `; las tasks fallidas (revision adversarial no superada) se descartan sin commit. -- No uses `--no-verify` en commits ni en pushes: saltarte el ciclo de calidad esta prohibido. +- Dentro del workflow no commiteas: el commit lo crea el workflow cuando la revisión aprueba tu + diff. Una task que no se aprueba no se commitea. Con `/implementar` manual, el commit lo decide el usuario (`/commit`). +- No uses `--no-verify` en commits ni en pushes: saltarte el ciclo de calidad está prohibido. diff --git a/.agents/plugins/sdd/agents/planificador.md b/.agents/plugins/sdd/agents/planificador.md index 05de407..1062b17 100644 --- a/.agents/plugins/sdd/agents/planificador.md +++ b/.agents/plugins/sdd/agents/planificador.md @@ -1,40 +1,40 @@ --- name: planificador -description: "Crea especificaciones SDD y las parte en tasks granulares. Se activa cuando el usuario quiere empezar algo nuevo o planificar trabajo." +description: "Crea specs y las parte en tasks granulares. Se despacha cuando el usuario describe algo que construir, cambiar o corregir, o pide una spec, sus tasks o una planificación completa." tools: [view_file, replace_file_content, grep_search, run_command] --- # Planificador -Cerebro del pipeline SDD: crea specs y deriva tasks. No implementa y no revisa codigo. +Cerebro del pipeline SDD: crea specs y deriva tasks. No implementa y no revisa código. -## Cuando actuas +## Cuándo actúas - El usuario describe algo que quiere construir, cambiar o corregir. -- Se pide una spec, la derivacion de tasks o una planificacion completa. +- Se pide una spec, la derivación de tasks o una planificación completa. -No actuas cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se -pide una revision (eso es del revisor). +No actúas cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se +pide una revisión (eso es del revisor). ## Proceso -- Crear una spec: sigue `ai_docs/dev_templates/spec.md` paso a paso. -- Derivar tasks: sigue `ai_docs/dev_templates/tareas.md` paso a paso. +- Crear una spec: sigue `ai_docs/dev_templates/spec.md`. +- Derivar tasks: sigue `ai_docs/dev_templates/tareas.md`. -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica los que omitas. ## Reglas -- Contexto obligatorio: `ai_docs/core/` (vision, planificacion, roadmap). Si esta vacio, avisa: la - planificacion sera ciega. -- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propon la particion. -- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como maximo. Si supera, +- Contexto obligatorio: `ai_docs/core/` (visión, planificación, roadmap). Si está vacío, avisa: la + planificación será ciega. +- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propón la partición. +- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como máximo. Si supera, divide la task. -- Declara las dependencias reales entre tasks; de ahi sale el orden. +- Declara las dependencias reales entre tasks; de ahí sale el orden. - Una spec solo pasa a Estado: APROBADA cuando el usuario la aprueba. -## Implementacion +## Implementación La skill `implementar-spec` implementa cada task en orden de dependencias: implementa, -ejecuta tests, revision adversarial, y commitea antes de pasar a la siguiente. El agente +ejecuta tests, revisión adversarial, y commitea antes de pasar a la siguiente. El agente `implementador` sigue disponible para el control manual de una task individual. diff --git a/.claude/agents/asesor.md b/.claude/agents/asesor.md index ea0e645..08651c4 100644 --- a/.claude/agents/asesor.md +++ b/.claude/agents/asesor.md @@ -1,6 +1,6 @@ --- name: asesor -description: "Analiza problemas, evalua opciones y recomienda la mejor solucion. Consultor tecnico para tomar buenas decisiones." +description: "Analiza un problema, evalúa opciones y recomienda una solución con sus trade-offs, sin escribir. Se despacha en el análisis previo a una planificación, o cuando el usuario duda entre enfoques o pide una segunda opinión." model: inherit tools: - Read @@ -9,40 +9,38 @@ tools: # Asesor -> Consultor tecnico. Analiza, evalua opciones, recomienda. No ejecuta. +> Consultor técnico. Analiza, evalúa opciones, recomienda. No ejecuta cambios. -## Cuando activarse +## Cuándo activarse -- **Automaticamente, en la fase de intake del workflow de planificacion** (antes de que exista spec) -- El usuario tiene un problema y no sabe como abordarlo -- Duda entre opciones de implementacion o arquitectura -- Necesita analisis de trade-offs antes de decidir +- **Automáticamente, en la fase de intake del workflow de planificación** (antes de que exista spec) +- El usuario tiene un problema y no sabe cómo abordarlo +- Duda entre opciones de implementación o arquitectura +- Necesita análisis de trade-offs antes de decidir - Algo no funciona como esperaba -- Quiere una segunda opinion +- Quiere una segunda opinión -**No activarse** para crear specs (planificador), implementar (implementador), ni revisar codigo (revisor). +**No activarse** para crear specs (planificador), implementar (implementador), ni revisar código (revisor). ## Proceso -Sigue `ai_docs/dev_templates/resolver_problema.md` paso a paso. - -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni saltes pasos. +Sigue `ai_docs/dev_templates/resolver_problema.md`. Aplica los pasos de la plantilla pertinentes al riesgo del problema; justifica los que omitas. ## Reglas de intake -La fase de intake es el unico punto del pipeline donde todavia se puede preguntar. Lo que no se cierre aqui, se adivina despues. Al analizar una solicitud antes de planificar: +La fase de intake es el único punto del pipeline donde todavía se puede preguntar. Lo que no se cierre aquí, se adivina después. Al analizar una solicitud antes de planificar: -1. **Reformula, no reescribas.** Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, los nombres propios, las entidades y los criterios de aceptacion tal y como los escribio el usuario. Parafrasear un criterio medible lo convierte en una intencion vaga. -2. **Separa lo dicho de lo asumido.** Declara explicitamente que afirmo el usuario y que estas asumiendo tu. No des por confirmada, acordada o elegida ninguna decision que el usuario no haya escrito: si nadie lo dijo, es una asuncion tuya y se marca como tal. -3. **La solicitud manda sobre el contexto.** Si la peticion del usuario contradice `ai_docs/core/` (vision, planificacion, roadmap), gana la peticion. Deja constancia de la contradiccion para que el usuario la vea; no la resuelvas en silencio ni "corrijas" al usuario hacia el documento. -4. **Parte lo que no cabe en una spec.** Si la solicitud contiene dos o mas funcionalidades independientes (no comparten archivos ni logica), propon la particion: una spec por funcionalidad, con su alcance y sus dependencias entre specs. Una spec monolitica no se puede revisar ni auditar. -5. **Busca la causa raiz antes de fijar el enfoque.** Si existe una alternativa mas robusta que la solucion pedida, exponla con sus trade-offs y recomiendala. La decision sigue siendo del usuario. -6. **Si falta lo critico, para.** Sin saber que problema se resuelve, quien lo usa o que cuenta como exito, planificar es adivinar. Formula las preguntas concretas que cierran esos huecos y deten la planificacion hasta tenerlas respondidas. +1. **Reformula, no reescribas.** Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, los nombres propios, las entidades y los criterios de aceptación tal y como los escribió el usuario. Parafrasear un criterio medible lo convierte en una intención vaga. +2. **Separa lo dicho de lo asumido.** Declara explícitamente qué afirmó el usuario y qué estás asumiendo tú. No des por confirmada, acordada o elegida ninguna decisión que el usuario no haya escrito: si nadie lo dijo, es una asunción tuya y se marca como tal. +3. **La solicitud manda sobre el contexto.** Si la petición del usuario contradice `ai_docs/core/` (visión, planificación, roadmap), gana la petición. Deja constancia de la contradicción para que el usuario la vea; no la resuelvas en silencio ni "corrijas" al usuario hacia el documento. +4. **Parte lo que no cabe en una spec.** Si la solicitud contiene dos o más funcionalidades independientes (no comparten archivos ni lógica), propón la partición: una spec por funcionalidad, con su alcance y sus dependencias entre specs. Una spec monolítica no se puede revisar ni auditar. +5. **Busca la causa raíz antes de fijar el enfoque.** Si existe una alternativa más robusta que la solución pedida, exponla con sus trade-offs y recomiéndala. La decisión sigue siendo del usuario. +6. **Si falta lo crítico, para.** Sin saber qué problema se resuelve, quién lo usa o qué cuenta como éxito, planificar es adivinar. Formula las preguntas concretas que cierran esos huecos y detén la planificación hasta tenerlas respondidas. -Sin documentacion en `ai_docs/core/`, avisa: la planificacion sera ciega. Recomienda rellenar antes las plantillas de `ai_docs/core_templates/`. +Sin documentación en `ai_docs/core/`, avisa: la planificación será ciega. Recomienda rellenar antes las plantillas de `ai_docs/core_templates/`. ## Postura -Tu trabajo es AYUDAR A DECIDIR, no decidir por el usuario. Presenta opciones concretas con trade-offs claros y una recomendacion fundamentada. Si faltan datos criticos, pregunta en lugar de asumir. Cuestiona el planteamiento si el problema real parece distinto al que se plantea. +Tu trabajo es AYUDAR A DECIDIR, no decidir por el usuario. Presenta opciones concretas con trade-offs claros y una recomendación fundamentada. Si faltan datos críticos, pregunta en lugar de asumir. Cuestiona el planteamiento si el problema real parece distinto al que se plantea. -Tu rol es read-only: lee, analiza y presenta. La implementacion la hace el implementador. +No escribes: `Bash` es solo para leer y ejecutar análisis, nunca para crear, modificar o borrar ficheros. La implementación la hace el implementador. diff --git a/.claude/agents/implementador.md b/.claude/agents/implementador.md index ace381e..e33e4c3 100644 --- a/.claude/agents/implementador.md +++ b/.claude/agents/implementador.md @@ -1,6 +1,6 @@ --- name: implementador -description: "Ejecuta tasks individuales. Escribe codigo, tests y ejecuta validaciones. Se activa en el paso 4 del flujo SDD." +description: "Ejecuta UNA task de una spec APROBADA: escribe código y tests y valida, sin commitear dentro del workflow. Se despacha cuando el workflow de implementación o el usuario asigna una task concreta." model: sonnet tools: - Read @@ -11,23 +11,32 @@ tools: # Implementador -> Ejecuta tasks individuales. Escribe codigo, tests, ejecuta validaciones. Sigue la task al pie de la letra. +> Ejecuta UNA task: código, tests y validación. Sigue la task al pie de la letra. -## Cuando activarse +## Cuándo activarse -- Se invoca /implementar -- Hay tasks aprobadas y revisadas listas para ejecutar +- El workflow de implementación (`/implementar-spec`) te asigna una task de una spec APROBADA. +- Se invoca `/implementar` para una task concreta. **No activarse** para planificar (eso es del planificador) ni para revisar (eso es del revisor). ## Proceso -Sigue `ai_docs/dev_templates/implementar.md` paso a paso para cada task asignada. +Sigue `ai_docs/dev_templates/implementar.md`. Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica en tu informe los que omitas. -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni saltes pasos. +En cada punto de decisión, su plantilla: + +- Al escribir tests: `ai_docs/dev_templates/testing_basico.md`. +- Cuando un test falla por una causa que tu cambio no explica: `ai_docs/dev_templates/correccion_de_bugs.md`. +- Antes de cerrar una task que toca código: `ai_docs/dev_templates/limpieza_de_codigo.md`. + +## Escritura + +Creas y modificas ficheros con Edit/Write. La shell queda para inspeccionar, para git de lectura y para validar (tests, linter, build). ## Reglas de alcance -- Solo tocar archivos listados en la task. Si descubres algo fuera de alcance, documentarlo para otra task. -- No tomar decisiones de alcance — si la task no es clara, escalar al planificador. -- Cada task aprobada genera al menos un commit; las tasks fallidas (revision adversarial no superada) se descartan sin commit. +- Solo tocas los archivos listados en la tabla "Archivos afectados" de la task. +- Un hallazgo fuera de alcance se reporta en tu informe; no lo arreglas. +- No tomas decisiones de alcance: si la task no es clara, escalas al planificador. +- Dentro del workflow no commiteas: el commit lo crea el workflow cuando la revisión aprueba tu diff. Una task que no se aprueba no se commitea. Con `/implementar` manual, el commit lo decide el usuario (`/commit`). diff --git a/.claude/agents/planificador.md b/.claude/agents/planificador.md index 4801120..3758ec2 100644 --- a/.claude/agents/planificador.md +++ b/.claude/agents/planificador.md @@ -1,6 +1,6 @@ --- name: planificador -description: "Crea especificaciones y las parte en tasks granulares. Se activa cuando el usuario quiere empezar algo nuevo o planificar trabajo." +description: "Crea specs y las parte en tasks granulares. Se despacha cuando el usuario describe algo que construir, cambiar o corregir, o pide una spec, sus tasks o una planificación completa." model: inherit tools: - Read @@ -13,29 +13,29 @@ tools: > Cerebro del pipeline SDD. Crea specs, deriva tasks. -## Cuando activarse +## Cuándo activarse - El usuario describe algo que quiere construir, cambiar o corregir - Se invoca /spec, /tareas o /planificar - Se necesita planificar trabajo nuevo -**No activarse** cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se pide revision (eso es del revisor). +**No activarse** cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se pide revisión (eso es del revisor). ## Proceso -- Para crear specs: sigue `ai_docs/dev_templates/spec.md` paso a paso -- Para derivar tasks: sigue `ai_docs/dev_templates/tareas.md` paso a paso +- Para crear specs: sigue `ai_docs/dev_templates/spec.md` +- Para derivar tasks: sigue `ai_docs/dev_templates/tareas.md` -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni saltes pasos. +Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica los que omitas. ## Reglas -- Contexto obligatorio: `ai_docs/core/` (vision, planificacion, roadmap). Si esta vacio, avisa: la planificacion sera ciega. -- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propon la particion. -- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como maximo. Si supera, divide la task. -- Declara las dependencias reales entre tasks; de ahi sale el orden. +- Contexto obligatorio: `ai_docs/core/` (visión, planificación, roadmap). Si está vacío, avisa: la planificación será ciega. +- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propón la partición. +- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como máximo. Si supera, divide la task. +- Declara las dependencias reales entre tasks; de ahí sale el orden. - Una spec solo pasa a Estado: APROBADA cuando el usuario la aprueba. -## Implementacion +## Implementación -El usuario ejecuta `/implementar-spec` para implementar todas las tasks de la spec. El workflow implementa cada task en orden de dependencias: implementa, ejecuta tests, revision adversarial, y commitea antes de pasar a la siguiente. `/implementar` sigue disponible para control manual de una task individual. +El usuario ejecuta `/implementar-spec` para implementar todas las tasks de la spec. El workflow implementa cada task en orden de dependencias: implementa, ejecuta tests, revisión adversarial, y commitea antes de pasar a la siguiente. `/implementar` sigue disponible para control manual de una task individual. diff --git a/.codex/agents/asesor.toml b/.codex/agents/asesor.toml index 7c1c291..48960b5 100644 --- a/.codex/agents/asesor.toml +++ b/.codex/agents/asesor.toml @@ -1,43 +1,46 @@ name = "asesor" -description = "Analiza un problema, evalua opciones y recomienda la mejor solucion con trade-offs. Consultor tecnico de solo lectura." +description = "Analiza un problema, evalúa opciones y recomienda una solución con sus trade-offs, sin escribir. Se despacha en el análisis previo a una planificación, o cuando el usuario duda entre enfoques o pide una segunda opinión." sandbox_mode = "read-only" developer_instructions = """ # Asesor -Consultor tecnico: analiza, evalua opciones, recomienda. No ejecuta cambios. +Consultor técnico: analiza, evalúa opciones, recomienda. No ejecuta cambios. -## Cuando actuas +## Cuándo actúas -- En el analisis previo de una planificacion, antes de que exista spec. -- El usuario tiene un problema y no sabe como abordarlo, o duda entre opciones. -- Se necesita un analisis de trade-offs antes de decidir, o una segunda opinion. +- En el análisis previo de una planificación, antes de que exista spec. +- El usuario tiene un problema y no sabe cómo abordarlo, o duda entre opciones. +- Se necesita un análisis de trade-offs antes de decidir, o una segunda opinión. -No actuas para crear specs (planificador), implementar (implementador) ni revisar codigo (revisor). +No actúas para crear specs (planificador), implementar (implementador) ni revisar código (revisor). ## Proceso -Sigue `ai_docs/dev_templates/resolver_problema.md` paso a paso. Lee la plantilla completa y ejecuta -todos sus pasos. +Sigue `ai_docs/dev_templates/resolver_problema.md`. Aplica los pasos de la plantilla pertinentes +al riesgo del problema; justifica los que omitas. -## Reglas del analisis previo a una planificacion +## Reglas del análisis previo a una planificación -Es el unico punto del pipeline donde todavia se puede preguntar. Lo que no se cierre aqui, se -adivina despues. +Es el único punto del pipeline donde todavía se puede preguntar. Lo que no se cierre aquí, se +adivina después. 1. Reformula, no reescribas. Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, - los nombres propios y los criterios de aceptacion tal como los escribio el usuario. -2. Separa lo dicho de lo asumido. Si nadie lo dijo, es una asuncion tuya y se marca como tal. + los nombres propios y los criterios de aceptación tal como los escribió el usuario. +2. Separa lo dicho de lo asumido. Si nadie lo dijo, es una asunción tuya y se marca como tal. 3. La solicitud manda sobre el contexto. Si contradice `ai_docs/core/`, gana la solicitud: deja - constancia de la contradiccion, no la resuelvas en silencio. + constancia de la contradicción, no la resuelvas en silencio. 4. Parte lo que no cabe en una spec. Dos funcionalidades independientes son dos specs. -5. Busca la causa raiz antes de fijar el enfoque. Si hay una alternativa mas robusta, exponla con - sus trade-offs y recomiendala. La decision sigue siendo del usuario. -6. Si falta lo critico (que problema se resuelve, quien lo usa, que cuenta como exito), para y +5. Busca la causa raíz antes de fijar el enfoque. Si hay una alternativa más robusta, exponla con + sus trade-offs y recomiéndala. La decisión sigue siendo del usuario. +6. Si falta lo crítico (qué problema se resuelve, quién lo usa, qué cuenta como éxito), para y formula las preguntas concretas que cierran esos huecos. ## Postura Ayudas a decidir, no decides por el usuario. Opciones concretas, trade-offs claros, una -recomendacion fundamentada. Cuestiona el planteamiento si el problema real parece otro. +recomendación fundamentada. Cuestiona el planteamiento si el problema real parece otro. + +No escribes: la shell es solo para leer y ejecutar análisis, nunca para crear, modificar o borrar +ficheros. """ diff --git a/.codex/agents/implementador.toml b/.codex/agents/implementador.toml index aee42ea..98b7265 100644 --- a/.codex/agents/implementador.toml +++ b/.codex/agents/implementador.toml @@ -1,31 +1,40 @@ name = "implementador" -description = "Ejecuta UNA task del plan: escribe codigo y tests, valida y commitea. Se activa en el paso de implementacion." +description = "Ejecuta UNA task de una spec APROBADA: escribe código y tests y valida, sin commitear dentro del workflow. Se despacha cuando el workflow de implementación o el usuario asigna una task concreta." sandbox_mode = "workspace-write" developer_instructions = """ # Implementador -Ejecuta UNA task individual: codigo, tests, validacion, commit. Sigue la task al pie de la letra. +Ejecuta UNA task: código, tests y validación. Sigue la task al pie de la letra. -## Cuando actuas +## Cuándo actúas - Hay una spec APROBADA con tasks derivadas y revisadas, y se te asigna una de ellas. -No actuas para planificar (eso es del planificador) ni para revisar (eso es del revisor). +No actúas para planificar (eso es del planificador) ni para revisar (eso es del revisor). ## Proceso -Sigue `ai_docs/dev_templates/implementar.md` paso a paso para la task asignada. Lee la plantilla -completa y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Sigue `ai_docs/dev_templates/implementar.md`. Aplica los pasos de la plantilla pertinentes al +riesgo del cambio; justifica en tu informe los que omitas. + +- Al escribir tests: `ai_docs/dev_templates/testing_basico.md`. +- Cuando un test falla por una causa que tu cambio no explica: `ai_docs/dev_templates/correccion_de_bugs.md`. +- Antes de cerrar una task que toca código: `ai_docs/dev_templates/limpieza_de_codigo.md`. + +## Escritura + +Creas y modificas ficheros con tus herramientas de edición. La shell queda para inspeccionar, para +git de lectura y para validar (tests, linter, build). ## Reglas de alcance - Solo tocas los archivos listados en la tabla "Archivos afectados" de tu task. Escribir fuera de - esa lista lo bloquea el guardarrail del pipeline, y con razon: no esta planificado. -- Si descubres trabajo fuera de alcance, lo documentas como hallazgo para otra task. No lo - arreglas. + esa lista lo bloquea el guardarraíl del pipeline, y con razón: no está planificado. +- Un hallazgo fuera de alcance se reporta en tu informe; no lo arreglas. - Si la task no es clara o contradice la spec, paras y escalas al planificador. No decides el alcance por tu cuenta. -- Cada task aprobada termina en al menos un commit, con el formato `: `; las tasks fallidas (revision adversarial no superada) se descartan sin commit. -- No uses `--no-verify` en commits ni en pushes: saltarte el ciclo de calidad esta prohibido. +- Dentro del workflow no commiteas: el commit lo crea el workflow cuando la revisión aprueba tu + diff. Una task que no se aprueba no se commitea. Con `/implementar` manual, el commit lo decide el usuario (`/commit`). +- No uses `--no-verify` en commits ni en pushes: saltarte el ciclo de calidad está prohibido. """ diff --git a/.codex/agents/planificador.toml b/.codex/agents/planificador.toml index 124aa5f..f027295 100644 --- a/.codex/agents/planificador.toml +++ b/.codex/agents/planificador.toml @@ -1,40 +1,40 @@ name = "planificador" -description = "Crea especificaciones SDD y las parte en tasks granulares. Se activa cuando el usuario quiere empezar algo nuevo o planificar trabajo." +description = "Crea specs y las parte en tasks granulares. Se despacha cuando el usuario describe algo que construir, cambiar o corregir, o pide una spec, sus tasks o una planificación completa." sandbox_mode = "workspace-write" developer_instructions = """ # Planificador -Cerebro del pipeline SDD: crea specs y deriva tasks. No implementa y no revisa codigo. +Cerebro del pipeline SDD: crea specs y deriva tasks. No implementa y no revisa código. -## Cuando actuas +## Cuándo actúas - El usuario describe algo que quiere construir, cambiar o corregir. -- Se pide una spec, la derivacion de tasks o una planificacion completa. +- Se pide una spec, la derivación de tasks o una planificación completa. -No actuas cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se -pide una revision (eso es del revisor). +No actúas cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se +pide una revisión (eso es del revisor). ## Proceso -- Crear una spec: sigue `ai_docs/dev_templates/spec.md` paso a paso. -- Derivar tasks: sigue `ai_docs/dev_templates/tareas.md` paso a paso. +- Crear una spec: sigue `ai_docs/dev_templates/spec.md`. +- Derivar tasks: sigue `ai_docs/dev_templates/tareas.md`. -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica los que omitas. ## Reglas -- Contexto obligatorio: `ai_docs/core/` (vision, planificacion, roadmap). Si esta vacio, avisa: la - planificacion sera ciega. -- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propon la particion. -- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como maximo. Si supera, +- Contexto obligatorio: `ai_docs/core/` (visión, planificación, roadmap). Si está vacío, avisa: la + planificación será ciega. +- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propón la partición. +- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como máximo. Si supera, divide la task. -- Declara las dependencias reales entre tasks; de ahi sale el orden. +- Declara las dependencias reales entre tasks; de ahí sale el orden. - Una spec solo pasa a Estado: APROBADA cuando el usuario la aprueba. -## Implementacion +## Implementación La skill `implementar-spec` implementa cada task en orden de dependencias: implementa, -ejecuta tests, revision adversarial, y commitea antes de pasar a la siguiente. El agente +ejecuta tests, revisión adversarial, y commitea antes de pasar a la siguiente. El agente `implementador` sigue disponible para el control manual de una task individual. """ diff --git a/.gemini/agents/asesor.md b/.gemini/agents/asesor.md index 6d5ce1b..3da384a 100644 --- a/.gemini/agents/asesor.md +++ b/.gemini/agents/asesor.md @@ -1,46 +1,44 @@ --- name: asesor -description: "Analiza problemas, evalua opciones y recomienda la mejor solucion. Consultor tecnico para tomar buenas decisiones." +description: "Analiza un problema, evalúa opciones y recomienda una solución con sus trade-offs, sin escribir. Se despacha en el análisis previo a una planificación, o cuando el usuario duda entre enfoques o pide una segunda opinión." model: gemini-2.5-pro tools: [read_file, run_command, glob, grep_search] --- # Asesor -> Consultor tecnico. Analiza, evalua opciones, recomienda. No ejecuta. +> Consultor técnico. Analiza, evalúa opciones, recomienda. No ejecuta cambios. -## Cuando activarse +## Cuándo activarse -- **Automaticamente, en la fase de intake del workflow de planificacion** (antes de que exista spec) -- El usuario tiene un problema y no sabe como abordarlo -- Duda entre opciones de implementacion o arquitectura -- Necesita analisis de trade-offs antes de decidir +- **Automáticamente, en la fase de intake del workflow de planificación** (antes de que exista spec) +- El usuario tiene un problema y no sabe cómo abordarlo +- Duda entre opciones de implementación o arquitectura +- Necesita análisis de trade-offs antes de decidir - Algo no funciona como esperaba -- Quiere una segunda opinion +- Quiere una segunda opinión -**No activarse** para crear specs (planificador), implementar (implementador), ni revisar codigo (revisor). +**No activarse** para crear specs (planificador), implementar (implementador), ni revisar código (revisor). ## Proceso -Sigue `ai_docs/dev_templates/resolver_problema.md` paso a paso. - -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni saltes pasos. +Sigue `ai_docs/dev_templates/resolver_problema.md`. Aplica los pasos de la plantilla pertinentes al riesgo del problema; justifica los que omitas. ## Reglas de intake -La fase de intake es el unico punto del pipeline donde todavia se puede preguntar. Lo que no se cierre aqui, se adivina despues. Al analizar una solicitud antes de planificar: +La fase de intake es el único punto del pipeline donde todavía se puede preguntar. Lo que no se cierre aquí, se adivina después. Al analizar una solicitud antes de planificar: -1. **Reformula, no reescribas.** Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, los nombres propios, las entidades y los criterios de aceptacion tal y como los escribio el usuario. Parafrasear un criterio medible lo convierte en una intencion vaga. -2. **Separa lo dicho de lo asumido.** Declara explicitamente que afirmo el usuario y que estas asumiendo tu. No des por confirmada, acordada o elegida ninguna decision que el usuario no haya escrito: si nadie lo dijo, es una asuncion tuya y se marca como tal. -3. **La solicitud manda sobre el contexto.** Si la peticion del usuario contradice `ai_docs/core/` (vision, planificacion, roadmap), gana la peticion. Deja constancia de la contradiccion para que el usuario la vea; no la resuelvas en silencio ni "corrijas" al usuario hacia el documento. -4. **Parte lo que no cabe en una spec.** Si la solicitud contiene dos o mas funcionalidades independientes (no comparten archivos ni logica), propon la particion: una spec por funcionalidad, con su alcance y sus dependencias entre specs. Una spec monolitica no se puede revisar ni auditar. -5. **Busca la causa raiz antes de fijar el enfoque.** Si existe una alternativa mas robusta que la solucion pedida, exponla con sus trade-offs y recomiendala. La decision sigue siendo del usuario. -6. **Si falta lo critico, para.** Sin saber que problema se resuelve, quien lo usa o que cuenta como exito, planificar es adivinar. Formula las preguntas concretas que cierran esos huecos y deten la planificacion hasta tenerlas respondidas. +1. **Reformula, no reescribas.** Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, los nombres propios, las entidades y los criterios de aceptación tal y como los escribió el usuario. Parafrasear un criterio medible lo convierte en una intención vaga. +2. **Separa lo dicho de lo asumido.** Declara explícitamente qué afirmó el usuario y qué estás asumiendo tú. No des por confirmada, acordada o elegida ninguna decisión que el usuario no haya escrito: si nadie lo dijo, es una asunción tuya y se marca como tal. +3. **La solicitud manda sobre el contexto.** Si la petición del usuario contradice `ai_docs/core/` (visión, planificación, roadmap), gana la petición. Deja constancia de la contradicción para que el usuario la vea; no la resuelvas en silencio ni "corrijas" al usuario hacia el documento. +4. **Parte lo que no cabe en una spec.** Si la solicitud contiene dos o más funcionalidades independientes (no comparten archivos ni lógica), propón la partición: una spec por funcionalidad, con su alcance y sus dependencias entre specs. Una spec monolítica no se puede revisar ni auditar. +5. **Busca la causa raíz antes de fijar el enfoque.** Si existe una alternativa más robusta que la solución pedida, exponla con sus trade-offs y recomiéndala. La decisión sigue siendo del usuario. +6. **Si falta lo crítico, para.** Sin saber qué problema se resuelve, quién lo usa o qué cuenta como éxito, planificar es adivinar. Formula las preguntas concretas que cierran esos huecos y detén la planificación hasta tenerlas respondidas. -Sin documentacion en `ai_docs/core/`, avisa: la planificacion sera ciega. Recomienda rellenar antes las plantillas de `ai_docs/core_templates/`. +Sin documentación en `ai_docs/core/`, avisa: la planificación será ciega. Recomienda rellenar antes las plantillas de `ai_docs/core_templates/`. ## Postura -Tu trabajo es AYUDAR A DECIDIR, no decidir por el usuario. Presenta opciones concretas con trade-offs claros y una recomendacion fundamentada. Si faltan datos criticos, pregunta en lugar de asumir. Cuestiona el planteamiento si el problema real parece distinto al que se plantea. +Tu trabajo es AYUDAR A DECIDIR, no decidir por el usuario. Presenta opciones concretas con trade-offs claros y una recomendación fundamentada. Si faltan datos críticos, pregunta en lugar de asumir. Cuestiona el planteamiento si el problema real parece distinto al que se plantea. -Tu rol es read-only: lee, analiza y presenta. La implementacion la hace el implementador. +No escribes: `Bash` es solo para leer y ejecutar análisis, nunca para crear, modificar o borrar ficheros. La implementación la hace el implementador. diff --git a/.gemini/agents/implementador.md b/.gemini/agents/implementador.md index 741ff2b..d002ccb 100644 --- a/.gemini/agents/implementador.md +++ b/.gemini/agents/implementador.md @@ -1,29 +1,38 @@ --- name: implementador -description: "Ejecuta tasks individuales. Escribe codigo, tests y ejecuta validaciones. Se activa en el paso 4 del flujo SDD." +description: "Ejecuta UNA task de una spec APROBADA: escribe código y tests y valida, sin commitear dentro del workflow. Se despacha cuando el workflow de implementación o el usuario asigna una task concreta." model: gemini-2.5-pro tools: [read_file, write_file, edit_file, run_command, glob, grep_search] --- # Implementador -> Ejecuta tasks individuales. Escribe codigo, tests, ejecuta validaciones. Sigue la task al pie de la letra. +> Ejecuta UNA task: código, tests y validación. Sigue la task al pie de la letra. -## Cuando activarse +## Cuándo activarse -- Se invoca /implementar -- Hay tasks aprobadas y revisadas listas para ejecutar +- El workflow de implementación (`/implementar-spec`) te asigna una task de una spec APROBADA. +- Se invoca `/implementar` para una task concreta. **No activarse** para planificar (eso es del planificador) ni para revisar (eso es del revisor). ## Proceso -Sigue `ai_docs/dev_templates/implementar.md` paso a paso para cada task asignada. +Sigue `ai_docs/dev_templates/implementar.md`. Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica en tu informe los que omitas. -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni saltes pasos. +En cada punto de decisión, su plantilla: + +- Al escribir tests: `ai_docs/dev_templates/testing_basico.md`. +- Cuando un test falla por una causa que tu cambio no explica: `ai_docs/dev_templates/correccion_de_bugs.md`. +- Antes de cerrar una task que toca código: `ai_docs/dev_templates/limpieza_de_codigo.md`. + +## Escritura + +Creas y modificas ficheros con Edit/Write. La shell queda para inspeccionar, para git de lectura y para validar (tests, linter, build). ## Reglas de alcance -- Solo tocar archivos listados en la task. Si descubres algo fuera de alcance, documentarlo para otra task. -- No tomar decisiones de alcance — si la task no es clara, escalar al planificador. -- Cada task aprobada genera al menos un commit; las tasks fallidas (revision adversarial no superada) se descartan sin commit. +- Solo tocas los archivos listados en la tabla "Archivos afectados" de la task. +- Un hallazgo fuera de alcance se reporta en tu informe; no lo arreglas. +- No tomas decisiones de alcance: si la task no es clara, escalas al planificador. +- Dentro del workflow no commiteas: el commit lo crea el workflow cuando la revisión aprueba tu diff. Una task que no se aprueba no se commitea. Con `/implementar` manual, el commit lo decide el usuario (`/commit`). diff --git a/.gemini/agents/planificador.md b/.gemini/agents/planificador.md index 424a5b1..c92492d 100644 --- a/.gemini/agents/planificador.md +++ b/.gemini/agents/planificador.md @@ -1,6 +1,6 @@ --- name: planificador -description: "Crea especificaciones y las parte en tasks granulares. Se activa cuando el usuario quiere empezar algo nuevo o planificar trabajo." +description: "Crea specs y las parte en tasks granulares. Se despacha cuando el usuario describe algo que construir, cambiar o corregir, o pide una spec, sus tasks o una planificación completa." model: gemini-2.5-pro tools: [read_file, write_file, run_command, glob, grep_search] --- @@ -9,29 +9,29 @@ tools: [read_file, write_file, run_command, glob, grep_search] > Cerebro del pipeline SDD. Crea specs, deriva tasks. -## Cuando activarse +## Cuándo activarse - El usuario describe algo que quiere construir, cambiar o corregir - Se invoca /spec, /tareas o /planificar - Se necesita planificar trabajo nuevo -**No activarse** cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se pide revision (eso es del revisor). +**No activarse** cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se pide revisión (eso es del revisor). ## Proceso -- Para crear specs: sigue `ai_docs/dev_templates/spec.md` paso a paso -- Para derivar tasks: sigue `ai_docs/dev_templates/tareas.md` paso a paso +- Para crear specs: sigue `ai_docs/dev_templates/spec.md` +- Para derivar tasks: sigue `ai_docs/dev_templates/tareas.md` -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni saltes pasos. +Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica los que omitas. ## Reglas -- Contexto obligatorio: `ai_docs/core/` (vision, planificacion, roadmap). Si esta vacio, avisa: la planificacion sera ciega. -- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propon la particion. -- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como maximo. Si supera, divide la task. -- Declara las dependencias reales entre tasks; de ahi sale el orden. +- Contexto obligatorio: `ai_docs/core/` (visión, planificación, roadmap). Si está vacío, avisa: la planificación será ciega. +- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propón la partición. +- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como máximo. Si supera, divide la task. +- Declara las dependencias reales entre tasks; de ahí sale el orden. - Una spec solo pasa a Estado: APROBADA cuando el usuario la aprueba. -## Implementacion +## Implementación -El usuario ejecuta `/implementar-spec` para implementar todas las tasks de la spec. El workflow implementa cada task en orden de dependencias: implementa, ejecuta tests, revision adversarial, y commitea antes de pasar a la siguiente. `/implementar` sigue disponible para control manual de una task individual. +El usuario ejecuta `/implementar-spec` para implementar todas las tasks de la spec. El workflow implementa cada task en orden de dependencias: implementa, ejecuta tests, revisión adversarial, y commitea antes de pasar a la siguiente. `/implementar` sigue disponible para control manual de una task individual. diff --git a/docs-src/agents/asesor.md b/docs-src/agents/asesor.md index 3aded98..c3330a6 100644 --- a/docs-src/agents/asesor.md +++ b/docs-src/agents/asesor.md @@ -5,43 +5,46 @@ declaran aqui: el nombre viene del fichero fuente y el modelo de scripts/model-policy.json. --> --- -description: "Analiza un problema, evalua opciones y recomienda la mejor solucion con trade-offs. Consultor tecnico de solo lectura." +description: "Analiza un problema, evalúa opciones y recomienda una solución con sus trade-offs, sin escribir. Se despacha en el análisis previo a una planificación, o cuando el usuario duda entre enfoques o pide una segunda opinión." --- # Asesor -Consultor tecnico: analiza, evalua opciones, recomienda. No ejecuta cambios. +Consultor técnico: analiza, evalúa opciones, recomienda. No ejecuta cambios. -## Cuando actuas +## Cuándo actúas -- En el analisis previo de una planificacion, antes de que exista spec. -- El usuario tiene un problema y no sabe como abordarlo, o duda entre opciones. -- Se necesita un analisis de trade-offs antes de decidir, o una segunda opinion. +- En el análisis previo de una planificación, antes de que exista spec. +- El usuario tiene un problema y no sabe cómo abordarlo, o duda entre opciones. +- Se necesita un análisis de trade-offs antes de decidir, o una segunda opinión. -No actuas para crear specs (planificador), implementar (implementador) ni revisar codigo (revisor). +No actúas para crear specs (planificador), implementar (implementador) ni revisar código (revisor). ## Proceso -Sigue `ai_docs/dev_templates/resolver_problema.md` paso a paso. Lee la plantilla completa y ejecuta -todos sus pasos. +Sigue `ai_docs/dev_templates/resolver_problema.md`. Aplica los pasos de la plantilla pertinentes +al riesgo del problema; justifica los que omitas. -## Reglas del analisis previo a una planificacion +## Reglas del análisis previo a una planificación -Es el unico punto del pipeline donde todavia se puede preguntar. Lo que no se cierre aqui, se -adivina despues. +Es el único punto del pipeline donde todavía se puede preguntar. Lo que no se cierre aquí, se +adivina después. 1. Reformula, no reescribas. Resume la solicitud en 1-3 frases, pero copia literalmente las cifras, - los nombres propios y los criterios de aceptacion tal como los escribio el usuario. -2. Separa lo dicho de lo asumido. Si nadie lo dijo, es una asuncion tuya y se marca como tal. + los nombres propios y los criterios de aceptación tal como los escribió el usuario. +2. Separa lo dicho de lo asumido. Si nadie lo dijo, es una asunción tuya y se marca como tal. 3. La solicitud manda sobre el contexto. Si contradice `ai_docs/core/`, gana la solicitud: deja - constancia de la contradiccion, no la resuelvas en silencio. + constancia de la contradicción, no la resuelvas en silencio. 4. Parte lo que no cabe en una spec. Dos funcionalidades independientes son dos specs. -5. Busca la causa raiz antes de fijar el enfoque. Si hay una alternativa mas robusta, exponla con - sus trade-offs y recomiendala. La decision sigue siendo del usuario. -6. Si falta lo critico (que problema se resuelve, quien lo usa, que cuenta como exito), para y +5. Busca la causa raíz antes de fijar el enfoque. Si hay una alternativa más robusta, exponla con + sus trade-offs y recomiéndala. La decisión sigue siendo del usuario. +6. Si falta lo crítico (qué problema se resuelve, quién lo usa, qué cuenta como éxito), para y formula las preguntas concretas que cierran esos huecos. ## Postura Ayudas a decidir, no decides por el usuario. Opciones concretas, trade-offs claros, una -recomendacion fundamentada. Cuestiona el planteamiento si el problema real parece otro. +recomendación fundamentada. Cuestiona el planteamiento si el problema real parece otro. + +No escribes: la shell es solo para leer y ejecutar análisis, nunca para crear, modificar o borrar +ficheros. diff --git a/docs-src/agents/implementador.md b/docs-src/agents/implementador.md index f913c1b..0e941e4 100644 --- a/docs-src/agents/implementador.md +++ b/docs-src/agents/implementador.md @@ -5,31 +5,40 @@ declaran aqui: el nombre viene del fichero fuente y el modelo de scripts/model-policy.json. --> --- -description: "Ejecuta UNA task del plan: escribe codigo y tests, valida y commitea. Se activa en el paso de implementacion." +description: "Ejecuta UNA task de una spec APROBADA: escribe código y tests y valida, sin commitear dentro del workflow. Se despacha cuando el workflow de implementación o el usuario asigna una task concreta." --- # Implementador -Ejecuta UNA task individual: codigo, tests, validacion, commit. Sigue la task al pie de la letra. +Ejecuta UNA task: código, tests y validación. Sigue la task al pie de la letra. -## Cuando actuas +## Cuándo actúas - Hay una spec APROBADA con tasks derivadas y revisadas, y se te asigna una de ellas. -No actuas para planificar (eso es del planificador) ni para revisar (eso es del revisor). +No actúas para planificar (eso es del planificador) ni para revisar (eso es del revisor). ## Proceso -Sigue `ai_docs/dev_templates/implementar.md` paso a paso para la task asignada. Lee la plantilla -completa y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Sigue `ai_docs/dev_templates/implementar.md`. Aplica los pasos de la plantilla pertinentes al +riesgo del cambio; justifica en tu informe los que omitas. + +- Al escribir tests: `ai_docs/dev_templates/testing_basico.md`. +- Cuando un test falla por una causa que tu cambio no explica: `ai_docs/dev_templates/correccion_de_bugs.md`. +- Antes de cerrar una task que toca código: `ai_docs/dev_templates/limpieza_de_codigo.md`. + +## Escritura + +Creas y modificas ficheros con tus herramientas de edición. La shell queda para inspeccionar, para +git de lectura y para validar (tests, linter, build). ## Reglas de alcance - Solo tocas los archivos listados en la tabla "Archivos afectados" de tu task. Escribir fuera de - esa lista lo bloquea el guardarrail del pipeline, y con razon: no esta planificado. -- Si descubres trabajo fuera de alcance, lo documentas como hallazgo para otra task. No lo - arreglas. + esa lista lo bloquea el guardarraíl del pipeline, y con razón: no está planificado. +- Un hallazgo fuera de alcance se reporta en tu informe; no lo arreglas. - Si la task no es clara o contradice la spec, paras y escalas al planificador. No decides el alcance por tu cuenta. -- Cada task aprobada termina en al menos un commit, con el formato `: `; las tasks fallidas (revision adversarial no superada) se descartan sin commit. -- No uses `--no-verify` en commits ni en pushes: saltarte el ciclo de calidad esta prohibido. +- Dentro del workflow no commiteas: el commit lo crea el workflow cuando la revisión aprueba tu + diff. Una task que no se aprueba no se commitea. Con `/implementar` manual, el commit lo decide el usuario (`/commit`). +- No uses `--no-verify` en commits ni en pushes: saltarte el ciclo de calidad está prohibido. diff --git a/docs-src/agents/planificador.md b/docs-src/agents/planificador.md index 50bc229..50f20f0 100644 --- a/docs-src/agents/planificador.md +++ b/docs-src/agents/planificador.md @@ -5,40 +5,40 @@ declaran aqui: el nombre viene del fichero fuente y el modelo de scripts/model-policy.json. --> --- -description: "Crea especificaciones SDD y las parte en tasks granulares. Se activa cuando el usuario quiere empezar algo nuevo o planificar trabajo." +description: "Crea specs y las parte en tasks granulares. Se despacha cuando el usuario describe algo que construir, cambiar o corregir, o pide una spec, sus tasks o una planificación completa." --- # Planificador -Cerebro del pipeline SDD: crea specs y deriva tasks. No implementa y no revisa codigo. +Cerebro del pipeline SDD: crea specs y deriva tasks. No implementa y no revisa código. -## Cuando actuas +## Cuándo actúas - El usuario describe algo que quiere construir, cambiar o corregir. -- Se pide una spec, la derivacion de tasks o una planificacion completa. +- Se pide una spec, la derivación de tasks o una planificación completa. -No actuas cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se -pide una revision (eso es del revisor). +No actúas cuando ya hay tasks aprobadas y toca implementar (eso es del implementador) ni cuando se +pide una revisión (eso es del revisor). ## Proceso -- Crear una spec: sigue `ai_docs/dev_templates/spec.md` paso a paso. -- Derivar tasks: sigue `ai_docs/dev_templates/tareas.md` paso a paso. +- Crear una spec: sigue `ai_docs/dev_templates/spec.md`. +- Derivar tasks: sigue `ai_docs/dev_templates/tareas.md`. -Lee la plantilla completa y ejecuta todos sus pasos. No condenses ni te saltes pasos. +Aplica los pasos de la plantilla pertinentes al riesgo del cambio; justifica los que omitas. ## Reglas -- Contexto obligatorio: `ai_docs/core/` (vision, planificacion, roadmap). Si esta vacio, avisa: la - planificacion sera ciega. -- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propon la particion. -- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como maximo. Si supera, +- Contexto obligatorio: `ai_docs/core/` (visión, planificación, roadmap). Si está vacío, avisa: la + planificación será ciega. +- Una spec por funcionalidad independiente. Si la solicitud abarca varias, propón la partición. +- Cada task declara sus archivos en la tabla "Archivos afectados" y toca 6 como máximo. Si supera, divide la task. -- Declara las dependencias reales entre tasks; de ahi sale el orden. +- Declara las dependencias reales entre tasks; de ahí sale el orden. - Una spec solo pasa a Estado: APROBADA cuando el usuario la aprueba. -## Implementacion +## Implementación La skill `implementar-spec` implementa cada task en orden de dependencias: implementa, -ejecuta tests, revision adversarial, y commitea antes de pasar a la siguiente. El agente +ejecuta tests, revisión adversarial, y commitea antes de pasar a la siguiente. El agente `implementador` sigue disponible para el control manual de una task individual. From bcbd7b3292f8d5f5c74760fee79cb5d90a5798ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:21:07 +0000 Subject: [PATCH 12/15] fix: leer el mensaje de git commit -F en el lint de commits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La plantilla de commit enseñaba git commit -m "[mensaje]", que se rompe con un cuerpo multilínea o con comillas. Ahora enseña a escribir el mensaje en un fichero temporal con ruta absoluta literal y pasarlo con -F. Para que ese mensaje no quede sin revisar, extractCommitMessage lee también -F, --file y --file=, resueltos contra el directorio en curso; un fichero ilegible, -F -, una ruta con variables o un bash -c anidado no generan aviso ni error. La plantilla deja de pedir una segunda confirmación cuando el usuario ya pidió el commit de forma explícita. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- ai_docs/dev_templates/hacer_commit.md | 12 +++-- hooks/sdd-commit-rules.js | 64 ++++++++++++++++++++++++- hooks/tests/sdd-commit-guard.test.js | 69 +++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 7 deletions(-) diff --git a/ai_docs/dev_templates/hacer_commit.md b/ai_docs/dev_templates/hacer_commit.md index 4a8e9be..9e701d1 100644 --- a/ai_docs/dev_templates/hacer_commit.md +++ b/ai_docs/dev_templates/hacer_commit.md @@ -1,6 +1,6 @@ # Plantilla de Commit Git -> **Cuando usar:** Cuando quieras guardar tus cambios en Git con un commit limpio y ordenado. En el flujo SDD, cada task aprobada genera al menos un commit; las tasks fallidas (revision adversarial no superada) se descartan sin commit. +> **Cuando usar:** Cuando quieras guardar tus cambios en Git con un commit limpio y ordenado. En el flujo SDD, cada task aprobada genera al menos un commit; las tasks fallidas (revision adversarial no superada) no se commitean. > **Como usar:** Copia este documento completo y pegalo en tu asistente de IA. Luego dile que quieres hacer commit. --- @@ -21,7 +21,7 @@ Estas instrucciones son OBLIGATORIAS. Debes seguirlas en todo momento: - NUNCA uses `git add .` ni `git add -A` sin revisar que se esta incluyendo. - NUNCA incluyas archivos sensibles (.env, credenciales, tokens, claves API). - NUNCA incluyas archivos de configuracion de IDE (.cursor/) en el commit. -- SIEMPRE presenta el mensaje de commit para aprobacion antes de ejecutar. +- SIEMPRE muestra el mensaje de commit antes de ejecutar. Si el usuario ya pidió el commit de forma explícita («haz el commit», «commitea esto»), esa petición es la autorización: muestra el mensaje y commitea sin pedir una segunda confirmación. Si solo pidió preparar o proponer el commit, pide aprobación antes de ejecutar. - En el flujo SDD, cada commit debe corresponder a una task o sub-tarea logica. No mezclar cambios de tasks distintas en un mismo commit. --- @@ -117,7 +117,7 @@ esas relecturas sin arriesgar servir datos obsoletos. --- -## Paso 4: Presentar al usuario para aprobacion +## Paso 4: Presentar al usuario ``` Commit propuesto: @@ -131,14 +131,16 @@ Mensaje: Proceder? (S/n) ``` -**ESPERAR aprobacion explicita antes de ejecutar.** +**Si el usuario ya pidió el commit de forma explícita**, su petición es la autorización: muestra el bloque sin la línea `Proceder?` y pasa al Paso 5 sin esperar otra confirmación. + +**Si solo pidió preparar o proponer el commit**, espera su aprobación explícita antes de ejecutar. --- ## Paso 5: Ejecutar y verificar 1. Anadir archivos: `git add ` -2. Crear commit: `git commit -m "[mensaje]"` +2. Crear commit: escribe el mensaje completo (asunto, línea en blanco y cuerpo) en un fichero temporal fuera del árbol de trabajo y pásalo con `git commit -F `, escribiendo la ruta como ruta absoluta literal, sin variables (`$TMP`, `~`): el hook de commit no expande variables y solo puede leer una ruta escrita tal cual. Un mensaje multilínea o con comillas citado en la orden se rompe; desde un fichero llega intacto y el hook de commit también lo revisa. Borra el fichero tras el commit 3. Verificar: `git log --oneline -1` para confirmar **Si falla por un pre-commit hook:** diff --git a/hooks/sdd-commit-rules.js b/hooks/sdd-commit-rules.js index eea74f6..3ce6c4f 100644 --- a/hooks/sdd-commit-rules.js +++ b/hooks/sdd-commit-rules.js @@ -12,7 +12,10 @@ const { spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const { resolveRepoPath } = require('./sdd-plan-state'); -const { invocacionesDe, usaFlag } = require('./sdd-git-command'); +const { invocacionesDe, usaFlag, tokenizar } = require('./sdd-git-command'); + +// Separadores de shell sin comillas: cierran la invocación en curso al buscar el fichero de -F. +const SEPARADORES_SHELL = new Set(['&&', '||', ';', '|', '&', '\n']); const VALID_TYPES = ['feat', 'fix', 'update', 'refactor', 'create', 'optimize', 'remove', 'rename', 'docs', 'test', 'style', 'chore']; const SUBJECT_MAX_LEN = 72; @@ -172,7 +175,64 @@ function extractCommitMessage(cmd) { let m; while ((m = re.exec(cmd)) !== null) parts.push(m[1]); } - return parts.length > 0 ? parts.join('\n\n') : null; + if (parts.length > 0) return parts.join('\n\n'); + + return messageFromFile(cmd); +} + +/** + * Mensaje de un `git commit -F ` (también `-F`, `--file ` y `--file=`). + * + * La ruta relativa se resuelve contra el directorio en curso del hook, como `stagedFiles`. Límite + * conocido: en `cd sub && git commit -F m.txt` git lee `sub/m.txt` y aquí se busca `m.txt` en el + * directorio del hook; el módulo no sigue los `cd` del comando. Con `-F -` el mensaje llega por + * stdin y no hay fichero que leer. Tampoco se expanden variables (`-F "$TMP/m.txt"` busca el texto + * literal `$TMP/m.txt`) ni se analiza el script de un `bash -c` anidado: el mismo límite que + * declara `sdd-git-command.js`, que no ejecuta nada. Cualquier fallo de lectura devuelve null: sin mensaje no hay + * aviso de formato, y un aviso nunca justifica romper el commit. + */ +function messageFromFile(cmd) { + const ruta = commitFileArg(cmd); + if (!ruta || ruta === '-') return null; + try { + const contenido = fs.readFileSync(path.resolve(ruta), 'utf8').replace(/\r\n/g, '\n'); + // git descarta las líneas en blanco iniciales: el asunto es la primera línea con texto. + return contenido.replace(/^(?:[ \t]*\n)+/, '') || null; + } catch { + return null; + } +} + +// Recorre los tokens reales (sin texto entrecomillado de otros flags) de cada segmento del +// comando y devuelve el valor de -F/--file de la primera invocación de `git ... commit`. +function commitFileArg(cmd) { + let tokens; + try { + tokens = tokenizar(cmd); + } catch { + return null; + } + + let vistoGit = false; + let enCommit = false; + for (let i = 0; i < tokens.length; i += 1) { + const { valor, entrecomillado } = tokens[i]; + if (!entrecomillado && SEPARADORES_SHELL.has(valor)) { + vistoGit = false; + enCommit = false; + continue; + } + if (!enCommit) { + if (!entrecomillado && valor === 'git') vistoGit = true; + else if (vistoGit && !entrecomillado && valor === 'commit') enCommit = true; + continue; + } + if (entrecomillado) continue; + if (valor === '-F' || valor === '--file') return tokens[i + 1] ? tokens[i + 1].valor : null; + if (valor.startsWith('--file=')) return valor.slice('--file='.length); + if (valor.startsWith('-F')) return valor.slice(2); + } + return null; } function extractPrBody(cmd) { diff --git a/hooks/tests/sdd-commit-guard.test.js b/hooks/tests/sdd-commit-guard.test.js index 7fedf92..582b5b4 100644 --- a/hooks/tests/sdd-commit-guard.test.js +++ b/hooks/tests/sdd-commit-guard.test.js @@ -8,6 +8,7 @@ const test = require('node:test'); const assert = require('node:assert'); +const fs = require('fs'); const path = require('path'); const { runHook, tempDir, writeFile } = require('./helpers'); const { commitWarnings, usesNoVerify } = require('../sdd-commit-rules'); @@ -221,3 +222,71 @@ test('gh pr create sin ai_docs/tasks/ -> sin aviso ni excepcion', () => { assert.ok(!warnings.some(w => /CONVERGENCE_PENDING/.test(w))); }); + +// El mensaje de `git commit -F ` se lee del fichero, resuelto contra el directorio en curso: +// estos tests fijan el cwd en un directorio temporal con el fichero de mensaje y lo borran al acabar. +function conDirMensaje(fn) { + const dir = tempDir('sdd-commit-file-'); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('git commit -F con asunto de 80 caracteres -> warn COMMIT_SUBJECT_TOO_LONG', () => conDirMensaje(dir => { + writeFile(path.join(dir, 'msg.txt'), 'docs: ' + 'x'.repeat(74) + '\n'); + + const warnings = withCwd(dir, () => commitWarnings('git commit -F msg.txt')); + + assert.ok(warnings.some(w => /COMMIT_SUBJECT_TOO_LONG: subject tiene 80 chars/.test(w)), warnings.join('\n')); +})); + +test('git commit --file= con Co-Authored-By de IA -> warn COMMIT_COAUTHOR_FORBIDDEN', () => conDirMensaje(dir => { + writeFile(path.join(dir, 'msg.txt'), + 'fix: corrige el parseo\n\nEl parser perdia el ultimo campo.\n\nCo-Authored-By: Claude \n'); + + const warnings = withCwd(dir, () => commitWarnings('git commit --file=msg.txt')); + + assert.ok(warnings.some(w => /COMMIT_COAUTHOR_FORBIDDEN/.test(w)), warnings.join('\n')); +})); + +test('git commit --file y -F pegado tambien se leen', () => conDirMensaje(dir => { + writeFile(path.join(dir, 'msg.txt'), 'nope: asunto con tipo invalido\n'); + + for (const cmd of ['git commit --file msg.txt', 'git commit -Fmsg.txt', 'git add . && git commit -F "msg.txt"']) { + const warnings = withCwd(dir, () => commitWarnings(cmd)); + assert.ok(warnings.some(w => /COMMIT_TYPE_INVALID/.test(w)), cmd + ': ' + warnings.join('\n')); + } +})); + +test('git commit -F a un fichero inexistente -> sin aviso de mensaje ni excepcion', () => conDirMensaje(dir => { + const warnings = withCwd(dir, () => commitWarnings('git commit -F no_existe.txt')); + + assert.ok(!warnings.some(w => /COMMIT_/.test(w)), warnings.join('\n')); +})); + +test('git commit -F - (stdin) -> sin aviso de mensaje', () => conDirMensaje(dir => { + writeFile(path.join(dir, '-'), 'nope: no debe leerse\n'); + + const warnings = withCwd(dir, () => commitWarnings('git commit -F -')); + + assert.ok(!warnings.some(w => /COMMIT_/.test(w)), warnings.join('\n')); +})); + +test('git commit -F con un mensaje correcto -> sin avisos', () => conDirMensaje(dir => { + writeFile(path.join(dir, 'msg.txt'), + '\nfix: corrige el parseo de "campos" con comillas\r\n\r\nEl parser perdia el ultimo campo.\r\n'); + + const warnings = withCwd(dir, () => commitWarnings('git commit -F msg.txt')); + + assert.deepStrictEqual(warnings, []); +})); + +test('-F de otro programa en el mismo comando no se lee como mensaje del commit', () => conDirMensaje(dir => { + writeFile(path.join(dir, 'msg.txt'), 'nope: no debe leerse\n'); + + const warnings = withCwd(dir, () => commitWarnings('grep -F msg.txt x; git commit')); + + assert.deepStrictEqual(warnings, []); +})); From 941c27b221614d01c21e08f6ebaf85c30355f757 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:25:00 +0000 Subject: [PATCH 13/15] =?UTF-8?q?chore:=20CI=20m=C3=ADnimo=20con=20npm=20t?= =?UTF-8?q?est=20y=20deriva,=20y=20--test-timeout=20en=20la=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El README remite la frontera dura a CI y el repo no tenía workflows. test.yml ejecuta npm test y compile.js --check en cada PR y en push a main, con permisos de solo lectura, sin credenciales persistidas y con la versión mínima de Node que declara engines. --test-timeout=120000 evita que una prueba colgada bloquee la suite sin nombrar su fichero; solo protege contra cuelgues (el caso más lento tarda unos 5 s). La opción exige Node 20.11, así que engines y la documentación suben a >= 20.11. Los globs van sin comillas porque Node 20 no los expande. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .github/workflows/test.yml | 28 ++++++++++++++++++++++++++++ CONTRIBUTING.md | 2 +- README.md | 4 ++-- package.json | 4 ++-- 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b486b53 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: test + +# Frontera dura fuera de la máquina del desarrollador: la suite completa y la +# deriva de los derivados compilados. La versión de Node es la mínima que +# declara `engines` en package.json. +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: '20.11' + - name: Suite de tests + run: npm test + - name: Deriva de derivados + run: node scripts/compile.js --check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75b4511..6ff0c16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,7 +6,7 @@ Gracias por tu interes en mejorar el framework. Este documento explica como repo ## Antes de empezar -- **Node.js >= 20** — requerido por los hooks (`hooks/*.js`). Verifica con `node --version` +- **Node.js >= 20.11** — requerido por los hooks (`hooks/*.js`). Verifica con `node --version` - Lee `CLAUDE.md` — define el estilo, el flujo SDD y los limites del framework. Todo cambio debe ser coherente con ellos - **Tests:** `npm test` ejecuta los tests de contrato de los hooks. Todo cambio en `hooks/` llega con sus tests en el mismo commit - **Deriva:** `npm run check-drift` verifica que las superficies **generadas** coinciden con su fuente. Ejecutalo antes del push si tocas `.claude/` o `docs-src/`. No cubre las superficies que se mantienen a mano, y son mas de las que parece: lee "Artefactos generados y deriva" antes de dar por portado un cambio diff --git a/README.md b/README.md index ecf8076..0c1292e 100644 --- a/README.md +++ b/README.md @@ -456,7 +456,7 @@ agentic-engineering-framework/ ├── CHANGELOG.md # Historial de cambios por version ├── SECURITY.md # Reporte de vulnerabilidades y alcance ├── LICENSE # CC BY 4.0 -├── package.json # Metadatos + engines (Node >= 20) +├── package.json # Metadatos + engines (Node >= 20.11) ├── .github/ # Plantillas de issue y de Pull Request │ ├── .claude/ # Configuracion Claude Code @@ -577,7 +577,7 @@ Refuerzo adicional: `.codex/rules/sdd-enforcement.rules` prohibe esos mismos com **Limite honesto de la senal de revision:** el hash que `sdd-review-gate.js` contrasta protege contra commits accidentales sin revision, no contra una falsificacion deliberada. El diff cacheado es visible para cualquier proceso con acceso a shell en la misma sesion, asi que ese mismo proceso puede recalcular el hash y escribir la senal directamente, sin haber pasado la revision adversarial. No es una limitacion cerrable mezclando un secreto en el hash: para que el gate lo verifique de forma mecanica, el secreto tendria que persistir en un sitio (disco, variable de entorno) igual de accesible para quien intenta falsificar la senal, lo que anula la proteccion que se buscaba anadir. La senal ata el diff a "hubo una revision", no autentica quien la emitio. Para una frontera dura frente a falsificacion deliberada, usa proteccion de rama y CI, no este hook. -**Tests:** `npm test` ejecuta los tests de contrato de los hooks (Node >= 20, sin dependencias). +**Tests:** `npm test` ejecuta los tests de contrato de los hooks (Node >= 20.11, sin dependencias). **Modelo por defecto (Claude Code):** `.claude/settings.json` fija `"model": "claude-opus-4-8"`. Opus 4.8 es el modelo mas capaz para planificacion y revision exhaustiva. Override puntual con `/model sonnet` si necesitas velocidad en tareas mecanicas. diff --git a/package.json b/package.json index 0ee6433..7e5e58b 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,10 @@ }, "homepage": "https://github.com/gmoncor/agentic-engineering-framework#readme", "engines": { - "node": ">=20" + "node": ">=20.11" }, "scripts": { - "test": "node --test \"hooks/tests/*.test.js\" \"tests/*.test.js\"", + "test": "node --test --test-timeout=120000 hooks/tests/*.test.js tests/*.test.js", "check-drift": "node scripts/compile.js --check" }, "keywords": [ From d1a39324053dbf431f2b57e9c7979c489465e6ce Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 16:28:54 +0000 Subject: [PATCH 14/15] fix: apartar en git stash el trabajo no commiteado en vez de borrarlo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /implementar-spec descartaba el trabajo de una task fallida con reset --hard + clean -fd sobre todo el árbol: borraba cambios sin commitear del usuario y, en --parallel, el trabajo de las tareas hermanas en curso. Además, sin comprobar el árbol al arrancar, el add -A de la primera task absorbía cambios ajenos en su commit. Ahora el workflow no arranca con cambios fuera de ai_docs/ y en secuencial lo comprueba antes de cada task. El trabajo que no se commitea se aparta en un stash "sdd-apartado: ": en secuencial, todo el proyecto salvo ai_docs/; en paralelo, solo las rutas cambiadas de su ámbito, incluidos borrados ya preparados. Si el apartado falla o deja residuo, no se destruye nada y el recorrido secuencial se detiene. En secuencial, add, diff y commit se acotan al proyecto (-- .), así que en un monorepo no arrastran cambios de fuera. El README explica cómo recuperar lo apartado. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- .claude/workflows/implementar-spec.js | 50 ++- .claude/workflows/lib/orquestacion.js | 370 ++++++++++++++-- README.md | 21 +- tests/implementar-spec-ejecucion.test.js | 518 +++++++++++++++++++++-- 4 files changed, 888 insertions(+), 71 deletions(-) diff --git a/.claude/workflows/implementar-spec.js b/.claude/workflows/implementar-spec.js index 576ec2e..33dee05 100644 --- a/.claude/workflows/implementar-spec.js +++ b/.claude/workflows/implementar-spec.js @@ -144,9 +144,10 @@ async function raizDelProyecto() { return path.dirname(path.dirname(await resolverEnProyecto('.claude/workflows'))) } -// ── Ambito de la task: acota su commit a sus propios ficheros en modo concurrente ── +// ── Ámbito de la task: acota su commit y su apartado en modo concurrente ────── // En modo concurrente todas las tasks de un nivel comparten arbol e indice: sin acotar, `git add -// -A` prepara tambien el trabajo sin commitear de una hermana en curso (F1 de orquestacion.js). El +// -A` prepara también el trabajo sin commitear de una hermana en curso (F1 de orquestacion.js), y +// apartar el trabajo de una task fallida se llevaría también el de sus hermanas. El // ambito es la lista de ficheros que la TASK DECLARA en su propia tabla "Archivos afectados" // (parser SSOT hooks/sdd-task-files.js, el mismo que usa el guard de pipeline), no el auto-reporte // del implementador: con el guard activo coinciden por construccion. @@ -154,7 +155,8 @@ async function raizDelProyecto() { // Mismo patron de carga que emitirSenalRevision: resolverEnProyecto + comprobacion de existencia + // cargarModulo. Si el proyecto no tiene el parser instalado, o la task no tiene una tabla legible, // el ambito degrada a una lista vacia con el motivo registrado — orquestacion.js decide entonces -// que hacer con un ambito vacio (conserva `add -A` sin acotar, ver diffPreparado). +// qué hacer con un ámbito vacío (no lo usa como pathspec, ver diffPreparado; y no aparta +// nada, ver apartarTrabajo). // // Se calcula una sola vez por task (no en cada llamada a git): el resultado se cachea por su path. const ambitoCache = new Map() @@ -190,9 +192,11 @@ async function calcularAmbitoTask(task) { // El workflow controla el commit de cada task (el implementador ya no commitea): // primero se revisa el diff, y solo si la revision aprueba se emite la senal y se // commitea. Se ejecuta git por child_process para no depender del shell del agente. +// Corre en la raíz del proyecto: las rutas del ámbito y el pathspec "." del commit, del apartado +// y de la comprobación de árbol limpio son relativos a ella, no al directorio en curso. async function git(argv) { const cp = await import('node:child_process') - const r = cp.spawnSync('git', argv, { encoding: 'utf8', timeout: 15000 }) + const r = cp.spawnSync('git', argv, { cwd: raizProyecto, encoding: 'utf8', timeout: 15000 }) // Un spawn que no llega a arrancar (git ausente) o que agota su tiempo deja status en null y el // motivo en r.error, no en stderr: sin recogerlo, el fallo se queda sin causa que reportar. return { code: r.status, out: r.stdout || '', err: r.stderr || (r.error ? r.error.message : '') } @@ -204,10 +208,22 @@ async function git(argv) { // Un fallo de infraestructura pasaba por veredicto, se registraba la constancia de revision del // diff vacio y el gate denegaba el commit despues sin explicacion. -// Deshace el trabajo sin commitear de una task descartada. git clean respeta .gitignore. -async function descartarTrabajo() { - await git(['reset', '--hard', 'HEAD']) - await git(['clean', '-fd']) +// Aparta, sin destruirlo, el trabajo sin commitear de una task que no se commitea: queda en +// `git stash` con nombre y se recupera con `git stash pop`. En secuencial, todo el árbol salvo +// ai_docs/; en concurrente, solo el ámbito de la task. El criterio vive en +// orquestacion.apartarTrabajo, probado con dobles y con git real. +async function apartarTrabajo(task) { + return orq.apartarTrabajo(task, { + spawnGit: git, + log: registro, + modoParalelo: modoParalelo, + ambito: modoParalelo ? await ambitoTask(task) : undefined, + }) +} + +// Antes de cada task del recorrido secuencial: el árbol tiene que seguir limpio fuera de ai_docs/. +async function arbolLimpio() { + return orq.comprobarArbolLimpio(git) } // El registro del flujo se pasa a orquestacion.js como dependencia. @@ -354,6 +370,14 @@ for (var w = 0; w < niveles.length; w++) { log('Nivel ' + (w + 1) + ': ' + niveles[w].map(function(t) { return t.titulo }).join(', ')) } +// El árbol tiene que estar limpio fuera de ai_docs/ antes de implementar: en secuencial, el `add +// -A` de la primera task absorberia esos cambios en su commit, y apartar el trabajo de una task +// fallida se los llevaría con él. Los documentos de plan quedan fuera: el guard también los exime. +const estadoInicial = await orq.comprobarArbolLimpio(git) +if (!estadoInicial.limpio) { + return { spec: specPath, error: orq.mensajeArbolSucio(estadoInicial) } +} + // ── Fase 2: Implementacion (con revision por task antes del commit) ─────────── // Las tasks se implementan una tras otra en orden topologico: cada nivel de // dependencia antes que el siguiente y, dentro de un nivel, una task despues de @@ -473,27 +497,29 @@ async function revisarYComitear(task, resultado) { revisar: revisarDiff, corregir: corregirTask, emitirSenal: emitirSenalRevision, - descartar: descartarTrabajo, + descartar: apartarTrabajo, ambito: ambito, }) } // Ejecuta una task completa: gate de dependencias fallidas, implementacion, gate de // tests y, si pasa, revision + commit. El gate de tests corre la suite real y lee su -// exit code: rojo bloquea el commit y descarta el trabajo de la task; la falta de +// exit code: rojo bloquea el commit y aparta el trabajo de la task; la falta de // comando bloquea solo si la task toca codigo. SDD_GUARD_SKIP=1 degrada ese bloqueo a // aviso (escape puntual para un fallo ajeno a la task), pero nunca el fallo de git. // // El orden y las decisiones viven en orquestacion.js, probados con dobles; aqui se // cablean las piezas con efecto: el agente implementador, el gate que spawnea la -// suite, la revision + commit y el descarte del trabajo. +// suite, la revisión + commit y el apartado del trabajo. En secuencial, además, la comprobación +// de árbol limpio antes de cada task: ninguna empieza con el residuo de la anterior. async function ejecutarTask(task, resultadosPrevios) { return orq.ejecutarTask(task, resultadosPrevios, { log: registro, implementar: implementarTask, gateTests: gateTests, revisarYComitear: revisarYComitear, - descartar: descartarTrabajo, + descartar: apartarTrabajo, + arbolLimpio: modoParalelo ? undefined : arbolLimpio, }) } diff --git a/.claude/workflows/lib/orquestacion.js b/.claude/workflows/lib/orquestacion.js index bbb7de8..91e6ab2 100644 --- a/.claude/workflows/lib/orquestacion.js +++ b/.claude/workflows/lib/orquestacion.js @@ -401,6 +401,253 @@ function falloDeGit(resultado, mensaje, log) { return resultado; } +// ── Árbol limpio y apartado recuperable ────────────────────────────────────── + +/** + * Directorio de los documentos de plan. Queda fuera de la comprobación de árbol + * limpio y del apartado: specs y tasks pueden estar a medio editar sin ser + * trabajo de ninguna task, y el guard de escrituras también los exime. + */ +const DIR_PLAN = 'ai_docs'; +const PATHSPEC_SIN_PLAN = ['.', ':(exclude)' + DIR_PLAN]; + +/** `git status` que lista, fichero a fichero, lo que no está commiteado fuera de ai_docs/. */ +const ARGV_ESTADO = ['status', '--porcelain', '--untracked-files=all', '--'].concat(PATHSPEC_SIN_PLAN); + +/** Ruta de una línea de `git status --porcelain`: el destino si es un renombrado, sin comillas. */ +function rutaDePorcelain(linea) { + let ruta = linea.slice(3); + const flecha = ruta.indexOf(' -> '); + if (flecha !== -1) ruta = ruta.slice(flecha + 4); + if (ruta.length >= 2 && ruta.startsWith('"') && ruta.endsWith('"')) { + ruta = ruta.slice(1, -1).replace(/\\(["\\])/g, '$1'); + } + return ruta; +} + +function esDelPlan(ruta) { + return ruta === DIR_PLAN || ruta.startsWith(DIR_PLAN + '/'); +} + +/** + * Lee la salida de `git status --porcelain` y dice si el árbol está limpio + * fuera de ai_docs/. Devuelve `{ limpio, rutas }`. + * + * El pathspec ya excluye ai_docs/; el filtro se repite aquí para que la + * decisión no dependa de que el git instalado entienda la exclusión. + */ +function evaluarArbolLimpio(salida) { + const rutas = String(salida == null ? '' : salida) + .split(/\r?\n/) + .filter(function (l) { return l.trim().length > 3; }) + .map(rutaDePorcelain) + .filter(function (r) { return r && !esDelPlan(r); }); + return { limpio: rutas.length === 0, rutas }; +} + +/** + * Ejecuta `git status` y evalúa el resultado. Un git que no corrió no acredita + * un árbol limpio: se devuelve `limpio: false` con el error, para que quien + * llama se detenga en vez de seguir sobre un estado desconocido. + */ +async function comprobarArbolLimpio(spawn) { + const estado = await gitVerificado(ARGV_ESTADO, spawn); + if (!estado.ok) return { limpio: false, rutas: [], error: estado.error }; + return evaluarArbolLimpio(estado.out); +} + +/** Las primeras rutas de una lista, para un mensaje legible. */ +function resumenRutas(rutas) { + const max = 10; + const lista = (rutas || []).slice(0, max).join(', '); + return rutas && rutas.length > max ? lista + ' y ' + (rutas.length - max) + ' más' : lista; +} + +/** + * Motivo por el que el workflow no arranca la implementación con el árbol sucio, + * con las tres salidas que tiene quien lo lanzó. + */ +function mensajeArbolSucio(estado) { + const causa = estado.error + ? 'no se pudo comprobar que el árbol esté limpio (' + estado.error + ')' + : 'hay cambios sin commitear fuera de ' + DIR_PLAN + '/: ' + resumenRutas(estado.rutas); + return 'No se implementa: ' + causa + '. El commit de la primera task los absorbería, y el ' + + 'apartado de una task fallida se los llevaría con su trabajo. Antes de relanzar: ' + + 'commitéalos, apártalos con `git stash push --include-untracked`, o lanza la spec en ' + + 'un worktree de git limpio.'; +} + +/** + * Argumentos del `git stash` que aparta el trabajo de una task que no se + * commitea. Devuelve `null` cuando no hay nada que se pueda atribuir a la task. + * + * - Secuencial: todo el árbol salvo ai_docs/. El árbol estaba limpio al empezar + * la task, así que lo que queda es suyo, también lo que escribió fuera de su + * tabla; acotarlo al ámbito dejaría ese resto para el commit de la siguiente. + * - Concurrente: solo `ambito`, las rutas de la task con cambios. El árbol es + * compartido con las hermanas en curso; sin ámbito no hay nada atribuible. + */ +function argvApartado(task, ambito, modoParalelo) { + const base = ['stash', 'push', '--include-untracked', '-m', 'sdd-apartado: ' + task.titulo, '--']; + if (!modoParalelo) return base.concat(PATHSPEC_SIN_PLAN); + if (!Array.isArray(ambito) || ambito.length === 0) return null; + return base.concat(ambito.map(function (r) { return rutaNativa(r); })); +} + +/** + * Rutas con cambios (`git status --porcelain -z`) que caen dentro del ámbito. + * El `stash` con una ruta que no casa con ningún fichero falla A MEDIAS: guarda + * la entrada y deja el árbol sin limpiar. Por eso el pathspec del apartado + * concurrente es solo lo que de verdad cambió, no la tabla entera. + * + * Devuelve `{ rutas, borradasEnIndice }`. Las segundas son las rutas cuyo + * borrado ya está preparado en el índice (X = 'D', o el origen de un + * renombrado): no existen ni en el árbol ni en el índice, así que el pathspec + * del `stash` no casaría con ellas hasta devolverlas al índice. + */ +function rutasCambiadasEnAmbito(salidaZ, ambito, prefijo) { + const declaradas = (ambito || []).map(function (r) { + return rutaNativa(r).replace(/^\.\//, '').replace(/\/+$/, ''); + }).filter(Boolean); + const dentro = function (ruta) { + return declaradas.some(function (a) { return ruta === a || ruta.startsWith(a + '/'); }); + }; + + const entradas = String(salidaZ || '').split('\0'); + const rutas = []; + const borradasEnIndice = []; + const relativa = function (cruda) { + return (prefijo && cruda.startsWith(prefijo)) ? cruda.slice(prefijo.length) : cruda; + }; + for (let i = 0; i < entradas.length; i++) { + const e = entradas[i]; + if (e.length <= 3) continue; + const xy = e.slice(0, 2); + const destino = relativa(e.slice(3)); + // Un renombrado o copia trae el origen en la entrada siguiente. + const origen = (/[RC]/.test(xy) && i + 1 < entradas.length) ? relativa(entradas[++i]) : null; + for (const r of [destino, origen]) { + if (r && dentro(r) && !rutas.includes(r)) rutas.push(r); + } + if (xy[0] === 'D' && dentro(destino)) borradasEnIndice.push(destino); + if (xy[0] === 'R' && origen && dentro(origen)) borradasEnIndice.push(origen); + } + return { rutas, borradasEnIndice }; +} + +/** + * Aparta, sin destruirlo, el trabajo de una task que no se commitea: queda en + * `git stash` con el nombre "sdd-apartado: ", recuperable con + * `git stash pop`. Nunca ejecuta `reset --hard` ni `clean`. + * + * `deps`: `spawnGit`, `modoParalelo`, `ambito`, `log`. + * + * Devuelve `{ ok, nota, detener }`. `detener` pide parar el recorrido + * secuencial: si el apartado falla o deja residuo, la siguiente task empezaría + * con ese resto y su `add -A` lo commitearía como propio. + */ +async function apartarTrabajo(task, deps) { + const d = deps || {}; + const log = registrar(d); + const modoParalelo = !!d.modoParalelo; + const titulo = task.titulo; + + let ambito = d.ambito; + if (modoParalelo) { + if (!Array.isArray(ambito) || ambito.length === 0) { + log('Task ' + titulo + ': trabajo conservado sin commitear (sin ámbito declarado no se puede ' + + 'separar del de las hermanas)'); + return { + ok: false, detener: false, + nota: 'trabajo conservado sin commitear: sin ámbito declarado no se puede apartar sin ' + + 'llevarse el de las hermanas', + }; + } + const estado = await gitVerificado( + ['status', '--porcelain', '-z', '--untracked-files=all', '--'].concat(ambito.map(function (r) { + return rutaNativa(r); + })), d.spawnGit); + if (!estado.ok) { + log('Task ' + titulo + ': no se pudo apartar su trabajo — ' + estado.error); + return { ok: false, detener: false, nota: 'trabajo conservado sin commitear: ' + estado.error }; + } + // `status --porcelain` da las rutas desde la raíz del repositorio; el ámbito + // es relativo al proyecto, que puede ser un subdirectorio del repositorio. + const prefijo = await gitVerificado(['rev-parse', '--show-prefix'], d.spawnGit); + if (!prefijo.ok) { + log('Task ' + titulo + ': no se pudo apartar su trabajo — ' + prefijo.error); + return { ok: false, detener: false, nota: 'trabajo conservado sin commitear: ' + prefijo.error }; + } + const cambiadas = rutasCambiadasEnAmbito(estado.out, ambito, prefijo.out.trim()); + ambito = cambiadas.rutas; + if (ambito.length === 0) return { ok: true, detener: false, nota: 'nada que apartar en su ámbito' }; + + // Un borrado ya preparado en el índice (lo deja así la revisión, tras su + // `add -A`) se devuelve al índice para que el pathspec del `stash` case con + // él; el borrado sigue en el árbol y el `stash` lo guarda igual. + if (cambiadas.borradasEnIndice.length > 0) { + const devuelto = await gitVerificado(['reset', '-q', '--'].concat(cambiadas.borradasEnIndice), d.spawnGit); + if (!devuelto.ok) { + log('Task ' + titulo + ': no se pudo apartar su trabajo — ' + devuelto.error); + return { ok: false, detener: false, nota: 'trabajo conservado sin commitear: ' + devuelto.error }; + } + } + } + + // La cima de la pila antes y después dice si el `stash` guardó algo: con + // «No local changes to save» sale con 0 y no crea entrada, y ese mensaje + // cambia con el idioma de git. + const cimaAntes = await cimaDeStash(d.spawnGit); + const argv = argvApartado(task, ambito, modoParalelo); + const apartado = await gitVerificado(argv, d.spawnGit); + if (!apartado.ok) { + log('Task ' + titulo + ': no se pudo apartar su trabajo — ' + apartado.error + + '. No se destruye nada' + (modoParalelo ? '' : '; el recorrido se detiene')); + return { + ok: false, detener: !modoParalelo, + nota: 'no se pudo apartar el trabajo (' + apartado.error + '); se conserva sin commitear', + }; + } + + const guardado = (await cimaDeStash(d.spawnGit)) !== cimaAntes; + const nota = guardado + ? 'trabajo apartado en git stash "sdd-apartado: ' + titulo + '"' + : 'nada que apartar: git stash no guardó cambios'; + if (!modoParalelo) { + const tras = await comprobarArbolLimpio(d.spawnGit); + if (!tras.limpio) { + const resto = tras.error || resumenRutas(tras.rutas); + log('Task ' + titulo + ': tras apartar su trabajo queda residuo (' + resto + '); el recorrido se detiene'); + return { ok: false, detener: true, nota: nota + ', pero queda residuo: ' + resto }; + } + } + + log('Task ' + titulo + ': ' + nota + (guardado ? ' (recuperable con git stash pop)' : '')); + return { ok: true, detener: false, nota }; +} + +/** + * Commit de la cima de `git stash`, o cadena vacía si la pila está vacía o la + * lectura no corre. Solo sirve para comparar antes y después de un `stash push`. + */ +async function cimaDeStash(spawn) { + const r = await spawn(['rev-parse', '-q', '--verify', 'refs/stash']); + return r && r.code === 0 ? String(r.out || '').trim() : ''; +} + +/** + * Llama a `deps.descartar(task)` y deja constancia en el resultado. Un + * `descartar` que no devuelve nada cuenta como apartado sin incidencias. + */ +async function apartarYRegistrar(task, resultado, deps) { + const salida = (await deps.descartar(task)) || {}; + if (salida.nota) { + resultado.notas = (resultado.notas ? resultado.notas + ' ' : '') + '(' + salida.nota + ')'; + } + if (salida.detener) resultado.detener_recorrido = true; + return resultado; +} + // ── Revision del diff de una task y su commit ──────────────────────────────── /** @@ -414,10 +661,12 @@ function falloDeGit(resultado, mensaje, log) { * e indice, y sin acotar, `add -A` prepara tambien el trabajo sin commitear de * una hermana en curso (F1) — el commit de una absorbe el de otra, en el camino * feliz, sin que nadie falle. Sin `ambito` (modo secuencial, donde no hay - * hermanas que contaminen el indice) el comportamiento es el de siempre. + * hermanas que contaminen el índice) se acota al proyecto (`-- .`, relativo a + * su raíz): en un monorepo, `add -A` sin pathspec prepararía también lo de + * fuera del subdirectorio, que la comprobación de árbol limpio no mira. */ async function diffPreparado(spawn, ambito) { - const pathspec = (Array.isArray(ambito) && ambito.length > 0) ? ['--'].concat(ambito) : []; + const pathspec = pathspecDelCommit(ambito); const preparado = await gitVerificado(['add', '-A'].concat(pathspec), spawn); if (!preparado.ok) return { ok: false, error: preparado.error }; @@ -428,6 +677,15 @@ async function diffPreparado(spawn, ambito) { return { ok: true, diff: lectura.out }; } +/** + * Pathspec común de `add`, `diff --cached` y `commit`: el ámbito si lo hay; si + * no, el proyecto entero (`.`), lo mismo que cubre la comprobación de árbol + * limpio. Lo que se revisa y lo que se commitea cubren así las mismas rutas. + */ +function pathspecDelCommit(ambito) { + return ['--'].concat((Array.isArray(ambito) && ambito.length > 0) ? ambito : ['.']); +} + /** Emite la constancia atada al diff revisado y commitea. El commit tambien se lee por exit code. */ async function comitearAprobada(task, resultado, contexto, deps) { const log = registrar(deps); @@ -441,8 +699,8 @@ async function comitearAprobada(task, resultado, contexto, deps) { // ya stageo una hermana en curso — exactamente lo que diffPreparado evita en // add/diff pero que aqui, sin pathspec, volveria a colar. `git commit -- ` // commitea solo esas rutas del indice y deja el resto preparado para su dueña. - const ambito = deps.ambito; - const pathspec = (Array.isArray(ambito) && ambito.length > 0) ? ['--'].concat(ambito) : []; + // Sin ámbito, el commit se acota al proyecto, igual que add/diff. + const pathspec = pathspecDelCommit(deps.ambito); // Un commit denegado (por un gate, por un indice bloqueado) dejaria el trabajo // sin commitear y el informe diria lo contrario. @@ -461,7 +719,8 @@ async function comitearAprobada(task, resultado, contexto, deps) { * reflejado. * * `deps`: `spawnGit` (ejecuta git), `revisar`, `corregir`, `emitirSenal`, - * `descartar` (deshace el trabajo sin commitear), `log`, `ambito` (opcional: + * `descartar(task)` (aparta, recuperable, el trabajo sin commitear; ver + * apartarTrabajo), `log`, `ambito` (opcional: * ficheros declarados de la task; se calcula una vez por task, antes de esta * llamada, no por cada comando de git). */ @@ -476,7 +735,7 @@ async function revisarYComitear(task, resultado, deps) { // motivo: quien lea el resultado sabe que este commit, si lo hay, no se acoto. if (Array.isArray(ambito) && ambito.length === 0) { resultado.notas = (resultado.notas ? resultado.notas + ' ' : '') - + '(ambito vacio: task sin tabla "Archivos afectados" legible, no se acota el commit)'; + + '(ambito vacio: task sin tabla "Archivos afectados" legible; el commit solo se acota al proyecto)'; } const primera = await diffPreparado(d.spawnGit, ambito); @@ -521,16 +780,15 @@ async function revisarYComitear(task, resultado, deps) { veredicto = revision ? revision.veredicto : 'ERROR'; } - // Sigue adversa: no se commitea. Se descarta el trabajo para que no contamine - // el diff de la siguiente task. + // Sigue adversa: no se commitea. Se aparta el trabajo, recuperable, para que + // no contamine el diff de la siguiente task. if (veredicto !== 'APROBADA') { log('Task ' + task.titulo + ': FALLIDA (revision ' + veredicto + '), no se commitea'); - await d.descartar(); resultado.resultado = 'FALLIDA'; resultado.revision = revision; resultado.notas = (resultado.notas ? resultado.notas + ' ' : '') + '(revision adversarial: ' + veredicto + ')'; - return resultado; + return apartarYRegistrar(task, resultado, d); } return comitearAprobada(task, resultado, { diff, revision }, d); @@ -580,7 +838,7 @@ function escapeActivo(env) { /** * Gate de tests y, si pasa, revision + commit. El rojo de la suite BLOQUEA el - * commit y descarta el trabajo de la task: es el unico veredicto admisible sobre + * commit y aparta el trabajo de la task: es el único veredicto admisible sobre * los tests, y no se cree ningun numero que el implementador reporte. */ async function trasGateTests(task, resultado, deps) { @@ -595,11 +853,10 @@ async function trasGateTests(task, resultado, deps) { if (gate.estado === 'FALLIDA' && !escapeActivo(deps.env)) { log('Task ' + task.titulo + ': FALLIDA (gate de tests) — ' + gate.nota + ', no se commitea'); - await deps.descartar(); resultado.resultado = 'FALLIDA'; resultado.notas = (resultado.notas ? resultado.notas + ' ' : '') + '(gate de tests: ' + gate.nota + ')'; - return resultado; + return apartarYRegistrar(task, resultado, deps); } if (gate.estado !== 'PASA') { log('Gate de tests (' + task.titulo + '): ' + gate.nota); @@ -616,8 +873,11 @@ async function trasGateTests(task, resultado, deps) { * resultado FALLIDA. De eso depende que el fallo de una task no tumbe a las * hermanas de su mismo nivel cuando el nivel se lanza a la vez. * - * `deps`: `implementar`, `gateTests`, `revisarYComitear`, `descartar`, `log`, - * `env`. + * `deps`: `implementar`, `gateTests`, `revisarYComitear`, `descartar(task)`, + * `log`, `env` y, solo en el recorrido secuencial, `arbolLimpio()`: antes de + * implementar se comprueba que no queda residuo fuera de ai_docs/ (devuelve + * `{ limpio, rutas, error }`). Con residuo la task no se implementa y el + * recorrido se detiene: su `add -A` lo commitearía como propio. */ async function ejecutarTask(task, resultadosPrevios, deps) { const d = deps || {}; @@ -631,12 +891,28 @@ async function ejecutarTask(task, resultadosPrevios, deps) { } try { + if (d.arbolLimpio) { + const estado = await d.arbolLimpio(); + if (!estado || !estado.limpio) { + const resto = (estado && estado.error) || resumenRutas(estado && estado.rutas); + log('Task ' + task.titulo + ': no se implementa, el árbol tiene residuo fuera de ' + + DIR_PLAN + '/ (' + resto + '); el recorrido se detiene'); + const r = resultadoVacio(task, 'No se implementó: residuo sin commitear fuera de ' + + DIR_PLAN + '/ al empezar (' + resto + ')'); + r.detener_recorrido = true; + return r; + } + } + log('Implementando: ' + task.titulo); const resultado = await d.implementar(task); - // Sin resultado, o el propio implementador fallo: nada que revisar ni commitear. + // Sin resultado, o el propio implementador falló: nada que revisar ni + // commitear, pero lo que haya escrito se aparta, recuperable, para que la + // siguiente task empiece con el árbol limpio. if (!resultado || resultado.resultado === 'FALLIDA') { - return resultado || resultadoVacio(task, 'El agente no retorno resultado'); + const fallida = resultado || resultadoVacio(task, 'El agente no retorno resultado'); + return d.descartar ? await apartarYRegistrar(task, fallida, d) : fallida; } return await trasGateTests(task, resultado, d); @@ -665,35 +941,54 @@ async function ejecutarTask(task, resultadosPrevios, deps) { * cual fallo, no solo la primera excepcion). El commit de cada task se acota a * sus propios ficheros declarados (F1: `diffPreparado` con `ambito`), asi que ya * no absorbe en silencio el trabajo sin commitear de una hermana en curso; y una - * task cuyo trabajo desaparecio del working tree -absorbido por otra o borrado - * por su descarte- ya no se reporta COMPLETADA (F2: `revisarYComitear` distingue - * el diff vacio legitimo del inesperado por `archivos_modificados`). Lo que - * SIGUE sin hacer: no hay single-writer por fichero, no detecta colisiones entre - * tasks que tocan el mismo archivo y no particiona el trabajo por ellas ni aisla - * arboles de trabajo. Todas las tasks de un mismo nivel comparten el mismo - * working tree e indice mientras se implementan; si el gate de tests de una - * falla, el descarte de su trabajo (`reset --hard` + `clean -fd`) se lleva TODO - * cambio sin commitear en ese momento, incluido el de las hermanas aun en curso - * -lo que cambia es que esa hermana ya no se reporta como completada-. Evitar - * que dos tasks se pisen (mismo fichero) sigue siendo responsabilidad de quien - * pide el modo concurrente, no del framework. Las puertas de calidad (gate de - * tests, revision del diff) se aplican por task en los dos modos. + * task cuyo trabajo desapareció del working tree -absorbido por otra- ya no se + * reporta COMPLETADA (F2: `revisarYComitear` distingue el diff vacio legitimo + * del inesperado por `archivos_modificados`). El trabajo de una task que no se + * commitea se aparta en `git stash`, acotado a las rutas de su ámbito que + * cambiaron (apartarTrabajo): no se lleva lo que las hermanas tienen en el + * árbol y es recuperable; sin ámbito no se aparta nada. Lo que SIGUE sin hacer: no hay single-writer por + * fichero, no detecta colisiones entre tasks que tocan el mismo archivo y no + * particiona el trabajo por ellas ni aísla árboles de trabajo. Todas las tasks + * de un mismo nivel comparten el mismo working tree e índice mientras se + * implementan: si dos declaran el mismo fichero, el apartado de una se lleva + * también lo que la otra escribió en él. Evitar que dos tasks se pisen sigue + * siendo responsabilidad de quien pide el modo concurrente, no del framework. + * Las puertas de calidad (gate de tests, revisión del diff) se aplican por task + * en los dos modos. + * + * PARADA. Un resultado con `detener_recorrido` (el apartado falló o dejó + * residuo, o la task encontró el árbol sucio al empezar) detiene el recorrido: + * en secuencial tras esa task, en concurrente al cerrar su nivel. Las tasks que + * quedan se reportan FALLIDAS sin implementar, con el motivo. */ async function recorrerNiveles(niveles, ejecutar, opciones) { const modoParalelo = !!(opciones && opciones.modoParalelo); const acumulados = []; + let parada = null; + for (const nivel of (niveles || [])) { const completados = acumulados.slice(); + if (parada) { + for (const task of nivel) acumulados.push(noEjecutada(task, parada)); + continue; + } + if (modoParalelo) { const resultadosNivel = await Promise.all(nivel.map(function (task) { return ejecutar(task, completados); })); - for (const r of resultadosNivel) acumulados.push(r); + for (const r of resultadosNivel) { + acumulados.push(r); + if (!parada && r && r.detener_recorrido) parada = r; + } } else { for (const task of nivel) { - acumulados.push(await ejecutar(task, completados)); + if (parada) { acumulados.push(noEjecutada(task, parada)); continue; } + const r = await ejecutar(task, completados); + acumulados.push(r); + if (r && r.detener_recorrido) parada = r; } } } @@ -701,6 +996,12 @@ async function recorrerNiveles(niveles, ejecutar, opciones) { return acumulados; } +/** Resultado de una task que no llegó a ejecutarse porque el recorrido se detuvo. */ +function noEjecutada(task, parada) { + return resultadoVacio(task, 'No se implementó: el recorrido se detuvo en la task ' + + parada.task_titulo + ' (' + (parada.notas || 'sin motivo registrado') + ')'); +} + /** * Cuenta el resultado del conjunto. Solo COMPLETADA cuenta como completada: un * PARCIAL, un FALLIDA o un resultado ausente dejan la task sin cerrar. @@ -795,6 +1096,11 @@ module.exports = { parsearArgs, gitVerificado, falloDeGit, + evaluarArbolLimpio, + comprobarArbolLimpio, + mensajeArbolSucio, + argvApartado, + apartarTrabajo, revisarYComitear, depsFallidas, ejecutarTask, diff --git a/README.md b/README.md index 0c1292e..d978816 100644 --- a/README.md +++ b/README.md @@ -439,10 +439,29 @@ El framework lanza a la vez las tasks de un nivel y reporta el resultado de cada - **No hay un unico escritor por fichero.** Dos tasks del mismo nivel pueden escribir el mismo archivo. - **No detecta colisiones** entre tasks que tocan los mismos archivos, ni reparte el trabajo por ellas. -- **No aisla el arbol de trabajo.** Todas las tasks de un nivel comparten el mismo working tree mientras se implementan. Si una falla su gate de tests o su revision, el descarte de sus cambios (`git reset --hard` + `git clean -fd`) se lleva por delante todo cambio sin commitear en ese instante, incluido el de una task hermana aun en curso. +- **No aisla el arbol de trabajo.** Todas las tasks de un nivel comparten el mismo working tree mientras se implementan. Si una falla su gate de tests o su revisión, sus ficheros de la tabla "Archivos afectados" que cambiaron se apartan en `git stash` y salen del árbol: lo que las hermanas tienen en el árbol no se toca, salvo en un fichero que declaren las dos. La entrada del stash guarda, eso sí, todo lo que hubiera preparado en el índice en ese momento, también lo de una hermana: para recuperar solo lo de la task, usa `git restore --source=stash@{N} -- ` (ver más abajo). Si falla una task sin tabla legible, no se aparta nada: su trabajo se queda sin commitear en el árbol, y lo dice la nota de su resultado. Evitar que dos tasks hermanas se pisen es **responsabilidad del invocador**, no del framework: pide `--parallel` solo cuando las tasks de cada nivel tocan archivos disjuntos, y si necesitas aislamiento real, separa el trabajo en specs distintas y corre cada una en su propio worktree de git (ver "Aislamiento por worktree" en `/implementar-spec`). +### Árbol limpio y trabajo apartado + +`/implementar-spec` no arranca si hay cambios sin commitear fuera de `ai_docs/`: el commit de la primera task se los llevaría. Commitéalos, apártalos con `git stash push --include-untracked`, o lanza la spec en un worktree limpio. En modo secuencial lo comprueba también antes de cada task, y se detiene si encuentra residuo. + +Ninguna ruta del workflow ejecuta `git reset --hard` ni `git clean`. El trabajo de una task que no se commitea (tests en rojo, revisión adversa o implementador fallido) se aparta en una entrada de `git stash` llamada `sdd-apartado: `: en secuencial, todo el árbol salvo `ai_docs/`; en `--parallel`, del árbol solo salen sus ficheros, aunque la entrada guarde también lo preparado en el índice por una hermana. Si el apartado falla, no se destruye nada y, en secuencial, el recorrido se detiene. Los ficheros ignorados por `.gitignore` no se tocan. + +Para recuperarlo: + +```bash +git stash list # las entradas sdd-apartado: +git stash show -p --include-untracked stash@{N} +git stash pop stash@{N} # lo devuelve al árbol +git restore --source=stash@{N} -- # solo esas rutas, si la entrada trae más +git restore --source='stash@{N}^3' -- # ídem, para ficheros nuevos (sin seguimiento) +git stash drop stash@{N} # o lo retira si ya no lo quieres +``` + +Las entradas se acumulan mientras no las recuperes o retires. + --- ## Que hay en cada carpeta diff --git a/tests/implementar-spec-ejecucion.test.js b/tests/implementar-spec-ejecucion.test.js index b8bfd27..02e4fd8 100644 --- a/tests/implementar-spec-ejecucion.test.js +++ b/tests/implementar-spec-ejecucion.test.js @@ -59,6 +59,8 @@ function cederElTurno() { return new Promise(resolve => setImmediate(resolve)); } +const CIMA_STASH = 'rev-parse -q --verify refs/stash'; + /** * Doble del spawn de git. `respuestas` se indexa por el argv unido con espacios * (o por su primer tramo, para un `commit` cuyo mensaje no se conoce de antemano). @@ -68,6 +70,8 @@ function cederElTurno() { function gitDoble(respuestas) { const llamadas = []; const veces = new Map(); + // Cima de la pila de stash: cambia con cada `stash push` que sale con 0, como en git. + let entradas = 0; async function spawn(argv) { const clave = argv.join(' '); @@ -75,11 +79,16 @@ function gitDoble(respuestas) { const n = (veces.get(clave) || 0) + 1; veces.set(clave, n); - const r = (respuestas || {})[clave] !== undefined + let r = (respuestas || {})[clave] !== undefined ? (respuestas || {})[clave] : (respuestas || {})[argv[0]]; - if (r === undefined) return { code: 0, out: '', err: '' }; - return typeof r === 'function' ? r(n) : r; + if (r === undefined && clave === CIMA_STASH) { + r = entradas > 0 ? { code: 0, out: 'stash' + entradas + '\n', err: '' } : { code: 1, out: '', err: '' }; + } + if (r === undefined) r = { code: 0, out: '', err: '' }; + const salida = typeof r === 'function' ? r(n) : r; + if (argv[0] === 'stash' && argv[1] === 'push' && salida && salida.code === 0) entradas += 1; + return salida; } return { llamadas, spawn }; @@ -457,7 +466,7 @@ function depsRevision(git, overrides) { } test('revisarYComitear: revision aprobada -> constancia atada a ESE diff y commit verificado', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps, visto } = depsRevision(git); const resultado = Object.assign(resultadoDe(tarea('a')), { commit_message: 'feat: implementa a', commit_cuerpo: 'que y por que' }); @@ -471,14 +480,14 @@ test('revisarYComitear: revision aprobada -> constancia atada a ESE diff y commi assert.strictEqual(r.resultado, 'COMPLETADA'); assert.strictEqual(r.marca_revision, 'hash-de-' + DIFF.length); assert.deepStrictEqual(git.llamadas, - ['add -A', 'diff --cached', 'commit -m feat: implementa a -m que y por que']); + ['add -A -- .', 'diff --cached -- .', 'commit -m feat: implementa a -m que y por que -- .']); }); test('revisarYComitear: un diff que no se pudo leer no se confunde con un working tree limpio', async () => { // El defecto que cierra: git no corre, la salida estandar viene vacia igual que // en un working tree limpio, se registraba la constancia de ese vacio y el gate // denegaba el commit despues sin que nada explicase por que. - const git = gitDoble({ 'diff --cached': { code: 128, out: '', err: 'fatal: index.lock existe' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 128, out: '', err: 'fatal: index.lock existe' } }); const { deps, visto } = depsRevision(git); const r = await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); @@ -494,19 +503,19 @@ test('revisarYComitear: un diff que no se pudo leer no se confunde con un workin }); test('revisarYComitear: si la preparacion del diff no corre, no hay revision ni commit', async () => { - const git = gitDoble({ 'add -A': { code: null, out: '', err: 'spawnSync git ETIMEDOUT' } }); + const git = gitDoble({ 'add -A -- .': { code: null, out: '', err: 'spawnSync git ETIMEDOUT' } }); const { deps, visto } = depsRevision(git); const r = await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); - assert.deepStrictEqual(git.llamadas, ['add -A'], 'no se sigue leyendo tras un git que no corrio'); + assert.deepStrictEqual(git.llamadas, ['add -A -- .'], 'no se sigue leyendo tras un git que no corrio'); assert.deepStrictEqual(visto.revisiones, []); assert.strictEqual(r.resultado, 'FALLIDA'); assert.match(r.notas, /sin codigo de salida/); }); test('revisarYComitear: exit 0 sin nada preparado es un working tree limpio, no un fallo', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: '', err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: '', err: '' } }); const { deps, visto } = depsRevision(git); // Task sin ficheros declarados: es la situacion legitima que este caso fija, distinta de F2 // (diff vacio con trabajo declarado), cubierta abajo. @@ -528,7 +537,7 @@ test('revisarYComitear: exit 0 sin nada preparado es un working tree limpio, no // las tasks posteriores avanzaban creyendo que el fichero o el contrato existia. test('revisarYComitear: diff vacio con archivos_modificados declarados es un exito falso, no COMPLETADA', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: '', err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: '', err: '' } }); const { deps, visto } = depsRevision(git); const resultado = resultadoDe(tarea('a')); // archivos_modificados: ['src/a.js'], no vacio @@ -545,7 +554,7 @@ test('ejecutarTask: la task cuyo trabajo desaparecio protege a las posteriores v // Composicion real: el diff vacio inesperado se traduce en FALLIDA, y esa FALLIDA // bloquea a quien dependa de ella (a diferencia de antes, cuando avanzaban sobre un // contrato que en realidad no existia). - const git = gitDoble({ 'diff --cached': { code: 0, out: '', err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: '', err: '' } }); const deps = Object.assign(depsTask().deps, { revisarYComitear: (t, r) => orq.revisarYComitear(t, r, depsRevision(git).deps), }); @@ -592,26 +601,26 @@ test('revisarYComitear: el ambito acota tambien la segunda preparacion, tras una ); }); -test('revisarYComitear: ambito vacio en modo concurrente conserva el add sin acotar y registra el motivo', async () => { +test('revisarYComitear: ambito vacio en modo concurrente acota el add solo al proyecto y registra el motivo', async () => { // Task sin tabla "Archivos afectados" legible: acotar a una lista vacia equivaldria a no - // commitear nunca, asi que se conserva el comportamiento actual (add -A sin pathspec). - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); + // commitear nunca, asi que solo se acota al proyecto (add -A -- .). + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps } = depsRevision(git, { ambito: [] }); const r = await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); - assert.deepStrictEqual(git.llamadas.slice(0, 2), ['add -A', 'diff --cached']); + assert.deepStrictEqual(git.llamadas.slice(0, 2), ['add -A -- .', 'diff --cached -- .']); assert.match(r.notas, /ambito vacio/); assert.strictEqual(r.resultado, 'COMPLETADA'); }); -test('revisarYComitear: sin ambito (modo secuencial) el add sigue sin acotar y sin nota alguna', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); +test('revisarYComitear: sin ambito (modo secuencial) el add se acota al proyecto y sin nota alguna', async () => { + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps } = depsRevision(git); // sin `ambito` en absoluto const r = await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); - assert.deepStrictEqual(git.llamadas.slice(0, 2), ['add -A', 'diff --cached']); + assert.deepStrictEqual(git.llamadas.slice(0, 2), ['add -A -- .', 'diff --cached -- .']); assert.doesNotMatch(r.notas || '', /ambito/); }); @@ -629,20 +638,23 @@ test('revisarYComitear: en modo concurrente, el commit tambien llega acotado al 'el commit debe acotarse al mismo ambito que add/diff, o absorbe el indice de una hermana'); }); -test('revisarYComitear: sin ambito, el commit sigue sin pathspec (compatibilidad con modo secuencial)', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); +test('revisarYComitear: sin ámbito (modo secuencial), add, diff y commit se acotan al proyecto', async () => { + // En un monorepo el proyecto es un subdirectorio: sin `-- .` se prepararía y + // commitearía también lo de fuera, que la comprobación de árbol limpio no mira. + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps } = depsRevision(git); // sin `ambito` await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); + assert.deepStrictEqual(git.llamadas.slice(0, 2), ['add -A -- .', 'diff --cached -- .']); const commit = git.llamadas.find(c => c.startsWith('commit')); - assert.doesNotMatch(commit, /--/, 'sin ambito no debe aparecer pathspec en el commit'); + assert.match(commit, / -- \.$/, 'el commit cubre lo mismo que se revisó'); }); test('revisarYComitear: revision adversa -> una pasada de correccion y re-revision del nuevo diff', async () => { const CORREGIDO = DIFF + '+corregido\n'; const git = gitDoble({ - 'diff --cached': n => ({ code: 0, out: n === 1 ? DIFF : CORREGIDO, err: '' }), + 'diff --cached -- .': n => ({ code: 0, out: n === 1 ? DIFF : CORREGIDO, err: '' }), }); let veredictos = ['NECESITA_CORRECCIONES', 'APROBADA']; const { deps, visto } = depsRevision(git, { @@ -663,7 +675,7 @@ test('revisarYComitear: revision adversa -> una pasada de correccion y re-revisi }); test('revisarYComitear: revision que sigue adversa -> se descarta el trabajo y no se commitea', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps, visto } = depsRevision(git, { revisar: async (t, diff) => { visto1.push(diff); return { veredicto: 'RECHAZADA' }; }, }); @@ -681,7 +693,7 @@ test('revisarYComitear: revision que sigue adversa -> se descarta el trabajo y n }); test('revisarYComitear: una revision que no parsea cuenta como adversa, no como aprobada', async () => { - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps, visto } = depsRevision(git, { revisar: async () => null }); const r = await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); @@ -693,7 +705,7 @@ test('revisarYComitear: una revision que no parsea cuenta como adversa, no como test('revisarYComitear: un commit denegado no se reporta como task commiteada', async () => { const git = gitDoble({ - 'diff --cached': { code: 0, out: DIFF, err: '' }, + 'diff --cached -- .': { code: 0, out: DIFF, err: '' }, commit: { code: 1, out: '', err: 'el gate de revision denego el commit' }, }); const { deps } = depsRevision(git); @@ -707,7 +719,7 @@ test('revisarYComitear: un commit denegado no se reporta como task commiteada', test('revisarYComitear: sin commit_message propuesto el subject se deriva del titulo y se recorta', async () => { const largo = 'titulo interminable '.repeat(6).trim(); - const git = gitDoble({ 'diff --cached': { code: 0, out: DIFF, err: '' } }); + const git = gitDoble({ 'diff --cached -- .': { code: 0, out: DIFF, err: '' } }); const { deps } = depsRevision(git); await orq.revisarYComitear(tarea(largo), { task_titulo: largo, resultado: 'COMPLETADA' }, deps); @@ -929,3 +941,457 @@ test('descubrirComandoTest: package.json con JSON corrupto no tumba el descubrim const r = orq.descubrirComandoTest(base); assert.strictEqual(r.fuente, 'pytest.ini'); }); + +// ── Árbol limpio y apartado recuperable ────────────────────────────────────── +// El descarte de una task fallida era `reset --hard` + `clean -fd` sobre todo el +// árbol: borraba el trabajo sin commitear del usuario y, en concurrente, el de las +// hermanas en curso. Ahora se aparta en `git stash`, y el recorrido secuencial +// exige el árbol limpio fuera de ai_docs/ antes de cada task. +// +// Control positivo: con el descarte antiguo cableado como `descartar`, los casos +// que miran el git grabado caen por la llamada a `reset --hard`. + +const ESTADO = 'status --porcelain --untracked-files=all -- . :(exclude)ai_docs'; +const APARTADO_SECUENCIAL = /^stash push --include-untracked -m sdd-apartado: .+ -- \. :\(exclude\)ai_docs$/; + +function sinDestruccion(llamadas) { + for (const c of llamadas) { + assert.ok(!/^reset --hard/.test(c), 'ninguna ruta del workflow destruye el trabajo: ' + c); + assert.ok(!/^clean /.test(c), 'ninguna ruta del workflow destruye el trabajo: ' + c); + } +} + +/** `descartar(task)` cableado como en el workflow, sobre el git grabado. */ +function descartarCon(git, modoParalelo, ambito) { + return t => orq.apartarTrabajo(t, { spawnGit: git.spawn, modoParalelo, ambito }); +} + +/** + * Doble de git con estado: el implementador «escribe» poniendo `sucio`, el + * apartado lo limpia y `status` informa según ese estado. Así se observa con qué + * árbol empieza la task siguiente. + */ +function gitConEstado() { + const arbol = { sucio: false, stashes: 0 }; + const git = gitDoble({ + [ESTADO]: () => ({ code: 0, out: arbol.sucio ? '?? src/escrito.js\n' : '', err: '' }), + stash: () => { arbol.sucio = false; arbol.stashes += 1; return { code: 0, out: '', err: '' }; }, + }); + return { arbol, git }; +} + +test('evaluarArbolLimpio: un cambio fuera de ai_docs/ ensucia el árbol; uno dentro, no', () => { + assert.deepStrictEqual(orq.evaluarArbolLimpio(''), { limpio: true, rutas: [] }); + assert.deepStrictEqual(orq.evaluarArbolLimpio('?? ai_docs/tasks/001_a.md\n M ai_docs/core/x.md\n'), + { limpio: true, rutas: [] }); + assert.deepStrictEqual( + orq.evaluarArbolLimpio(' M src/a.js\n?? "con espacio.txt"\nR viejo.js -> nuevo.js\n?? ai_docs/x.md\n'), + { limpio: false, rutas: ['src/a.js', 'con espacio.txt', 'nuevo.js'] }); + // Un fichero cuyo nombre solo empieza como el directorio de plan no está exento. + assert.strictEqual(orq.evaluarArbolLimpio('?? ai_docs_copia.md\n').limpio, false); +}); + +test('comprobarArbolLimpio: git que no corre no acredita un árbol limpio', async () => { + const git = gitDoble({ [ESTADO]: { code: 128, out: '', err: 'fatal: not a git repository' } }); + const r = await orq.comprobarArbolLimpio(git.spawn); + assert.strictEqual(r.limpio, false); + assert.match(r.error, /exit 128/); + assert.match(orq.mensajeArbolSucio(r), /no se pudo comprobar/); +}); + +test('arranque: con un fichero fuera de ai_docs/ modificado no se implementa y el mensaje da las tres salidas', async () => { + const git = gitDoble({ [ESTADO]: { code: 0, out: ' M src/a.js\n', err: '' } }); + const estado = await orq.comprobarArbolLimpio(git.spawn); + assert.strictEqual(estado.limpio, false); + const mensaje = orq.mensajeArbolSucio(estado); + assert.match(mensaje, /src\/a\.js/); + assert.match(mensaje, /commit/); + assert.match(mensaje, /git stash push --include-untracked/); + assert.match(mensaje, /worktree/); + + // La misma comprobación, antes de cada task: el doble de implementar no se llama. + const { deps, visto } = depsTask({ arbolLimpio: () => orq.comprobarArbolLimpio(git.spawn) }); + const resultados = await orq.recorrerNiveles([tareas('a', 'b')], (t, p) => orq.ejecutarTask(t, p, deps)); + assert.deepStrictEqual(visto.implementadas, []); + assert.strictEqual(resultados[0].detener_recorrido, true); + assert.match(resultados[0].notas, /residuo sin commitear fuera de ai_docs\/ al empezar \(src\/a\.js\)/); + assert.match(resultados[1].notas, /el recorrido se detuvo en la task a/); + assert.deepStrictEqual(orq.resumirResultados(resultados), { completadas: 0, fallidas: 2 }); +}); + +test('arranque: un cambio solo bajo ai_docs/ no impide implementar', async () => { + const git = gitDoble({ [ESTADO]: { code: 0, out: '?? ai_docs/tasks/001_a.md\n', err: '' } }); + assert.strictEqual((await orq.comprobarArbolLimpio(git.spawn)).limpio, true); + + const { deps, visto } = depsTask({ arbolLimpio: () => orq.comprobarArbolLimpio(git.spawn) }); + await orq.recorrerNiveles([tareas('a', 'b')], (t, p) => orq.ejecutarTask(t, p, deps)); + assert.deepStrictEqual(visto.implementadas, ['a', 'b']); +}); + +test('apartado: gate rojo en secuencial aparta todo el árbol salvo ai_docs/, sin reset ni clean', async () => { + const git = gitDoble(); + const { deps } = depsTask({ + gateTests: async () => ({ estado: 'FALLIDA', nota: 'Tests en rojo (exit 1) via package.json' }), + descartar: descartarCon(git, false), + }); + + const r = await orq.ejecutarTask(tarea('a'), [], deps); + + assert.strictEqual(r.resultado, 'FALLIDA'); + assert.ok(git.llamadas.some(c => APARTADO_SECUENCIAL.test(c)), git.llamadas.join('\n')); + assert.ok(git.llamadas.includes('stash push --include-untracked -m sdd-apartado: a -- . :(exclude)ai_docs')); + sinDestruccion(git.llamadas); + assert.match(r.notas, /trabajo apartado en git stash "sdd-apartado: a"/); + assert.ok(!r.detener_recorrido); +}); + +test('apartado: gate rojo en concurrente aparta solo las rutas cambiadas del ámbito', async () => { + const git = gitDoble({ + 'status --porcelain -z --untracked-files=all -- src/a.js src/a.test.js': + { code: 0, out: 'M src/a.js\0?? src/a.test.js\0', err: '' }, + }); + const { deps } = depsTask({ + gateTests: async () => ({ estado: 'FALLIDA', nota: 'Tests en rojo (exit 1) via package.json' }), + descartar: descartarCon(git, true, ['src/a.js', 'src/a.test.js']), + }); + + const r = await orq.ejecutarTask(tarea('a'), [], deps); + + assert.strictEqual(r.resultado, 'FALLIDA'); + assert.ok(git.llamadas.includes('stash push --include-untracked -m sdd-apartado: a -- src/a.js src/a.test.js'), + git.llamadas.join('\n')); + assert.ok(!git.llamadas.some(c => APARTADO_SECUENCIAL.test(c)), 'en concurrente no se aparta el árbol entero'); + sinDestruccion(git.llamadas); +}); + +test('apartado: revisión adversa tras la corrección aparta, en los dos modos, sin reset ni clean', async () => { + for (const modoParalelo of [false, true]) { + const ambito = modoParalelo ? ['src/a.js'] : undefined; + const git = gitDoble({ + 'diff --cached -- .': { code: 0, out: DIFF, err: '' }, + 'diff --cached -- src/a.js': { code: 0, out: DIFF, err: '' }, + 'status --porcelain -z --untracked-files=all -- src/a.js': { code: 0, out: ' M src/a.js\0', err: '' }, + }); + const { deps } = depsRevision(git, { + revisar: async () => ({ veredicto: 'RECHAZADA' }), + descartar: descartarCon(git, modoParalelo, ambito), + ambito, + }); + + const r = await orq.revisarYComitear(tarea('a'), resultadoDe(tarea('a')), deps); + + assert.strictEqual(r.resultado, 'FALLIDA'); + const esperado = modoParalelo + ? 'stash push --include-untracked -m sdd-apartado: a -- src/a.js' + : 'stash push --include-untracked -m sdd-apartado: a -- . :(exclude)ai_docs'; + assert.ok(git.llamadas.includes(esperado), 'modoParalelo=' + modoParalelo + '\n' + git.llamadas.join('\n')); + assert.ok(!git.llamadas.some(c => c.startsWith('commit'))); + sinDestruccion(git.llamadas); + } +}); + +test('apartado: en concurrente sin ámbito no se aparta nada y la task queda FALLIDA con la nota', async () => { + const git = gitDoble(); + const { deps } = depsTask({ + gateTests: async () => ({ estado: 'FALLIDA', nota: 'Tests en rojo (exit 1) via package.json' }), + descartar: descartarCon(git, true, []), + }); + + const r = await orq.ejecutarTask(tarea('a'), [], deps); + + assert.strictEqual(r.resultado, 'FALLIDA'); + assert.ok(!git.llamadas.some(c => c.startsWith('stash')), git.llamadas.join('\n')); + sinDestruccion(git.llamadas); + assert.match(r.notas, /trabajo conservado sin commitear/); + assert.ok(!r.detener_recorrido, 'en concurrente las hermanas siguen'); +}); + +test('apartado: en concurrente, nada cambiado en el ámbito -> no hay stash que pueda fallar a medias', async () => { + const git = gitDoble(); + const r = await orq.apartarTrabajo(tarea('a'), { spawnGit: git.spawn, modoParalelo: true, ambito: ['src/a.js'] }); + assert.strictEqual(r.ok, true); + assert.ok(!git.llamadas.some(c => c.startsWith('stash'))); +}); + +test('apartado: un stash que sale con 128 no destruye nada y detiene el recorrido secuencial', async () => { + const git = gitDoble({ stash: { code: 128, out: '', err: 'fatal: index.lock' } }); + const { deps, visto } = depsTask({ + gateTests: async t => (t.titulo === 'a' + ? { estado: 'FALLIDA', nota: 'Tests en rojo (exit 1) via package.json' } + : { estado: 'PASA', nota: 'verde' }), + descartar: descartarCon(git, false), + }); + + const resultados = await orq.recorrerNiveles([tareas('a', 'b')], (t, p) => orq.ejecutarTask(t, p, deps)); + + sinDestruccion(git.llamadas); + assert.deepStrictEqual(visto.implementadas, ['a'], 'la siguiente absorbería el trabajo no apartado'); + assert.strictEqual(resultados[0].resultado, 'FALLIDA'); + assert.strictEqual(resultados[0].detener_recorrido, true); + assert.match(resultados[0].notas, /no se pudo apartar el trabajo .*exit 128.*se conserva sin commitear/); + assert.match(resultados[1].notas, /el recorrido se detuvo en la task a/); +}); + +test('apartado: si tras apartar queda residuo, el recorrido se detiene y la task siguiente no se implementa', async () => { + // Primera lectura (precheck de a): limpio. Segunda (tras el stash): residuo. + const git = gitDoble({ + [ESTADO]: n => ({ code: 0, out: n === 1 ? '' : '?? src/ignorado-por-el-stash.js\n', err: '' }), + }); + const { deps, visto } = depsTask({ + gateTests: async () => ({ estado: 'FALLIDA', nota: 'Tests en rojo (exit 1) via package.json' }), + descartar: descartarCon(git, false), + arbolLimpio: () => orq.comprobarArbolLimpio(git.spawn), + }); + + const resultados = await orq.recorrerNiveles([tareas('a'), tareas('b')], (t, p) => orq.ejecutarTask(t, p, deps)); + + assert.deepStrictEqual(visto.implementadas, ['a']); + assert.strictEqual(resultados[0].detener_recorrido, true); + assert.match(resultados[0].notas, /queda residuo: src\/ignorado-por-el-stash\.js/); + assert.strictEqual(resultados[1].resultado, 'FALLIDA'); + sinDestruccion(git.llamadas); +}); + +test('apartado: el implementador que escribe y devuelve FALLIDA o nada deja el árbol limpio a la siguiente', async () => { + for (const devuelto of ['FALLIDA', null]) { + const { arbol, git } = gitConEstado(); + const { deps, visto } = depsTask({ + implementar: async t => { + visto.implementadas.push(t.titulo); + if (t.titulo !== 'a') return resultadoDe(t); + arbol.sucio = true; + return devuelto && resultadoDe(t, devuelto); + }, + descartar: descartarCon(git, false), + arbolLimpio: () => orq.comprobarArbolLimpio(git.spawn), + }); + + const resultados = await orq.recorrerNiveles([tareas('a', 'b')], (t, p) => orq.ejecutarTask(t, p, deps)); + + assert.ok(git.llamadas.includes('stash push --include-untracked -m sdd-apartado: a -- . :(exclude)ai_docs'), + 'devuelto=' + devuelto); + assert.strictEqual(arbol.stashes, 1); + assert.deepStrictEqual(visto.implementadas, ['a', 'b'], 'devuelto=' + devuelto); + assert.deepStrictEqual(resultados.map(r => r.resultado), ['FALLIDA', 'COMPLETADA']); + sinDestruccion(git.llamadas); + } +}); + +test('apartado: sin el apartado en esa rama, el precheck por task detiene el recorrido', async () => { + for (const devuelto of ['FALLIDA', null]) { + const { arbol, git } = gitConEstado(); + const { deps, visto } = depsTask({ + implementar: async t => { + visto.implementadas.push(t.titulo); + arbol.sucio = true; + return devuelto && resultadoDe(t, devuelto); + }, + descartar: undefined, + arbolLimpio: () => orq.comprobarArbolLimpio(git.spawn), + }); + + const resultados = await orq.recorrerNiveles([tareas('a', 'b')], (t, p) => orq.ejecutarTask(t, p, deps)); + + assert.deepStrictEqual(visto.implementadas, ['a'], 'devuelto=' + devuelto); + assert.strictEqual(resultados[1].detener_recorrido, true); + assert.match(resultados[1].notas, /residuo sin commitear .*src\/escrito\.js/); + } +}); + +// ── Apartado con git real ──────────────────────────────────────────────────── +// El doble acredita qué se pide a git; estos casos acreditan que git hace lo que +// se espera de esos argumentos: qué sale del árbol, qué queda y qué guarda el stash. + +const cp = require('node:child_process'); + +function repoTemporal(subdirProyecto) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'apartado-')); + // `spawn` corre donde el workflow: en la raíz del proyecto, que en un monorepo + // es un subdirectorio del repositorio. + const proyecto = subdirProyecto ? path.join(dir, subdirProyecto) : dir; + fs.mkdirSync(proyecto, { recursive: true }); + const spawn = async argv => { + const r = cp.spawnSync('git', argv, { cwd: proyecto, encoding: 'utf8' }); + return { code: r.status, out: r.stdout || '', err: r.stderr || (r.error ? r.error.message : '') }; + }; + const g = argv => { + const r = cp.spawnSync('git', argv, { cwd: dir, encoding: 'utf8' }); + assert.strictEqual(r.status, 0, argv.join(' ') + ': ' + r.stderr); + return r.stdout; + }; + g(['init', '-q']); + g(['config', 'user.name', 'prueba']); + g(['config', 'user.email', 'prueba@example.com']); + g(['config', 'commit.gpgsign', 'false']); + return { dir, spawn, g, escribir: (ruta, txt) => escribirArchivo(dir, ruta, txt) }; +} + +test('apartado con git real, secuencial: sale todo lo de la task, también lo escrito fuera de su tabla; ai_docs/ queda', async () => { + const repo = repoTemporal(); + try { + repo.escribir('base.txt', 'base\n'); + repo.g(['add', '-A']); + repo.g(['commit', '-q', '-m', 'base']); + + repo.escribir('a.txt', 'de la task\n'); + repo.escribir('fuera.txt', 'fuera de su tabla\n'); + repo.escribir('ai_docs/x.md', 'plan\n'); + + const r = await orq.apartarTrabajo(tarea('a'), { spawnGit: repo.spawn, modoParalelo: false }); + + assert.strictEqual(r.ok, true, r.nota); + assert.ok(!fs.existsSync(path.join(repo.dir, 'a.txt'))); + assert.ok(!fs.existsSync(path.join(repo.dir, 'fuera.txt')), 'la siguiente task lo commitearía como suyo'); + assert.strictEqual(fs.readFileSync(path.join(repo.dir, 'ai_docs/x.md'), 'utf8'), 'plan\n'); + assert.strictEqual(repo.g(['status', '--porcelain', '--', '.', ':(exclude)ai_docs']), ''); + assert.strictEqual((await orq.comprobarArbolLimpio(repo.spawn)).limpio, true); + + const guardado = repo.g(['stash', 'show', '-p', '--include-untracked', 'stash@{0}']); + assert.match(guardado, /a\.txt/); + assert.match(guardado, /fuera\.txt/); + assert.doesNotMatch(guardado, /ai_docs/); + assert.match(repo.g(['stash', 'list']), /sdd-apartado: a/); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test('apartado con git real, concurrente: sale solo el ámbito de la task; la hermana queda intacta', async () => { + const repo = repoTemporal(); + try { + repo.escribir('a.txt', 'a original\n'); + repo.escribir('b.txt', 'b original\n'); + repo.g(['add', '-A']); + repo.g(['commit', '-q', '-m', 'base']); + + repo.escribir('a.txt', 'a de la task\n'); + repo.escribir('nuevo.txt', 'nuevo de la task\n'); + repo.escribir('b.txt', 'b de la hermana\n'); + // El gate de tests ya preparó el ámbito de la task en el índice compartido. + repo.g(['add', '-A', '--', 'a.txt', 'nuevo.txt']); + + // El ámbito declara también un fichero que la task no llegó a crear: con él en + // el pathspec, `git stash` guardaría la entrada y dejaría el árbol sin limpiar. + const r = await orq.apartarTrabajo(tarea('a'), { + spawnGit: repo.spawn, modoParalelo: true, ambito: ['a.txt', 'nuevo.txt', 'declarado_sin_crear.txt'], + }); + + assert.strictEqual(r.ok, true, r.nota); + assert.strictEqual(fs.readFileSync(path.join(repo.dir, 'b.txt'), 'utf8'), 'b de la hermana\n'); + assert.strictEqual(fs.readFileSync(path.join(repo.dir, 'a.txt'), 'utf8'), 'a original\n'); + assert.ok(!fs.existsSync(path.join(repo.dir, 'nuevo.txt'))); + assert.strictEqual(repo.g(['status', '--porcelain']), ' M b.txt\n'); + + const guardado = repo.g(['stash', 'show', '-p', '--include-untracked', 'stash@{0}']); + assert.match(guardado, /a de la task/); + assert.match(guardado, /nuevo de la task/); + assert.doesNotMatch(guardado, /b\.txt/); + assert.strictEqual(repo.g(['stash', 'list']).trim().split('\n').length, 1, 'una sola entrada'); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test('comprobarArbolLimpio con git real: ai_docs/ no cuenta; cualquier otra ruta sí', async () => { + const repo = repoTemporal(); + try { + repo.escribir('base.txt', 'base\n'); + repo.g(['add', '-A']); + repo.g(['commit', '-q', '-m', 'base']); + + repo.escribir('ai_docs/tasks/001_a.md', 'plan\n'); + assert.strictEqual((await orq.comprobarArbolLimpio(repo.spawn)).limpio, true); + + repo.escribir('src/a.js', 'x\n'); + assert.deepStrictEqual(await orq.comprobarArbolLimpio(repo.spawn), { limpio: false, rutas: ['src/a.js'] }); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test('apartado: si git stash no guarda nada, la nota no dice que se apartó', async () => { + // «No local changes to save» sale con 0 y no crea entrada: la cima de la pila no cambia. + const git = gitDoble({ [CIMA_STASH]: { code: 0, out: 'stash-previo\n', err: '' } }); + const r = await orq.apartarTrabajo(tarea('a'), { spawnGit: git.spawn, modoParalelo: false }); + + assert.strictEqual(r.ok, true); + assert.doesNotMatch(r.nota, /trabajo apartado/); + assert.match(r.nota, /nada que apartar/); +}); + +test('apartado con git real, concurrente: un borrado ya preparado en el índice se aparta y se recupera', async () => { + const repo = repoTemporal(); + try { + repo.escribir('a.txt', 'a original\n'); + repo.escribir('borrar.txt', 'se borra\n'); + repo.escribir('b.txt', 'b original\n'); + repo.g(['add', '-A']); + repo.g(['commit', '-q', '-m', 'base']); + + // La task modifica a.txt y borra borrar.txt; la hermana cambia b.txt y lo prepara. + repo.escribir('a.txt', 'a de la task\n'); + fs.rmSync(path.join(repo.dir, 'borrar.txt')); + repo.escribir('b.txt', 'b de la hermana\n'); + repo.g(['add', '--', 'b.txt']); + + // Estado que deja la primera preparación de la revisión (`add -A -- <ámbito>`): + // el borrado queda preparado en el índice y la ruta ya no existe ni en el + // árbol ni en el índice. + const ambito = ['a.txt', 'borrar.txt']; + repo.g(['add', '-A', '--'].concat(ambito)); + + const r = await orq.apartarTrabajo(tarea('a'), { spawnGit: repo.spawn, modoParalelo: true, ambito }); + + assert.strictEqual(r.ok, true, r.nota); + assert.match(r.nota, /trabajo apartado en git stash "sdd-apartado: a"/); + assert.strictEqual(repo.g(['status', '--porcelain', '--', 'a.txt', 'borrar.txt']), ''); + assert.strictEqual(fs.readFileSync(path.join(repo.dir, 'b.txt'), 'utf8'), 'b de la hermana\n'); + + const guardado = repo.g(['diff', 'stash@{0}^1', 'stash@{0}', '--', 'a.txt', 'borrar.txt']); + assert.match(guardado, /a de la task/); + assert.match(guardado, /deleted file mode [0-7]+\n[^\n]*\n--- a\/borrar\.txt/); + + // Recuperable solo lo de la task, aunque la entrada guarde también el índice de la hermana. + repo.g(['restore', '--source=stash@{0}', '--', 'a.txt', 'borrar.txt']); + assert.strictEqual(fs.readFileSync(path.join(repo.dir, 'a.txt'), 'utf8'), 'a de la task\n'); + assert.ok(!fs.existsSync(path.join(repo.dir, 'borrar.txt'))); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); + +test('commit con git real en un monorepo: lo de fuera del proyecto no entra en el commit de la task', async () => { + const repo = repoTemporal('proyecto'); + try { + repo.escribir('otro.txt', 'otro original\n'); + repo.escribir('proyecto/a.txt', 'a original\n'); + repo.g(['add', '-A']); + repo.g(['commit', '-q', '-m', 'base']); + + // otro.txt queda fuera del proyecto: la comprobación de árbol limpio no lo ve. + repo.escribir('otro.txt', 'cambio ajeno al proyecto\n'); + assert.strictEqual((await orq.comprobarArbolLimpio(repo.spawn)).limpio, true); + repo.escribir('proyecto/a.txt', 'a de la task\n'); + + const revisados = []; + const deps = { + spawnGit: repo.spawn, + log: () => {}, + revisar: async (t, diff) => { revisados.push(diff); return { veredicto: 'APROBADA' }; }, + corregir: async () => {}, + emitirSenal: async () => 'marca', + descartar: async () => {}, + }; + const resultado = Object.assign(resultadoDe(tarea('a')), + { archivos_modificados: ['a.txt'], commit_message: 'feat: a' }); + + const r = await orq.revisarYComitear(tarea('a'), resultado, deps); + + assert.strictEqual(r.resultado, 'COMPLETADA', r.notas); + assert.doesNotMatch(revisados[0], /otro\.txt/, 'el revisor no ve lo de fuera del proyecto'); + assert.strictEqual(repo.g(['show', '--name-only', '--format=', 'HEAD']), 'proyecto/a.txt\n'); + assert.strictEqual(repo.g(['status', '--porcelain']), ' M otro.txt\n'); + } finally { + fs.rmSync(repo.dir, { recursive: true, force: true }); + } +}); From c5ef69a642aad7ba9afa5c5326657a09d633da62 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:44:24 +0000 Subject: [PATCH 15/15] docs: registrar en el CHANGELOG los cambios de esta rama MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade a [Unreleased] el apartado sin destruir de /implementar-spec, las garantías nuevas de /planificar y de la revisión, el ámbito CommonJS en destinos ESM, los arreglos de update, de los hooks y de session-analyzer, el validador de frontmatter y el CI mínimo. Corrige la entrada de --parallel, que aún decía que el descarte de una task fallida borraba los cambios de sus hermanas. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y6TkghJV3bztMnLNXV4jTS --- CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e0074b..b1291f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,19 @@ El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y e - Aviso en el README sobre el riesgo de sobrescritura al instalar sobre un proyecto existente (deteccion de colision, comportamiento en TTY vs CI, y los archivos protegidos que quedan fuera de esa deteccion), con `--dry-run` documentado como primer paso recomendado antes de instalar sobre un proyecto existente - Nota en el README explicando por que el framework no ofrece una via nativa de plugin de Claude Code: un plugin se copia al cache de Claude Code, no a tu proyecto, asi que no puede entregar `CLAUDE.md` como contexto del proyecto ni escribir `.claude/agents/`, `.claude/commands/`, `.claude/skills/` ni `hooks/` en tu repositorio, que es justo lo que hace `npx ... install --backend claude` - Tests de contrato para instalacion secuencial de varios backends en el mismo proyecto (`claude` seguido de `gemini` o `codex`): verifican que los archivos propios de cada backend se preservan y que los archivos comunes se refrescan de forma identica a una instalacion limpia -- **Modo concurrente a peticion en `/implementar-spec` (`--parallel`).** Sin el flag el comportamiento no cambia: cada task se implementa, se revisa y se commitea antes de empezar la siguiente. Con el flag, las tasks de un mismo nivel de dependencias arrancan a la vez; los niveles se siguen recorriendo en orden. Las puertas de calidad son identicas en los dos modos: la suite del proyecto se ejecuta de verdad por task y cuenta su codigo de salida real, el diff de cada task pasa por revision adversarial antes del commit y la ultima task cierra con la comprobacion de convergencia con la spec. Una task fallida no cancela a sus hermanas. Lo que el framework NO hace en este modo: no reparte los archivos entre tasks, no detecta que dos tasks del mismo nivel escriban el mismo archivo y no aisla el arbol de trabajo, asi que el descarte de una task fallida se lleva por delante los cambios sin commitear de sus hermanas. Pedir `--parallel` solo con tasks que tocan archivos disjuntos es responsabilidad de quien invoca; el README documenta los dos modos lado a lado con la frontera de responsabilidad. Solo el backend de Claude Code tiene motor de workflows: en los demas, el orquestador implementa las tasks en orden +- **Modo concurrente a peticion en `/implementar-spec` (`--parallel`).** Sin el flag el comportamiento no cambia: cada task se implementa, se revisa y se commitea antes de empezar la siguiente. Con el flag, las tasks de un mismo nivel de dependencias arrancan a la vez; los niveles se siguen recorriendo en orden. Las puertas de calidad son identicas en los dos modos: la suite del proyecto se ejecuta de verdad por task y cuenta su codigo de salida real, el diff de cada task pasa por revision adversarial antes del commit y la ultima task cierra con la comprobacion de convergencia con la spec. Una task fallida no cancela a sus hermanas. Lo que el framework NO hace en este modo: no reparte los archivos entre tasks, no detecta que dos tasks del mismo nivel escriban el mismo archivo y no aisla el arbol de trabajo. El trabajo de una task fallida se aparta en `git stash` acotado a las rutas que cambio dentro de su ambito, sin tocar el de sus hermanas (ver "Fixed"). Pedir `--parallel` solo con tasks que tocan archivos disjuntos es responsabilidad de quien invoca; el README documenta los dos modos lado a lado con la frontera de responsabilidad. Solo el backend de Claude Code tiene motor de workflows: en los demas, el orquestador implementa las tasks en orden + +- CI minimo (`.github/workflows/test.yml`): `npm test` y el gate de deriva (`node scripts/compile.js --check`) en cada PR y en push a `main`, con permisos de solo lectura y la version minima de Node que declara `engines`. La suite usa `--test-timeout` para que una prueba colgada no bloquee la ejecucion sin nombrar su fichero +- Validador de frontmatter en proceso (`tests/frontmatter.test.js`) para skills, fragmentos de `docs-src`, agentes y comandos, y guía de autoría en `CONTRIBUTING.md` (fórmula de la `description`, cuándo usar `disable-model-invocation`, campos por tipo y tokens que leen los hooks) +- `update` avisa, sin fusionar nada, de los hooks y claves de la plantilla que faltan en un `.claude/settings.json` o `hooks/config.json` editado localmente, con la entrada exacta a añadir o la opción `--reset-protected` ### Changed +- **`/implementar-spec` no arranca con cambios sin commitear fuera de `ai_docs/` y aparta en vez de borrar.** El trabajo de una task que no se commitea queda en un `git stash` con nombre (`sdd-apartado: `), recuperable; en secuencial se comprueba el árbol antes de cada task y `add`/`diff`/`commit` se acotan al proyecto, así que en un monorepo no arrastran cambios de fuera. El README explica cómo recuperar lo apartado +- **La revisión bloquea solo lo material.** Una sola definición de BLOQUEANTE (defecto material con evidencia, incluido todo fallo de seguridad sea cual sea su severidad percibida); la duda sin evidencia va como aviso. El revisor reporta las tasks de convergencia en vez de crearlas. Los agentes aplican los pasos de su plantilla pertinentes al riesgo y justifican los omitidos; cada agente tiene una descripción única en los cuatro backends +- Las plantillas de planificación piden premisas verificadas con evidencia, control positivo y negativo en los criterios de ausencia y un registro de decisiones con estado (`VIGENTE` / `SUSTITUIDA` / `DESCARTADA`); las de implementación y tests piden reportar los hallazgos fuera de alcance, declarar el coste de cada verificación nueva y no depender del reloj +- La plantilla de commit enseña a escribir el mensaje en un fichero y pasarlo con `git commit -F`, y el lint de commits lo lee; no pide una segunda confirmación cuando el usuario ya pidió el commit +- `engines.node` sube a `>=20.11`, la versión mínima que admite `--test-timeout` - **Las diez skills que Codex y Antigravity mantenian a mano pasan a generarse desde su comando.** `.agents/skills/{asesor,auditar,estado,implementar,implementar-spec,inicio,planificar,revision,spec,tareas}/SKILL.md` eran copias sin lazo mecanico con su origen: una regla anadida al comando de `.claude/commands/` llegaba a Claude Code y a Gemini CLI, y a esos dos backends solo si alguien la portaba a mano en la misma PR. Ahora salen del mismo comando con el transform `command-to-skill`, y el gate de deriva (`npm run check-drift`) las verifica como a cualquier otra salida generada. Lo propio del formato de skill (el frontmatter de activacion, y el texto que solo tiene sentido ahi) se declara en `docs-src/skills/.md`; `planificar` e `implementar-spec` declaran ademas `source-body: omit` y aportan su procedimiento completo, porque su comando delega en el motor de workflows que solo tiene Claude Code, que es la misma excepcion que ya tenian esos dos comandos para Gemini - Un bloque `` … `` dentro de un comando marca el texto que solo vale en el backend de Claude Code y no llega a ninguna otra superficie generada. Es la via para que una instruccion viva en el comando canonico sin propagarse; sin marcador, todo el cuerpo viaja - **El framework deja de declarar un modo de ejecucion por defecto: ni lo prohibe, ni lo prescribe.** Los documentos de contexto de los cuatro backends, las plantillas de planificacion y el README presentaban antes "una task tras otra" como el unico modo posible, y algunas superficies llegaban a descartar la ejecucion concurrente; una correccion intermedia sustituyo esa prohibicion por la prescripcion contraria (un "por defecto" declarado como comportamiento del producto), lo que dejaba dos afirmaciones incompatibles conviviendo en el mismo repositorio: la plantilla de roadmap ya delegaba la decision en quien implementa el plan, mientras el nucleo compartido, sus tres fragmentos de backend, el comando `/implementar-spec`, las plantillas distribuidas (`core_templates/`, `dev_templates/`), los manifiestos (`package.json`, `gemini-extension.json`) y el ejemplo de dogfood seguian fijandola como regla. Ahora ninguna superficie nucleo, plantilla o manifiesto narra el par defecto/peticion como comportamiento del framework: el orden de dependencias sigue siendo un hecho estructural del plan, y si se ejecuta en secuencia o en paralelo lo decide quien implementa. La capacidad tecnica (el flag `--parallel` de `/implementar-spec` y el computo de niveles de dependencia) no cambia. Los dos tests que exigian la doctrina (`tests/backend-parity-content.test.js`, `tests/compile.test.js`) dejan de exigirla sin perder la red que impide reintroducir la prohibicion contraria (`negacionCategorica` sobre el README); `tests/vocabulario-concurrencia.test.js` deja de exigir que los manifiestos califiquen el modo lineal de "por defecto" y su comentario de contrato deja de certificar esa redaccion como "la descripcion correcta del producto", conservando intacto su barrido anti-prohibicion. Se corrigen ademas, en la misma revision: el wiring de Antigravity, ausente de la seccion de hooks del README pese a estar cableado (con un canario nuevo que impide que vuelva a faltar); la promesa de que las reglas de Cursor replican el flujo SDD (ninguna de las 43 lo hace); un modo `enforce` de `sdd-turn-budget` que podia bloquear su propio checkpoint cuando el commit pendiente incluia un archivo nuevo; el corazon del gate de tests (`evaluarGateTests`/`descubrirComandoTest`) sin ningun test directo pese a ser la garantia central de que el gate ejecuta el comando real; y el modelo de sesion anunciado por el instalador, que citaba `claude-opus-4-8` de memoria en vez de leerlo de `.claude/settings.json` @@ -34,6 +43,13 @@ El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/) y e ### Fixed +- `/implementar-spec` descartaba el trabajo de una task fallida con `reset --hard` + `clean -fd` sobre todo el árbol: borraba cambios sin commitear del usuario y, en `--parallel`, el trabajo en curso de las tareas hermanas. Ahora lo aparta en `git stash` y nunca destruye +- `/planificar` podía aprobar un plan con una task cuya revisión falló, o aceptar un `APROBADO` con huecos o incoherencias. Ahora ambos casos dan al menos `NECESITA_AJUSTES` y la segunda pasada revisa solo lo pendiente, con sus hallazgos previos +- En un proyecto con `"type": "module"`, los workflows fallaban con `require is not defined`: `.claude/workflows/lib/` y `scripts/` llevan ahora su propio `package.json` con `"type": "commonjs"` +- `update` daba por editado localmente un fichero que solo cambiaba de fin de línea (`core.autocrlf`) o que el usuario ya había dejado igual a la versión nueva, y dejaba de actualizarlo. El hash normaliza CRLF en ficheros de texto y los sidecars existentes siguen valiendo +- `sdd-read-before-edit` avisaba al editar un fichero que el propio agente acababa de crear +- `sdd-session-start` no avisaba cuando no podía crear el directorio del registro de sesiones; los tests de hooks ya no dependen del reloj ni se omiten al ejecutar como root +- `session-analyzer` contaba varias veces cada respuesta (una por línea de streaming), no tarifaba la caché ni los modelos vigentes y no incluía los subagentes - `/estado` no sabia reportar las tasks de convergencia en Codex ni en Antigravity: la clasificacion de `NNN_convergencia_.md` y la linea "Tasks de convergencia pendientes" estaban en el comando de Claude Code y en el de Gemini CLI, pero no en la skill que leen los otros dos backends, que si generan esas tasks al cerrar una spec. Al generarse la skill desde el comando, los cuatro backends reportan lo mismo - La skill de implementacion de una spec completa (Codex y Antigravity) solo contemplaba dos veredictos de convergencia (`CONVERGIDA` y `DIVERGE`) y no decia que hacer cuando una task quedaba FALLIDA o BLOQUEADA. Se anade el tercer veredicto, `OMITIDA`, ya presente en el comando de Claude Code, con la via de salida: resolver esas tasks y volver a lanzar la implementacion - El aviso sobre el gate de revision aparecia en los comandos `revision` e `implementar` del backend de Gemini CLI, donde ese hook no se cablea y por tanto no hay forma legitima de satisfacerlo — justo lo contrario de lo que declaran `GEMINI.md` y la tabla de mapeo de hooks. El aviso queda marcado como exclusivo de Claude Code y ya no viaja a los demas backends