@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/capture.mjs
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Hook de captura. Lo ejecutan Claude Code, Codex y agy al terminar cada turno.
|
|
3
|
+
//
|
|
4
|
+
// Reglas que gobiernan este archivo:
|
|
5
|
+
// - Nunca falla. Cualquier error sale por exit 0 con un aviso a stderr: un
|
|
6
|
+
// hook que revienta le arruina la sesión al usuario.
|
|
7
|
+
// - Nunca llama a un modelo. Cero tokens, cero red, cero términos de uso.
|
|
8
|
+
// - Nunca tarda. El presupuesto del hook Stop de Claude Code es de 1,5 s
|
|
9
|
+
// COMPARTIDO con los demás hooks del event. El caso normal usa unos
|
|
10
|
+
// 260-340 ms; con miles de archivos modificados a la vez, el reloj de
|
|
11
|
+
// lib/budget.mjs corta trabajo caro antes de llegar ahí (ver gitFacts en
|
|
12
|
+
// lib/facts.mjs) en vez de dejar que el hook se cancele y se pierda el
|
|
13
|
+
// relevo entero.
|
|
14
|
+
// - Nunca escribe fuera de .tambo/ del proyecto y ~/.chaski/.
|
|
15
|
+
import { readFileSync, mkdirSync } from 'node:fs';
|
|
16
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
17
|
+
import { statePath, relayPath, readJson, acquireLock, globalDir, git, currentBranch, pruneOrphanRelays, existsSync, projectDir, tambo } from './lib/paths.mjs';
|
|
18
|
+
import { gitFacts, transcriptFacts } from './lib/facts.mjs';
|
|
19
|
+
import { render, writeAtomic, sign } from './lib/relay.mjs';
|
|
20
|
+
import { redact } from './lib/secrets.mjs';
|
|
21
|
+
import { shouldAsk, askText, askPayload } from './lib/ask.mjs';
|
|
22
|
+
import { createBudget, STOP_HOOK_BUDGET_MS } from './lib/budget.mjs';
|
|
23
|
+
|
|
24
|
+
main();
|
|
25
|
+
|
|
26
|
+
function main() {
|
|
27
|
+
// Reloj único para todo el hook, desde la primera línea de main(). Es la
|
|
28
|
+
// mitad "presupuesto global" del arreglo: cada paso caro de gitFacts
|
|
29
|
+
// consulta budget.remaining() antes de lanzar un subproceso en vez de
|
|
30
|
+
// intentarlo siempre y confiar en que algún timeout lo salve. Arranca aquí
|
|
31
|
+
// y no antes porque todo lo anterior (imports, parseo de este archivo) ya
|
|
32
|
+
// corrió antes de que exista un reloj que lo cuente — es overhead de Node
|
|
33
|
+
// que no podemos medir desde dentro del script.
|
|
34
|
+
const budget = createBudget(STOP_HOOK_BUDGET_MS);
|
|
35
|
+
let liberar = null;
|
|
36
|
+
try {
|
|
37
|
+
const ev = readPayload();
|
|
38
|
+
const sessionDir = resolve(ev.cwd || process.cwd());
|
|
39
|
+
const cwd = projectDir(sessionDir);
|
|
40
|
+
|
|
41
|
+
const config = loadConfig(cwd);
|
|
42
|
+
|
|
43
|
+
// Un subagente que termina promueve su conclusión al relevo del padre, y
|
|
44
|
+
// nada más. Tiene camino propio porque la captura normal haría dos daños:
|
|
45
|
+
// leería el transcript DEL SUBAGENTE y dejaría su tamaño como marca de
|
|
46
|
+
// agua del padre —que en su próximo turno leería desde la posición
|
|
47
|
+
// equivocada— y derivaría los facts de git del árbol del hijo, que puede
|
|
48
|
+
// ser otro worktree.
|
|
49
|
+
if (ev.hook_event_name === 'SubagentStop') {
|
|
50
|
+
return promote(ev, cwd, config);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Los demás eventos de un subagente se descartan: su exploración
|
|
54
|
+
// contaminaría el relevo con cien lecturas de archivos y tres caminos
|
|
55
|
+
// abandonados que a nadie le sirven mañana.
|
|
56
|
+
if (ev.agent_id) return ok();
|
|
57
|
+
|
|
58
|
+
// Un solo escritor. Si otro proceso tiene el lock, este turno se saltea:
|
|
59
|
+
// el siguiente lo recupera, porque la marca de agua no avanzó.
|
|
60
|
+
liberar = acquireLock(cwd);
|
|
61
|
+
if (!liberar) return ok();
|
|
62
|
+
|
|
63
|
+
const branch = currentBranch(cwd, budget);
|
|
64
|
+
const rutaEstado = statePath(cwd, branch);
|
|
65
|
+
const rutaRelevo = relayPath(cwd, branch);
|
|
66
|
+
const previo = readJson(rutaEstado, {}) ?? {};
|
|
67
|
+
|
|
68
|
+
// La marca de agua pertenece a UNA sesión y a UN transcript. Si cambia
|
|
69
|
+
// cualquiera de los dos, se lee desde el principio: arrastrar el offset
|
|
70
|
+
// de ayer a un transcript nuevo haría que se saltee todo el trabajo de
|
|
71
|
+
// hoy hasta ese byte. El caso aparece al volver a trabajar al día
|
|
72
|
+
// siguiente, que es justo at el relevo importa.
|
|
73
|
+
const mismaSesion = previo.session && ev.session_id && previo.session === ev.session_id;
|
|
74
|
+
const mismoTranscript = previo.transcript && ev.transcript_path
|
|
75
|
+
&& previo.transcript === ev.transcript_path;
|
|
76
|
+
const desde = (mismaSesion && mismoTranscript) ? (previo.offset ?? 0) : 0;
|
|
77
|
+
|
|
78
|
+
const t = transcriptFacts(ev.transcript_path, desde, config, budget);
|
|
79
|
+
const g = gitFacts(cwd, config, budget, branch);
|
|
80
|
+
const headFromLog = g.commits?.[0]?.split(' ')[0] || null;
|
|
81
|
+
|
|
82
|
+
const estado = {
|
|
83
|
+
version: 1,
|
|
84
|
+
writtenAt: new Date().toISOString(),
|
|
85
|
+
agent: ev.__agente ?? 'desconocido',
|
|
86
|
+
session: ev.session_id ?? null,
|
|
87
|
+
// La ruta del transcript se guarda para poder invalidar la marca de agua.
|
|
88
|
+
transcript: ev.transcript_path ?? null,
|
|
89
|
+
offset: t.offset,
|
|
90
|
+
// Los turns se acumulan dentro de una sesión; en una nueva se reinician,
|
|
91
|
+
// o el relevo diría "1.200 turns" de trabajo que ya no está en contexto.
|
|
92
|
+
turns: (mismaSesion ? (previo.turns ?? 0) : 0) + t.turns,
|
|
93
|
+
facts: g,
|
|
94
|
+
// HEAD al momento de escribir: inject.mjs lo compara para detectar deriva.
|
|
95
|
+
// Sale del primer commit de `git log -5` sin lanzar otro subproceso; solo
|
|
96
|
+
// si el log fue recortado por presupuesto se pide con `gitHead`.
|
|
97
|
+
head: g.isRepo ? (headFromLog ?? gitHead(cwd, budget) ?? null) : null,
|
|
98
|
+
// Si algún paso de gitFacts se quedó sin presupuesto, se declara aquí
|
|
99
|
+
// -en un campo de hechos, no de narrative- para que el agent siguiente
|
|
100
|
+
// sepa que el relevo es incompleto por diseño y no por error silencioso.
|
|
101
|
+
trimmed: g.trimmed ?? [],
|
|
102
|
+
commands: fusionar(mismaSesion ? previo.commands : [], t.commands),
|
|
103
|
+
lastFile: lastTouched(g, previo),
|
|
104
|
+
// La narrative es del agent, no de este script. Se preserva tal cual.
|
|
105
|
+
narrative: previo.narrative ?? null,
|
|
106
|
+
narrativeAuthor: previo.narrativeAuthor ?? null,
|
|
107
|
+
narrativeWrittenAt: previo.narrativeWrittenAt ?? null,
|
|
108
|
+
// Las conclusiones de los subagents se preservan, como la narrative: las
|
|
109
|
+
// escribió un modelo y no se pueden recalcular. Sin esto, `promote()`
|
|
110
|
+
// las guardaba y la captura del final del MISMO turno las borraba, así
|
|
111
|
+
// que la sección "Trabajo delegado" duraba lo que duraba el turno.
|
|
112
|
+
//
|
|
113
|
+
// Se preservan entre sesiones a propósito, no solo dentro de una. Atarlas
|
|
114
|
+
// a la sesión las borraría en la primera captura del CLI siguiente, que
|
|
115
|
+
// es justo el escenario que esta herramienta existe para cubrir: el
|
|
116
|
+
// agent nuevo las lee al arrancar y las perdería para el que venga
|
|
117
|
+
// después. Su antigüedad se muestra en el relevo, que es la forma honesta
|
|
118
|
+
// de que algo viejo se note sin desaparecer.
|
|
119
|
+
subagents: previo.subagents ?? null,
|
|
120
|
+
// Tareas en segundo plano que quedan corriendo. Si cambias de CLI con
|
|
121
|
+
// algo a medio ejecutar, el agent nuevo tiene que saberlo.
|
|
122
|
+
backgroundTasks: liveTasks(ev),
|
|
123
|
+
redactions: 0,
|
|
124
|
+
event: ev.hook_event_name ?? null,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// Un corte por cuota se sella y se deja anotado: es el instante del relevo.
|
|
128
|
+
const detalleLimite = `${ev.error_type ?? ''} ${ev.error_message ?? ''} ${ev.error ?? ''} ${ev.terminationReason ?? ''}`;
|
|
129
|
+
if ((ev.hook_event_name === 'StopFailure' && /rate.?limit/i.test(ev.error_type ?? ''))
|
|
130
|
+
|| /rate.?limit|quota.?exceeded|MAX_TOKEN_BUDGET_EXCEEDED/i.test(detalleLimite)) {
|
|
131
|
+
estado.sealedAtLimit = {
|
|
132
|
+
at: estado.writtenAt,
|
|
133
|
+
motivo: ev.error_type || ev.terminationReason || 'rate_limit',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const { redactions } = redact(JSON.stringify(estado.commands), config);
|
|
138
|
+
estado.redactions = redactions;
|
|
139
|
+
|
|
140
|
+
const md = render(estado);
|
|
141
|
+
estado.signature = sign(md);
|
|
142
|
+
|
|
143
|
+
// ¿Toca pedirle la narrativa al agent? Se decide ANTES de escribir, para
|
|
144
|
+
// que el instante de la petición quede en el mismo estado que se guarda:
|
|
145
|
+
// el hook no tiene memoria entre turnos y el debounce vive ahí.
|
|
146
|
+
const motivo = ev.hook_event_name === 'Stop'
|
|
147
|
+
? shouldAsk(ev, estado, previo, config) : null;
|
|
148
|
+
if (motivo) {
|
|
149
|
+
estado.askedAt = estado.writtenAt;
|
|
150
|
+
estado.askedReason = motivo;
|
|
151
|
+
} else {
|
|
152
|
+
estado.askedAt = previo.askedAt ?? null;
|
|
153
|
+
estado.askedReason = previo.askedReason ?? null;
|
|
154
|
+
}
|
|
155
|
+
// Cuántos turnos tenía el relevo cuando se escribió la narrativa: es con
|
|
156
|
+
// esto que se mide si quedó atrás, y lo anota el código, no el modelo.
|
|
157
|
+
//
|
|
158
|
+
// Se compara contra `narrativeStamp` —la marca que esta misma función ya
|
|
159
|
+
// contabilizó— y NO contra `previo.narrativeWrittenAt`. La narrativa se
|
|
160
|
+
// escribe fuera de banda, directo en estado.json, así que en la captura
|
|
161
|
+
// siguiente el previo ya trae la marca nueva y los dos valores coinciden:
|
|
162
|
+
// comparándolos, el contador nunca se movía de cero y la política creía
|
|
163
|
+
// que habían pasado todos los turnos de la sesión desde la última
|
|
164
|
+
// narrativa. Resultado: la pedía cada vez que expiraba el debounce, para
|
|
165
|
+
// siempre. Con `turns` en 2.049 y el contador en 0, se veía en cada turno.
|
|
166
|
+
const narrativaNueva = (estado.narrativeWrittenAt ?? null) !== (previo.narrativeStamp ?? null);
|
|
167
|
+
// `turns` se reinicia en cada sesión nueva; arrastrar `turnsAtNarrative`
|
|
168
|
+
// de la sesión anterior dejaba `turns - turnsAtNarrative` en negativo y
|
|
169
|
+
// postergaba la petición tantos turnos como hubiera durado la sesión previa.
|
|
170
|
+
const previosEnSesion = mismaSesion && (previo.turnsAtNarrative ?? 0) <= (estado.turns ?? 0)
|
|
171
|
+
? (previo.turnsAtNarrative ?? 0) : 0;
|
|
172
|
+
estado.turnsAtNarrative = narrativaNueva ? (estado.turns ?? 0) : previosEnSesion;
|
|
173
|
+
estado.narrativeStamp = estado.narrativeWrittenAt ?? null;
|
|
174
|
+
|
|
175
|
+
writeAtomic(rutaRelevo, md);
|
|
176
|
+
writeAtomic(rutaEstado, JSON.stringify(estado, null, 2));
|
|
177
|
+
heartbeat(estado.agent);
|
|
178
|
+
recordQuota(ev, estado);
|
|
179
|
+
if (narrativaNueva && estado.narrative) recordHistory(cwd, estado, config);
|
|
180
|
+
if (motivo || (narrativaNueva && estado.narrative)) recordCost(estado, motivo, narrativaNueva);
|
|
181
|
+
occasionalPrune(cwd, config);
|
|
182
|
+
|
|
183
|
+
// La petición va por stdout y solo después de que el relevo esté escrito:
|
|
184
|
+
// si algo de arriba fallara, se pierde la petición y no el relevo.
|
|
185
|
+
//
|
|
186
|
+
// El contrato lo arma `askPayload`, porque NO es el mismo en los tres:
|
|
187
|
+
// Claude Code usa `additionalContext`, Codex usa `decision: "block"` y
|
|
188
|
+
// Antigravity usa `"continue"`; otro valor le da permiso para parar.
|
|
189
|
+
if (motivo) {
|
|
190
|
+
// Resolver el repo hijo no carga su skill en una sesión abierta en la
|
|
191
|
+
// carpeta contenedora. Tampoco sirve la copia instalada para otro CLI:
|
|
192
|
+
// en ambos casos la petición debe incluir cómo escribir la narrativa.
|
|
193
|
+
const relativeSession = relative(cwd, sessionDir);
|
|
194
|
+
const inProject = relativeSession !== '..'
|
|
195
|
+
&& !relativeSession.startsWith('..' + sep) && !isAbsolute(relativeSession);
|
|
196
|
+
const skillBase = estado.agent === 'claude code' ? '.claude' : '.agents';
|
|
197
|
+
const hasSkill = inProject
|
|
198
|
+
&& existsSync(join(cwd, skillBase, 'skills', 'relevo', 'SKILL.md'));
|
|
199
|
+
const salida = askPayload(estado.agent, askText(motivo, hasSkill));
|
|
200
|
+
if (salida) process.stdout.write(JSON.stringify(salida) + '\n');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Aviso para quien mire stderr (doctor, una terminal, logs del CLI). El
|
|
204
|
+
// relevo mismo ya lo dice -- esto es la copia para quien no lo abra.
|
|
205
|
+
if (estado.trimmed.length) {
|
|
206
|
+
process.stderr.write(
|
|
207
|
+
`chaski: relevo recortado por presupuesto de tiempo (${budget.elapsed()} ms) -- `
|
|
208
|
+
+ `${estado.trimmed.length} paso(s) omitido(s)\n`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
} catch (e) {
|
|
212
|
+
process.stderr.write(`chaski: captura omitida (${e?.message ?? e})\n`);
|
|
213
|
+
} finally {
|
|
214
|
+
if (liberar) liberar();
|
|
215
|
+
ok();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function ok() { process.exitCode = 0; }
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Promueve el resultado de un subagente al relevo del padre.
|
|
223
|
+
*
|
|
224
|
+
* Promoción, no concatenación: sube lo que sobrevive —la conclusión que
|
|
225
|
+
* declaró al terminar y por qué paró— y se descarta el proceso. El relevo
|
|
226
|
+
* guarda lo que el equipo de agentes **logró**, no lo que **tanteó**.
|
|
227
|
+
*
|
|
228
|
+
* Beneficio secundario: como es mecánico, diez subagents no multiplican por
|
|
229
|
+
* diez el costo. No hay síntesis aquí; solo el padre sintetiza, una vez, sobre
|
|
230
|
+
* resultados ya condensados.
|
|
231
|
+
*
|
|
232
|
+
* El diseño original proponía un quipu hijo efímero en `.tambo/sub/<id>.json`.
|
|
233
|
+
* Resultó innecesario: el payload de `SubagentStop` ya trae la conclusión, así
|
|
234
|
+
* que un archivo intermedio solo agregaría algo que limpiar.
|
|
235
|
+
*/
|
|
236
|
+
function promote(ev, cwd, config) {
|
|
237
|
+
let liberar = null;
|
|
238
|
+
try {
|
|
239
|
+
liberar = acquireLock(cwd);
|
|
240
|
+
if (!liberar) return ok(); // el padre está escribiendo; se pierde esta promoción
|
|
241
|
+
|
|
242
|
+
const branch = currentBranch(cwd);
|
|
243
|
+
const rutaEstado = statePath(cwd, branch);
|
|
244
|
+
const rutaRelevo = relayPath(cwd, branch);
|
|
245
|
+
const estado = readJson(rutaEstado, null);
|
|
246
|
+
// Sin relevo del padre no hay dónde promote. Crear uno desde un subagente
|
|
247
|
+
// dejaría un relevo con la conclusión de un hijo y ningún hecho.
|
|
248
|
+
if (!estado) return ok();
|
|
249
|
+
|
|
250
|
+
// Sin identidad del subagente no se promueve nada.
|
|
251
|
+
//
|
|
252
|
+
// Pasó en producción y se vio en el relevo: la sección "Trabajo delegado"
|
|
253
|
+
// quedó con CUATRO MENSAJES DEL USUARIO —"dale, abre el issue", "ya,
|
|
254
|
+
// gracias, ahora vuelvo"— presentados como conclusiones de subagentes y
|
|
255
|
+
// con la etiqueta vacía. Algún CLI dispara este evento con el último
|
|
256
|
+
// mensaje de la conversación, que puede ser el del usuario, donde Claude
|
|
257
|
+
// Code pone el del subagente. Sin `agent_type` no hay forma de saber que
|
|
258
|
+
// hubo un subagente de verdad, así que no se promueve: un relevo que
|
|
259
|
+
// afirma que alguien concluyó algo que nunca pasó es peor que uno sin la
|
|
260
|
+
// sección.
|
|
261
|
+
//
|
|
262
|
+
// La distinción exacta importa: lo que llegaba NO era el campo ausente,
|
|
263
|
+
// era el campo VACÍO. Por eso `??` no lo cubría y la etiqueta se
|
|
264
|
+
// renderizaba como `****`.
|
|
265
|
+
//
|
|
266
|
+
// Ausente se sigue aceptando con un nombre genérico, porque un CLI puede
|
|
267
|
+
// no mandarlo y la promoción es legítima. Presente pero vacío se rechaza:
|
|
268
|
+
// es la huella de quien mandó el mensaje del usuario como si fuera la
|
|
269
|
+
// conclusión de un subagente.
|
|
270
|
+
if ('agent_type' in ev && !String(ev.agent_type ?? '').trim()) return ok();
|
|
271
|
+
const kind = String(ev.agent_type ?? '').trim() || 'subagente';
|
|
272
|
+
|
|
273
|
+
const { texto } = redact(String(ev.last_assistant_message ?? '').trim(), config);
|
|
274
|
+
const conclusion = summarizeConclusion(texto);
|
|
275
|
+
if (!conclusion) return ok();
|
|
276
|
+
|
|
277
|
+
const entrada = {
|
|
278
|
+
kind,
|
|
279
|
+
conclusion,
|
|
280
|
+
at: new Date().toISOString(),
|
|
281
|
+
// `stop_reason` distinto de end_turn suele significar que no terminó
|
|
282
|
+
// lo que estaba haciendo, y eso es justamente lo que hay que avisar.
|
|
283
|
+
reason: ev.stop_reason && ev.stop_reason !== 'end_turn' ? ev.stop_reason : null,
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const previas = (estado.subagents ?? []).filter((s) => s.conclusion !== conclusion);
|
|
287
|
+
// Presupuesto: el relevo tiene techo de tamaño y los subagents compiten
|
|
288
|
+
// con los facts por ese espacio.
|
|
289
|
+
estado.subagents = [...previas, entrada].slice(-6);
|
|
290
|
+
estado.writtenAt = new Date().toISOString();
|
|
291
|
+
|
|
292
|
+
const md = render(estado);
|
|
293
|
+
estado.signature = sign(md);
|
|
294
|
+
writeAtomic(rutaRelevo, md);
|
|
295
|
+
writeAtomic(rutaEstado, JSON.stringify(estado, null, 2));
|
|
296
|
+
} catch (e) {
|
|
297
|
+
process.stderr.write(`chaski: promoción omitida (${e?.message ?? e})\n`);
|
|
298
|
+
} finally {
|
|
299
|
+
if (liberar) liberar();
|
|
300
|
+
}
|
|
301
|
+
return ok();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* La conclusión de un subagente, acotada.
|
|
306
|
+
*
|
|
307
|
+
* Se queda con los primeros párrafos útiles: un `last_assistant_message`
|
|
308
|
+
* puede traer el informe completo, y el relevo tiene presupuesto. Lo que
|
|
309
|
+
* importa es qué logró y qué falló, que suele estar al principio.
|
|
310
|
+
*/
|
|
311
|
+
function summarizeConclusion(texto) {
|
|
312
|
+
if (!texto) return null;
|
|
313
|
+
const limpio = texto
|
|
314
|
+
.replace(/```[\s\S]*?```/g, '[bloque de código]') // el código ya está en el diff
|
|
315
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
316
|
+
.trim();
|
|
317
|
+
if (!limpio) return null;
|
|
318
|
+
return limpio.length > 400 ? limpio.slice(0, 400).trimEnd() + '…' : limpio;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Lee el payload JSON de stdin. Cada CLI nombra sus campos distinto. */
|
|
322
|
+
function readPayload() {
|
|
323
|
+
let bruto = '';
|
|
324
|
+
try { bruto = readFileSync(0, 'utf8'); } catch {}
|
|
325
|
+
let ev = {};
|
|
326
|
+
try { ev = JSON.parse(bruto) || {}; } catch {}
|
|
327
|
+
|
|
328
|
+
// Normalización defensiva: ninguno de los tres prometió estabilidad, y
|
|
329
|
+
// Antigravity usa camelCase y nombres propios. Verificado contra payloads
|
|
330
|
+
// reales de Claude Code y de agy.
|
|
331
|
+
const ws = Array.isArray(ev.workspacePaths) ? ev.workspacePaths[0] : null;
|
|
332
|
+
ev.transcript_path = ev.transcript_path ?? ev.transcriptPath ?? ev.transcript ?? null;
|
|
333
|
+
ev.hook_event_name = ev.hook_event_name ?? ev.hookEventName ?? ev.event ?? process.argv[2] ?? null;
|
|
334
|
+
ev.session_id = ev.session_id ?? ev.sessionId ?? ev.conversationId ?? ev.conversation_id ?? null;
|
|
335
|
+
ev.cwd = ev.cwd ?? ws ?? ev.workspace ?? ev.project_dir ?? process.cwd();
|
|
336
|
+
// agy manda el último prompt del usuario; sirve como pista de la tarea.
|
|
337
|
+
ev.__ultimoPrompt = ev.lastUserInput ?? ev.user_prompt ?? null;
|
|
338
|
+
// Normalizado aquí y no al estampar el latido: `estado.agent` es lo que
|
|
339
|
+
// encabeza el relevo que lee el agent siguiente, y la grafía cambiaba según
|
|
340
|
+
// el evento que lo hubiera escrito.
|
|
341
|
+
ev.__agente = canonicalAgent(process.argv[3] ?? detectAgent(ev));
|
|
342
|
+
return ev;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Grafía única para un CLI, venga del argumento del hook o de la detección.
|
|
347
|
+
*
|
|
348
|
+
* Los nombres con espacio viajan con guión en la línea de comando (ver `cmd()`
|
|
349
|
+
* en cli.mjs); aquí vuelven a su forma legible, que es la que lee un agent en
|
|
350
|
+
* el relevo. `doctor` ya comparaba normalizando, así que esto solo cierra el
|
|
351
|
+
* lado de la escritura.
|
|
352
|
+
*/
|
|
353
|
+
function canonicalAgent(agent) {
|
|
354
|
+
const n = String(agent ?? '').trim().toLowerCase().replace(/-/g, ' ');
|
|
355
|
+
return n === 'claude' ? 'claude code' : (n || 'desconocido');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function detectAgent(ev) {
|
|
359
|
+
if (process.env.CLAUDECODE || ev.hook_event_name === 'StopFailure') return 'claude code';
|
|
360
|
+
if (process.env.CODEX_HOME || process.env.CODEX_SESSION) return 'codex';
|
|
361
|
+
if (process.env.AGY_SESSION || process.env.ANTIGRAVITY_SESSION) return 'agy';
|
|
362
|
+
return 'desconocido';
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function loadConfig(cwd) {
|
|
366
|
+
const proyecto = readJson(join(cwd, 'chaski.json'), {}) ?? {};
|
|
367
|
+
const local = readJson(join(cwd, 'chaski.local.json'), {}) ?? {};
|
|
368
|
+
const global = readJson(join(globalDir(), 'config.json'), {}) ?? {};
|
|
369
|
+
// El techo de política del repo no se puede subir desde el archivo local:
|
|
370
|
+
// una política que el individuo puede relajar no es una política.
|
|
371
|
+
const merged = { ...global, ...proyecto, ...local };
|
|
372
|
+
merged.redact = [...(global.redact ?? []), ...(proyecto.redact ?? []), ...(local.redact ?? [])];
|
|
373
|
+
merged.excludePaths = [
|
|
374
|
+
...(global.excludePaths ?? []), ...(proyecto.excludePaths ?? []), ...(local.excludePaths ?? []),
|
|
375
|
+
];
|
|
376
|
+
return merged;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function fusionar(previos, nuevos) {
|
|
380
|
+
const out = [...(previos ?? []), ...(nuevos ?? [])];
|
|
381
|
+
return out.slice(-12);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function lastTouched(g, previo) {
|
|
385
|
+
// Solo archivos: un directorio sin trackear no es "el último archivo tocado".
|
|
386
|
+
const cand = (g.dirty ?? []).filter((s) => !s.ruta.endsWith('/'));
|
|
387
|
+
if (!cand.length) return previo.lastFile ?? null;
|
|
388
|
+
// Aproximación: el que acumula más cambios. El orden del diff no indica
|
|
389
|
+
// recencia, así que no sirve para esto.
|
|
390
|
+
const conCambios = cand.filter((s) => s.mas !== null);
|
|
391
|
+
if (conCambios.length) {
|
|
392
|
+
return conCambios.sort((a, b) => (b.mas + b.menos) - (a.mas + a.menos))[0].ruta;
|
|
393
|
+
}
|
|
394
|
+
return cand[0].ruta;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function gitHead(cwd, budget) { return git(cwd, ['rev-parse', '--short', 'HEAD'], { budget }); }
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Tareas en segundo plano todavía corriendo, del payload del hook.
|
|
401
|
+
* Confirmado en el payload real de Claude Code: campo `background_tasks`,
|
|
402
|
+
* con id, status y description. Los otros CLIs pueden no enviarlo.
|
|
403
|
+
*/
|
|
404
|
+
function liveTasks(ev) {
|
|
405
|
+
const tareas = ev.background_tasks ?? ev.backgroundTasks ?? [];
|
|
406
|
+
if (!Array.isArray(tareas)) return [];
|
|
407
|
+
return tareas
|
|
408
|
+
.filter((t) => t && /running|pending|in_progress/i.test(t.status ?? ''))
|
|
409
|
+
.map((t) => ({
|
|
410
|
+
description: String(t.description ?? t.id ?? 'sin descripción').slice(0, 120),
|
|
411
|
+
kind: t.type ?? null,
|
|
412
|
+
}))
|
|
413
|
+
.slice(0, 6);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Limpieza de relevos de ramas borradas, una vez al día como máximo.
|
|
418
|
+
*
|
|
419
|
+
* No va en cada turno: listar las ramas es otro subproceso de git, y el
|
|
420
|
+
* presupuesto del hook `Stop` es de 1,5 s compartido. Una vez al día alcanza
|
|
421
|
+
* de sobra para algo que acumula unos KB por rama.
|
|
422
|
+
*/
|
|
423
|
+
function occasionalPrune(cwd, config) {
|
|
424
|
+
try {
|
|
425
|
+
const marca = join(globalDir(), 'ultima-limpieza');
|
|
426
|
+
const ahora = Date.now();
|
|
427
|
+
if (existsSync(marca)) {
|
|
428
|
+
const previa = parseInt(readFileSync(marca, 'utf8'), 10);
|
|
429
|
+
if (Number.isFinite(previa) && ahora - previa < 86_400_000) return;
|
|
430
|
+
}
|
|
431
|
+
writeAtomic(marca, String(ahora));
|
|
432
|
+
pruneOrphanRelays(cwd, {
|
|
433
|
+
factsOnly: config?.pruneAfterDays,
|
|
434
|
+
withNarrative: config?.pruneNarrativeAfterDays,
|
|
435
|
+
});
|
|
436
|
+
} catch {}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Latido por CLI: doctor avisa si alguno dejó de ejecutar su hook.
|
|
441
|
+
*
|
|
442
|
+
* El name llega normalizado porque un mismo CLI entraba por dos vías con dos
|
|
443
|
+
* grafías: `cmd()` de cli.mjs pasa `claude-code` como argumento —con guión,
|
|
444
|
+
* porque el shell parte los argumentos por espacio— y `detectAgent()`, que es
|
|
445
|
+
* el camino de respaldo cuando no hay argumento, devuelve `claude code`. El
|
|
446
|
+
* archivo acababa con las dos claves para la misma herramienta, y la
|
|
447
|
+
* atribución del relevo cambiaba de grafía según qué evento la hubiera
|
|
448
|
+
* escrito. Al escribir se funden las variantes y se conserva la más reciente,
|
|
449
|
+
* para que los archivos que ya quedaron con las dos converjan solos.
|
|
450
|
+
*/
|
|
451
|
+
function heartbeat(agent) {
|
|
452
|
+
try {
|
|
453
|
+
const ruta = join(globalDir(), 'latidos.json');
|
|
454
|
+
const l = readJson(ruta, {}) ?? {};
|
|
455
|
+
const name = canonicalAgent(agent);
|
|
456
|
+
const ahora = new Date().toISOString();
|
|
457
|
+
for (const k of Object.keys(l)) {
|
|
458
|
+
if (k !== name && canonicalAgent(k) === name) {
|
|
459
|
+
if (l[k] > ahora) l[name] = l[k];
|
|
460
|
+
delete l[k];
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
l[name] = l[name] > ahora ? l[name] : ahora;
|
|
464
|
+
writeAtomic(ruta, JSON.stringify(l, null, 2));
|
|
465
|
+
} catch {}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Estado de cuota por CLI en `~/.chaski/quota.json`, leído por `chaski next`.
|
|
470
|
+
* Solo escribe cuando ocurre un corte por límite o cuando un CLI que estaba
|
|
471
|
+
* marcado como agotado vuelve a completar un turno normal.
|
|
472
|
+
*/
|
|
473
|
+
function recordQuota(ev, estado) {
|
|
474
|
+
try {
|
|
475
|
+
const ruta = join(globalDir(), 'quota.json');
|
|
476
|
+
const q = readJson(ruta, {}) ?? {};
|
|
477
|
+
const name = canonicalAgent(estado.agent);
|
|
478
|
+
if (estado.sealedAtLimit) {
|
|
479
|
+
const msg = String(ev.error_message ?? ev.error ?? '').trim().slice(0, 200) || null;
|
|
480
|
+
q[name] = {
|
|
481
|
+
at: estado.writtenAt,
|
|
482
|
+
reason: estado.sealedAtLimit.motivo,
|
|
483
|
+
message: msg,
|
|
484
|
+
};
|
|
485
|
+
writeAtomic(ruta, JSON.stringify(q, null, 2));
|
|
486
|
+
} else if (ev.hook_event_name === 'Stop' && q[name]) {
|
|
487
|
+
delete q[name];
|
|
488
|
+
writeAtomic(ruta, JSON.stringify(q, null, 2));
|
|
489
|
+
}
|
|
490
|
+
} catch {}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Registro append-only de narrativas en `.tambo/historia/entries.jsonl`,
|
|
495
|
+
* consultado por `chaski why`. Cuando la narrativa se reescribe en el relevo,
|
|
496
|
+
* la anterior sigue viva aquí sin ocupar presupuesto de contexto al inyectar.
|
|
497
|
+
*/
|
|
498
|
+
function recordHistory(cwd, estado, config) {
|
|
499
|
+
try {
|
|
500
|
+
const dir = join(tambo(cwd), 'historia');
|
|
501
|
+
mkdirSync(dir, { recursive: true });
|
|
502
|
+
const ruta = join(dir, 'entries.jsonl');
|
|
503
|
+
const { texto } = redact(String(estado.narrative ?? '').trim(), config);
|
|
504
|
+
if (!texto) return;
|
|
505
|
+
const previas = existsSync(ruta) ? readFileSync(ruta, 'utf8') : '';
|
|
506
|
+
const lineas = previas.split('\n').filter(Boolean);
|
|
507
|
+
const ultima = lineas.length ? JSON.parse(lineas[lineas.length - 1]) : null;
|
|
508
|
+
const branch = estado.facts?.branch ?? 'sin-rama';
|
|
509
|
+
if (ultima && ultima.branch === branch && ultima.narrative === texto) return;
|
|
510
|
+
const entrada = {
|
|
511
|
+
at: estado.narrativeWrittenAt ?? estado.writtenAt,
|
|
512
|
+
author: canonicalAgent(estado.narrativeAuthor ?? estado.agent),
|
|
513
|
+
branch,
|
|
514
|
+
head: estado.head ?? null,
|
|
515
|
+
narrative: texto,
|
|
516
|
+
};
|
|
517
|
+
const nuevo = (previas ? previas.trimEnd() + '\n' : '') + JSON.stringify(entrada) + '\n';
|
|
518
|
+
writeAtomic(ruta, nuevo);
|
|
519
|
+
} catch {}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Contabilidad local de turnos extra pedidos por chaski y narrativas escritas,
|
|
524
|
+
* en `~/.chaski/cost.json`, para `chaski cost`.
|
|
525
|
+
*/
|
|
526
|
+
function recordCost(estado, motivo, narrativaNueva) {
|
|
527
|
+
try {
|
|
528
|
+
const ruta = join(globalDir(), 'cost.json');
|
|
529
|
+
const c = readJson(ruta, { asks: [], narratives: [] }) ?? { asks: [], narratives: [] };
|
|
530
|
+
const agent = canonicalAgent(estado.agent);
|
|
531
|
+
const branch = estado.facts?.branch ?? 'sin-rama';
|
|
532
|
+
if (motivo) {
|
|
533
|
+
c.asks = [...(Array.isArray(c.asks) ? c.asks : []), {
|
|
534
|
+
at: estado.writtenAt, agent, branch, reason: motivo,
|
|
535
|
+
}].slice(-500);
|
|
536
|
+
}
|
|
537
|
+
if (narrativaNueva && estado.narrative) {
|
|
538
|
+
const author = canonicalAgent(estado.narrativeAuthor ?? estado.agent);
|
|
539
|
+
c.narratives = [...(Array.isArray(c.narratives) ? c.narratives : []), {
|
|
540
|
+
at: estado.narrativeWrittenAt ?? estado.writtenAt, agent: author, branch,
|
|
541
|
+
}].slice(-500);
|
|
542
|
+
}
|
|
543
|
+
writeAtomic(ruta, JSON.stringify(c, null, 2));
|
|
544
|
+
} catch {}
|
|
545
|
+
}
|