@cristobalbn/chaski 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/inject.mjs ADDED
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env node
2
+ // Hook de inyección. Lo ejecutan los tres CLIs al arrancar una sesión.
3
+ //
4
+ // Lo que este script imprime por stdout entra al contexto del agent, así que
5
+ // es la única parte de chaski que está en el camino crítico. Reglas:
6
+ // - Presupuesto duro de tamaño: por encima de 10.000 caracteres el CLI
7
+ // reemplaza el contenido por una ruta y el relevo no llega.
8
+ // - Si no hay relevo, no imprime nada. Un agent no debe recibir ruido.
9
+ // - Si la signature no valida, imprime igual pero rotulado: fallar en silencio
10
+ // dejaría al usuario creyendo que funciona.
11
+ import { readFileSync, existsSync, statSync } from 'node:fs';
12
+ import { relayPath, statePath, readJson, currentBranch, git, projectDir } from './lib/paths.mjs';
13
+ import { preamble, CLOSING_MARK, isValidSignature, render, MAX_CHARS } from './lib/relay.mjs';
14
+ import { createBudget, SESSION_START_BUDGET_MS } from './lib/budget.mjs';
15
+
16
+ main();
17
+
18
+ function main() {
19
+ // Mismo mecanismo que capture.mjs (ver lib/budget.mjs), aplicado aquí
20
+ // porque este es el único código de chaski en el camino crítico: lo que
21
+ // imprime entra al contexto antes de que el usuario pueda escribir. Las
22
+ // llamadas a git de detectDrift están medidas como baratas incluso en
23
+ // casos extremos (ver auditoría), pero el presupuesto de 4.000 ms por
24
+ // llamada tampoco se justificaba aquí solo, por la misma razón que en
25
+ // capture.mjs: ningún timeout por llamada protege si nadie lo deriva de
26
+ // cuánto queda.
27
+ const budget = createBudget(SESSION_START_BUDGET_MS);
28
+ try {
29
+ const cwd = projectDir(cwdFromPayload());
30
+ const ramaActual = currentBranch(cwd, budget);
31
+ const ruta = relayPath(cwd, ramaActual);
32
+ if (!existsSync(ruta)) return;
33
+
34
+ let md = readFileSync(ruta, 'utf8');
35
+ if (!md.trim()) return;
36
+
37
+ const estado = readJson(statePath(cwd, ramaActual), {}) ?? {};
38
+ let verificado = estado.signature ? isValidSignature(md, estado.signature) : false;
39
+
40
+ // El Markdown se renderiza al capturar, así que una narrative escrita
41
+ // después —con /relevo— no aparece hasta la captura siguiente. Si la
42
+ // sesión se corta justo ahí, se perdería lo más valioso del relevo. Si el
43
+ // estado trae una narrative que el Markdown no tiene, se re-renderiza.
44
+ if (estado.narrative && !md.includes(estado.narrative.trim().slice(0, 60))) {
45
+ md = render(estado);
46
+ // No asumir "verificado" porque viene del mismo estado.json: quien
47
+ // puede escribirlo ya podía inyectar texto editando relevo.md, y ahí
48
+ // el aviso de "no verificado" sí aparece. Si no se recalcula aquí, lo
49
+ // único que se gana es suprimir el rótulo justo cuando dos personas
50
+ // comparten .tambo/ con claves distintas — el caso para el que existe
51
+ // la firma.
52
+ verificado = estado.signature ? isValidSignature(md, estado.signature) : false;
53
+ }
54
+
55
+ const partes = [preamble(estado, verificado), '', md];
56
+
57
+ // Si el repo se movió desde que se escribió el relevo, hay que decirlo:
58
+ // un relevo desactualizado presentado como verdad es peor que nada.
59
+ const deriva = detectDrift(cwd, estado, budget, ramaActual);
60
+ if (deriva) partes.push('', deriva);
61
+
62
+ partes.push('', CLOSING_MARK);
63
+
64
+ let salida = partes.join('\n');
65
+ if (salida.length > MAX_CHARS + 1200) {
66
+ salida = salida.slice(0, MAX_CHARS + 1000) + '\n\n_[Recortado.]_\n' + CLOSING_MARK;
67
+ }
68
+ process.stdout.write(emitir(salida, process.argv[3]));
69
+ } catch (e) {
70
+ process.stderr.write(`chaski: no se pudo inyectar el relevo (${e?.message ?? e})\n`);
71
+ } finally {
72
+ process.exitCode = 0;
73
+ }
74
+ }
75
+
76
+ function cwdFromPayload() {
77
+ let ev = {};
78
+ try { ev = JSON.parse(readFileSync(0, 'utf8')) || {}; } catch {}
79
+ // Antigravity usa camelCase y manda un array de rutas de workspace en vez
80
+ // de un cwd. Documentado en su contrato de hooks.
81
+ const ws = Array.isArray(ev.workspacePaths) ? ev.workspacePaths[0] : null;
82
+ return ev.cwd ?? ws ?? ev.workspace ?? ev.project_dir ?? process.cwd();
83
+ }
84
+
85
+ /**
86
+ * Cada CLI espera un formato de salida distinto en el hook de inicio:
87
+ *
88
+ * - Claude Code y Codex: texto plano, que se agrega al contexto.
89
+ * - Antigravity: un objeto JSON con `injectSteps`. Si se le manda texto
90
+ * plano no inyecta nada — falla en silencio, que es el peor caso.
91
+ */
92
+ function emitir(texto, agent) {
93
+ if (/^agy$/i.test(agent ?? '')) {
94
+ return JSON.stringify({
95
+ injectSteps: [{ systemMessage: { systemMessage: texto } }],
96
+ }) + '\n';
97
+ }
98
+ return texto + '\n';
99
+ }
100
+
101
+ /** ¿Cambió el repo desde que se escribió el relevo? */
102
+ function detectDrift(cwd, estado, budget = null, rama = undefined) {
103
+ const avisos = [];
104
+
105
+ const ramaActual = rama === undefined ? currentBranch(cwd, budget) : rama;
106
+ if (estado.facts?.branch && ramaActual && estado.facts.branch !== ramaActual) {
107
+ avisos.push(`- El relevo se escribió en \`${estado.facts.branch}\` y ahora estás en \`${ramaActual}\`.`);
108
+ }
109
+
110
+ const edadHoras = estado.writtenAt ? (Date.now() - new Date(estado.writtenAt).getTime()) / 3600000 : 0;
111
+ if (edadHoras > 24) {
112
+ avisos.push(`- Tiene ${Math.round(edadHoras / 24)} día(s) de antigüedad.`);
113
+ }
114
+
115
+ avisos.push(...changesSinceRelay(cwd, estado, budget));
116
+
117
+ if (!avisos.length) return null;
118
+ return [
119
+ '**ATENCIÓN: el repositorio cambió desde este relevo.**',
120
+ ...avisos,
121
+ '',
122
+ 'Verifica contra el código antes de actuar sobre lo que dice este relevo.',
123
+ ].join('\n');
124
+ }
125
+
126
+ /**
127
+ * Qué pasó en el repositorio desde que se escribió el relevo.
128
+ *
129
+ * El caso que motiva esto: alguien se va una semana, su compañero sigue
130
+ * trabajando y hasta termina lo que había quedado a medias. El relevo sigue
131
+ * afirmando que falta, y el agent nuevo podría rehacerlo. Avisar "el
132
+ * repositorio cambió" no alcanza: hay que decir QUÉ cambió, QUIÉN lo hizo y,
133
+ * sobre todo, si toca los archivos que la narrative declara pendientes.
134
+ */
135
+ function changesSinceRelay(cwd, estado, budget = null) {
136
+ const avisos = [];
137
+ const head = git(cwd, ['rev-parse', '--short', 'HEAD'], { budget });
138
+ if (!estado.head || !head || estado.head === head) return avisos;
139
+
140
+ // Si el commit del relevo ya no existe, la historia se reescribió
141
+ // (rebase, squash, fuerza). Nada de lo que sigue es comparable.
142
+ const existe = git(cwd, ['cat-file', '-e', `${estado.head}^{commit}`], { budget }) !== null;
143
+ if (!existe) {
144
+ avisos.push(`- El commit del relevo (\`${estado.head}\`) ya no existe: la historia se reescribió.`);
145
+ avisos.push(' Trata todo este relevo como una hipótesis y verifica cada punto.');
146
+ return avisos;
147
+ }
148
+
149
+ const rango = `${estado.head}..HEAD`;
150
+ const log = git(cwd, ['log', rango, '--format=%h|%an|%s'], { budget }) ?? '';
151
+ const commits = log.split('\n').filter(Boolean).map((l) => {
152
+ const [h, autor, ...resto] = l.split('|');
153
+ return { h, autor, asunto: resto.join('|') };
154
+ });
155
+
156
+ if (commits.length) {
157
+ const autores = [...new Set(commits.map((c) => c.autor))];
158
+ avisos.push(`- ${commits.length} commit(s) nuevo(s) desde entonces, de: ${autores.join(', ')}.`);
159
+ for (const c of commits.slice(0, 6)) {
160
+ avisos.push(` \`${c.h}\` ${c.autor}: ${c.asunto.slice(0, 70)}`);
161
+ }
162
+ if (commits.length > 6) avisos.push(` … y ${commits.length - 6} más`);
163
+ }
164
+
165
+ // Lo importante: ¿alguien tocó lo que la narrative dice que está pendiente?
166
+ // `--name-only` compara árboles, no contenido: a diferencia de `--numstat`
167
+ // en facts.mjs, no escala con el tamaño del diff. Medido con 10.000
168
+ // archivos cambiados en un commit: ~50 ms.
169
+ const tocados = new Set((git(cwd, ['diff', '--name-only', rango], { budget }) ?? '')
170
+ .split('\n').filter(Boolean));
171
+ const mencionados = mentionedPaths(estado.narrative);
172
+ const collisions = mencionados.filter((r) =>
173
+ [...tocados].some((t) => t === r || t.endsWith('/' + r) || r.endsWith('/' + t)));
174
+
175
+ if (collisions.length) {
176
+ avisos.push('');
177
+ avisos.push('- **Archivos que este relevo menciona y que ya cambiaron desde entonces:**');
178
+ for (const r of collisions.slice(0, 8)) avisos.push(` \`${r}\``);
179
+ avisos.push(' Es posible que lo que el relevo declara pendiente ya esté resuelto.');
180
+ avisos.push(' Léelos antes de retomar: no rehagas trabajo ya hecho.');
181
+ }
182
+
183
+ return avisos;
184
+ }
185
+
186
+ /**
187
+ * Archivos que la narrative menciona.
188
+ *
189
+ * Reconoce `src/auth/refresh.ts`, `src/auth/refresh.ts:41` y también
190
+ * `refresh.ts` a secas, porque una narrative escrita a mano no siempre pone
191
+ * la ruta completa. Los falsos positivos no importan: el resultado se cruza
192
+ * contra los archivos que git dice que cambiaron, así que una palabra con
193
+ * punto que no sea un archivo no sobrevive al cruce.
194
+ */
195
+ function mentionedPaths(narrative) {
196
+ if (!narrative) return [];
197
+ const re = /\b((?:[\w.@-]+\/)*[\w.-]+\.[a-zA-Z0-9]{1,8})(?::\d+)?\b/g;
198
+ return [...new Set([...String(narrative).matchAll(re)].map((m) => m[1]))].slice(0, 30);
199
+ }
package/src/leer.mjs ADDED
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ // Puente de compatibilidad. Este archivo se llamaba así antes de que el código
3
+ // se homologara al inglés, y `leer.mjs` pasó a ser `inject.mjs`.
4
+ //
5
+ // El motivo completo está en `capturar.mjs`, el otro puente. En resumen: los
6
+ // hooks guardan la ruta absoluta del script en una configuración que vive
7
+ // fuera del repositorio, así que quien actualiza chaski sin volver a correr
8
+ // `chaski init` sigue invocando este nombre.
9
+ //
10
+ // Este sí avisa, porque corre una vez por sesión y no una vez por turno. El
11
+ // aviso va por stderr y NUNCA por stdout: lo que este script imprime por
12
+ // stdout entra al contexto del agent, y una línea de más ahí es ruido que el
13
+ // agent trata como dato.
14
+ process.stderr.write(
15
+ 'chaski: tus hooks apuntan a un nombre de archivo antiguo (leer.mjs). '
16
+ + 'Funciona igual, pero ejecuta `chaski init` para actualizarlos.\n',
17
+ );
18
+
19
+ await import('./inject.mjs');
@@ -0,0 +1,194 @@
1
+ // Cuándo pedirle al agent que escriba la narrativa, y cómo pedírselo.
2
+ //
3
+ // El modo manual tiene un defecto estructural, no de disciplina del usuario:
4
+ // la narrativa la escribe el agent, así que **necesita cuota**. Quien espera a
5
+ // estar al límite descubre que el momento en que más la necesita es justo
6
+ // aquel en que ya no se puede generar. Y el corte por cuota es abrupto: deja
7
+ // una tarea a medias y un relevo que solo tiene hechos, sin el porqué ni el
8
+ // siguiente paso, que es lo único que no se deriva de git.
9
+ //
10
+ // La solución no es recordárselo mejor al usuario: es pedirla **temprano y
11
+ // repartida**, cuando todavía hay cuota de sobra. Así nunca depende del final.
12
+ //
13
+ // Cómo: el hook `Stop` de Claude Code acepta `{decision:"block", reason}`, que
14
+ // mantiene al agent trabajando con una instrucción más. No se invoca ningún
15
+ // modelo por fuera —los términos de los tres proveedores lo prohíben para
16
+ // suscripciones de consumidor, ver el invariante 5— sino que se le pide al
17
+ // agent que YA está en sesión. Es uso interactivo, y además es técnicamente
18
+ // mejor que una llamada externa: ese agent tiene la conversación en contexto y
19
+ // en caché, así que sabe lo que ningún proceso aparte podría reconstruir.
20
+ //
21
+ // El costo es real y hay que respetarlo: cada disparo agrega un turno a la
22
+ // conversación del usuario. Un hook que bloqueara en cada `Stop` duplicaría
23
+ // sus turnos y sería insufrible, así que el debounce no es un ajuste fino sino
24
+ // una condición de usabilidad.
25
+
26
+ /** Minutos mínimos entre dos peticiones, para no agregar un turno cada rato. */
27
+ export const DEBOUNCE_MIN = 12;
28
+
29
+ /** Turnos de trabajo mínimos desde la última narrativa antes de volver a pedir. */
30
+ export const TURNOS_MIN = 8;
31
+
32
+ /**
33
+ * Minutos que una narrativa se considera fresca. Mientras lo sea, no se vuelve
34
+ * a pedir ni aunque haya commits nuevos.
35
+ *
36
+ * Sin esto, una sesión que mergea tres PR seguidos recibía una petición por
37
+ * merge: cada uno mueve el HEAD, y "hay commits nuevos" disparaba aunque la
38
+ * narrativa fuera de hace un minuto y siguiera siendo exacta. El usuario lo
39
+ * vio como un mensaje después de cada prompt, que es justo el modo de fallo
40
+ * que el debounce existe para evitar.
41
+ */
42
+ export const NARRATIVA_FRESCA_MIN = 20;
43
+
44
+ /**
45
+ * ¿Hay que pedir la narrativa en este `Stop`?
46
+ *
47
+ * Devuelve `null` si no, o el motivo (string corto) si sí. El motivo entra en
48
+ * el texto que lee el agent, así que explica POR QUÉ se le pide ahora.
49
+ *
50
+ * @param ev payload del hook
51
+ * @param estado el estado que se acaba de calcular para este turno
52
+ * @param previo el estado del turno anterior
53
+ * @param config configuración mezclada del proyecto
54
+ * @param ahora Date.now() inyectable, para poder probar sin esperar
55
+ */
56
+ export function shouldAsk(ev, estado, previo, config, ahora = Date.now()) {
57
+ // Apagable. Quien no quiera un turno extra nunca, pone `askNarrative: false`.
58
+ if (config?.askNarrative === false) return null;
59
+
60
+ // Los tres soportan mantener al agent un turno más, pero NO con el mismo
61
+ // contrato (ver `askPayload`). Un CLI desconocido no se intenta.
62
+ if (!askPayload(estado.agent, '')) return null;
63
+
64
+ // Protección contra bucles. Sin esto, un hook que bloquea vuelve a
65
+ // bloquear en el turno que él mismo provocó, y no termina nunca.
66
+ //
67
+ // Claude Code y Codex traen `stop_hook_active` justamente para esto.
68
+ // Antigravity no documenta nada equivalente: trae `executionNum`, que
69
+ // cuenta las vueltas del bucle del agent, y un tope propio de
70
+ // continuaciones forzadas cuyo valor no está documentado. Así que ahí la
71
+ // protección la pone chaski: solo se pide en la primera vuelta.
72
+ if (ev.stop_hook_active) return null;
73
+ if (Number(ev.executionNum ?? 1) > 1) return null;
74
+
75
+ // Un `Stop` sin trabajo detrás no necesita narrativa: no hay nada que
76
+ // contar y el turno extra sería puro ruido.
77
+ const hechos = estado.facts ?? {};
78
+ const sucios = Array.isArray(hechos.dirty) ? hechos.dirty.length : 0;
79
+ const commitsNuevos = previo?.head && estado.head && previo.head !== estado.head;
80
+ if (!sucios && !commitsNuevos && !estado.commands?.length) return null;
81
+
82
+ // Debounce. Se guarda en el estado, así que sobrevive al reinicio del
83
+ // proceso: el hook corre de cero en cada turno y no tiene memoria propia.
84
+ const ultima = previo?.askedAt ? Date.parse(previo.askedAt) : 0;
85
+ if (ultima && (ahora - ultima) / 60000 < DEBOUNCE_MIN) return null;
86
+
87
+ // Sin narrativa todavía: es el caso que más duele, porque un corte deja el
88
+ // relevo con hechos y sin intención.
89
+ if (!estado.narrative) return 'el relevo todavía no tiene narrativa';
90
+
91
+ // Una narrativa recién escrita no se vuelve a pedir, pase lo que pase. Es
92
+ // el corte más fuerte y va primero: lo que hace insufrible a esta función no
93
+ // es pedirla de más en una sesión larga, es pedirla dos veces seguidas.
94
+ const edadNarrativa = estado.narrativeWrittenAt
95
+ ? (ahora - Date.parse(estado.narrativeWrittenAt)) / 60000 : Infinity;
96
+ if (edadNarrativa < NARRATIVA_FRESCA_MIN) return null;
97
+
98
+ // Narrativa que quedó atrás. Se mide en turnos de trabajo y en commits, no
99
+ // en tiempo: una sesión larga sin cambios no necesita reescribirla.
100
+ // Si cambió la sesión o el contador previo quedó por encima del actual tras
101
+ // un reinicio de sesión, la cuenta parte desde cero en la sesión nueva.
102
+ const mismaSesion = !previo?.session || !estado?.session || previo.session === estado.session;
103
+ const baseTurns = mismaSesion && (previo?.turnsAtNarrative ?? 0) <= (estado.turns ?? 0)
104
+ ? (previo?.turnsAtNarrative ?? 0) : 0;
105
+ const turnosDesde = (estado.turns ?? 0) - baseTurns;
106
+ if (commitsNuevos) return 'hay commits nuevos desde la última narrativa';
107
+ if (turnosDesde >= TURNOS_MIN) return `van ${turnosDesde} turnos desde la última narrativa`;
108
+
109
+ return null;
110
+ }
111
+
112
+ /**
113
+ * El texto de la petición.
114
+ *
115
+ * Tiene DOS lectores con necesidades opuestas, y ahí estaba el problema: el
116
+ * agent necesita saber cómo escribir la narrativa, y el usuario —que lo ve en
117
+ * pantalla, porque el CLI lo muestra— solo necesita saber qué está pasando.
118
+ * Mezclarlo le dejaba al usuario nombres de campo y rutas de archivo que no le
119
+ * sirven de nada. Sus palabras: «queda como raro, muy técnico para el
120
+ * usuario».
121
+ *
122
+ * Así que el cómo se delega a la skill, que es del agent y no se ve en
123
+ * pantalla. El mensaje visible queda en una línea legible.
124
+ *
125
+ * La parte técnica solo aparece cuando el agent no tiene la skill disponible
126
+ * en su sesión, porque ahí es la única forma de que la petición sea accionable.
127
+ * Es el caso raro pagando su propio costo, en vez de que lo pague el normal.
128
+ *
129
+ * Nada de marcos tipo «orden del sistema»: la documentación de los hooks avisa
130
+ * que un texto así puede activar las defensas contra inyección de prompts del
131
+ * propio modelo.
132
+ */
133
+ export function askText(motivo, haySkill = true) {
134
+ const linea = `chaski: antes de cerrar, guarda el relevo de este trabajo — ${motivo}.`;
135
+ if (haySkill) return `${linea} Usa la skill \`relevo\`.`;
136
+ return [
137
+ linea,
138
+ 'Anota qué quedó a medias y dónde, qué descartaste y el siguiente paso, en',
139
+ 'los campos `narrative`, `narrativeAuthor` y `narrativeWrittenAt` de',
140
+ '`.tambo/branches/<rama>/estado.json` en la raíz del repositorio. No edites `relevo.md`.',
141
+ ].join('\n');
142
+ }
143
+
144
+ /**
145
+ * El objeto que hay que imprimir por stdout para que el agent siga un turno
146
+ * más, en el contrato de SU CLI. Devuelve `null` para un CLI desconocido.
147
+ *
148
+ * Los tres lo soportan, y ninguno igual. La diferencia que más importa es la
149
+ * de Antigravity: su clave es `"continue"`, y su documentación dice que
150
+ * **cualquier otro valor permite parar**. Así que imprimirle el `"block"` de
151
+ * Claude Code no da error — simplemente no hace nada, en silencio, que es el
152
+ * peor modo de fallo posible para esta herramienta. Por eso la salida se arma
153
+ * por CLI y no se comparte.
154
+ *
155
+ * Verificado contra la documentación de cada uno, no en vivo: la primera
156
+ * sesión real de cada CLI es la verificación de verdad. El riesgo de
157
+ * equivocarse es bajo y asimétrico: si el contrato estuviera mal, el agent
158
+ * termina el turno como siempre y se pierde la petición, no el relevo.
159
+ */
160
+ export function askPayload(agent, reason) {
161
+ const a = String(agent ?? '').toLowerCase();
162
+
163
+ // Claude Code: `additionalContext` y NO `decision: "block"`.
164
+ //
165
+ // Los dos mantienen al agent un turno más y los dos respetan
166
+ // `stop_hook_active` y el tope de continuaciones. La diferencia es cómo se
167
+ // presenta: con `block`, la interfaz rotula la respuesta como **«Stop hook
168
+ // error»** —la misma palabra que usa para un hook que de verdad falló— y el
169
+ // usuario ve un error en cada petición. Con `additionalContext` el rótulo es
170
+ // «Stop hook feedback». La documentación lo llama literalmente «non-error
171
+ // feedback for Claude».
172
+ //
173
+ // El usuario lo reportó dos veces: «la palabra error no es bonita verla en
174
+ // ninguno caso y menos para un usuario». Y tenía razón más allá de la
175
+ // estética: en una herramienta publicada, un error aparente en cada turno
176
+ // dice que está rota.
177
+ //
178
+ // No se combinan los dos campos: la documentación no dice qué rótulo saldría.
179
+ if (/^claude/.test(a)) {
180
+ return { hookSpecificOutput: { hookEventName: 'Stop', additionalContext: reason } };
181
+ }
182
+
183
+ // Codex: `decision: "block"`, y el `reason` se convierte en el prompt de
184
+ // continuación. Su documentación no describe un equivalente de
185
+ // `additionalContext` para `Stop`, así que aquí el formato se queda.
186
+ if (a === 'codex') return { decision: 'block', reason };
187
+
188
+ // Antigravity: `continue` bloquea el stop y reentra al bucle; el `reason` se
189
+ // inyecta como mensaje de sistema. Su documentación dice que **cualquier otro
190
+ // valor permite parar**, así que mandarle el `"block"` de Claude Code no
191
+ // daría error: no haría nada, en silencio, el peor modo de fallo posible.
192
+ if (a === 'agy' || a === 'antigravity') return { decision: 'continue', reason };
193
+ return null;
194
+ }
@@ -0,0 +1,64 @@
1
+ // Presupuesto de tiempo del hook.
2
+ //
3
+ // El hook `Stop` de Claude Code comparte 1,5 s con los demás hooks del mismo
4
+ // evento, que no controlamos. Antes de este módulo no existía ningún reloj:
5
+ // el único timeout era el de cada llamada a `git()` (4.000 ms), mayor que el
6
+ // presupuesto entero, así que no protegía nada — una sola llamada cara (un
7
+ // `diff --numstat` sobre miles de archivos) podía agotar el hook completo sin
8
+ // que nada se lo impidiera, y el relevo del turno se perdía entero.
9
+ //
10
+ // La solución es doble a propósito, y las dos mitades son necesarias:
11
+ // - Un reloj GLOBAL desde el arranque del hook, contra el que cada paso
12
+ // consulta cuánto queda ANTES de lanzar un subproceso. Esto es lo que
13
+ // permite decidir qué omitir cuando no cabe todo (ver gitFacts en
14
+ // facts.mjs), y es barato: un Date.now(), nada de spawnear nada.
15
+ // - Un timeout por llamada DERIVADO de lo que queda del reloj global. Es
16
+ // la red de seguridad para cuando la estimación de costo de un paso
17
+ // resultó optimista (disco lento, antivirus, OneDrive bloqueando el
18
+ // archivo): ninguna llamada individual puede ya exceder el presupuesto
19
+ // completo, porque su timeout nunca es mayor que lo que queda.
20
+ export const STOP_HOOK_BUDGET_MS = 1100; // de 1.500 ms compartidos, deja ~400 ms (27%) para los demás hooks del evento
21
+
22
+ // inject.mjs no tiene un presupuesto compartido documentado como el de Stop,
23
+ // pero es "el único código en el camino crítico" (ver inject.mjs): el usuario
24
+ // espera a que termine antes de poder escribir. Medido: sus llamadas a git
25
+ // son baratas por construcción (usan `--name-only`, nunca `--numstat`, así
26
+ // que no escalan con el tamaño del diff) incluso con 3.000 commits o 10.000
27
+ // archivos cambiados en un rango. Este presupuesto es la misma red de
28
+ // seguridad por si el día de mañana aparece un caso que sí escale mal -- no
29
+ // es una resta de un límite externo conocido, como sí lo es el de Stop.
30
+ export const SESSION_START_BUDGET_MS = 800;
31
+
32
+ // Una llamada a la que le queda menos que esto no vale la pena intentarla:
33
+ // el costo fijo de spawnear git (medido: 20-40 ms en Windows) ya se la come.
34
+ export const MIN_GIT_CALL_MS = 60;
35
+
36
+ /** Reloj de un solo uso para un presupuesto de `ms` desde este instante. */
37
+ export function createBudget(ms) {
38
+ const inicio = Date.now();
39
+ const limite = inicio + ms;
40
+ return {
41
+ remaining() { return limite - Date.now(); },
42
+ expired() { return Date.now() >= limite; },
43
+ elapsed() { return Date.now() - inicio; },
44
+ };
45
+ }
46
+
47
+ /** ¿Queda tiempo suficiente para intentar un paso de costo mínimo `piso`? */
48
+ export function hasTime(budget, piso = MIN_GIT_CALL_MS) {
49
+ return !budget || budget.remaining() > piso;
50
+ }
51
+
52
+ /**
53
+ * El timeout que de verdad debe usar una llamada: nunca más que lo que queda
54
+ * del presupuesto global, aunque quien llama haya pedido más.
55
+ *
56
+ * Separado de `git()` a propósito, para poder probarlo sin spawnear nada: la
57
+ * alternativa (reintroducir el bug a mano para verificar el test) necesitaría
58
+ * un comando real lo bastante lento como para notar la diferencia, lo que
59
+ * vuelve el test lento y frágil entre máquinas. Esta función aislada se
60
+ * prueba con un presupuesto falso, sin spawnear ni esperar nada.
61
+ */
62
+ export function effectiveTimeout(timeout, budget) {
63
+ return budget ? Math.min(timeout, budget.remaining()) : timeout;
64
+ }