nexakit 0.1.0 → 0.2.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/.claude-plugin/marketplace.json +18 -1
- package/README.md +40 -30
- package/assets/brand/README.md +0 -2
- package/assets/runtime/package-lock.json +2 -2
- package/bin/agent-task.js +12 -10
- package/bin/agent-toolkit-gemini.js +11 -9
- package/bin/agent-toolkit-inbox.js +12 -10
- package/bin/agent-toolkit-mcp.js +14 -8
- package/bin/agent-toolkit.js +12 -9
- package/bin/nexakit.js +12 -9
- package/bin/project-memory-mcp.js +12 -9
- package/bin/project-memory.js +5 -3
- package/{skills/depurar → catalogs/skills/vercel-composition-patterns}/LICENSE.txt +6 -6
- package/catalogs/skills/vercel-react-best-practices/LICENSE.txt +27 -0
- package/catalogs/skills.json +70 -35
- package/docs/skills/evidencias/2026-10-28-recomendador-preparacion.md +1 -1
- package/package.json +5 -3
- package/plugins/nexakit-modelo/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-modelo/hooks/hooks.json +1 -0
- package/plugins/nexakit-modelo/hooks/register.js +48 -0
- package/plugins/nexakit-modelo/hooks/status.js +8 -0
- package/plugins/nexakit-modelo/tests/register.test.ts +21 -0
- package/plugins/nexakit-panel/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-panel/README.md +20 -0
- package/plugins/nexakit-panel/hooks/hooks.json +1 -0
- package/plugins/nexakit-panel/hooks/lienzo.js +382 -0
- package/plugins/nexakit-panel/hooks/panel.js +279 -0
- package/plugins/nexakit-panel/hooks/register.tsx +248 -0
- package/plugins/nexakit-panel/tests/lienzo.test.ts +142 -0
- package/plugins/nexakit-panel/tests/panel.test.ts +240 -0
- package/plugins/nexakit-panel/tests/register.test.ts +167 -0
- package/plugins/nexakit-panel/types/index.d.ts +41 -0
- package/plugins/nexakit-sesion/.claude-plugin/plugin.json +1 -0
- package/plugins/nexakit-sesion/README.md +12 -0
- package/plugins/nexakit-sesion/hooks/aviso.js +38 -0
- package/plugins/nexakit-sesion/hooks/hooks.json +1 -0
- package/plugins/nexakit-sesion/hooks/register.js +28 -0
- package/plugins/nexakit-sesion/tests/register.test.ts +96 -0
- package/schemas/skill-catalog.schema.json +9 -2
- package/skills/cerrar-memoria/SKILL.md +6 -2
- package/skills/cerrar-memoria/references/cierre.md +5 -1
- package/skills/elegir-modelo/SKILL.md +68 -0
- package/skills/elegir-modelo/modelos.mjs +108 -0
- package/skills/gemini/SKILL.md +1 -1
- package/skills/project-handoff/SKILL.md +39 -12
- package/skills/project-handoff/references/handoff-template.md +24 -17
- package/skills/quality-gate/SKILL.md +6 -12
- package/skills/revisar/SKILL.md +6 -16
- package/skills/{desarrollo-con-pruebas/LICENSE.txt → revisar-skill/LICENSE-find-skills.txt} +5 -5
- package/skills/revisar-skill/SKILL.md +94 -0
- package/skills/revision-cruzada/SKILL.md +53 -475
- package/skills/web-design-guidelines/AGENTS.md +156 -0
- package/skills/{spec-driven-development → web-design-guidelines}/LICENSE.txt +1 -6
- package/skills/web-design-guidelines/SKILL.md +20 -30
- package/src/agent-models.js +53 -13
- package/src/brand.js +117 -0
- package/src/cli-executable.js +5 -3
- package/src/cli.js +16 -9
- package/src/connect.js +56 -4
- package/src/init.js +9 -1
- package/src/install-lock.js +127 -0
- package/src/install-registry.js +61 -0
- package/src/legacy-repair.js +71 -0
- package/src/link-states.js +172 -0
- package/src/memory-locator.js +118 -0
- package/src/missing-dependencies.js +21 -0
- package/src/model-command.js +147 -0
- package/src/panel-cli.js +182 -0
- package/src/project-root.js +41 -0
- package/src/project-signals.js +339 -0
- package/src/recommend-report.js +77 -0
- package/src/review-instructions.js +211 -0
- package/src/review-policy.js +35 -0
- package/src/revision-adapters.js +157 -0
- package/src/revision-context.js +86 -0
- package/src/revision-isolation.js +222 -0
- package/src/revision-process.js +103 -0
- package/src/revision-provenance.js +89 -0
- package/src/revision-snapshot.js +120 -0
- package/src/revision.js +242 -0
- package/src/setup-apply.js +231 -0
- package/src/setup-plan.js +231 -0
- package/src/setup-state.js +277 -0
- package/src/setup-tui.js +555 -0
- package/src/skill-catalog-add.js +91 -0
- package/src/skill-catalog-audit.js +26 -27
- package/src/skill-files.js +39 -0
- package/src/skill-recommendations.js +321 -208
- package/src/skill-scan-report.js +14 -0
- package/src/skill-scan.js +178 -0
- package/src/stable-command.js +53 -0
- package/src/stable-install.js +222 -0
- package/src/superpowers.js +133 -0
- package/src/sync-apply.js +8 -5
- package/src/sync-files.js +2 -1
- package/src/terminal-text.js +68 -0
- package/src/toolkit-cli.js +397 -70
- package/src/toolkit-commands-guide.js +53 -39
- package/src/toolkit-setup.js +507 -110
- package/assets/brand/agent-toolkit-mark-v1.png +0 -0
- package/skills/depurar/SKILL.md +0 -69
- package/skills/depurar/agents/openai.yaml +0 -7
- package/skills/depurar/references/tecnicas-de-diagnostico.md +0 -37
- package/skills/desarrollo-con-pruebas/SKILL.md +0 -55
- package/skills/desarrollo-con-pruebas/agents/openai.yaml +0 -7
- package/skills/desarrollo-con-pruebas/references/buenas-pruebas.md +0 -33
- package/skills/estado/SKILL.md +0 -41
- package/skills/programar/SKILL.md +0 -19
- package/skills/spec-driven-development/SKILL.md +0 -48
- package/skills/spec-driven-development/agents/openai.yaml +0 -7
- package/skills/spec-driven-development/references/spec-template.md +0 -55
- package/skills/spec-driven-development/references/workflow.md +0 -70
- package/src/toolkit-tui.js +0 -961
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { describe, expect, test } from 'claude-code/testing';
|
|
2
|
+
import {
|
|
3
|
+
alias, barra, barraEsfuerzo, crear, duracion, ejemplo, estadoPorFamilia, etiquetaEnjambre, etiquetaModelo, familia, hora,
|
|
4
|
+
iniciarAgente, registrarAsesor, registrarHerramienta, registrarPaso, registrarRecomendacion, registrarUso, resumenDelegacion, sinSecretos, esRevision, iniciarRevision, leerSalidaRevision, terminarRevision, terminarAgente, tituloDeOrquesta, tokens,
|
|
5
|
+
} from '../hooks/panel.js';
|
|
6
|
+
|
|
7
|
+
describe('nexakit-panel · lógica', () => {
|
|
8
|
+
test('el nombre del modelo se lee como persona: familia y versión', () => {
|
|
9
|
+
expect(etiquetaModelo('claude-sonnet-5-5')).toBe('Sonnet 5.5');
|
|
10
|
+
expect(etiquetaModelo('claude-opus-5-5')).toBe('Opus 5.5');
|
|
11
|
+
expect(etiquetaModelo('claude-haiku-5-5')).toBe('Haiku 5.5');
|
|
12
|
+
expect(etiquetaModelo('claude-sonnet-4-20250514')).toBe('Sonnet 4');
|
|
13
|
+
expect(etiquetaModelo('claude-sonnet-4-5-20250929')).toBe('Sonnet 4.5');
|
|
14
|
+
expect(etiquetaModelo('sonnet')).toBe('Sonnet');
|
|
15
|
+
expect(etiquetaModelo('modelo-raro')).toBe('modelo-raro');
|
|
16
|
+
expect(etiquetaModelo(null)).toBe('modelo ?');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('la familia decide el color del modelo', () => {
|
|
20
|
+
expect(familia('claude-haiku-5-5')).toBe('haiku');
|
|
21
|
+
expect(familia('claude-sonnet-5-5')).toBe('sonnet');
|
|
22
|
+
expect(familia('claude-opus-5-5')).toBe('opus');
|
|
23
|
+
expect(familia('otra-cosa')).toBe('otro');
|
|
24
|
+
expect(familia(undefined)).toBe('otro');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('el esfuerzo se dibuja como barra de 4', () => {
|
|
28
|
+
expect(barraEsfuerzo('low')).toBe('▮▯▯▯');
|
|
29
|
+
expect(barraEsfuerzo('medium')).toBe('▮▮▯▯');
|
|
30
|
+
expect(barraEsfuerzo('high')).toBe('▮▮▮▯');
|
|
31
|
+
expect(barraEsfuerzo('xhigh')).toBe('▮▮▮▮');
|
|
32
|
+
expect(barraEsfuerzo('max')).toBe('▮▮▮▮');
|
|
33
|
+
expect(barraEsfuerzo(null)).toBe('▯▯▯▯');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('duración y hora legibles', () => {
|
|
37
|
+
expect(duracion(400)).toBe('0 s');
|
|
38
|
+
expect(duracion(12_400)).toBe('12 s');
|
|
39
|
+
expect(duracion(75_000)).toBe('1 min 15 s');
|
|
40
|
+
expect(hora(new Date(2026, 10, 4, 9, 5, 7).getTime())).toBe('09:05:07');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('un paso de la sesión principal fija el líder; el de un subagente solo su modelo', () => {
|
|
44
|
+
let s = crear();
|
|
45
|
+
s = registrarPaso(s, { modelo: 'claude-sonnet-5-5', esfuerzo: 'high', at: 1 });
|
|
46
|
+
expect(s.lider).toMatchObject({ modelo: 'claude-sonnet-5-5', esfuerzo: 'high', leidos: 0, escritos: 0 });
|
|
47
|
+
expect(s.pasos).toBe(1);
|
|
48
|
+
s = iniciarAgente(s, { id: 'a1', nombre: 'search', modelo: 'claude-haiku-5-5', at: 2 });
|
|
49
|
+
s = registrarPaso(s, { modelo: 'claude-haiku-5-5', esfuerzo: 'low', agentId: 'a1', at: 3 });
|
|
50
|
+
expect(s.lider.modelo).toBe('claude-sonnet-5-5');
|
|
51
|
+
expect(s.pasos).toBe(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('un subagente empieza, cuenta herramientas y termina, y todo queda en el registro', () => {
|
|
55
|
+
let s = crear();
|
|
56
|
+
s = iniciarAgente(s, { id: 'a1', nombre: 'grep', modelo: 'claude-haiku-5-5', at: 1000 });
|
|
57
|
+
expect(s.agentes[0]).toMatchObject({ id: 'a1', estado: 'corriendo', herramientas: 0, fin: null });
|
|
58
|
+
s = registrarHerramienta(s, { tool: 'Grep', detalle: 'auth', agentId: 'a1', at: 2000 });
|
|
59
|
+
s = registrarHerramienta(s, { tool: 'Read', detalle: 'src/auth.ts', at: 3000 });
|
|
60
|
+
expect(s.agentes[0].herramientas).toBe(1);
|
|
61
|
+
s = terminarAgente(s, { id: 'a1', ok: true, at: 5000 });
|
|
62
|
+
expect(s.agentes[0]).toMatchObject({ estado: 'listo', fin: 5000 });
|
|
63
|
+
expect(s.registro.map((r) => r.quien)).toEqual(['grep', 'grep', 'líder', 'grep']);
|
|
64
|
+
s = terminarAgente(iniciarAgente(s, { id: 'a2', nombre: 'audit', modelo: 'x', at: 6000 }), { id: 'a2', ok: false, at: 7000 });
|
|
65
|
+
expect(s.agentes[1].estado).toBe('error');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('las consultas al asesor se acumulan y cero consultas no ensucian nada', () => {
|
|
69
|
+
let s = crear();
|
|
70
|
+
expect(registrarAsesor(s, { llamadas: 0, at: 1 })).toBe(s);
|
|
71
|
+
s = registrarAsesor(s, { llamadas: 1, at: 10 });
|
|
72
|
+
s = registrarAsesor(s, { llamadas: 2, at: 20 });
|
|
73
|
+
expect(s.asesor).toMatchObject({ llamadas: 3, ultimo: 20 });
|
|
74
|
+
expect(s.registro.at(-1)?.texto).toContain('2 veces');
|
|
75
|
+
expect(s.registro.at(0)?.texto).toContain('1 vez');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('el registro y la lista de agentes tienen tope y no crecen sin límite', () => {
|
|
79
|
+
let s = crear();
|
|
80
|
+
for (let i = 0; i < 200; i++) s = registrarHerramienta(s, { tool: 'Read', detalle: `f${i}`, at: i });
|
|
81
|
+
expect(s.registro.length).toBe(60);
|
|
82
|
+
expect(s.registro.at(-1)?.texto).toContain('f199');
|
|
83
|
+
for (let i = 0; i < 20; i++) s = iniciarAgente(s, { id: `a${i}`, nombre: `n${i}`, modelo: 'm', at: i });
|
|
84
|
+
expect(s.agentes.length).toBe(8);
|
|
85
|
+
expect(s.agentes.at(-1)?.id).toBe('a19');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('un evento de un agente desconocido no rompe nada', () => {
|
|
89
|
+
const s = crear();
|
|
90
|
+
expect(terminarAgente(s, { id: 'no-existe', ok: true, at: 1 }).agentes).toEqual([]);
|
|
91
|
+
expect(registrarHerramienta(s, { tool: 'Bash', detalle: 'ls', agentId: 'no-existe', at: 1 }).registro[0].quien).toBe('agente');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('nexakit-panel · diagrama', () => {
|
|
96
|
+
const con = (agentes: Array<[string, string, 'corriendo' | 'listo' | 'error', number]>) => {
|
|
97
|
+
let s = crear();
|
|
98
|
+
s = registrarPaso(s, { modelo: 'claude-sonnet-5-5', esfuerzo: 'high' });
|
|
99
|
+
agentes.forEach(([nombre, modelo, estado, tools], i) => {
|
|
100
|
+
s = iniciarAgente(s, { id: 'a' + i, nombre, modelo, at: i });
|
|
101
|
+
for (let k = 0; k < tools; k++) s = registrarHerramienta(s, { tool: 'Read', detalle: 'f', agentId: 'a' + i, at: 10 + k });
|
|
102
|
+
if (estado !== 'corriendo') s = terminarAgente(s, { id: 'a' + i, ok: estado === 'listo', at: 99 });
|
|
103
|
+
});
|
|
104
|
+
return s;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
test('la barra es proporcional, de ancho fijo y nunca se pasa', () => {
|
|
108
|
+
expect(barra(0, 10)).toBe('░░░░░░░░░░');
|
|
109
|
+
expect(barra(0.5, 10)).toBe('█████░░░░░');
|
|
110
|
+
expect(barra(1, 10)).toBe('██████████');
|
|
111
|
+
expect(barra(2, 4)).toBe('████');
|
|
112
|
+
expect(barra(-1, 4)).toBe('░░░░');
|
|
113
|
+
expect(barra(Number.NaN, 4)).toBe('░░░░');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('el resumen de delegación cuenta lo real', () => {
|
|
117
|
+
const s = con([['search', 'claude-haiku-5-5', 'listo', 2], ['grep', 'claude-haiku-5-5', 'corriendo', 1], ['audit', 'claude-haiku-5-5', 'error', 0], ['otro', 'claude-haiku-5-5', 'listo', 4]]);
|
|
118
|
+
expect(resumenDelegacion(s.agentes)).toEqual({ total: 4, corriendo: 1, listos: 2, errores: 1, herramientas: 7 });
|
|
119
|
+
expect(resumenDelegacion([])).toEqual({ total: 0, corriendo: 0, listos: 0, errores: 0, herramientas: 0 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('el título nombra al líder y, si hay subagentes, a su familia más usada', () => {
|
|
123
|
+
expect(tituloDeOrquesta(crear())).toBe('NEXAKIT · ORQUESTA');
|
|
124
|
+
const solo = registrarPaso(crear(), { modelo: 'claude-sonnet-5-5', esfuerzo: 'high' });
|
|
125
|
+
expect(tituloDeOrquesta(solo)).toBe('NEXAKIT · ORQUESTA · SONNET 5.5 DIRIGE');
|
|
126
|
+
const mixto = con([['a', 'claude-haiku-5-5', 'corriendo', 0], ['b', 'claude-haiku-5-5', 'listo', 0], ['c', 'claude-opus-5-5', 'listo', 0]]);
|
|
127
|
+
expect(tituloDeOrquesta(mixto)).toBe('NEXAKIT · ORQUESTA · SONNET 5.5 DIRIGE · HAIKU 5.5 EN ENJAMBRE');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('la línea de estado dice por familia qué ocurre y no inventa lo que no ocurre', () => {
|
|
131
|
+
const s = registrarAsesor(con([['a', 'claude-haiku-5-5', 'corriendo', 0], ['b', 'claude-haiku-5-5', 'listo', 0]]), { llamadas: 2, at: 5 });
|
|
132
|
+
expect(estadoPorFamilia(s)).toEqual(['haiku [enjambre]', 'subagentes [1/2]', 'asesor [consultado 2]']);
|
|
133
|
+
expect(estadoPorFamilia(crear())).toEqual([]);
|
|
134
|
+
expect(estadoPorFamilia(con([['a', 'claude-haiku-5-5', 'listo', 0]]))).toEqual(['haiku [listo]', 'subagentes [1/1]']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('el alias es lo que se escribe en --model', () => {
|
|
138
|
+
expect(alias('claude-sonnet-5-5')).toBe('sonnet');
|
|
139
|
+
expect(alias('claude-opus-5-5')).toBe('opus');
|
|
140
|
+
expect(alias('rara')).toBe('rara');
|
|
141
|
+
expect(alias(null)).toBe('?');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('la etiqueta del enjambre es la misma que usa el título', () => {
|
|
145
|
+
expect(etiquetaEnjambre([])).toBeNull();
|
|
146
|
+
const mixto = con([['a', 'claude-haiku-5-5', 'corriendo', 0], ['b', 'claude-haiku-5-5', 'listo', 0], ['c', 'claude-opus-5-5', 'listo', 0]]);
|
|
147
|
+
expect(etiquetaEnjambre(mixto.agentes)).toBe('Haiku 5.5');
|
|
148
|
+
expect(tituloDeOrquesta(mixto)).toContain(etiquetaEnjambre(mixto.agentes)!.toUpperCase());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('el ejemplo es determinista y trae de todo para juzgar el diseño: líder, asesor, tres subagentes y registro', () => {
|
|
152
|
+
const e = ejemplo(1_000_000);
|
|
153
|
+
expect(e).toEqual(ejemplo(1_000_000));
|
|
154
|
+
expect(e.lider.modelo).toBe('claude-sonnet-5-5');
|
|
155
|
+
expect(e.asesor.llamadas).toBeGreaterThan(0);
|
|
156
|
+
expect(e.agentes.map((a) => a.estado).sort()).toEqual(['corriendo', 'corriendo', 'listo']);
|
|
157
|
+
expect(e.registro.length).toBeGreaterThan(5);
|
|
158
|
+
expect(Math.max(...e.registro.map((r) => r.t))).toBeLessThanOrEqual(1_000_000);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('el uso real se acumula: leídos = entrada + caché, escritos = salida, por líder y por subagente', () => {
|
|
162
|
+
const u = (i: number, o: number, cr: number, cw: number) => ({ input_tokens: i, output_tokens: o, cache_read_input_tokens: cr, cache_creation_input_tokens: cw, model: 'm' });
|
|
163
|
+
let s = iniciarAgente(crear(), { id: 'a1', nombre: 'search', modelo: 'claude-haiku-5-5', at: 1 });
|
|
164
|
+
s = registrarUso(s, { usage: u(100, 20, 1000, 50) });
|
|
165
|
+
s = registrarUso(s, { usage: u(10, 5, 0, 0) });
|
|
166
|
+
s = registrarUso(s, { agentId: 'a1', usage: u(7, 3, 300, 0) });
|
|
167
|
+
s = registrarUso(s, { agentId: 'desconocido', usage: u(1, 1, 1, 1) });
|
|
168
|
+
s = registrarUso(s, { usage: null });
|
|
169
|
+
expect(s.lider).toMatchObject({ leidos: 1160, escritos: 25 });
|
|
170
|
+
expect(s.agentes[0]).toMatchObject({ leidos: 307, escritos: 3 });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('el asesor acumula su tiempo y cada subagente recuerda su última acción', () => {
|
|
174
|
+
let s = registrarAsesor(crear(), { llamadas: 1, at: 10, tiempo: 1500 });
|
|
175
|
+
s = registrarAsesor(s, { llamadas: 1, at: 20, tiempo: 500 });
|
|
176
|
+
expect(s.asesor).toMatchObject({ llamadas: 2, tiempo: 2000 });
|
|
177
|
+
s = iniciarAgente(s, { id: 'a1', nombre: 'grep', modelo: 'claude-haiku-5-5', at: 1 });
|
|
178
|
+
s = registrarHerramienta(s, { tool: 'Grep', detalle: 'verifyToken', agentId: 'a1', at: 2 });
|
|
179
|
+
expect(s.agentes[0].ultimo).toBe('Grep · verifyToken');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('los tokens se leen en k y M', () => {
|
|
183
|
+
expect(tokens(0)).toBe('0');
|
|
184
|
+
expect(tokens(999)).toBe('999');
|
|
185
|
+
expect(tokens(1500)).toBe('1.5k');
|
|
186
|
+
expect(tokens(668_400)).toBe('668k');
|
|
187
|
+
expect(tokens(2_340_000)).toBe('2.3M');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('el líder cuenta sus herramientas aunque el registro tenga tope', () => {
|
|
191
|
+
let s = crear();
|
|
192
|
+
for (let i = 0; i < 150; i++) s = registrarHerramienta(s, { tool: 'Read', detalle: 'f', at: i });
|
|
193
|
+
expect(s.lider.herramientas).toBe(150);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('cada consulta al asesor guarda cuánto duró', () => {
|
|
197
|
+
const s = registrarAsesor(crear(), { llamadas: 1, at: 10, tiempo: 4200 });
|
|
198
|
+
expect(s.registro.at(-1)).toMatchObject({ quien: 'asesor', ms: 4200 });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('la recomendación de modelo se guarda tal cual llega', () => {
|
|
202
|
+
const s = registrarRecomendacion(crear(), { modelo: 'sonnet', esfuerzo: 'medium', motivo: 'uso diario' });
|
|
203
|
+
expect(s.recomendacion).toEqual({ modelo: 'sonnet', esfuerzo: 'medium', motivo: 'uso diario' });
|
|
204
|
+
expect(crear().recomendacion).toBeNull();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('los detalles se guardan sin secretos evidentes', () => {
|
|
208
|
+
expect(sinSecretos('export GITHUB_TOKEN=ghp_abc123 && npm test')).toBe('export GITHUB_TOKEN=*** && npm test');
|
|
209
|
+
expect(sinSecretos('curl -H "Authorization: Bearer abc.def" x')).toBe('curl -H "Authorization: Bearer ***" x');
|
|
210
|
+
expect(sinSecretos('API_KEY=sk-123 node app')).toBe('API_KEY=*** node app');
|
|
211
|
+
expect(sinSecretos('mysql --password=hunter2 db')).toBe('mysql --password=*** db');
|
|
212
|
+
expect(sinSecretos('npm test')).toBe('npm test');
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('reconoce una revisión enviada a otra IA y no confunde la consulta de disponibles', () => {
|
|
216
|
+
expect(esRevision('~/.nexakit/nexakit revision --con codex --modelo gpt-6.1-sol --esfuerzo high --encargo /tmp/e.md')).toEqual({ ia: 'codex', modelo: 'gpt-6.1-sol', esfuerzo: 'high' });
|
|
217
|
+
expect(esRevision('C:\\Users\\x\\.nexakit\\nexakit.cmd revision --con antigravity --modelo gemini-3.8-flash-high --esfuerzo high --encargo e.md')).toMatchObject({ ia: 'antigravity' });
|
|
218
|
+
expect(esRevision('~/.nexakit/nexakit revision --disponibles --principal claude')).toBeNull();
|
|
219
|
+
expect(esRevision('npm test')).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('lee del resultado de la revisión el modelo que respondió y los tokens reales', () => {
|
|
223
|
+
const salida = 'Informe…\nModelo pedido: gpt-6.1-sol · high · Modelo que respondió: gpt-6.1-sol · high (registro)\nTokens: entrada 41000 (caché 3000) · salida 900\n';
|
|
224
|
+
expect(leerSalidaRevision(salida)).toEqual({ respondio: 'gpt-6.1-sol', entrada: 41000, cache: 3000, salida: 900 });
|
|
225
|
+
expect(leerSalidaRevision('Tokens: entrada no informado (caché no informado) · salida no informado')).toEqual({ respondio: null, entrada: null, cache: null, salida: null });
|
|
226
|
+
expect(leerSalidaRevision('')).toEqual({ respondio: null, entrada: null, cache: null, salida: null });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('una revisión empieza, termina con sus cifras y se ve en el estado y el registro', () => {
|
|
230
|
+
let s = iniciarRevision(crear(), { id: 'r1', ia: 'codex', modelo: 'gpt-6.1-sol', esfuerzo: 'high', at: 1000 });
|
|
231
|
+
expect(s.revisiones[0]).toMatchObject({ ia: 'codex', estado: 'revisando', fin: null });
|
|
232
|
+
expect(estadoPorFamilia(s)).toContain('codex [revisando]');
|
|
233
|
+
s = terminarRevision(s, { id: 'r1', ok: true, at: 46_000, cifras: { respondio: 'gpt-6.1-sol', entrada: 41000, cache: 3000, salida: 900 } });
|
|
234
|
+
expect(s.revisiones[0]).toMatchObject({ estado: 'lista', fin: 46_000, entrada: 41000, salida: 900 });
|
|
235
|
+
expect(estadoPorFamilia(s)).toContain('codex [revisó]');
|
|
236
|
+
expect(s.registro.map((r) => r.quien)).toEqual(['codex', 'codex']);
|
|
237
|
+
expect(terminarRevision(s, { id: 'no', ok: true, at: 1, cifras: null })).toBe(s);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, test } from 'claude-code/testing';
|
|
2
|
+
|
|
3
|
+
const PANE = {
|
|
4
|
+
plugin: 'nexakit-panel',
|
|
5
|
+
component: 'Pane',
|
|
6
|
+
requestId: 'panel',
|
|
7
|
+
props: { title: 'Orquesta', isFocused: false, bodyColumns: 70, placement: 'dock', scroll: { offset: 0, bodyRows: 40 }, view: {} },
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
let reloj = 1_000_000;
|
|
11
|
+
const drain = async (stream: any) => {
|
|
12
|
+
for await (const _ of stream) { /* el panel solo observa */ }
|
|
13
|
+
return stream.result;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Lo que el motor contestaría por debajo del mod.
|
|
17
|
+
function motor($: any, on: any, uso: any = { startedAt: 0, context: { window: 1000000, percent: 31 }, rateLimits: [{ kind: 'five_hour', percentUsed: 43 }, { kind: 'seven_day', percentUsed: 60 }] }, falla = false, salidaBash = '') {
|
|
18
|
+
on('command.register', () => ({ value: { command: 'panel' } }));
|
|
19
|
+
on('ui.open', () => ({ value: { isPlaced: true } }));
|
|
20
|
+
on('session.start', (_: any, e: any) => ({ cwd: e.cwd }));
|
|
21
|
+
on('clock.now', () => ({ value: (reloj += 1000) }));
|
|
22
|
+
on('session.cwd', () => ({ value: '/prueba/mi-app' }));
|
|
23
|
+
on('session.usage', () => { if (falla) throw new Error('sin datos'); return { value: uso }; });
|
|
24
|
+
on('tool.call', () => ({ result: { stdout: salidaBash, stderr: '' } }));
|
|
25
|
+
on('agent.spawn', (_: any, e: any) => ({ model: e.model ?? 'claude-haiku-5-5', agentId: `ag-${e.name ?? 'x'}` }));
|
|
26
|
+
on('turn.complete', () => ({ text: '' }));
|
|
27
|
+
on('turn.step', async function* (_: any, e: any) {
|
|
28
|
+
return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null,
|
|
29
|
+
serverToolUses: e.index === 1 ? [{ id: 's1', name: 'advisor', input: {}, startedAt: 1, endedAt: 2 }]
|
|
30
|
+
: e.index === 2 ? [{ id: 's2', name: 'web_search', input: {}, startedAt: 3, endedAt: 4 }] : undefined };
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const paso = ($: any, extra: any) =>
|
|
34
|
+
drain($.turn.step({ turnId: 't1', index: 0, model: 'claude-sonnet-5-5', effort: 'high', messageCount: 3, ...extra }));
|
|
35
|
+
const turnoListo = ($: any, extra: any) =>
|
|
36
|
+
$.turn.complete({ reason: 'answer', answer: 'ok', durationMs: 5, isAborted: false, turnId: 't1', ...extra });
|
|
37
|
+
|
|
38
|
+
describe('nexakit-panel · en la sesión', () => {
|
|
39
|
+
test('dibuja líder, subagentes, asesor, registro y límites con lo que informa el motor', async ($, on) => {
|
|
40
|
+
motor($, on);
|
|
41
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
42
|
+
await paso($, {}); // líder: Sonnet 5.5 · high
|
|
43
|
+
await paso($, { index: 1 }); // el paso trae una consulta al asesor
|
|
44
|
+
await paso($, { index: 2 }); // otra llamada del servidor: no es el asesor
|
|
45
|
+
await $.agent.spawn({ prompt: 'mapea', name: 'search', subagentType: 'Explore', description: 'mapear el repo', model: 'claude-haiku-5-5' });
|
|
46
|
+
await $.tool.call({ tool: 'Read', file_path: 'src/auth.ts', agentId: 'ag-search' });
|
|
47
|
+
await turnoListo($, { agentId: 'ag-search' });
|
|
48
|
+
|
|
49
|
+
for (const surface of ['terminal', 'desktop'] as const) {
|
|
50
|
+
const ui = await $.ui.mount({ ...PANE, surface });
|
|
51
|
+
expect((await ui.find({ type: 'Text', text: /Sonnet 5\.5 · sesión principal/ }))?.text).toBeTruthy();
|
|
52
|
+
expect((await ui.find({ type: 'Text', text: /esfuerzo ▮▮▮▯ high/ }))?.text).toBeTruthy();
|
|
53
|
+
expect((await ui.find({ type: 'Text', text: /Asesor · consultas 1/ }))?.text).toBeTruthy();
|
|
54
|
+
expect((await ui.find({ type: 'Text', text: /search/ }))?.text).toBeTruthy();
|
|
55
|
+
expect((await ui.find({ type: 'Text', text: /Haiku 5\.5/ }))?.text).toBeTruthy();
|
|
56
|
+
expect((await ui.find({ type: 'Text', text: /✓ listo/ }))?.text).toBeTruthy();
|
|
57
|
+
expect((await ui.find({ type: 'Text', text: /Read · src\/auth\.ts/ }))?.text).toBeTruthy();
|
|
58
|
+
expect((await ui.find({ type: 'Text', text: /contexto .*31 % +5 h .*43 % +semana .*60 %/ }))?.text).toBeTruthy();
|
|
59
|
+
await ui.unmount();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('con ancho de sobra dibuja el diagrama completo con la estructura de la imagen', async ($, on) => {
|
|
64
|
+
motor($, on);
|
|
65
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
66
|
+
await paso($, {});
|
|
67
|
+
await paso($, { index: 1 });
|
|
68
|
+
await $.agent.spawn({ prompt: 'mapea', name: 'search', subagentType: 'Explore', description: 'mapear', model: 'claude-haiku-5-5' });
|
|
69
|
+
await $.agent.spawn({ prompt: 'busca', name: 'grep', subagentType: 'Explore', description: 'buscar', model: 'claude-haiku-5-5' });
|
|
70
|
+
await $.tool.call({ tool: 'Read', file_path: 'src/auth.ts', agentId: 'ag-search' });
|
|
71
|
+
await turnoListo($, { agentId: 'ag-search' });
|
|
72
|
+
|
|
73
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, bodyColumns: 120 } });
|
|
74
|
+
const hay = async (texto: RegExp) => expect((await ui.find({ type: 'Text', text: texto }))?.text, String(texto)).toBeTruthy();
|
|
75
|
+
for (const t of [/NEXAKIT/, /SONNET 5\.5 DIRIGE/, /HAIKU 5\.5 EN ENJAMBRE/, /═{50,}/, /sonnet 5\.5 · líder/, /ASESOR/,
|
|
76
|
+
/◆ \d\d:\d\d:\d\d consulta/, /Sonnet 5\.5 · sesión principal/, /pasos 2 · herramientas 0/, /DELEGACIÓN · subagentes/,
|
|
77
|
+
/delegar a subagentes/, /search/, /✓ listo/, /◐ corriendo/, /de vuelta en Sonnet 5\.5 · líder/, /esperando 1 subagente/,
|
|
78
|
+
/registro de sesión/, /→ src\/auth\.ts/, /claude --model sonnet/, /\[enjambre\]/, /\[consultado 1\]/, /31 %/, /43 %/, /60 %/]) {
|
|
79
|
+
await hay(t);
|
|
80
|
+
}
|
|
81
|
+
await ui.unmount();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('en el diagrama ancho sin actividad no inventa cajas ni números', async ($, on) => {
|
|
85
|
+
motor($, on, { startedAt: 0, context: { window: 1000000 }, rateLimits: [] });
|
|
86
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
87
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, bodyColumns: 120 } });
|
|
88
|
+
expect((await ui.find({ type: 'Text', text: /Aún no hay actividad/ }))?.text).toBeTruthy();
|
|
89
|
+
for (const falso of [/DELEGACIÓN/, /ASESOR/, /de vuelta en/, /registro de sesión/]) expect(await ui.find({ type: 'Text', text: falso })).toBeFalsy();
|
|
90
|
+
await ui.unmount();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('con más subagentes de los que caben avisa de los que no caben y no los pierde en silencio', async ($, on) => {
|
|
94
|
+
motor($, on);
|
|
95
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
96
|
+
await paso($, {});
|
|
97
|
+
for (let i = 0; i < 6; i++) await $.agent.spawn({ prompt: 'x', name: 'tarea' + i, description: 'd', model: 'claude-haiku-5-5' });
|
|
98
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, bodyColumns: 120 } });
|
|
99
|
+
expect((await ui.find({ type: 'Text', text: /\+3 más/ }))?.text).toBeTruthy();
|
|
100
|
+
expect((await ui.find({ type: 'Text', text: /DELEGACIÓN · subagentes/ }))?.text).toBeTruthy();
|
|
101
|
+
expect((await ui.find({ type: 'Text', text: /^ *6 *$/ }))?.text).toBeTruthy();
|
|
102
|
+
await ui.unmount();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('/panel-demo enseña datos de ejemplo marcados como ficticios, y el panel real no se contamina', async ($, on) => {
|
|
106
|
+
motor($, on);
|
|
107
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
108
|
+
await $.command.run({ command: 'panel-demo', args: '' } as any);
|
|
109
|
+
let ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, bodyColumns: 120 } });
|
|
110
|
+
expect((await ui.find({ type: 'Text', text: /EJEMPLO · datos ficticios/ }))?.text).toBeTruthy();
|
|
111
|
+
expect((await ui.find({ type: 'Text', text: /DELEGACIÓN/ }))?.text).toBeTruthy();
|
|
112
|
+
await ui.unmount();
|
|
113
|
+
await $.command.run({ command: 'panel-demo', args: '' } as any);
|
|
114
|
+
ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, bodyColumns: 120 } });
|
|
115
|
+
expect(await ui.find({ type: 'Text', text: /EJEMPLO/ })).toBeFalsy();
|
|
116
|
+
expect((await ui.find({ type: 'Text', text: /Aún no hay actividad/ }))?.text).toBeTruthy();
|
|
117
|
+
await ui.unmount();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('guarda una foto del estado real en ~/.nexakit/panel para nexakit panel, sin secretos', async ($, on) => {
|
|
121
|
+
const escritos: Record<string, string> = {};
|
|
122
|
+
motor($, on);
|
|
123
|
+
on('env.get', (_: any, e: any) => ({ value: e.name === 'HOME' ? '/home/prueba' : undefined }));
|
|
124
|
+
on('session.id', () => ({ value: 'sesion-123' }));
|
|
125
|
+
on('fs.write', (_: any, e: any) => { escritos[e.path] = e.text; return { value: undefined }; });
|
|
126
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
127
|
+
await paso($, {});
|
|
128
|
+
await $.tool.call({ tool: 'Bash', command: 'export API_TOKEN=abc123 && npm test' } as any);
|
|
129
|
+
const foto = JSON.parse(escritos['/home/prueba/.nexakit/panel/actual.json']);
|
|
130
|
+
expect(foto).toMatchObject({ version: 1, sesion: 'sesion-123', carpeta: 'mi-app' });
|
|
131
|
+
expect(foto.estado.lider.modelo).toBe('claude-sonnet-5-5');
|
|
132
|
+
expect(foto.uso.context.percent).toBe(31);
|
|
133
|
+
expect(JSON.stringify(foto)).not.toContain('abc123');
|
|
134
|
+
expect(escritos['/home/prueba/.nexakit/panel/sesion-123.json']).toBe(escritos['/home/prueba/.nexakit/panel/actual.json']);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('una revisión que Claude manda a Codex aparece con su modelo y los tokens que informa', async ($, on) => {
|
|
138
|
+
motor($, on, undefined, false, 'informe\nModelo pedido: gpt-6.1-sol · high · Modelo que respondió: gpt-6.1-sol · high (registro)\nTokens: entrada 41000 (caché 3000) · salida 900\n');
|
|
139
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
140
|
+
await paso($, {});
|
|
141
|
+
await $.tool.call({ tool: 'Bash', command: '~/.nexakit/nexakit revision --con codex --modelo gpt-6.1-sol --esfuerzo high --encargo /tmp/e.md' } as any);
|
|
142
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal', props: { ...PANE.props, bodyColumns: 120 } });
|
|
143
|
+
for (const t of [/REVISIÓN/, /Codex/, /✓ lista/, /tokens 42k/, /codex · revisa/, /\[revisó\]/]) {
|
|
144
|
+
expect((await ui.find({ type: 'Text', text: t }))?.text, String(t)).toBeTruthy();
|
|
145
|
+
}
|
|
146
|
+
await ui.unmount();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('sin actividad el panel lo dice y no inventa nada', async ($, on) => {
|
|
150
|
+
motor($, on, { startedAt: 0, context: { window: 1000000 }, rateLimits: [] });
|
|
151
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
152
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' });
|
|
153
|
+
expect((await ui.find({ type: 'Text', text: /Aún no hay actividad/ }))?.text).toBeTruthy();
|
|
154
|
+
expect(await ui.find({ type: 'Text', text: /Asesor/ })).toBeFalsy();
|
|
155
|
+
expect(await ui.find({ type: 'Text', text: /contexto/ })).toBeFalsy();
|
|
156
|
+
await ui.unmount();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('si falla la lectura del uso, el panel se dibuja igual', async ($, on) => {
|
|
160
|
+
motor($, on, undefined, true);
|
|
161
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
162
|
+
await paso($, {});
|
|
163
|
+
const ui = await $.ui.mount({ ...PANE, surface: 'terminal' });
|
|
164
|
+
expect((await ui.find({ type: 'Text', text: /Sonnet 5\.5/ }))?.text).toBeTruthy();
|
|
165
|
+
await ui.unmount();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type PanelAgente = {
|
|
2
|
+
id: string;
|
|
3
|
+
nombre: string;
|
|
4
|
+
modelo: string;
|
|
5
|
+
estado: 'corriendo' | 'listo' | 'error';
|
|
6
|
+
inicio: number;
|
|
7
|
+
fin: number | null;
|
|
8
|
+
herramientas: number;
|
|
9
|
+
leidos: number;
|
|
10
|
+
escritos: number;
|
|
11
|
+
ultimo: string;
|
|
12
|
+
};
|
|
13
|
+
export type PanelRevision = {
|
|
14
|
+
id: string;
|
|
15
|
+
ia: string;
|
|
16
|
+
modelo: string | null;
|
|
17
|
+
esfuerzo: string | null;
|
|
18
|
+
estado: 'revisando' | 'lista' | 'error';
|
|
19
|
+
inicio: number;
|
|
20
|
+
fin: number | null;
|
|
21
|
+
entrada: number | null;
|
|
22
|
+
cache: number | null;
|
|
23
|
+
salida: number | null;
|
|
24
|
+
respondio: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type PanelRegistro = { t: number; quien: string; texto: string; ms?: number };
|
|
27
|
+
export type PanelEstado = {
|
|
28
|
+
lider: { modelo: string | null; esfuerzo: string | null; leidos: number; escritos: number; herramientas: number };
|
|
29
|
+
agentes: PanelAgente[];
|
|
30
|
+
asesor: { llamadas: number; ultimo: number | null; tiempo: number };
|
|
31
|
+
registro: PanelRegistro[];
|
|
32
|
+
pasos: number;
|
|
33
|
+
recomendacion: { modelo: string; esfuerzo: string; motivo: string } | null;
|
|
34
|
+
revisiones: PanelRevision[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
declare module 'claude-code' {
|
|
38
|
+
interface PluginState {
|
|
39
|
+
'nexakit-panel': { panel: Shaped<PanelEstado>; demo: Shaped<boolean> };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"name": "nexakit-sesion", "version": "0.1.0", "description": "Muestra cuánto contexto y cuota llevas y avisa cuándo conviene cerrar la sesión y abrir otra, sin cerrarla por ti.", "license": "MIT"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# nexakit-sesion
|
|
2
|
+
|
|
3
|
+
Muestra en la línea de estado de Claude Code cuánto contexto llevas y tus límites de uso de 5 horas y semanal,
|
|
4
|
+
por ejemplo `contexto 42 % · 5 h 14 % · semana 79 %`. Al pasar del 60 % y del 80 % de contexto avisa una vez de
|
|
5
|
+
que es buen momento para escribir el traspaso y abrir una sesión nueva. Solo avisa: nunca cierra la sesión.
|
|
6
|
+
|
|
7
|
+
Los datos salen de Claude Code (`$.session.usage()`); los límites se mueven en puntos enteros y solo cuando una
|
|
8
|
+
respuesta de la API informa un valor nuevo.
|
|
9
|
+
|
|
10
|
+
## Pruebas
|
|
11
|
+
|
|
12
|
+
`claude plugin test plugins/nexakit-sesion`
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Umbrales de contexto a partir de los cuales conviene pensar en una sesión nueva.
|
|
2
|
+
export const UMBRAL_AVISO = 60;
|
|
3
|
+
export const UMBRAL_URGENTE = 80;
|
|
4
|
+
|
|
5
|
+
const entero = (valor) => Math.round(valor);
|
|
6
|
+
const esNumero = (valor) => typeof valor === 'number' && Number.isFinite(valor);
|
|
7
|
+
|
|
8
|
+
// 0 = sin aviso, 1 = conviene, 2 = urgente. Sin dato nunca avisa.
|
|
9
|
+
export function avisoDeContexto(percent) {
|
|
10
|
+
if (!esNumero(percent)) return 0;
|
|
11
|
+
if (percent >= UMBRAL_URGENTE) return 2;
|
|
12
|
+
if (percent >= UMBRAL_AVISO) return 1;
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Línea de estado: solo lo que se conoce; undefined si no hay nada que mostrar.
|
|
17
|
+
export function textoDeEstado(usage) {
|
|
18
|
+
const partes = [];
|
|
19
|
+
if (esNumero(usage?.context?.percent)) partes.push(`contexto ${entero(usage.context.percent)} %`);
|
|
20
|
+
for (const limite of usage?.rateLimits ?? []) {
|
|
21
|
+
if (!esNumero(limite?.percentUsed)) continue;
|
|
22
|
+
if (limite.kind === 'five_hour') partes.push(`5 h ${entero(limite.percentUsed)} %`);
|
|
23
|
+
if (limite.kind === 'seven_day') partes.push(`semana ${entero(limite.percentUsed)} %`);
|
|
24
|
+
}
|
|
25
|
+
return partes.length > 0 ? partes.join(' · ') : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// El aviso solo recomienda: nunca promete ni hace el cierre por la persona.
|
|
29
|
+
export function mensajeDeAviso(nivel, percent) {
|
|
30
|
+
const p = entero(percent);
|
|
31
|
+
if (nivel === 1) {
|
|
32
|
+
return `Contexto al ${p} %. Si vas a cambiar de tarea, es buen momento: escribe el traspaso y abre una sesión nueva.`;
|
|
33
|
+
}
|
|
34
|
+
if (nivel === 2) {
|
|
35
|
+
return `Contexto al ${p} %. Cada turno relee más contexto: cierra esta tarea, escribe el traspaso y abre una sesión nueva.`;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"modules":["./register.js"]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { avisoDeContexto, mensajeDeAviso, textoDeEstado } from './aviso.js';
|
|
2
|
+
|
|
3
|
+
export function register(on) {
|
|
4
|
+
// Último nivel avisado: solo se vuelve a avisar al subir de nivel o tras bajar y volver a subir.
|
|
5
|
+
let ultimoAviso = 0;
|
|
6
|
+
|
|
7
|
+
on('turn.complete', async ($, e, next) => {
|
|
8
|
+
try {
|
|
9
|
+
if (!e.agentId) {
|
|
10
|
+
const usage = await $.session.usage();
|
|
11
|
+
$.ui.status(textoDeEstado(usage));
|
|
12
|
+
const percent = usage?.context?.percent;
|
|
13
|
+
const nivel = avisoDeContexto(percent);
|
|
14
|
+
if (nivel > ultimoAviso) $.ui.toast(mensajeDeAviso(nivel, percent), { timeoutMs: 10000 });
|
|
15
|
+
ultimoAviso = nivel;
|
|
16
|
+
}
|
|
17
|
+
} catch {
|
|
18
|
+
// Medir nunca debe romper el turno.
|
|
19
|
+
}
|
|
20
|
+
return next(e);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
on('session.end', async ($, e, next) => {
|
|
24
|
+
ultimoAviso = 0;
|
|
25
|
+
$.ui.status(undefined);
|
|
26
|
+
return next(e);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { describe, expect, test } from 'claude-code/testing';
|
|
2
|
+
import { avisoDeContexto, textoDeEstado, mensajeDeAviso } from '../hooks/aviso.js';
|
|
3
|
+
|
|
4
|
+
describe('nexakit-sesion · lógica', () => {
|
|
5
|
+
test('los umbrales avisan al 60 % y al 80 %, y nunca sin dato', () => {
|
|
6
|
+
expect(avisoDeContexto(0)).toBe(0);
|
|
7
|
+
expect(avisoDeContexto(59.9)).toBe(0);
|
|
8
|
+
expect(avisoDeContexto(60)).toBe(1);
|
|
9
|
+
expect(avisoDeContexto(79.9)).toBe(1);
|
|
10
|
+
expect(avisoDeContexto(80)).toBe(2);
|
|
11
|
+
expect(avisoDeContexto(100)).toBe(2);
|
|
12
|
+
expect(avisoDeContexto(undefined)).toBe(0);
|
|
13
|
+
expect(avisoDeContexto(Number.NaN)).toBe(0);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('la línea de estado junta contexto y límites de Claude, y omite lo que no se conoce', () => {
|
|
17
|
+
const usage = {
|
|
18
|
+
context: { window: 1000000, percent: 41.6 },
|
|
19
|
+
rateLimits: [
|
|
20
|
+
{ kind: 'five_hour', percentUsed: 88 },
|
|
21
|
+
{ kind: 'seven_day', percentUsed: 14.5 },
|
|
22
|
+
{ kind: 'spend_limit', percentUsed: 3 },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
expect(textoDeEstado(usage)).toBe('contexto 42 % · 5 h 88 % · semana 15 %');
|
|
26
|
+
expect(textoDeEstado({ context: { window: 1000 }, rateLimits: [] })).toBeUndefined();
|
|
27
|
+
expect(textoDeEstado({ context: { window: 1000, percent: 5 }, rateLimits: [] })).toBe('contexto 5 %');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('cada aviso dice el porcentaje, propone el traspaso y no promete cerrar nada', () => {
|
|
31
|
+
const uno = mensajeDeAviso(1, 62.2);
|
|
32
|
+
const dos = mensajeDeAviso(2, 83.7);
|
|
33
|
+
expect(uno).toContain('62 %');
|
|
34
|
+
expect(dos).toContain('84 %');
|
|
35
|
+
for (const texto of [uno, dos]) {
|
|
36
|
+
expect(texto).toContain('traspaso');
|
|
37
|
+
expect(texto).toContain('sesión nueva');
|
|
38
|
+
}
|
|
39
|
+
expect(mensajeDeAviso(0, 10)).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('nexakit-sesion · en la sesión', () => {
|
|
44
|
+
const montar = async ($: any, on: any, porcentajes: number[]) => {
|
|
45
|
+
const estados: (string | undefined)[] = [];
|
|
46
|
+
const avisos: string[] = [];
|
|
47
|
+
let turno = 0;
|
|
48
|
+
on('session.start', (_: any, e: any) => ({ cwd: e.cwd }));
|
|
49
|
+
on('turn.complete', () => ({ text: 'listo' }));
|
|
50
|
+
on('session.end', () => ({ sessionId: 's1' }));
|
|
51
|
+
on('clock.now', () => ({ value: 0 }));
|
|
52
|
+
on('fs.write', () => ({ value: undefined }));
|
|
53
|
+
on('session.usage', () => ({
|
|
54
|
+
value: { startedAt: 0, context: { window: 1000000, percent: porcentajes[Math.min(turno, porcentajes.length - 1)] }, rateLimits: [] },
|
|
55
|
+
}));
|
|
56
|
+
on('ui.status', (_: any, e: any) => {
|
|
57
|
+
estados.push(e.text);
|
|
58
|
+
return { value: undefined };
|
|
59
|
+
});
|
|
60
|
+
on('ui.toast', (_: any, e: any) => {
|
|
61
|
+
avisos.push(e.text);
|
|
62
|
+
return { value: undefined };
|
|
63
|
+
});
|
|
64
|
+
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/prueba' });
|
|
65
|
+
const terminar = async () => {
|
|
66
|
+
await $.turn.complete({ reason: 'answer', answer: 'ok', durationMs: 5, isAborted: false, turnId: `t${turno}` });
|
|
67
|
+
turno += 1;
|
|
68
|
+
};
|
|
69
|
+
return { estados, avisos, terminar };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
test('avisa una sola vez por umbral y vuelve a avisar si el contexto baja y vuelve a subir', async ($, on) => {
|
|
73
|
+
const { estados, avisos, terminar } = await montar($, on, [30, 61, 65, 82, 85, 20, 70]);
|
|
74
|
+
await terminar(); // 30
|
|
75
|
+
expect(avisos).toHaveLength(0);
|
|
76
|
+
await terminar(); // 61 -> primer aviso
|
|
77
|
+
expect(avisos).toHaveLength(1);
|
|
78
|
+
await terminar(); // 65 -> sin repetir
|
|
79
|
+
expect(avisos).toHaveLength(1);
|
|
80
|
+
await terminar(); // 82 -> aviso urgente
|
|
81
|
+
expect(avisos).toHaveLength(2);
|
|
82
|
+
expect(avisos[1]).toContain('82 %');
|
|
83
|
+
await terminar(); // 85 -> sin repetir
|
|
84
|
+
expect(avisos).toHaveLength(2);
|
|
85
|
+
await terminar(); // 20 -> tras compactar o limpiar
|
|
86
|
+
await terminar(); // 70 -> vuelve a avisar
|
|
87
|
+
expect(avisos).toHaveLength(3);
|
|
88
|
+
expect(estados.at(-1)).toBe('contexto 70 %');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('al terminar la sesión limpia su línea de estado', async ($, on) => {
|
|
92
|
+
const { estados } = await montar($, on, [10]);
|
|
93
|
+
await $.session.end({ reason: 'exit' });
|
|
94
|
+
expect(estados.at(-1)).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|