changebook 0.3.2 → 0.4.1
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 +5 -2
- package/dist/analyze.js +26 -4
- package/dist/canonical.js +44 -0
- package/dist/git.js +34 -0
- package/dist/guard.js +276 -0
- package/dist/hook.js +128 -34
- package/dist/import.js +32 -7
- package/dist/index.js +30 -4
- package/dist/supabase.js +114 -0
- package/dist/sync.js +100 -14
- package/dist/tools.js +146 -9
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/import.js
CHANGED
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { atlasWebUrl } from "./browser.js";
|
|
13
|
-
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, } from "./git.js";
|
|
13
|
+
import { commitDiff, execFileAsync, GIT_MAX_BUFFER_BYTES, gitErrorMessage, MAX_DIFF_CHARACTERS, usableSummary, } from "./git.js";
|
|
14
|
+
import { canonicalDiffHash } from "./canonical.js";
|
|
14
15
|
import { optimizeTokensForAI } from "./optimize.js";
|
|
15
16
|
// Under the server's MAX_BATCH_ITEMS (25) to leave headroom.
|
|
16
17
|
const CHUNK_SIZE = 20;
|
|
@@ -52,11 +53,16 @@ export async function importHistory(db, options = {}) {
|
|
|
52
53
|
trivial += 1;
|
|
53
54
|
continue;
|
|
54
55
|
}
|
|
56
|
+
// El servidor decide con esto si enruta al modelo pequeño. Va opcional: un
|
|
57
|
+
// servidor antiguo lo ignora sin romperse.
|
|
58
|
+
const summary = usableSummary(commit.message);
|
|
55
59
|
items.push({
|
|
56
60
|
compressedDiff: compressed,
|
|
57
61
|
rawDiffChars: diff.length,
|
|
62
|
+
rawContentHash: canonicalDiffHash(diff),
|
|
58
63
|
commitHash: commit.hash,
|
|
59
64
|
committedAt: commit.date,
|
|
65
|
+
...(summary ? { agentSummary: summary } : {}),
|
|
60
66
|
});
|
|
61
67
|
}
|
|
62
68
|
if (items.length === 0) {
|
|
@@ -132,15 +138,34 @@ function sleep(ms) {
|
|
|
132
138
|
// ── git helpers ───────────────────────────────────────────────────────────────
|
|
133
139
|
async function listCommits(cwd, count) {
|
|
134
140
|
try {
|
|
135
|
-
|
|
141
|
+
// -z separa los commits por NUL en vez de por salto de línea. Es
|
|
142
|
+
// obligatorio desde que se pide %B: el mensaje tiene saltos dentro, así que
|
|
143
|
+
// partir por "\n" mezclaría el cuerpo de un commit con el siguiente.
|
|
144
|
+
const { stdout } = await execFileAsync("git", [
|
|
145
|
+
"log",
|
|
146
|
+
"-n",
|
|
147
|
+
String(count),
|
|
148
|
+
"--no-merges",
|
|
149
|
+
"-z",
|
|
150
|
+
"--pretty=format:%H|%cI|%B",
|
|
151
|
+
], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
|
|
136
152
|
return stdout
|
|
137
|
-
.split("\
|
|
153
|
+
.split("\0")
|
|
138
154
|
.filter(Boolean)
|
|
139
|
-
.map((
|
|
140
|
-
|
|
141
|
-
|
|
155
|
+
.map((record) => {
|
|
156
|
+
// Solo los DOS primeros "|" son separadores: el hash y la fecha ISO no
|
|
157
|
+
// pueden contener uno, pero el mensaje sí, y hay que dejarlo entero.
|
|
158
|
+
const firstPipe = record.indexOf("|");
|
|
159
|
+
const secondPipe = record.indexOf("|", firstPipe + 1);
|
|
160
|
+
if (firstPipe < 0 || secondPipe < 0)
|
|
161
|
+
return null;
|
|
162
|
+
return {
|
|
163
|
+
hash: record.slice(0, firstPipe),
|
|
164
|
+
date: record.slice(firstPipe + 1, secondPipe),
|
|
165
|
+
message: record.slice(secondPipe + 1),
|
|
166
|
+
};
|
|
142
167
|
})
|
|
143
|
-
.filter((c) => c.hash && c.date);
|
|
168
|
+
.filter((c) => c != null && !!c.hash && !!c.date);
|
|
144
169
|
}
|
|
145
170
|
catch (error) {
|
|
146
171
|
const message = gitErrorMessage(error);
|
package/dist/index.js
CHANGED
|
@@ -8,11 +8,15 @@
|
|
|
8
8
|
* The CLI subcommands feed and connect that memory without the VS Code
|
|
9
9
|
* extension: login, analyze, sync, init, open.
|
|
10
10
|
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
11
14
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
15
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
13
16
|
import { analyze } from "./analyze.js";
|
|
14
17
|
import { atlasWebUrl, openInBrowser } from "./browser.js";
|
|
15
18
|
import { clearCredentials, credentialsPath } from "./credentials.js";
|
|
19
|
+
import { runGuard } from "./guard.js";
|
|
16
20
|
import { hookStatus, installHook, uninstallHook } from "./hook.js";
|
|
17
21
|
import { importHistory } from "./import.js";
|
|
18
22
|
import { registerAgents } from "./init.js";
|
|
@@ -20,6 +24,10 @@ import { login } from "./login.js";
|
|
|
20
24
|
import { AUTH_HELP, Supabase } from "./supabase.js";
|
|
21
25
|
import { syncContextFiles } from "./sync.js";
|
|
22
26
|
import { registerTools } from "./tools.js";
|
|
27
|
+
// Single source of truth for the reported version: package.json (dist is one
|
|
28
|
+
// level below it). Avoids the hardcoded "0.2.0" drifting from the published
|
|
29
|
+
// version (audit M5). test/mcpVersion.test.ts pins package.json ↔ server.json.
|
|
30
|
+
const VERSION = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
23
31
|
const HELP = `changebook — the ChangeBook product memory, from any terminal
|
|
24
32
|
|
|
25
33
|
Usage:
|
|
@@ -31,14 +39,19 @@ Usage:
|
|
|
31
39
|
Backfill the last N commits (default 25) via the
|
|
32
40
|
Anthropic Batch API — 50% cheaper, non-interactive
|
|
33
41
|
changebook hook install|uninstall|status [dir]
|
|
34
|
-
Git
|
|
42
|
+
Git hooks: analyze every new commit (post-commit) and
|
|
43
|
+
warn before committing to a module with an open alert
|
|
44
|
+
(pre-commit signal guard)
|
|
45
|
+
changebook guard [dir] Check staged files against open atlas alerts
|
|
46
|
+
(what the pre-commit hook runs; exit 3 = block)
|
|
35
47
|
changebook sync [dir] Refresh the product map inside CLAUDE.md/AGENTS.md
|
|
36
48
|
changebook init [dir] login + register MCP in every agent found + hook + sync
|
|
37
49
|
changebook open Open the web atlas in the browser
|
|
38
50
|
changebook serve Run the MCP server on stdio (default with no arguments)
|
|
39
51
|
|
|
40
52
|
Environment: CHANGEBOOK_REFRESH_TOKEN / CHANGEBOOK_ACCESS_TOKEN override the stored
|
|
41
|
-
session (CI/headless); CHANGEBOOK_PROJECT scopes queries to one project
|
|
53
|
+
session (CI/headless); CHANGEBOOK_PROJECT scopes queries to one project;
|
|
54
|
+
CHANGEBOOK_GUARD=off|block tunes the pre-commit signal guard (default: warn).`;
|
|
42
55
|
function requireCredentials(db) {
|
|
43
56
|
if (!db.hasCredentials()) {
|
|
44
57
|
console.error(AUTH_HELP);
|
|
@@ -48,7 +61,7 @@ function requireCredentials(db) {
|
|
|
48
61
|
async function serve(db) {
|
|
49
62
|
const server = new McpServer({
|
|
50
63
|
name: "changebook-mcp-server",
|
|
51
|
-
version:
|
|
64
|
+
version: VERSION,
|
|
52
65
|
});
|
|
53
66
|
registerTools(server, db);
|
|
54
67
|
if (!db.hasCredentials()) {
|
|
@@ -104,11 +117,17 @@ async function main() {
|
|
|
104
117
|
case "login":
|
|
105
118
|
await login();
|
|
106
119
|
return;
|
|
107
|
-
case "logout":
|
|
120
|
+
case "logout": {
|
|
121
|
+
// Revoke server-side first (best-effort), then forget the local file —
|
|
122
|
+
// otherwise the refresh token stays alive on the server after "logout".
|
|
123
|
+
const db = new Supabase();
|
|
124
|
+
if (db.hasCredentials())
|
|
125
|
+
await db.signOut();
|
|
108
126
|
console.error(clearCredentials()
|
|
109
127
|
? `✓ Session removed from ${credentialsPath()}`
|
|
110
128
|
: "No stored session.");
|
|
111
129
|
return;
|
|
130
|
+
}
|
|
112
131
|
case "analyze": {
|
|
113
132
|
const db = new Supabase();
|
|
114
133
|
requireCredentials(db);
|
|
@@ -121,6 +140,13 @@ async function main() {
|
|
|
121
140
|
await importHistory(db, parseImportArgs(process.argv.slice(3)));
|
|
122
141
|
return;
|
|
123
142
|
}
|
|
143
|
+
case "guard": {
|
|
144
|
+
// No requireCredentials: a logged-out (or offline, or never-imported)
|
|
145
|
+
// repo must commit exactly as before — runGuard resolves every failure
|
|
146
|
+
// to exit 0 itself. The explicit exit also drops any fetch still racing
|
|
147
|
+
// the timeout, so the commit never waits on a dangling socket.
|
|
148
|
+
return process.exit(await runGuard(new Supabase(), arg ?? process.cwd()));
|
|
149
|
+
}
|
|
124
150
|
case "hook": {
|
|
125
151
|
const dir = process.argv[4] ?? process.cwd();
|
|
126
152
|
if (arg === "install")
|
package/dist/supabase.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* refresh token when needed (refresh does not require a captcha).
|
|
7
7
|
*/
|
|
8
8
|
import { loadCredentials, saveCredentials } from "./credentials.js";
|
|
9
|
+
import { slugifyProject } from "./guard.js";
|
|
9
10
|
// Public defaults — the anon key is the same public key the web app ships.
|
|
10
11
|
const DEFAULT_URL = "https://oyosihxkecspjkiligga.supabase.co";
|
|
11
12
|
const DEFAULT_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im95b3NpaHhrZWNzcGpraWxpZ2dhIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODMwMDYyNTUsImV4cCI6MjA5ODU4MjI1NX0.TU-UK1DToHmLHp9q7QEQ5eAa7V3sq3HtgCxPEa-DvDE";
|
|
@@ -70,6 +71,14 @@ export class Supabase {
|
|
|
70
71
|
if (this.projectFilterCache !== undefined)
|
|
71
72
|
return this.projectFilterCache;
|
|
72
73
|
if (!this.projectEnv) {
|
|
74
|
+
// Sin proyecto explícito: si la cuenta tiene VARIOS proyectos, negar la
|
|
75
|
+
// mezcla — la frontera por proyecto que el MCP hospedado ya aplica
|
|
76
|
+
// (auditoría M3: el stdio devolvía "" = todos los proyectos y mezclaba
|
|
77
|
+
// historia/módulos/contexto). Con 0 o 1 proyecto no hay ambigüedad.
|
|
78
|
+
const some = await this.rest("projects?select=id&limit=2");
|
|
79
|
+
if (some.length > 1) {
|
|
80
|
+
throw new SupabaseError("Esta cuenta tiene varios proyectos. Define CHANGEBOOK_PROJECT (nombre o slug del repo) o pasa `project` en la tool para no mezclar sus datos.", 400);
|
|
81
|
+
}
|
|
73
82
|
this.projectFilterCache = "";
|
|
74
83
|
return this.projectFilterCache;
|
|
75
84
|
}
|
|
@@ -84,6 +93,111 @@ export class Supabase {
|
|
|
84
93
|
this.projectFilterCache = `&project_id=eq.${rows[0].id}`;
|
|
85
94
|
return this.projectFilterCache;
|
|
86
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Per-call variant of projectFilter(): resolves an explicit project (tool
|
|
98
|
+
* argument — the repo the agent is working in) by server-side slug first,
|
|
99
|
+
* then exact name; falls back to the env-based filter when absent. The
|
|
100
|
+
* atlas is per-project, so tools pass what the agent gave them here.
|
|
101
|
+
*/
|
|
102
|
+
async projectFilterFor(project) {
|
|
103
|
+
const wanted = project?.trim();
|
|
104
|
+
if (!wanted)
|
|
105
|
+
return this.projectFilter();
|
|
106
|
+
const slug = slugifyProject(wanted);
|
|
107
|
+
let rows = slug
|
|
108
|
+
? await this.rest(`projects?select=id&slug=eq.${encodeURIComponent(slug)}&limit=1`)
|
|
109
|
+
: [];
|
|
110
|
+
if (rows.length === 0) {
|
|
111
|
+
rows = await this.rest(`projects?select=id&name=eq.${encodeURIComponent(wanted)}&limit=1`);
|
|
112
|
+
}
|
|
113
|
+
if (rows.length === 0) {
|
|
114
|
+
throw new SupabaseError(`No ChangeBook project named "${wanted}" (by slug or name).`, 404);
|
|
115
|
+
}
|
|
116
|
+
return `&project_id=eq.${rows[0].id}`;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* POST one row via PostgREST as the signed-in user (RLS applies). Used for
|
|
120
|
+
* best-effort metering inserts — callers typically fire-and-forget it.
|
|
121
|
+
*/
|
|
122
|
+
async insertRow(table, row) {
|
|
123
|
+
if (!this.hasCredentials())
|
|
124
|
+
throw new SupabaseError(AUTH_HELP, 401);
|
|
125
|
+
if (!this.accessToken)
|
|
126
|
+
await this.refresh();
|
|
127
|
+
let res = await this.insertOnce(table, row);
|
|
128
|
+
if (res.status === 401 && this.refreshToken) {
|
|
129
|
+
await this.refresh();
|
|
130
|
+
res = await this.insertOnce(table, row);
|
|
131
|
+
}
|
|
132
|
+
if (!res.ok) {
|
|
133
|
+
throw new SupabaseError(`Insert into ${table} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** POST /rest/v1/rpc/<fn> as the signed-in user (definer rules apply). */
|
|
137
|
+
async callRpc(fn, args) {
|
|
138
|
+
if (!this.hasCredentials())
|
|
139
|
+
throw new SupabaseError(AUTH_HELP, 401);
|
|
140
|
+
if (!this.accessToken)
|
|
141
|
+
await this.refresh();
|
|
142
|
+
let res = await this.rpcOnce(fn, args);
|
|
143
|
+
if (res.status === 401 && this.refreshToken) {
|
|
144
|
+
await this.refresh();
|
|
145
|
+
res = await this.rpcOnce(fn, args);
|
|
146
|
+
}
|
|
147
|
+
if (!res.ok) {
|
|
148
|
+
throw new SupabaseError(`RPC ${fn} failed (${res.status}): ${(await res.text()).slice(0, 200)}`, res.status);
|
|
149
|
+
}
|
|
150
|
+
return (await res.json());
|
|
151
|
+
}
|
|
152
|
+
rpcOnce(fn, args) {
|
|
153
|
+
return fetch(`${this.url}/rest/v1/rpc/${fn}`, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: {
|
|
156
|
+
apikey: this.anonKey,
|
|
157
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
158
|
+
"Content-Type": "application/json",
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify(args),
|
|
161
|
+
signal: AbortSignal.timeout(15_000),
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
insertOnce(table, row) {
|
|
165
|
+
return fetch(`${this.url}/rest/v1/${table}`, {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: {
|
|
168
|
+
apikey: this.anonKey,
|
|
169
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
170
|
+
"Content-Type": "application/json",
|
|
171
|
+
Prefer: "return=minimal",
|
|
172
|
+
},
|
|
173
|
+
body: JSON.stringify(row),
|
|
174
|
+
signal: AbortSignal.timeout(10_000),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Revoke the session server-side (GoTrue /logout). Best-effort: the caller
|
|
179
|
+
* still clears the local file afterwards. The extension already does this;
|
|
180
|
+
* without it a `changebook logout` left the refresh token alive on the
|
|
181
|
+
* server (audit B4).
|
|
182
|
+
*/
|
|
183
|
+
async signOut() {
|
|
184
|
+
if (!this.accessToken) {
|
|
185
|
+
try {
|
|
186
|
+
await this.refresh();
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return; // no valid token to revoke
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
await fetch(`${this.url}/auth/v1/logout?scope=global`, {
|
|
193
|
+
method: "POST",
|
|
194
|
+
headers: {
|
|
195
|
+
apikey: this.anonKey,
|
|
196
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
197
|
+
},
|
|
198
|
+
signal: AbortSignal.timeout(10_000),
|
|
199
|
+
}).catch(() => { });
|
|
200
|
+
}
|
|
87
201
|
/** GET a PostgREST path (e.g. "changelog?select=...") as the signed-in user. */
|
|
88
202
|
async rest(pathWithQuery) {
|
|
89
203
|
if (!this.hasCredentials())
|
package/dist/sync.js
CHANGED
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { readFile, writeFile } from "node:fs/promises";
|
|
14
14
|
import path from "node:path";
|
|
15
|
+
/** The project identity of the synced repo (same rule as analyze/guard). */
|
|
16
|
+
function projectNameFor(targetDir) {
|
|
17
|
+
return (process.env.CHANGEBOOK_PROJECT?.trim() ||
|
|
18
|
+
path.basename(path.resolve(targetDir)));
|
|
19
|
+
}
|
|
15
20
|
const START = "<!-- changebook:start -->";
|
|
16
21
|
const END = "<!-- changebook:end -->";
|
|
17
22
|
// DB text (notes, alert bodies, business impact) is AI-written and gets embedded
|
|
@@ -26,10 +31,12 @@ const MAX_MODULES = 15;
|
|
|
26
31
|
const MAX_CHANGES = 5;
|
|
27
32
|
const MAX_COUPLINGS = 5;
|
|
28
33
|
// El bloque entra en CADA sesión de agente del usuario, así que tiene un
|
|
29
|
-
// presupuesto fijo (~
|
|
30
|
-
// todo, se recorta por prioridad: regresiones >
|
|
31
|
-
// módulos > últimos cambios.
|
|
32
|
-
|
|
34
|
+
// presupuesto fijo (~750 tokens) y nunca crece sin control. Cuando no cabe
|
|
35
|
+
// todo, se recorta por prioridad: regresiones > encargos > acoplamientos >
|
|
36
|
+
// hotspots > módulos > últimos cambios. Subido de 2.000 a 3.000 el
|
|
37
|
+
// 2026-07-18: la cabecera ganó las reglas de frontera/aviso/file-context y
|
|
38
|
+
// con 2.000 expulsaba Módulos y Encargos enteros del mapa.
|
|
39
|
+
const SYNC_BUDGET_CHARS = 3_000;
|
|
33
40
|
// Co-change pair thresholds — same spirit as the web's signals: at least 3
|
|
34
41
|
// shared analyses and a ≥60% rate before we call it a dependency.
|
|
35
42
|
const MIN_PAIR_COUNT = 3;
|
|
@@ -38,9 +45,23 @@ const MIN_PAIR_RATE = 0.6;
|
|
|
38
45
|
const ALERT_WINDOW_DAYS = 14;
|
|
39
46
|
const MAX_ALERTS = 3;
|
|
40
47
|
export async function syncContextFiles(db, targetDir) {
|
|
41
|
-
const
|
|
48
|
+
const projectName = projectNameFor(targetDir);
|
|
49
|
+
// Frontera por proyecto también aquí (QA 2026-07-18): sin filtro, una
|
|
50
|
+
// cuenta con varios proyectos construiría el mapa de ESTE repo mezclando
|
|
51
|
+
// los datos de todos. Y si el proyecto aún no existe en el atlas, el mapa
|
|
52
|
+
// debe salir VACÍO — jamás el de otro proyecto.
|
|
53
|
+
let projectFilter;
|
|
54
|
+
let projectResolved = true;
|
|
55
|
+
try {
|
|
56
|
+
projectFilter = await db.projectFilterFor(projectName);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
projectFilter = "&project_id=eq.00000000-0000-0000-0000-000000000000";
|
|
60
|
+
projectResolved = false;
|
|
61
|
+
}
|
|
62
|
+
const projectId = /project_id=eq\.([0-9a-f-]+)/.exec(projectFilter)?.[1] ?? null;
|
|
42
63
|
const since = new Date(Date.now() - ALERT_WINDOW_DAYS * 24 * 3600 * 1000).toISOString();
|
|
43
|
-
const [moduleRows, changes, alerts] = await Promise.all([
|
|
64
|
+
const [moduleRows, changes, alerts, pendingTasks] = await Promise.all([
|
|
44
65
|
db.rest("change_module?select=changelog_id,module,domain,risk,files,note,created_at&order=created_at.desc&limit=500" +
|
|
45
66
|
projectFilter),
|
|
46
67
|
db.rest(`changelog?select=business_impact,created_at&order=created_at.desc&limit=${MAX_CHANGES}` +
|
|
@@ -48,19 +69,47 @@ export async function syncContextFiles(db, targetDir) {
|
|
|
48
69
|
// AI-detected regression warnings: best-effort — an error (older schema,
|
|
49
70
|
// RLS hiccup) must not block the sync of the rest of the map.
|
|
50
71
|
db
|
|
51
|
-
.rest(
|
|
72
|
+
.rest(
|
|
73
|
+
// resolved_at=is.null: a dismissed/auto-resolved alert inside the
|
|
74
|
+
// window must not resurface in every agent session as urgent.
|
|
75
|
+
`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&resolved_at=is.null&order=created_at.desc&limit=${MAX_ALERTS}` +
|
|
52
76
|
projectFilter)
|
|
53
77
|
.catch(() => []),
|
|
78
|
+
// Auto-remediación fase 2: los encargos pendientes entran en el bloque
|
|
79
|
+
// para que CUALQUIER sesión arranque sabiéndolos y se ofrezca a atacarlos
|
|
80
|
+
// (proponer, no ejecutar — la aprobación sigue siendo del humano).
|
|
81
|
+
// Best-effort como las alertas.
|
|
82
|
+
projectResolved && projectId
|
|
83
|
+
? db
|
|
84
|
+
.callRpc("list_agent_tasks", {
|
|
85
|
+
p_project_id: projectId,
|
|
86
|
+
})
|
|
87
|
+
.then((rows) => rows.filter((r) => r.status === "pending"))
|
|
88
|
+
.catch(() => [])
|
|
89
|
+
: Promise.resolve([]),
|
|
54
90
|
]);
|
|
55
|
-
const section = buildSection(moduleRows, changes, alerts);
|
|
91
|
+
const section = buildSection(moduleRows, changes, alerts, projectName, pendingTasks);
|
|
56
92
|
for (const name of ["CLAUDE.md", "AGENTS.md"]) {
|
|
57
93
|
const file = path.join(targetDir, name);
|
|
58
94
|
const updated = await upsertSection(file, section);
|
|
59
95
|
console.error(`${updated} ${name}`);
|
|
60
96
|
}
|
|
97
|
+
// El sync ES una consulta del atlas — la más apalancada: el mapa que
|
|
98
|
+
// destila entra en CADA sesión de agente vía CLAUDE.md/AGENTS.md sin
|
|
99
|
+
// pagar tool calls. Cuenta como lectura (best-effort).
|
|
100
|
+
if (projectResolved && projectId) {
|
|
101
|
+
await db
|
|
102
|
+
.insertRow("atlas_reads", {
|
|
103
|
+
project_id: projectId,
|
|
104
|
+
tool: "sync_context_files",
|
|
105
|
+
source: "sync",
|
|
106
|
+
chars_served: section.length,
|
|
107
|
+
})
|
|
108
|
+
.catch(() => { });
|
|
109
|
+
}
|
|
61
110
|
}
|
|
62
111
|
/** Exported for tests. */
|
|
63
|
-
export function buildSection(rows, changes, alerts = []) {
|
|
112
|
+
export function buildSection(rows, changes, alerts = [], projectName, pendingTasks = []) {
|
|
64
113
|
// Newest-first rows: the first occurrence of a module is its latest state.
|
|
65
114
|
const seen = new Map();
|
|
66
115
|
for (const row of rows) {
|
|
@@ -79,8 +128,26 @@ export function buildSection(rows, changes, alerts = []) {
|
|
|
79
128
|
// útiles ya van por entrada (regresiones, últimos cambios).
|
|
80
129
|
"## Mapa del producto (ChangeBook · auto-generado)",
|
|
81
130
|
"",
|
|
82
|
-
"Este proyecto tiene memoria en ChangeBook.
|
|
131
|
+
"Este proyecto tiene memoria en ChangeBook. Al EMPEZAR la sesión, oriéntate con UNA sola llamada a la tool MCP `atlas_project_brief` (mapa + alertas + encargos pendientes + últimos cambios); para profundizar: `atlas_module_detail` (detalle + diffs).",
|
|
132
|
+
"",
|
|
133
|
+
"Si vas a trabajar un encargo pendiente del atlas, anuncia primero al usuario en 2-3 líneas qué vas a hacer y por qué, antes de tocar código.",
|
|
134
|
+
"",
|
|
135
|
+
// QA de Raúl 2026-07-19: el atlas avisó de un riesgo real, el agente lo
|
|
136
|
+
// usó y siguió trabajando sin decir nada. Desde fuera, un guardián que
|
|
137
|
+
// trabaja callado y un producto que no hace nada son la misma cosa. El
|
|
138
|
+
// dueño paga esto: tiene que verlo ocurrir.
|
|
139
|
+
"Cuando el atlas te avise de un riesgo (alerta abierta, `atlas_file_context` o el guardián), DÍSELO al usuario en 1-2 líneas antes de seguir, aunque lo resuelvas tú: él no ve esos avisos.",
|
|
83
140
|
"",
|
|
141
|
+
"Antes de tocar un archivo concreto, consulta `atlas_file_context` con sus rutas: te da su módulo, riesgo, alertas abiertas e historia por una llamada — más barato que releer el código y su git log.",
|
|
142
|
+
"",
|
|
143
|
+
// Frontera por proyecto (QA 2026-07-18): el atlas y la cola de encargos
|
|
144
|
+
// son POR PROYECTO; sin este dato el agente recibiría los de otros.
|
|
145
|
+
...(projectName
|
|
146
|
+
? [
|
|
147
|
+
`El proyecto de ESTE repo es \`${sanitizeCell(projectName)}\`. El atlas es por proyecto: pasa SIEMPRE \`project: "${sanitizeCell(projectName)}"\` en las tools del atlas (mapa, historia, detalle, encargos y registro). Los encargos o datos de otros proyectos no pertenecen a esta sesión.`,
|
|
148
|
+
"",
|
|
149
|
+
]
|
|
150
|
+
: []),
|
|
84
151
|
// La instrucción de escritura vive AQUÍ y no en la doc de la tool: los
|
|
85
152
|
// agentes obedecen lo que el workspace les dice, no lo que una tool
|
|
86
153
|
// disponible insinúa (lección 2026-07-17: un agente con la tool conectada
|
|
@@ -124,14 +191,33 @@ export function buildSection(rows, changes, alerts = []) {
|
|
|
124
191
|
return `- **${m.module}** está marcado crítico${note ? ` — ${note}` : ""}`;
|
|
125
192
|
});
|
|
126
193
|
const changeLines = changes.map((c) => `- ${c.created_at.slice(0, 10)} — ${sanitizeCell((c.business_impact ?? "").slice(0, 140))}`);
|
|
194
|
+
// Auto-remediación fase 2: la cola entra en cada sesión para que el agente
|
|
195
|
+
// se OFREZCA a atacarla — proponer con plan y esperar el OK del humano,
|
|
196
|
+
// nunca ejecutar por su cuenta. Títulos deduplicados (la cola real puede
|
|
197
|
+
// llevar la misma señal encolada varias veces).
|
|
198
|
+
const taskTitles = [
|
|
199
|
+
...new Set(pendingTasks.map((t) => (t.title ?? "").trim()).filter(Boolean)),
|
|
200
|
+
].slice(0, 3);
|
|
201
|
+
const taskLines = taskTitles.length > 0
|
|
202
|
+
? [
|
|
203
|
+
`- Hay ${pendingTasks.length} encargo(s) pendientes en la cola de este proyecto. Al empezar, propón al usuario cuál atacarías y por qué, y espera su OK antes de tocar código. Cola viva: \`atlas_pending_tasks\`; cierra con \`atlas_complete_task\`.`,
|
|
204
|
+
...taskTitles.map((t) => `- ${sanitizeCell(t.slice(0, 120))}`),
|
|
205
|
+
]
|
|
206
|
+
: [];
|
|
127
207
|
// El orden de renderizado (legibilidad) y la prioridad de presupuesto
|
|
128
208
|
// (utilidad para el agente) son independientes: si no cabe todo, caen
|
|
129
209
|
// primero los últimos cambios y los módulos, nunca las regresiones.
|
|
130
210
|
const sections = [
|
|
131
|
-
{ key: "modules", priority:
|
|
211
|
+
{ key: "modules", priority: 4, title: "### Módulos", lines: moduleLines },
|
|
132
212
|
{
|
|
133
|
-
key: "
|
|
213
|
+
key: "tasks",
|
|
134
214
|
priority: 1,
|
|
215
|
+
title: "### Encargos pendientes del dueño (proponte atacarlos)",
|
|
216
|
+
lines: taskLines,
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
key: "couplings",
|
|
220
|
+
priority: 2,
|
|
135
221
|
title: "### Módulos que cambian juntos (si tocas uno, revisa el otro)",
|
|
136
222
|
lines: couplingLines,
|
|
137
223
|
},
|
|
@@ -143,13 +229,13 @@ export function buildSection(rows, changes, alerts = []) {
|
|
|
143
229
|
},
|
|
144
230
|
{
|
|
145
231
|
key: "hotspots",
|
|
146
|
-
priority:
|
|
232
|
+
priority: 3,
|
|
147
233
|
title: "### Avisos abiertos (revisar antes de modificar)",
|
|
148
234
|
lines: hotspotLines,
|
|
149
235
|
},
|
|
150
236
|
{
|
|
151
237
|
key: "changes",
|
|
152
|
-
priority:
|
|
238
|
+
priority: 5,
|
|
153
239
|
title: "### Últimos cambios",
|
|
154
240
|
lines: changeLines,
|
|
155
241
|
},
|