changebook 0.4.8 → 0.4.9
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/README.md +3 -0
- package/dist/analyze.js +4 -2
- package/dist/context.js +76 -29
- package/dist/credentials.js +42 -0
- package/dist/git.js +33 -1
- package/dist/impact.js +512 -0
- package/dist/index.js +54 -1
- package/dist/login.js +61 -1
- package/dist/supabase.js +48 -4
- package/dist/sync.js +18 -3
- package/dist/tools.js +65 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -50,6 +50,9 @@ your other servers.
|
|
|
50
50
|
| `changebook analyze [dir]` | Analyze the repo's uncommitted changes (`git diff HEAD`) and update the atlas — same pipeline as the VS Code extension, no editor needed. |
|
|
51
51
|
| `changebook analyze --commit [ref]` | Analyze one commit. Deduped by hash server-side, so re-runs never bill. |
|
|
52
52
|
| `changebook hook install\|uninstall\|status [dir]` | Git hooks: every new commit is analyzed in the background (post-commit, never blocks), and the signal guard warns before you commit to a module with an open alert (pre-commit). One pair of hooks covers Claude Code, Codex and manual commits — they all commit through git. |
|
|
53
|
+
| `changebook hook-context install\|uninstall\|status [dir]` | Claude Code `SessionStart` hook: pushes the fresh atlas map into **every** session at turn 0 — no tool call to remember, and generated on the spot so it can't go stale. Writes to `.claude/settings.json`; running the command **is** the consent, and it refuses to touch a config it can't parse. |
|
|
54
|
+
| `changebook hook-impact install\|uninstall\|status [dir]` | Claude Code `PreToolUse` hook: **before every edit**, tells the agent which modules depend on the file it is about to touch, plus any open alert and repeat-offender history. Never blocks an edit, never touches the network on the critical path (reads a short-lived cache in `.git/` and refreshes it out of band), warns once per file per session, and stays **silent** when there is nothing to say. |
|
|
55
|
+
| `changebook impact` / `changebook context [dir]` | What those two hooks run. Both read from stdin/disk, print a JSON payload (or nothing) and always exit 0 — you don't call them by hand. |
|
|
53
56
|
| `changebook guard [dir]` | What the pre-commit hook runs: checks staged files against the atlas' open alerts. Warn-only and fail-open by default; `CHANGEBOOK_GUARD=block` makes findings abort the commit (bypass once with `git commit --no-verify`), `CHANGEBOOK_GUARD=off` silences it. |
|
|
54
57
|
| `changebook sync [dir]` | Refresh the product map inside `CLAUDE.md`/`AGENTS.md`. |
|
|
55
58
|
| `changebook init [dir]` | login + register MCP server + install hook + sync, in one go. |
|
package/dist/analyze.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { atlasWebUrl } from "./browser.js";
|
|
9
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
9
|
+
import { commitDiff, execFileAsync, FICHEROS_GENERADOS, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
10
10
|
import { canonicalDiffHash } from "./canonical.js";
|
|
11
11
|
import { optimizeTokensForAI, truncateAtFileBoundary } from "./optimize.js";
|
|
12
12
|
export async function analyze(db, options = {}) {
|
|
@@ -124,7 +124,9 @@ async function commitMeta(cwd, ref) {
|
|
|
124
124
|
}
|
|
125
125
|
async function gitDiffHead(cwd) {
|
|
126
126
|
try {
|
|
127
|
-
|
|
127
|
+
// Mismos ficheros excluidos que en commitDiff: el atlas no se analiza a sí
|
|
128
|
+
// mismo (ver FICHEROS_GENERADOS).
|
|
129
|
+
const { stdout } = await execFileAsync("git", ["diff", "HEAD", "-U0", "--", ...FICHEROS_GENERADOS], {
|
|
128
130
|
cwd,
|
|
129
131
|
encoding: "utf8",
|
|
130
132
|
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
package/dist/context.js
CHANGED
|
@@ -28,6 +28,38 @@ import { fetchBriefSection } from "./sync.js";
|
|
|
28
28
|
import { commitAliasesShort, derivaContraHead } from "./tools.js";
|
|
29
29
|
/** Hard ceiling on the critical path: past this, emit nothing and move on. */
|
|
30
30
|
const CONTEXT_TIMEOUT_MS = 2_000;
|
|
31
|
+
/**
|
|
32
|
+
* A timeout that can lose the race without punishing the winner.
|
|
33
|
+
*
|
|
34
|
+
* The naive form — `new Promise(r => setTimeout(r, ms))` raced against the real
|
|
35
|
+
* work — has a bug that measurement makes obvious and reading never does: when
|
|
36
|
+
* the work wins, the timer is still pending, and Node will not exit until it
|
|
37
|
+
* fires. The process is done and just sits there. Measured 2026-07-26:
|
|
38
|
+
* `changebook context` took 2.18s wall clock to produce a payload it already had
|
|
39
|
+
* in ~200ms, on the session-start path, under a doc comment promising it would
|
|
40
|
+
* "never block it, slow it, or exit non-zero". It had been slowing every session
|
|
41
|
+
* by two seconds.
|
|
42
|
+
*
|
|
43
|
+
* `unref()` is the fix: the timer still fires if anything else is keeping the
|
|
44
|
+
* loop alive, but it never keeps the loop alive by itself. `cancelar()` is belt
|
|
45
|
+
* and braces for the long-lived case (the MCP server), where an unref'd timer
|
|
46
|
+
* would still fire pointlessly.
|
|
47
|
+
*
|
|
48
|
+
* Shared by both hooks on purpose. The defect existed independently in two
|
|
49
|
+
* places, which is the usual sign that the shape — not the instance — was wrong.
|
|
50
|
+
*/
|
|
51
|
+
export function presupuestoDeTiempo(ms) {
|
|
52
|
+
let cancelar = () => { };
|
|
53
|
+
const vencido = new Promise((resolve) => {
|
|
54
|
+
const t = setTimeout(() => resolve(null), ms);
|
|
55
|
+
t.unref?.();
|
|
56
|
+
cancelar = () => {
|
|
57
|
+
clearTimeout(t);
|
|
58
|
+
resolve(null);
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
return { vencido, cancelar };
|
|
62
|
+
}
|
|
31
63
|
async function buildPayload(db, dir) {
|
|
32
64
|
// Logged out → silent no-op. A fresh clone with the hook installed but no
|
|
33
65
|
// session must open exactly as fast as before.
|
|
@@ -60,24 +92,20 @@ async function buildPayload(db, dir) {
|
|
|
60
92
|
}
|
|
61
93
|
return drift ? `${section}\n\n${drift}` : section;
|
|
62
94
|
}
|
|
63
|
-
// ── SessionStart hook install (opt-in, per repo) ─────────────────────────────
|
|
64
|
-
//
|
|
65
|
-
// The push channel lives in `.claude/settings.json` under hooks.SessionStart.
|
|
66
|
-
// Consent is the act of running `changebook hook-context install` (or saying
|
|
67
|
-
// yes to init's offer) — this file is often committed and shared, so we NEVER
|
|
68
|
-
// write it silently and NEVER clobber a config we can't parse.
|
|
69
95
|
// `2>/dev/null || true`: if `changebook` isn't on PATH for some teammate, the
|
|
70
96
|
// shell error is swallowed and the hook exits 0 — a missing binary must not
|
|
71
|
-
// make a
|
|
72
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
97
|
+
// make a hook look failed. Both commands emit only the JSON payload (or
|
|
98
|
+
// nothing) on stdout.
|
|
99
|
+
export const CONTEXT_HOOK = {
|
|
100
|
+
event: "SessionStart",
|
|
101
|
+
command: "changebook context 2>/dev/null || true",
|
|
102
|
+
marker: "changebook context",
|
|
103
|
+
};
|
|
76
104
|
function settingsPath(dir) {
|
|
77
105
|
return path.join(path.resolve(dir), ".claude", "settings.json");
|
|
78
106
|
}
|
|
79
|
-
function groupHasMarker(g) {
|
|
80
|
-
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(
|
|
107
|
+
function groupHasMarker(g, marker) {
|
|
108
|
+
return (g.hooks ?? []).some((h) => (h.command ?? "").includes(marker));
|
|
81
109
|
}
|
|
82
110
|
function readSettings(file) {
|
|
83
111
|
let raw;
|
|
@@ -97,53 +125,71 @@ function readSettings(file) {
|
|
|
97
125
|
return null; // unparseable → caller must refuse, never clobber
|
|
98
126
|
}
|
|
99
127
|
}
|
|
100
|
-
export function
|
|
128
|
+
export function settingsHookInstalled(dir, spec) {
|
|
101
129
|
const s = readSettings(settingsPath(dir));
|
|
102
|
-
return Boolean(s?.hooks?.
|
|
130
|
+
return Boolean(s?.hooks?.[spec.event]?.some((g) => groupHasMarker(g, spec.marker)));
|
|
103
131
|
}
|
|
104
|
-
export function
|
|
132
|
+
export function installSettingsHook(dir, spec) {
|
|
105
133
|
const file = settingsPath(dir);
|
|
106
134
|
const settings = readSettings(file);
|
|
107
135
|
if (settings === null) {
|
|
108
136
|
throw new Error(`Refusing to touch ${file}: it isn't valid JSON. Fix or remove it, then retry.`);
|
|
109
137
|
}
|
|
110
138
|
const hooks = (settings.hooks ??= {});
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
139
|
+
const list = (hooks[spec.event] ??= []);
|
|
140
|
+
if (list.some((g) => groupHasMarker(g, spec.marker)))
|
|
113
141
|
return "already";
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
// El matcher solo se escribe si lo hay: un `matcher` presente y vacío es
|
|
143
|
+
// "todas las tools" en Claude Code, y en SessionStart no significa nada.
|
|
144
|
+
// Escribir la clave igualmente dejaría un campo mudo en un archivo que la
|
|
145
|
+
// gente lee y commitea.
|
|
146
|
+
list.push({
|
|
147
|
+
...(spec.matcher ? { matcher: spec.matcher } : {}),
|
|
148
|
+
hooks: [{ type: "command", command: spec.command }],
|
|
116
149
|
});
|
|
117
150
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
118
151
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
119
152
|
return "installed";
|
|
120
153
|
}
|
|
121
|
-
export function
|
|
154
|
+
export function uninstallSettingsHook(dir, spec) {
|
|
122
155
|
const file = settingsPath(dir);
|
|
123
156
|
const settings = readSettings(file);
|
|
124
|
-
if (!settings
|
|
157
|
+
if (!settings?.hooks?.[spec.event])
|
|
125
158
|
return "absent";
|
|
126
|
-
const before = settings.hooks.
|
|
159
|
+
const before = settings.hooks[spec.event];
|
|
127
160
|
// Drop our command from every group, then drop groups left empty. Foreign
|
|
128
161
|
// hooks in the same group (unusual, but possible) are preserved.
|
|
129
162
|
const after = before
|
|
130
163
|
.map((g) => ({
|
|
131
164
|
...g,
|
|
132
|
-
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(
|
|
165
|
+
hooks: (g.hooks ?? []).filter((h) => !(h.command ?? "").includes(spec.marker)),
|
|
133
166
|
}))
|
|
134
167
|
.filter((g) => (g.hooks ?? []).length > 0);
|
|
135
|
-
if (after.length === before.length &&
|
|
168
|
+
if (after.length === before.length &&
|
|
169
|
+
before.every((g) => !groupHasMarker(g, spec.marker))) {
|
|
136
170
|
return "absent";
|
|
137
171
|
}
|
|
138
172
|
if (after.length > 0)
|
|
139
|
-
settings.hooks.
|
|
173
|
+
settings.hooks[spec.event] = after;
|
|
140
174
|
else
|
|
141
|
-
delete settings.hooks.
|
|
175
|
+
delete settings.hooks[spec.event];
|
|
142
176
|
if (Object.keys(settings.hooks).length === 0)
|
|
143
177
|
delete settings.hooks;
|
|
144
178
|
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + "\n");
|
|
145
179
|
return "removed";
|
|
146
180
|
}
|
|
181
|
+
// Envoltorios del canal SessionStart. Se mantienen con su nombre porque son la
|
|
182
|
+
// superficie que ya consume index.ts (y, a través de él, `init`): generalizar
|
|
183
|
+
// por dentro no es motivo para renombrar por fuera.
|
|
184
|
+
export function contextHookInstalled(dir) {
|
|
185
|
+
return settingsHookInstalled(dir, CONTEXT_HOOK);
|
|
186
|
+
}
|
|
187
|
+
export function installContextHook(dir) {
|
|
188
|
+
return installSettingsHook(dir, CONTEXT_HOOK);
|
|
189
|
+
}
|
|
190
|
+
export function uninstallContextHook(dir) {
|
|
191
|
+
return uninstallSettingsHook(dir, CONTEXT_HOOK);
|
|
192
|
+
}
|
|
147
193
|
/**
|
|
148
194
|
* The pulse and the brief, composed. Separate from printContext so the WHOLE
|
|
149
195
|
* thing — including the pulse — stays inside the single timeout race: adding
|
|
@@ -170,11 +216,12 @@ async function buildSessionContext(db, dir) {
|
|
|
170
216
|
}
|
|
171
217
|
export async function printContext(db, dir) {
|
|
172
218
|
try {
|
|
173
|
-
const
|
|
219
|
+
const { vencido, cancelar } = presupuestoDeTiempo(CONTEXT_TIMEOUT_MS);
|
|
174
220
|
const additionalContext = await Promise.race([
|
|
175
221
|
buildSessionContext(db, dir),
|
|
176
|
-
|
|
222
|
+
vencido,
|
|
177
223
|
]);
|
|
224
|
+
cancelar();
|
|
178
225
|
if (!additionalContext)
|
|
179
226
|
return;
|
|
180
227
|
// The SessionStart contract: stdout JSON whose additionalContext is
|
package/dist/credentials.js
CHANGED
|
@@ -61,6 +61,48 @@ export function clearCredentials() {
|
|
|
61
61
|
return false;
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
const INFLIGHT_FILE = path.join(DIR, "refresh-inflight.json");
|
|
65
|
+
/**
|
|
66
|
+
* Deja constancia en disco de que ALGUIEN va a gastar el refresh token.
|
|
67
|
+
*
|
|
68
|
+
* Supabase lo rota **al recibir la peticion**, asi que entre "sale la peticion"
|
|
69
|
+
* y "guardamos el token nuevo" hay una ventana en la que el token del disco ya
|
|
70
|
+
* esta muerto sin que nadie lo sepa. Si el proceso no sobrevive a esa ventana
|
|
71
|
+
* —el `analyze` post-commit corre desacoplado en segundo plano, y basta con
|
|
72
|
+
* apagar el equipo o matar la terminal— el siguiente arranque reenvia el viejo,
|
|
73
|
+
* Supabase lo trata como robo y cierra la sesion.
|
|
74
|
+
*
|
|
75
|
+
* Esta marca no PUEDE evitarlo: nada del lado del cliente sobrevive a un
|
|
76
|
+
* `kill -9`. Lo que evita es que sea inexplicable. Sin ella, el usuario ve
|
|
77
|
+
* "Invalid Refresh Token: Already Used" y no tiene forma de saber por que; con
|
|
78
|
+
* ella se le puede decir cuando se quedo a medias la renovacion.
|
|
79
|
+
*/
|
|
80
|
+
export function markRefreshInFlight(now = new Date()) {
|
|
81
|
+
try {
|
|
82
|
+
fs.mkdirSync(DIR, { recursive: true, mode: 0o700 });
|
|
83
|
+
fs.writeFileSync(INFLIGHT_FILE, JSON.stringify({ at: now.toISOString(), pid: process.pid }), { mode: 0o600 });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Es un diagnostico, no una garantia: si no se puede escribir, seguimos.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
export function clearRefreshInFlight() {
|
|
90
|
+
try {
|
|
91
|
+
fs.rmSync(INFLIGHT_FILE, { force: true });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// idem
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
export function readRefreshInFlight() {
|
|
98
|
+
try {
|
|
99
|
+
const data = JSON.parse(fs.readFileSync(INFLIGHT_FILE, "utf8"));
|
|
100
|
+
return typeof data.at === "string" ? data : null;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
64
106
|
const LOCK_FILE = path.join(DIR, "refresh.lock");
|
|
65
107
|
function sleep(ms) {
|
|
66
108
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
package/dist/git.js
CHANGED
|
@@ -60,12 +60,44 @@ export function usableSummary(message) {
|
|
|
60
60
|
return (lastBreak > MIN_USEFUL_SUMMARY_CHARS ? cut.slice(0, lastBreak) : cut).trim();
|
|
61
61
|
}
|
|
62
62
|
/** `git show <hash> -U0` — the unified diff of a single commit, no context. */
|
|
63
|
+
/**
|
|
64
|
+
* Ficheros que ESCRIBE el propio ChangeBook y que por tanto no puede analizar.
|
|
65
|
+
*
|
|
66
|
+
* `sync` regenera el bloque del mapa dentro de CLAUDE.md/AGENTS.md, y `analyze`
|
|
67
|
+
* lo dispara en cada commit. Sin excluirlos se cierra un bucle: commiteas el
|
|
68
|
+
* mapa regenerado -> el análisis lo ve -> nace/actualiza el módulo "Mapeo de
|
|
69
|
+
* módulos" -> pasa a ser el más reciente -> el mapa se reordena -> vuelve a
|
|
70
|
+
* estar sucio. Cuesta un diff de una línea cada dos por tres y el 2026-07-25
|
|
71
|
+
* costó un conflicto de merge real.
|
|
72
|
+
*
|
|
73
|
+
* No es una preferencia de ruido: una herramienta que se mide a sí misma
|
|
74
|
+
* inventa actividad que no existe. Los 6 "cambios" del módulo del mapa son
|
|
75
|
+
* exactamente eso.
|
|
76
|
+
*
|
|
77
|
+
* Un commit que SOLO toca estos ficheros produce un diff vacío y `analyze` lo
|
|
78
|
+
* salta con su mensaje de siempre — que es la respuesta correcta: regenerar el
|
|
79
|
+
* mapa no es un cambio de producto.
|
|
80
|
+
*/
|
|
81
|
+
export const FICHEROS_GENERADOS = [
|
|
82
|
+
":(exclude)CLAUDE.md",
|
|
83
|
+
":(exclude)AGENTS.md",
|
|
84
|
+
":(exclude)**/CLAUDE.md",
|
|
85
|
+
":(exclude)**/AGENTS.md",
|
|
86
|
+
];
|
|
63
87
|
export async function commitDiff(cwd, hash) {
|
|
64
88
|
try {
|
|
65
89
|
const { stdout } = await execFileAsync("git",
|
|
66
90
|
// --end-of-options so a ref that starts with "-" (e.g. "-n5") is treated
|
|
67
91
|
// as a revision, never as a git option (argument injection).
|
|
68
|
-
[
|
|
92
|
+
[
|
|
93
|
+
"show",
|
|
94
|
+
"--pretty=format:",
|
|
95
|
+
"-U0",
|
|
96
|
+
"--end-of-options",
|
|
97
|
+
hash,
|
|
98
|
+
"--",
|
|
99
|
+
...FICHEROS_GENERADOS,
|
|
100
|
+
], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
69
101
|
return stdout;
|
|
70
102
|
}
|
|
71
103
|
catch (error) {
|
package/dist/impact.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `changebook impact` — el radio de impacto, EMPUJADO. Un hook `PreToolUse` de
|
|
3
|
+
* Claude Code que, justo antes de que el agente escriba en un archivo, le mete
|
|
4
|
+
* en contexto quién depende de lo que está a punto de tocar.
|
|
5
|
+
*
|
|
6
|
+
* POR QUÉ EXISTE, medido y no supuesto:
|
|
7
|
+
*
|
|
8
|
+
* El 2026-07-26, la tarea T4 del benchmark («vas a cambiar el comportamiento de
|
|
9
|
+
* computeRecidivism, ¿qué más hay que revisar?») se corrió con el atlas
|
|
10
|
+
* instalado y disponible. El agente hizo 32 turnos de grep, gastó 1.464.591
|
|
11
|
+
* tokens y llamó al atlas CERO veces: `atlas_adoption: 0`. El radio de impacto
|
|
12
|
+
* —desplegado, testado y en producción— fue invisible.
|
|
13
|
+
*
|
|
14
|
+
* La lección no es que faltara una tool. Es que un canal que depende de que el
|
|
15
|
+
* agente se acuerde de usarlo es un canal que se puede ignorar, y quien programa
|
|
16
|
+
* a base de «hazme esto» no pregunta nada: no hay turno en el que decida
|
|
17
|
+
* orientarse. Los disparadores literales de `sync.ts` mueven la aguja (60→100%
|
|
18
|
+
* medido el 2026-07-21) pero no la clavan: siguen necesitando que el agente
|
|
19
|
+
* reconozca una frase.
|
|
20
|
+
*
|
|
21
|
+
* Este canal no lo necesita. Salta porque va a editar, y punto.
|
|
22
|
+
*
|
|
23
|
+
* TRES REGLAS, y las tres son de supervivencia del producto:
|
|
24
|
+
*
|
|
25
|
+
* 1. NUNCA bloquea. Se emite `additionalContext` y nada más — sin
|
|
26
|
+
* `permissionDecision`, así que el sistema de permisos del usuario decide
|
|
27
|
+
* como si no estuviéramos. Un hook que puede tumbar una edición se
|
|
28
|
+
* desinstala el día que se equivoca.
|
|
29
|
+
* 2. NUNCA cuesta tiempo perceptible. Corre antes de CADA edición, así que la
|
|
30
|
+
* red se paga una vez cada CACHE_TTL_MS y el resto de las ediciones son una
|
|
31
|
+
* lectura de disco. Con presupuesto duro por encima: pasado eso, silencio.
|
|
32
|
+
* 3. NUNCA repite. Un aviso idéntico en las 20 ediciones seguidas del mismo
|
|
33
|
+
* archivo es ruido que se paga en tokens y que entrena al agente a
|
|
34
|
+
* ignorarlo. Se avisa una vez por (sesión, archivo).
|
|
35
|
+
*
|
|
36
|
+
* Y una cuarta que es la que hace que se lea: SILENCIO cuando no hay nada que
|
|
37
|
+
* decir. Sin dependientes, sin alerta abierta y sin reincidencia, no se emite
|
|
38
|
+
* una sola letra.
|
|
39
|
+
*/
|
|
40
|
+
import { spawn } from "node:child_process";
|
|
41
|
+
import * as fs from "node:fs";
|
|
42
|
+
import * as path from "node:path";
|
|
43
|
+
import { presupuestoDeTiempo } from "./context.js";
|
|
44
|
+
import { execFileAsync, gitPath } from "./git.js";
|
|
45
|
+
import { avisoRefutado, contarEnRepo, slugifyProject, } from "./guard.js";
|
|
46
|
+
import { computeRecidivism, dependentsOf } from "./tools.js";
|
|
47
|
+
/**
|
|
48
|
+
* Techo duro del camino crítico. Más corto que el de `context` (2 s) porque
|
|
49
|
+
* este corre por edición y no por sesión: 20 ediciones × 2 s serían 40 s de
|
|
50
|
+
* impuesto sobre el bucle que este producto dice acelerar.
|
|
51
|
+
*/
|
|
52
|
+
const IMPACT_TIMEOUT_MS = 1_200;
|
|
53
|
+
/** Igual que el guardián: las alertas se mueven a velocidad de análisis. */
|
|
54
|
+
const CACHE_TTL_MS = 5 * 60_000;
|
|
55
|
+
/**
|
|
56
|
+
* Ventana del grafo. Espejo de MODULE_GRAPH_WINDOW_ROWS en tools.ts: dos
|
|
57
|
+
* ventanas distintas darían dependientes distintos según se pregunte o se
|
|
58
|
+
* empuje, y el agente no tiene forma de saber cuál de las dos le mintió.
|
|
59
|
+
*/
|
|
60
|
+
const GRAPH_WINDOW_ROWS = 1_000;
|
|
61
|
+
/** Tope de filas de alertas; alimenta reincidencia (all-time) y abiertas. */
|
|
62
|
+
const ALERT_WINDOW_ROWS = 500;
|
|
63
|
+
/** Un archivo vuelve a avisar si se vuelve a él mucho después. */
|
|
64
|
+
const SEEN_TTL_MS = 30 * 60_000;
|
|
65
|
+
/** Cota del archivo de estado: es una nota, no un historial. */
|
|
66
|
+
const SEEN_MAX = 200;
|
|
67
|
+
/** Cuántos módulos se describen si el archivo pertenece a varios. */
|
|
68
|
+
const MAX_MODULES = 3;
|
|
69
|
+
/** Tope de greps de refutación: son síncronos y el techo no los desaloja. */
|
|
70
|
+
const MAX_REFUTACIONES = 3;
|
|
71
|
+
export const IMPACT_HOOK = {
|
|
72
|
+
event: "PreToolUse",
|
|
73
|
+
// Mismo `2>/dev/null || true` que CONTEXT_HOOK y por lo mismo: un
|
|
74
|
+
// `changebook` que no está en el PATH de un compañero no puede convertirse en
|
|
75
|
+
// un hook que parece roto en cada edición.
|
|
76
|
+
command: "changebook impact 2>/dev/null || true",
|
|
77
|
+
marker: "changebook impact",
|
|
78
|
+
// Las cuatro tools que escriben en un archivo. El matcher es lo que evita
|
|
79
|
+
// arrancar un proceso por cada Read, Grep o Bash del agente.
|
|
80
|
+
matcher: "Edit|Write|MultiEdit|NotebookEdit",
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* La(s) ruta(s) que la tool va a escribir. `file_path` cubre Edit, Write y
|
|
84
|
+
* MultiEdit; `notebook_path` cubre NotebookEdit. Se devuelve lista porque el
|
|
85
|
+
* contrato puede crecer, no porque hoy haya dos.
|
|
86
|
+
*/
|
|
87
|
+
export function pathsFromPayload(payload) {
|
|
88
|
+
const input = payload.tool_input ?? {};
|
|
89
|
+
return [input.file_path, input.notebook_path]
|
|
90
|
+
.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
91
|
+
.map((v) => v.trim());
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Ruta con los enlaces simbólicos resueltos, incluso si la ruta no existe.
|
|
95
|
+
*
|
|
96
|
+
* No es defensa preventiva: sin esto el hook callaba SIEMPRE en macOS. `git
|
|
97
|
+
* rev-parse --show-toplevel` devuelve la ruta real (`/private/var/…`) y el
|
|
98
|
+
* payload del hook trae la que se usó para llegar (`/var/…`, que es un enlace al
|
|
99
|
+
* anterior). Son el mismo directorio, pero `path.relative` entre las dos daba
|
|
100
|
+
* `../../…`, la ruta se descartaba por «caer fuera del árbol», y no se avisaba
|
|
101
|
+
* de nada nunca. Fallo real, cazado por los tests el 2026-07-26.
|
|
102
|
+
*
|
|
103
|
+
* Sube hasta el primer ancestro que existe y recompone el resto, porque
|
|
104
|
+
* `realpathSync` falla con lo inexistente y con `Write` no tiene por qué existir
|
|
105
|
+
* ni el archivo ni sus carpetas.
|
|
106
|
+
*/
|
|
107
|
+
function rutaRealProfunda(p) {
|
|
108
|
+
const abs = path.resolve(p);
|
|
109
|
+
const cola = [];
|
|
110
|
+
let actual = abs;
|
|
111
|
+
for (;;) {
|
|
112
|
+
try {
|
|
113
|
+
const real = fs.realpathSync(actual);
|
|
114
|
+
return cola.length > 0 ? path.join(real, ...cola.reverse()) : real;
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
const padre = path.dirname(actual);
|
|
118
|
+
if (padre === actual)
|
|
119
|
+
return abs; // se llegó a la raíz sin resolver nada
|
|
120
|
+
cola.push(path.basename(actual));
|
|
121
|
+
actual = padre;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Ruta absoluta (lo que manda el hook) → ruta relativa al repo (lo que guarda
|
|
127
|
+
* el atlas). `null` cuando queda fuera del árbol: un archivo de /tmp o del home
|
|
128
|
+
* no tiene módulo, y colarlo como ruta con `../` produciría cero coincidencias
|
|
129
|
+
* de forma silenciosa.
|
|
130
|
+
*/
|
|
131
|
+
export function repoRelative(toplevel, filePath) {
|
|
132
|
+
const raiz = rutaRealProfunda(toplevel);
|
|
133
|
+
const abs = path.isAbsolute(filePath)
|
|
134
|
+
? rutaRealProfunda(filePath)
|
|
135
|
+
: path.resolve(raiz, filePath);
|
|
136
|
+
const rel = path.relative(raiz, abs);
|
|
137
|
+
if (!rel || rel.startsWith("..") || path.isAbsolute(rel))
|
|
138
|
+
return null;
|
|
139
|
+
// El atlas guarda rutas de git: siempre con "/", también en Windows.
|
|
140
|
+
return rel.split(path.sep).join("/");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* ¿Merece este módulo un aviso?
|
|
144
|
+
*
|
|
145
|
+
* Es LA decisión del archivo. Se dice algo solo cuando hay algo que el agente
|
|
146
|
+
* no puede deducir del archivo que tiene delante: quién depende de él (no está
|
|
147
|
+
* escrito en ninguna parte del código), una alerta abierta (historia), o
|
|
148
|
+
* reincidencia (historia). El riesgo por sí solo NO cuenta: "risk: medium" en
|
|
149
|
+
* cada edición es exactamente el ruido que hace que se deje de leer.
|
|
150
|
+
*/
|
|
151
|
+
export function valeLaPena(m) {
|
|
152
|
+
return (m.dependents.length > 0 || m.alerts.length > 0 || m.priorRegressions >= 2);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* El texto que se inyecta. Deliberadamente calcado a `atlas_file_context`
|
|
156
|
+
* ("↘ DEPENDS ON THIS", "⚠ OPEN ALERT", "prior regressions"): el agente ya sabe
|
|
157
|
+
* qué hacer con esas palabras porque las ve cuando pregunta él. Dos redacciones
|
|
158
|
+
* para el mismo hecho serían dos hechos para él.
|
|
159
|
+
*/
|
|
160
|
+
export function impactText(file, modules) {
|
|
161
|
+
const lines = [`⚠ ChangeBook — impact radius of ${file}, before you edit it:`];
|
|
162
|
+
for (const m of modules.slice(0, MAX_MODULES)) {
|
|
163
|
+
// El riesgo se nombra solo cuando es alto: ver valeLaPena.
|
|
164
|
+
const alto = m.risk === "high" || m.risk === "hotspot";
|
|
165
|
+
lines.push(`- Module **${m.module}**` +
|
|
166
|
+
(alto ? ` · risk: ${m.risk}` : "") +
|
|
167
|
+
(m.priorRegressions >= 2
|
|
168
|
+
? ` · ⚠ ${m.priorRegressions} prior regressions`
|
|
169
|
+
: ""));
|
|
170
|
+
if (m.dependents.length > 0) {
|
|
171
|
+
lines.push(` - ↘ DEPENDS ON THIS: ${m.dependents.join(", ")} — check these too before you finish`);
|
|
172
|
+
}
|
|
173
|
+
for (const plain of m.alerts)
|
|
174
|
+
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
175
|
+
}
|
|
176
|
+
if (modules.length > MAX_MODULES) {
|
|
177
|
+
lines.push(`- (+${modules.length - MAX_MODULES} more module(s) affected)`);
|
|
178
|
+
}
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Rutas que ya se avisaron en ESTA sesión y siguen frescas. Sesión distinta →
|
|
183
|
+
* borrón y cuenta nueva: cada sesión abre con un contexto vacío, así que el
|
|
184
|
+
* aviso vuelve a hacer falta.
|
|
185
|
+
*/
|
|
186
|
+
export function rutasYaAvisadas(state, sessionId, ahora) {
|
|
187
|
+
if (!state || state.session_id !== sessionId)
|
|
188
|
+
return new Set();
|
|
189
|
+
const vivas = Object.entries(state.seen ?? {}).filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS);
|
|
190
|
+
return new Set(vivas.map(([ruta]) => ruta));
|
|
191
|
+
}
|
|
192
|
+
/** El estado siguiente, podado por TTL y por tamaño (más recientes primero). */
|
|
193
|
+
export function estadoSiguiente(state, sessionId, nuevas, ahora) {
|
|
194
|
+
const base = state && state.session_id === sessionId ? { ...(state.seen ?? {}) } : {};
|
|
195
|
+
for (const ruta of nuevas)
|
|
196
|
+
base[ruta] = ahora;
|
|
197
|
+
const podado = Object.entries(base)
|
|
198
|
+
.filter(([, at]) => typeof at === "number" && ahora - at < SEEN_TTL_MS)
|
|
199
|
+
.sort((a, b) => b[1] - a[1])
|
|
200
|
+
.slice(0, SEEN_MAX);
|
|
201
|
+
return { session_id: sessionId, seen: Object.fromEntries(podado) };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Módulos a los que pertenece un archivo, del más reciente al más antiguo.
|
|
205
|
+
*
|
|
206
|
+
* UNIÓN de filas y no la última foto, por la misma razón que `moduleFilesUnion`
|
|
207
|
+
* del guardián: los archivos que toca un módulo cambian en cada análisis, y
|
|
208
|
+
* quedarse con la fila más nueva silenciaba coincidencias reales (visto en vivo
|
|
209
|
+
* el 2026-07-18). El riesgo, en cambio, sí es el de la fila más nueva: es
|
|
210
|
+
* estado, no historia.
|
|
211
|
+
*/
|
|
212
|
+
export function modulosDelArchivo(file, rows) {
|
|
213
|
+
const out = new Map();
|
|
214
|
+
for (const row of rows) {
|
|
215
|
+
const label = (row.module ?? "").trim();
|
|
216
|
+
if (!label || out.has(label))
|
|
217
|
+
continue;
|
|
218
|
+
const files = Array.isArray(row.files) ? row.files.map(String) : [];
|
|
219
|
+
if (files.includes(file))
|
|
220
|
+
out.set(label, row.risk);
|
|
221
|
+
}
|
|
222
|
+
return [...out.entries()].map(([module, risk]) => ({ module, risk }));
|
|
223
|
+
}
|
|
224
|
+
async function cachePath(dir) {
|
|
225
|
+
return gitPath(dir, "changebook-impact-cache.json").catch(() => null);
|
|
226
|
+
}
|
|
227
|
+
function leerJson(file) {
|
|
228
|
+
if (!file)
|
|
229
|
+
return null;
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function escribirJson(file, data) {
|
|
238
|
+
if (!file)
|
|
239
|
+
return;
|
|
240
|
+
try {
|
|
241
|
+
fs.writeFileSync(file, JSON.stringify(data));
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// Un .git de solo lectura no puede convertirse en una edición fallida.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async function fetchAtlas(db, dir, env) {
|
|
248
|
+
// Mismo proyecto al que reporta analyze/hook: CHANGEBOOK_PROJECT manda, si no
|
|
249
|
+
// el nombre del directorio, casado por slug del servidor primero.
|
|
250
|
+
const candidate = env.CHANGEBOOK_PROJECT?.trim() || path.basename(path.resolve(dir));
|
|
251
|
+
const slug = slugifyProject(candidate);
|
|
252
|
+
let projects = slug
|
|
253
|
+
? await db.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
|
|
254
|
+
: [];
|
|
255
|
+
if (projects.length === 0) {
|
|
256
|
+
projects = await db.rest(`projects?select=id&name=eq.${encodeURIComponent(candidate)}&limit=1`);
|
|
257
|
+
}
|
|
258
|
+
const projectId = projects[0]?.id ?? null;
|
|
259
|
+
if (!projectId) {
|
|
260
|
+
return { fetched_at: Date.now(), project_id: null, rows: [], deps: [], alerts: [] };
|
|
261
|
+
}
|
|
262
|
+
// TRES consultas en paralelo, una vez cada 5 minutos y fuera del camino
|
|
263
|
+
// crítico, así que el número de rondas aquí da igual. Lo que NO da igual es
|
|
264
|
+
// que sean tres y no dos: `deps` y `files` viven en la misma tabla, pero no en
|
|
265
|
+
// la misma ventana.
|
|
266
|
+
//
|
|
267
|
+
// El grafo se pide con `deps=not.is.null`, exactamente como tools.ts. Traerlo
|
|
268
|
+
// de la ventana general costó un fallo real, cazado en el E2E del 2026-07-26:
|
|
269
|
+
// de 931 filas recientes solo 613 tenían grafo, así que el mismo módulo salía
|
|
270
|
+
// con 3 dependientes empujado y 4 preguntado. Dos respuestas para el mismo
|
|
271
|
+
// hecho, y el agente sin forma de saber cuál le mintió — el fallo contra el
|
|
272
|
+
// que yo mismo había escrito el comentario de GRAPH_WINDOW_ROWS.
|
|
273
|
+
//
|
|
274
|
+
// Y las alertas se traen SIN filtrar por resolved_at, porque las abiertas y la
|
|
275
|
+
// reincidencia (all-time) salen del mismo conjunto.
|
|
276
|
+
const [rows, deps, alerts] = await Promise.all([
|
|
277
|
+
db
|
|
278
|
+
.rest(`change_module?select=module,files,risk,created_at&project_id=eq.${projectId}&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
279
|
+
.catch(() => []),
|
|
280
|
+
db
|
|
281
|
+
.rest(`change_module?select=module,deps&project_id=eq.${projectId}&deps=not.is.null&order=created_at.desc&limit=${GRAPH_WINDOW_ROWS}`)
|
|
282
|
+
.catch(() => []),
|
|
283
|
+
db
|
|
284
|
+
.rest(`regression_alerts?select=module,plain,files,resolved_at,evidence_symbol,evidence_expect&project_id=eq.${projectId}&order=created_at.desc&limit=${ALERT_WINDOW_ROWS}`)
|
|
285
|
+
.catch(() => []),
|
|
286
|
+
]);
|
|
287
|
+
return { fetched_at: Date.now(), project_id: projectId, rows, deps, alerts };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Las señales, SIEMPRE de disco. Nunca de la red.
|
|
291
|
+
*
|
|
292
|
+
* Esto empezó consultando en línea y no funcionaba, y el fallo enseña más que el
|
|
293
|
+
* arreglo: medido contra producción el 2026-07-26, resolver el proyecto tarda
|
|
294
|
+
* ~590 ms y las dos consultas otros ~320 ms. Con el techo por edición no llegaba
|
|
295
|
+
* — y como no llegaba, nunca escribía la caché, así que TODAS las llamadas eran
|
|
296
|
+
* en frío para siempre. Una caché que solo se escribe al final de un camino que
|
|
297
|
+
* no termina no es una caché.
|
|
298
|
+
*
|
|
299
|
+
* Así que el camino crítico no toca la red jamás: lee el archivo, y si está frío
|
|
300
|
+
* o caducado lanza un refresco DESACOPLADO (`impact --warm`) y calla esta vez.
|
|
301
|
+
* Mismo patrón que el hook post-commit. El coste real es que la primera edición
|
|
302
|
+
* de cada ventana de 5 minutos no avisa; a cambio, las otras cuarenta cuestan
|
|
303
|
+
* una lectura de disco. En una sesión de vibe coding ese cambio es todo a favor.
|
|
304
|
+
*/
|
|
305
|
+
async function atlasSignals(db, dir) {
|
|
306
|
+
const file = await cachePath(dir);
|
|
307
|
+
// Sin sitio donde guardar (no es un repo git, .git de solo lectura) no hay
|
|
308
|
+
// canal: calentar sería gastar un proceso y tres consultas para tirar el
|
|
309
|
+
// resultado a la basura.
|
|
310
|
+
if (!file)
|
|
311
|
+
return null;
|
|
312
|
+
const cached = leerJson(file);
|
|
313
|
+
if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
|
|
314
|
+
return cached;
|
|
315
|
+
if (db.hasCredentials())
|
|
316
|
+
calentarDesacoplado(dir);
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Lanza el refresco de la caché en un proceso aparte y se olvida de él.
|
|
321
|
+
*
|
|
322
|
+
* `stdio: "ignore"` no es higiene, es obligatorio: el stdout del hijo llegaría
|
|
323
|
+
* a la misma tubería que Claude Code lee como salida del hook, y un JSON a
|
|
324
|
+
* medias o dos objetos pegados sería basura inyectada en el contexto del agente.
|
|
325
|
+
* `detached` + `unref` para que el padre pueda morir ya y no retenga la edición.
|
|
326
|
+
*/
|
|
327
|
+
function calentarDesacoplado(dir) {
|
|
328
|
+
try {
|
|
329
|
+
const hijo = spawn(process.execPath, [process.argv[1], "impact", "--warm", dir], {
|
|
330
|
+
detached: true,
|
|
331
|
+
stdio: "ignore",
|
|
332
|
+
});
|
|
333
|
+
hijo.unref();
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// Sin poder lanzarlo, la caché se quedará fría y no habrá avisos. Mal, pero
|
|
337
|
+
// no tan mal como que falle una edición.
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* `changebook impact --warm [dir]` — el lado desacoplado. Consulta y escribe la
|
|
342
|
+
* caché. Sin stdin, sin stdout, sin código de salida que importe.
|
|
343
|
+
*/
|
|
344
|
+
export async function warmImpactCache(db, dir, env = process.env) {
|
|
345
|
+
try {
|
|
346
|
+
if (!db.hasCredentials())
|
|
347
|
+
return;
|
|
348
|
+
const file = await cachePath(dir);
|
|
349
|
+
if (!file)
|
|
350
|
+
return;
|
|
351
|
+
// Carrera entre dos ediciones seguidas: si otro proceso ya dejó una caché
|
|
352
|
+
// fresca mientras este arrancaba, no gastar dos veces la misma consulta.
|
|
353
|
+
const cached = leerJson(file);
|
|
354
|
+
if (cached && Date.now() - cached.fetched_at < CACHE_TTL_MS)
|
|
355
|
+
return;
|
|
356
|
+
escribirJson(file, await fetchAtlas(db, dir, env));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
// Nadie está mirando esta salida; el síntoma de un fallo aquí es que no hay
|
|
360
|
+
// avisos, y eso ya lo cubre `hook-impact status`.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// ── El cuerpo ────────────────────────────────────────────────────────────────
|
|
364
|
+
/** stdin completo, acotado. El hook manda un objeto pequeño; nada más cabe. */
|
|
365
|
+
async function readStdin(limit = 256 * 1024) {
|
|
366
|
+
const chunks = [];
|
|
367
|
+
let total = 0;
|
|
368
|
+
for await (const chunk of process.stdin) {
|
|
369
|
+
const buf = Buffer.from(chunk);
|
|
370
|
+
total += buf.length;
|
|
371
|
+
if (total > limit)
|
|
372
|
+
break;
|
|
373
|
+
chunks.push(buf);
|
|
374
|
+
}
|
|
375
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
376
|
+
}
|
|
377
|
+
async function buildImpact(db, payload) {
|
|
378
|
+
const rutas = pathsFromPayload(payload);
|
|
379
|
+
if (rutas.length === 0)
|
|
380
|
+
return null;
|
|
381
|
+
const dir = payload.cwd?.trim() || process.cwd();
|
|
382
|
+
// El toplevel de git, no el cwd: el agente puede estar en un subdirectorio y
|
|
383
|
+
// el atlas guarda rutas relativas a la raíz del repo.
|
|
384
|
+
const toplevel = await execFileAsync("git", ["rev-parse", "--show-toplevel"], {
|
|
385
|
+
cwd: dir,
|
|
386
|
+
encoding: "utf8",
|
|
387
|
+
})
|
|
388
|
+
.then(({ stdout }) => stdout.trim())
|
|
389
|
+
.catch(() => dir);
|
|
390
|
+
const relativas = [
|
|
391
|
+
...new Set(rutas
|
|
392
|
+
.map((r) => repoRelative(toplevel, r))
|
|
393
|
+
.filter((r) => Boolean(r))),
|
|
394
|
+
];
|
|
395
|
+
if (relativas.length === 0)
|
|
396
|
+
return null;
|
|
397
|
+
const ahora = Date.now();
|
|
398
|
+
const sessionId = payload.session_id?.trim() || "sin-sesion";
|
|
399
|
+
const seenFile = await gitPath(dir, "changebook-impact-seen.json").catch(() => null);
|
|
400
|
+
const yaAvisadas = rutasYaAvisadas(leerJson(seenFile), sessionId, ahora);
|
|
401
|
+
const pendientes = relativas.filter((r) => !yaAvisadas.has(r));
|
|
402
|
+
if (pendientes.length === 0)
|
|
403
|
+
return null;
|
|
404
|
+
const signals = await atlasSignals(db, dir);
|
|
405
|
+
if (!signals?.project_id)
|
|
406
|
+
return null;
|
|
407
|
+
const recidivism = computeRecidivism(signals.alerts);
|
|
408
|
+
const abiertas = signals.alerts.filter((a) => !a.resolved_at);
|
|
409
|
+
// Refutación al servir, igual que en atlas_file_context: el mismo grep que
|
|
410
|
+
// corre el guardián en el pre-commit. De 7 alertas abiertas, 4 se caían con
|
|
411
|
+
// un grep. Aquí importa el doble: un aviso empujado que ya no es verdad
|
|
412
|
+
// enseña al agente a ignorar los empujados.
|
|
413
|
+
//
|
|
414
|
+
// Acotada a mano, y por una razón que no es cosmética: contarEnRepo usa
|
|
415
|
+
// execFileSync, o sea que BLOQUEA el bucle de eventos y el techo de arriba no
|
|
416
|
+
// puede desalojarlo — un Promise.race no gana a una llamada síncrona. Un
|
|
417
|
+
// archivo con muchas alertas vivas podría comerse el presupuesto entero a
|
|
418
|
+
// 30 ms por grep. Tres es lo que cabe de sobra; el resto pasa sin refutar,
|
|
419
|
+
// que es el lado seguro (la duda deja pasar el aviso).
|
|
420
|
+
let grepsRestantes = MAX_REFUTACIONES;
|
|
421
|
+
const refutada = (a) => {
|
|
422
|
+
if (grepsRestantes <= 0)
|
|
423
|
+
return false;
|
|
424
|
+
grepsRestantes -= 1;
|
|
425
|
+
return avisoRefutado(a, (s) => contarEnRepo(toplevel, s));
|
|
426
|
+
};
|
|
427
|
+
const depsRows = signals.deps ?? [];
|
|
428
|
+
const bloques = [];
|
|
429
|
+
const avisadas = [];
|
|
430
|
+
for (const file of pendientes) {
|
|
431
|
+
const modulos = modulosDelArchivo(file, signals.rows);
|
|
432
|
+
if (modulos.length === 0)
|
|
433
|
+
continue;
|
|
434
|
+
const dependientes = dependentsOf(modulos.map((m) => m.module), depsRows);
|
|
435
|
+
const impactos = modulos
|
|
436
|
+
.map((m) => ({
|
|
437
|
+
module: m.module,
|
|
438
|
+
risk: m.risk,
|
|
439
|
+
dependents: dependientes.get(m.module) ?? [],
|
|
440
|
+
alerts: abiertas
|
|
441
|
+
.filter((a) => (a.module ?? "").trim() === m.module && a.plain)
|
|
442
|
+
.filter((a) => !refutada(a))
|
|
443
|
+
.map((a) => a.plain),
|
|
444
|
+
priorRegressions: recidivism.get(m.module) ?? 0,
|
|
445
|
+
}))
|
|
446
|
+
.filter(valeLaPena);
|
|
447
|
+
if (impactos.length === 0)
|
|
448
|
+
continue;
|
|
449
|
+
bloques.push(impactText(file, impactos));
|
|
450
|
+
avisadas.push(file);
|
|
451
|
+
}
|
|
452
|
+
// Solo se marca como avisado lo que de verdad se dijo: si el archivo no tenía
|
|
453
|
+
// nada hoy pero mañana sale una alerta suya, el aviso tiene que poder salir.
|
|
454
|
+
if (avisadas.length > 0) {
|
|
455
|
+
escribirJson(seenFile, estadoSiguiente(leerJson(seenFile), sessionId, avisadas, ahora));
|
|
456
|
+
}
|
|
457
|
+
return bloques.length > 0 ? bloques.join("\n\n") : null;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* El comando. Falla abierto SIEMPRE: sin credenciales, sin red, sin proyecto,
|
|
461
|
+
* con el JSON roto o pasado el presupuesto, se emite cero y se sale con 0.
|
|
462
|
+
*
|
|
463
|
+
* Se emite `additionalContext` a secas, sin `permissionDecision`: la decisión de
|
|
464
|
+
* permisos se queda en manos del usuario, exactamente como si este hook no
|
|
465
|
+
* existiera. Y por contrato de Claude Code, salir con 2 bloquearía la edición —
|
|
466
|
+
* de ahí que aquí no se lance nunca nada hacia fuera.
|
|
467
|
+
*/
|
|
468
|
+
export async function printImpact(db) {
|
|
469
|
+
try {
|
|
470
|
+
// UN presupuesto para todo, no uno por etapa: dos carreras de 1,2 s en
|
|
471
|
+
// serie son 2,4 s de impuesto real por edición, que es justo lo que este
|
|
472
|
+
// techo existe para impedir. Se comparte entre leer stdin y leer la caché.
|
|
473
|
+
//
|
|
474
|
+
// Y se cancela en `finally`, no al final del camino feliz: los caminos que
|
|
475
|
+
// CALLAN son los más frecuentes (archivo limpio, ya avisado, caché fría) y
|
|
476
|
+
// cada `return` temprano dejaba el temporizador en pie. Medido el
|
|
477
|
+
// 2026-07-26: 1,39 s por edición silenciosa contra 0,23 s de la que habla —
|
|
478
|
+
// el proceso ya había terminado y se quedaba esperando a su propio reloj.
|
|
479
|
+
const { vencido, cancelar } = presupuestoDeTiempo(IMPACT_TIMEOUT_MS);
|
|
480
|
+
try {
|
|
481
|
+
const raw = await Promise.race([readStdin(), vencido.then(() => "")]);
|
|
482
|
+
if (!raw.trim())
|
|
483
|
+
return;
|
|
484
|
+
let payload;
|
|
485
|
+
try {
|
|
486
|
+
payload = JSON.parse(raw);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const additionalContext = await Promise.race([
|
|
492
|
+
buildImpact(db, payload).catch(() => null),
|
|
493
|
+
vencido,
|
|
494
|
+
]);
|
|
495
|
+
if (!additionalContext)
|
|
496
|
+
return;
|
|
497
|
+
process.stdout.write(JSON.stringify({
|
|
498
|
+
hookSpecificOutput: {
|
|
499
|
+
hookEventName: "PreToolUse",
|
|
500
|
+
additionalContext,
|
|
501
|
+
},
|
|
502
|
+
}));
|
|
503
|
+
}
|
|
504
|
+
finally {
|
|
505
|
+
cancelar();
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// Editar un archivo no puede fallar por nosotros.
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
//# sourceMappingURL=impact.js.map
|
package/dist/index.js
CHANGED
|
@@ -21,7 +21,8 @@ import { runGuard } from "./guard.js";
|
|
|
21
21
|
import { hookStatus, installHook, uninstallHook } from "./hook.js";
|
|
22
22
|
import { importHistory } from "./import.js";
|
|
23
23
|
import { registerAgents } from "./init.js";
|
|
24
|
-
import { contextHookInstalled, installContextHook, printContext, uninstallContextHook, } from "./context.js";
|
|
24
|
+
import { contextHookInstalled, installContextHook, installSettingsHook, printContext, settingsHookInstalled, uninstallContextHook, uninstallSettingsHook, } from "./context.js";
|
|
25
|
+
import { IMPACT_HOOK, printImpact, warmImpactCache } from "./impact.js";
|
|
25
26
|
import { login } from "./login.js";
|
|
26
27
|
import { AUTH_HELP, Supabase } from "./supabase.js";
|
|
27
28
|
import { syncContextFiles } from "./sync.js";
|
|
@@ -52,6 +53,12 @@ Usage:
|
|
|
52
53
|
changebook hook-context install|uninstall|status [dir]
|
|
53
54
|
Push the atlas into EVERY Claude Code session at turn 0
|
|
54
55
|
via a SessionStart hook in .claude/settings.json
|
|
56
|
+
changebook impact Print the blast radius of the file a PreToolUse hook
|
|
57
|
+
payload (on stdin) is about to edit
|
|
58
|
+
changebook hook-impact install|uninstall|status [dir]
|
|
59
|
+
Tell the agent who depends on a file BEFORE it edits it,
|
|
60
|
+
via a PreToolUse hook in .claude/settings.json. Never
|
|
61
|
+
blocks an edit; silent when there is nothing to say
|
|
55
62
|
changebook init [dir] login + register MCP in every agent found + hook + sync
|
|
56
63
|
changebook open Open the web atlas in the browser
|
|
57
64
|
changebook serve Run the MCP server on stdio (default with no arguments)
|
|
@@ -246,6 +253,43 @@ async function main() {
|
|
|
246
253
|
}
|
|
247
254
|
return;
|
|
248
255
|
}
|
|
256
|
+
case "impact": {
|
|
257
|
+
// --warm es el lado DESACOPLADO, el que sí toca la red: lo lanza el propio
|
|
258
|
+
// hook en un proceso aparte cuando encuentra la caché fría. No lee stdin y
|
|
259
|
+
// no escribe en stdout (ver calentarDesacoplado: su salida iría a la
|
|
260
|
+
// tubería que Claude Code lee como respuesta del hook).
|
|
261
|
+
if (arg === "--warm") {
|
|
262
|
+
await warmImpactCache(new Supabase(), process.argv[4] ?? process.cwd());
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Camino crítico de CADA edición: como `context`, jamás requireCredentials
|
|
266
|
+
// (saldría con 1) y jamás help. printImpact falla abierto — sin sesión,
|
|
267
|
+
// sin caché o lento, cero salida y exit 0. Y nunca sale con 2, que es el
|
|
268
|
+
// código con el que Claude Code BLOQUEA la edición.
|
|
269
|
+
await printImpact(new Supabase());
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case "hook-impact": {
|
|
273
|
+
const dir = process.argv[4] ?? process.cwd();
|
|
274
|
+
if (arg === "install") {
|
|
275
|
+
const r = installSettingsHook(dir, IMPACT_HOOK);
|
|
276
|
+
console.error(r === "installed"
|
|
277
|
+
? `✓ PreToolUse hook installed (${dir}/.claude/settings.json). Before every edit, the agent now gets told who depends on the file it is about to touch.`
|
|
278
|
+
: "PreToolUse hook already installed.");
|
|
279
|
+
}
|
|
280
|
+
else if (arg === "uninstall") {
|
|
281
|
+
const r = uninstallSettingsHook(dir, IMPACT_HOOK);
|
|
282
|
+
console.error(r === "removed"
|
|
283
|
+
? "✓ PreToolUse hook removed."
|
|
284
|
+
: "No ChangeBook PreToolUse hook found.");
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
console.error(settingsHookInstalled(dir, IMPACT_HOOK)
|
|
288
|
+
? `✓ ChangeBook PreToolUse hook installed (${dir}/.claude/settings.json).`
|
|
289
|
+
: "✗ No ChangeBook PreToolUse hook. Install with: changebook hook-impact install");
|
|
290
|
+
}
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
249
293
|
case "init": {
|
|
250
294
|
let db = new Supabase();
|
|
251
295
|
if (!db.hasCredentials()) {
|
|
@@ -272,6 +316,15 @@ async function main() {
|
|
|
272
316
|
console.error("\nOptional: push the atlas into EVERY Claude Code session at turn 0 " +
|
|
273
317
|
"(no tool call needed, never stale):\n changebook hook-context install");
|
|
274
318
|
}
|
|
319
|
+
// El segundo canal se ofrece aparte porque responde a otra pregunta. El
|
|
320
|
+
// primero da el mapa al abrir; este avisa de a quién te llevas por delante
|
|
321
|
+
// justo antes de escribir, que es lo que hace falta cuando nadie pregunta
|
|
322
|
+
// nada. Se ofrece, NUNCA se instala en silencio: mismo motivo que el otro,
|
|
323
|
+
// .claude/settings.json se commitea y se comparte.
|
|
324
|
+
if (!settingsHookInstalled(dir, IMPACT_HOOK)) {
|
|
325
|
+
console.error("\nOptional: before every edit, tell the agent who depends on the file " +
|
|
326
|
+
"it is about to touch:\n changebook hook-impact install");
|
|
327
|
+
}
|
|
275
328
|
console.error(`✓ Ready. Ask your agent about the atlas, or open ${atlasWebUrl()}`);
|
|
276
329
|
return;
|
|
277
330
|
}
|
package/dist/login.js
CHANGED
|
@@ -15,6 +15,38 @@ import * as http from "node:http";
|
|
|
15
15
|
import { atlasWebUrl, openInBrowser } from "./browser.js";
|
|
16
16
|
import { credentialsPath, saveCredentials } from "./credentials.js";
|
|
17
17
|
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
18
|
+
const SUPABASE_URL = process.env.CHANGEBOOK_SUPABASE_URL ??
|
|
19
|
+
"https://oyosihxkecspjkiligga.supabase.co";
|
|
20
|
+
const ANON_KEY = process.env.CHANGEBOOK_SUPABASE_ANON_KEY ??
|
|
21
|
+
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
|
|
22
|
+
/**
|
|
23
|
+
* Canjea un codigo de un solo uso por una sesion PROPIA de este CLI.
|
|
24
|
+
*
|
|
25
|
+
* Espejo de `exchangeTokenHash` de la extension (src/auth/supabaseAuth.ts), que
|
|
26
|
+
* lleva usando este camino desde que se diagnostico el problema. El backend
|
|
27
|
+
* acuña el codigo con `generateLink({type:"magiclink"})` en la funcion
|
|
28
|
+
* editor-handoff — no se manda ningun correo — y aqui se cambia por tokens que
|
|
29
|
+
* no comparte nadie mas.
|
|
30
|
+
*/
|
|
31
|
+
async function canjearTokenHash(tokenHash) {
|
|
32
|
+
const res = await fetch(`${SUPABASE_URL}/auth/v1/verify`, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { apikey: ANON_KEY, "Content-Type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ type: "magiclink", token_hash: tokenHash }),
|
|
36
|
+
signal: AbortSignal.timeout(30_000),
|
|
37
|
+
});
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`No se pudo canjear el codigo de acceso (${res.status}). Vuelve a intentarlo.`);
|
|
40
|
+
}
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
if (!data.refresh_token) {
|
|
43
|
+
throw new Error("El canje no devolvio una sesion utilizable.");
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
access_token: data.access_token ?? undefined,
|
|
47
|
+
refresh_token: data.refresh_token,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
18
50
|
const CALLBACK_PAGE = `<!DOCTYPE html>
|
|
19
51
|
<html><head><meta charset="utf-8"><title>ChangeBook</title></head>
|
|
20
52
|
<body style="font-family: sans-serif; max-width: 480px; margin: 80px auto; text-align: center;">
|
|
@@ -22,6 +54,7 @@ const CALLBACK_PAGE = `<!DOCTYPE html>
|
|
|
22
54
|
<script>
|
|
23
55
|
const params = new URLSearchParams(window.location.hash.slice(1));
|
|
24
56
|
const payload = {
|
|
57
|
+
token_hash: params.get("token_hash"),
|
|
25
58
|
access_token: params.get("access_token"),
|
|
26
59
|
refresh_token: params.get("refresh_token"),
|
|
27
60
|
state: params.get("state"),
|
|
@@ -62,6 +95,28 @@ export async function login() {
|
|
|
62
95
|
res.end();
|
|
63
96
|
return;
|
|
64
97
|
}
|
|
98
|
+
// VIA PREFERIDA: un codigo de un solo uso que se canjea por una
|
|
99
|
+
// sesion PROPIA de este CLI. La via heredada —recibir los tokens
|
|
100
|
+
// vivos de la pestana web— entrega a dos clientes el MISMO refresh
|
|
101
|
+
// token rotatorio, y Supabase revoca la familia entera en cuanto uno
|
|
102
|
+
// de los dos lo rota. Como el navegador refresca solo en segundo
|
|
103
|
+
// plano, siempre gana el, y la sesion del CLI moria ~30 min despues
|
|
104
|
+
// de cada login, con reloj. La extension ya usaba este camino desde
|
|
105
|
+
// que se diagnostico; al CLI no se le habia traido.
|
|
106
|
+
if (data.token_hash) {
|
|
107
|
+
canjearTokenHash(data.token_hash).then((sesion) => {
|
|
108
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
109
|
+
res.end('{"ok":true}');
|
|
110
|
+
server.close();
|
|
111
|
+
resolve(sesion);
|
|
112
|
+
}, (err) => {
|
|
113
|
+
res.writeHead(400);
|
|
114
|
+
res.end();
|
|
115
|
+
server.close();
|
|
116
|
+
reject(err);
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
65
120
|
if (!data.refresh_token) {
|
|
66
121
|
res.writeHead(400);
|
|
67
122
|
res.end();
|
|
@@ -93,7 +148,12 @@ export async function login() {
|
|
|
93
148
|
reject(new Error("Could not open a loopback port."));
|
|
94
149
|
return;
|
|
95
150
|
}
|
|
96
|
-
|
|
151
|
+
// `th=1` anuncia que este CLI sabe canjear un token_hash. La web solo lo
|
|
152
|
+
// manda si lo ve: sin la marca, un CLI antiguo recibiria un codigo que no
|
|
153
|
+
// entiende y el login fallaria sin explicacion. Arreglar un fallo de
|
|
154
|
+
// sesion rompiendo el login de quien no ha actualizado no seria arreglar
|
|
155
|
+
// nada.
|
|
156
|
+
const url = `${atlasWebUrl()}/?connect=cli&port=${address.port}&state=${state}&th=1`;
|
|
97
157
|
console.error("Opening your browser to sign in to ChangeBook…");
|
|
98
158
|
console.error(`If it doesn't open, visit:\n\n ${url}\n`);
|
|
99
159
|
openInBrowser(url);
|
package/dist/supabase.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* every query to the signed-in user. Refreshes the access token with the
|
|
6
6
|
* refresh token when needed (refresh does not require a captcha).
|
|
7
7
|
*/
|
|
8
|
-
import { loadCredentials, saveCredentials, withCredentialsLock, } from './credentials.js';
|
|
8
|
+
import { clearRefreshInFlight, credentialsPath, loadCredentials, markRefreshInFlight, readRefreshInFlight, saveCredentials, withCredentialsLock, } from './credentials.js';
|
|
9
9
|
import { slugifyProject } from './guard.js';
|
|
10
10
|
// Public defaults — the anon key is the same public key the web app ships.
|
|
11
11
|
const DEFAULT_URL = 'https://oyosihxkecspjkiligga.supabase.co';
|
|
@@ -335,10 +335,30 @@ export class Supabase {
|
|
|
335
335
|
return;
|
|
336
336
|
}
|
|
337
337
|
this.refreshToken = plan.refresh_token;
|
|
338
|
+
// Una renovacion anterior que quedo a medias: la dejo escrita ANTES de que
|
|
339
|
+
// esta salga, para no confundirla con la mia.
|
|
340
|
+
const aMedias = this.persistRotation ? readRefreshInFlight() : null;
|
|
341
|
+
// Supabase rota el token AL RECIBIR la peticion, asi que desde esta linea y
|
|
342
|
+
// hasta que se guarde el nuevo, el token del disco esta muerto sin que nadie
|
|
343
|
+
// lo sepa. Dejar constancia no evita que maten al proceso en esa ventana
|
|
344
|
+
// —el analyze post-commit corre desacoplado— pero convierte el sintoma de
|
|
345
|
+
// dentro de media hora en algo explicable.
|
|
346
|
+
if (this.persistRotation)
|
|
347
|
+
markRefreshInFlight();
|
|
338
348
|
const res = await this.refreshOnce(this.refreshToken);
|
|
339
349
|
if (!res.ok) {
|
|
340
350
|
const body = (await res.text()).slice(0, 300);
|
|
341
|
-
|
|
351
|
+
// El token esta gastado o muerto de todos modos: la marca ya no sirve.
|
|
352
|
+
if (this.persistRotation)
|
|
353
|
+
clearRefreshInFlight();
|
|
354
|
+
const yaUsado = /already[ _]used|refresh[ _]token[ _]not[ _]valid/i.test(body);
|
|
355
|
+
const explicacion = yaUsado && aMedias
|
|
356
|
+
? `\n\nQUE PASO: una renovacion anterior empezo el ${aMedias.at} (proceso ${aMedias.pid}) y no llego a terminar. ` +
|
|
357
|
+
`El servidor rotó el token al recibirla, pero este equipo no guardó el nuevo — normalmente porque mataron al proceso ` +
|
|
358
|
+
`(el analisis post-commit corre en segundo plano). Al reintentar con el viejo, Supabase lo toma por robado y cierra la sesion. ` +
|
|
359
|
+
`No has hecho nada mal y no hay nada que reparar: basta con volver a entrar.`
|
|
360
|
+
: '';
|
|
361
|
+
throw new SupabaseError(`Could not refresh the ChangeBook session (${res.status}): ${body}${explicacion}\n\n${AUTH_HELP}`, res.status);
|
|
342
362
|
}
|
|
343
363
|
const data = (await res.json());
|
|
344
364
|
this.accessToken = data.access_token;
|
|
@@ -352,9 +372,33 @@ export class Supabase {
|
|
|
352
372
|
refresh_token: this.refreshToken,
|
|
353
373
|
access_token: this.accessToken,
|
|
354
374
|
});
|
|
375
|
+
// Guardado: la ventana peligrosa se ha cerrado. Se retira la marca
|
|
376
|
+
// DESPUES de escribir, no antes — al reves dejaria un hueco en el que
|
|
377
|
+
// ni hay marca ni hay token nuevo, que es justo el estado que la marca
|
|
378
|
+
// existe para poder contar.
|
|
379
|
+
clearRefreshInFlight();
|
|
355
380
|
}
|
|
356
|
-
catch {
|
|
357
|
-
//
|
|
381
|
+
catch (err) {
|
|
382
|
+
// NO es inofensivo, aunque lo parezca desde aquí.
|
|
383
|
+
//
|
|
384
|
+
// Este proceso sigue funcionando con su sesion en memoria, y por eso el
|
|
385
|
+
// codigo anterior se lo tragaba en silencio. Pero el token rotado se ha
|
|
386
|
+
// perdido: Supabase ya invalido el viejo, asi que el SIGUIENTE proceso
|
|
387
|
+
// leera del disco un token gastado, lo reenviara, y la deteccion de
|
|
388
|
+
// reutilizacion revocara la sesion entera. El sintoma aparece minutos u
|
|
389
|
+
// horas despues, sin ninguna relacion visible con este fallo de
|
|
390
|
+
// escritura — que es lo que lo hacia indiagnosticable.
|
|
391
|
+
//
|
|
392
|
+
// Firma en los logs de auth del 2026-07-25: un refresco con 200 a las
|
|
393
|
+
// 06:51:54 y, diez minutos mas tarde y sin nada en medio, un 400
|
|
394
|
+
// "Possible abuse attempt". Es exactamente lo que deja este camino.
|
|
395
|
+
//
|
|
396
|
+
// No se lanza: tumbar la orden en curso castigaria al usuario por algo
|
|
397
|
+
// que todavia funciona. Se avisa, alto y con el arreglo puesto.
|
|
398
|
+
const motivo = err instanceof Error ? err.message : String(err);
|
|
399
|
+
console.error(`ChangeBook: no se pudo guardar la sesion renovada en ${credentialsPath()} (${motivo}).\n` +
|
|
400
|
+
`Esta orden termina bien, pero la siguiente reutilizara un token ya gastado y la sesion se cerrara sola.\n` +
|
|
401
|
+
`Comprueba los permisos de ese fichero y vuelve a entrar con: changebook login`);
|
|
358
402
|
}
|
|
359
403
|
}
|
|
360
404
|
}
|
package/dist/sync.js
CHANGED
|
@@ -200,12 +200,27 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
200
200
|
// la tool solo cubre definiciones y el agente acababa pagando atlas Y
|
|
201
201
|
// grep. Con repo local, grep gana; el sitio de symbol_lookup es el agente
|
|
202
202
|
// hospedado sin repo. Medir → actuar, aunque sea des-haciendo lo de ayer.
|
|
203
|
-
|
|
203
|
+
// Los VERBOS son literales a propósito. La ley medida el 2026-07-21 fue que
|
|
204
|
+
// la adopción es coincidencia LITERAL (los disparadores literales la movieron
|
|
205
|
+
// de 60/100/0 a 100/100/60), y el 2026-07-26 se comprobó el otro lado de la
|
|
206
|
+
// misma ley: T4 pidió «vas a cambiar el comportamiento de la función X» y el
|
|
207
|
+
// agente, con el atlas disponible, fue directo a grep — 32 turnos, 1,46M
|
|
208
|
+
// tokens y CERO llamadas al atlas. «editar un archivo» no casaba con cómo
|
|
209
|
+
// habla quien programa con un agente. Quien no pregunta nada no reconoce un
|
|
210
|
+
// disparador abstracto: hay que nombrar sus verbos.
|
|
211
|
+
'Disparador → tool: orientarte → `atlas_project_brief` (`intent:"orient"`) · antes de editar/cambiar/arreglar/refactorizar un archivo o función → `atlas_file_context` (dice QUIÉN DEPENDE de eso) · riesgos/acoplamientos → el brief. Tras cada commit: `atlas_record_change` (diff, hash, fecha, `summary` tuyo; reenviar es gratis).',
|
|
204
212
|
'',
|
|
205
213
|
'Tools diferidas, en UNA llamada: ToolSearch `select:mcp__changebook__atlas_project_brief,mcp__changebook__atlas_file_context,mcp__changebook__atlas_symbol_lookup`.',
|
|
206
214
|
'',
|
|
207
215
|
// Proyecto + conducta en UNA línea: cada char de cabecera expulsa mapa.
|
|
208
|
-
|
|
216
|
+
//
|
|
217
|
+
// «Anuncia» pasó a «di qué harías y ESPERA su OK» el 2026-07-26, a petición
|
|
218
|
+
// de Raúl y con razón: anunciar y ponerse a trabajar en el mismo turno no le
|
|
219
|
+
// deja decidir nada. Y valía para «un encargo», o sea solo para la cola;
|
|
220
|
+
// ahora vale al abrir, que es cuando el agente elige por su cuenta qué mirar
|
|
221
|
+
// y qué tocar. Lo que se gasta aquí se recupera en la línea de la cola, que
|
|
222
|
+
// decía esto mismo por segunda vez.
|
|
223
|
+
`${projectName ? `Pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\`. ` : ''}Al abrir: di en 2 líneas qué harías y por qué, y ESPERA su OK antes de tocar código. DILE los riesgos que el atlas te enseñe — él no los ve.`,
|
|
209
224
|
'',
|
|
210
225
|
];
|
|
211
226
|
if (modules.length === 0) {
|
|
@@ -266,7 +281,7 @@ export function buildSection(rows, changes, alerts = [], projectName, pendingTas
|
|
|
266
281
|
].slice(0, 3);
|
|
267
282
|
const taskLines = taskTitles.length > 0
|
|
268
283
|
? [
|
|
269
|
-
`- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto.
|
|
284
|
+
`- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Propón cuál atacarías. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
|
|
270
285
|
...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
|
|
271
286
|
]
|
|
272
287
|
: [];
|
package/dist/tools.js
CHANGED
|
@@ -96,6 +96,49 @@ export function quotedInList(values) {
|
|
|
96
96
|
.join(",");
|
|
97
97
|
}
|
|
98
98
|
export const FILES_CAP = 8;
|
|
99
|
+
const MAX_DEPENDENTS = 6;
|
|
100
|
+
/** Espejo de MODULE_COUNT_WINDOW_ROWS (supabase/functions/mcp/scope.ts).
|
|
101
|
+
* El mapa de la web dibuja las flechas sobre esta misma ventana: dos
|
|
102
|
+
* ventanas distintas darían dependencias distintas según se mire el dibujo
|
|
103
|
+
* o se pregunte al atlas. Paridad fijada en test/radioDeImpacto. */
|
|
104
|
+
const MODULE_GRAPH_WINDOW_ROWS = 1000;
|
|
105
|
+
export function dependentsOf(targets, rows) {
|
|
106
|
+
const graph = new Map();
|
|
107
|
+
for (const r of rows) {
|
|
108
|
+
const label = (r.module ?? "").trim();
|
|
109
|
+
if (!label || graph.has(label.toLowerCase()))
|
|
110
|
+
continue;
|
|
111
|
+
const deps = Array.isArray(r.deps)
|
|
112
|
+
? r.deps.map((d) => (typeof d === "string" ? d.trim() : "")).filter(Boolean)
|
|
113
|
+
: [];
|
|
114
|
+
graph.set(label.toLowerCase(), deps);
|
|
115
|
+
}
|
|
116
|
+
const reverse = new Map();
|
|
117
|
+
for (const r of rows) {
|
|
118
|
+
const from = (r.module ?? "").trim();
|
|
119
|
+
if (!from)
|
|
120
|
+
continue;
|
|
121
|
+
for (const to of graph.get(from.toLowerCase()) ?? []) {
|
|
122
|
+
const key = to.toLowerCase();
|
|
123
|
+
if (key === from.toLowerCase())
|
|
124
|
+
continue;
|
|
125
|
+
const set = reverse.get(key) ?? new Set();
|
|
126
|
+
set.add(from);
|
|
127
|
+
reverse.set(key, set);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const out = new Map();
|
|
131
|
+
for (const t of targets) {
|
|
132
|
+
const label = (t ?? "").trim();
|
|
133
|
+
if (!label)
|
|
134
|
+
continue;
|
|
135
|
+
const found = reverse.get(label.toLowerCase());
|
|
136
|
+
if (!found || found.size === 0)
|
|
137
|
+
continue;
|
|
138
|
+
out.set(label, [...found].sort((a, b) => a.localeCompare(b)).slice(0, MAX_DEPENDENTS));
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
99
142
|
/**
|
|
100
143
|
* Reincidencia (espejo de supabase/functions/mcp/scope.ts::computeRecidivism —
|
|
101
144
|
* paridad en test/reincidenciaFileContext). Por módulo, nº de problemas de
|
|
@@ -735,7 +778,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
735
778
|
for (const f of perFile) {
|
|
736
779
|
commitsByFile.set(f.file, recentCommitsForFile(f.changelogIds, commitById));
|
|
737
780
|
}
|
|
738
|
-
const [alerts, watched, recidivismRows] = await Promise.all([
|
|
781
|
+
const [alerts, watched, recidivismRows, depsRows] = await Promise.all([
|
|
739
782
|
moduleNames.length
|
|
740
783
|
? db.rest(`regression_alerts?select=module,plain,evidence_symbol,evidence_expect&resolved_at=is.null&module=in.(${encodeURIComponent(quotedInList(moduleNames))})&order=created_at.desc&limit=10` +
|
|
741
784
|
pf)
|
|
@@ -759,6 +802,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
759
802
|
pf)
|
|
760
803
|
.catch(() => [])
|
|
761
804
|
: Promise.resolve([]),
|
|
805
|
+
// Radio de impacto: grafo COMPLETO del proyecto (no filtrado por
|
|
806
|
+
// moduleNames — quién depende de X vive en las filas de OTROS
|
|
807
|
+
// módulos). En el mismo Promise.all: cero rondas extra, cero turnos
|
|
808
|
+
// extra. Best-effort: sin columna, el contexto se sirve sin radio.
|
|
809
|
+
db
|
|
810
|
+
.rest(`change_module?select=module,deps&deps=not.is.null&order=created_at.desc&limit=${MODULE_GRAPH_WINDOW_ROWS}` +
|
|
811
|
+
pf)
|
|
812
|
+
.catch(() => []),
|
|
762
813
|
]);
|
|
763
814
|
const watchedByFile = new Map();
|
|
764
815
|
for (const w of watched) {
|
|
@@ -787,6 +838,7 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
787
838
|
// (count(distinct plain), all-time), solo los con antecedentes (>= 2).
|
|
788
839
|
// Fuente única compartida (computeRecidivism) — antes 3 copias.
|
|
789
840
|
const recidivismByModule = computeRecidivism(recidivismRows);
|
|
841
|
+
const dependentsByModule = dependentsOf(moduleNames, depsRows);
|
|
790
842
|
// Ancla temporal: el último commit analizado del proyecto vs el HEAD
|
|
791
843
|
// de este árbol (misma puerta de proyecto que la refutación).
|
|
792
844
|
// Best-effort: el ancla jamás rompe la lectura que ancla.
|
|
@@ -829,6 +881,10 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
829
881
|
for (const plain of alertsByModule.get(m.module) ?? []) {
|
|
830
882
|
lines.push(` - ⚠ OPEN ALERT: ${plain}`);
|
|
831
883
|
}
|
|
884
|
+
const dependents = dependentsByModule.get(m.module);
|
|
885
|
+
if (dependents?.length) {
|
|
886
|
+
lines.push(` - ↘ DEPENDS ON THIS: ${dependents.join(", ")} — check these too before you finish`);
|
|
887
|
+
}
|
|
832
888
|
}
|
|
833
889
|
for (const w of watchedByFile.get(f.file) ?? []) {
|
|
834
890
|
lines.push(`- Current value: ${w.name} = ${w.value}` +
|
|
@@ -864,6 +920,14 @@ Returns (structured): { files: [{ file, modules: [{ module, risk, changes, last_
|
|
|
864
920
|
prior_regressions: recidivismByModule.get(m.module) ?? 0,
|
|
865
921
|
}))
|
|
866
922
|
.filter((x) => x.prior_regressions >= 2),
|
|
923
|
+
// Espejo del hospedado: el structuredContent no puede prometer
|
|
924
|
+
// menos que la prosa.
|
|
925
|
+
dependents: f.modules
|
|
926
|
+
.map((m) => ({
|
|
927
|
+
module: m.module,
|
|
928
|
+
dependents: dependentsByModule.get(m.module) ?? [],
|
|
929
|
+
}))
|
|
930
|
+
.filter((x) => x.dependents.length > 0),
|
|
867
931
|
watched_values: (watchedByFile.get(f.file) ?? []).map((w) => ({
|
|
868
932
|
name: w.name,
|
|
869
933
|
value: w.value,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "changebook",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"mcpName": "io.github.raulbr90/changebook",
|
|
5
5
|
"description": "ChangeBook for coding agents: MCP server (product memory for Claude Code/Codex) + CLI to sign in, analyze changes and sync the product map.",
|
|
6
6
|
"type": "module",
|
package/server.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
3
|
"name": "io.github.raulbr90/changebook",
|
|
4
4
|
"description": "Query your product's living memory: module map + analyzed change history. Read-only MCP tools.",
|
|
5
|
-
"version": "0.4.
|
|
5
|
+
"version": "0.4.9",
|
|
6
6
|
"websiteUrl": "https://changebook.dev",
|
|
7
7
|
"remotes": [
|
|
8
8
|
{
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
"registryType": "npm",
|
|
16
16
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
17
17
|
"identifier": "changebook",
|
|
18
|
-
"version": "0.4.
|
|
18
|
+
"version": "0.4.9",
|
|
19
19
|
"transport": {
|
|
20
20
|
"type": "stdio"
|
|
21
21
|
}
|