@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.
@@ -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
+ }