@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/AGENTS.md +376 -0
- package/CHANGELOG.md +289 -0
- package/LICENSE +21 -0
- package/README.md +588 -2
- package/SECURITY.md +75 -0
- package/package.json +48 -4
- package/skill/SKILL.md +80 -0
- package/src/capturar.mjs +24 -0
- package/src/capture.mjs +545 -0
- package/src/cli.mjs +1053 -0
- package/src/inject.mjs +199 -0
- package/src/leer.mjs +19 -0
- package/src/lib/ask.mjs +194 -0
- package/src/lib/budget.mjs +64 -0
- package/src/lib/facts.mjs +566 -0
- package/src/lib/paths.mjs +323 -0
- package/src/lib/relay.mjs +303 -0
- package/src/lib/secrets.mjs +148 -0
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');
|
package/src/lib/ask.mjs
ADDED
|
@@ -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
|
+
}
|