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/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
- const { stdout } = await execFileAsync("git", ["log", "-n", String(count), "--no-merges", "--pretty=format:%H|%cI"], { cwd, encoding: "utf8", maxBuffer: GIT_MAX_BUFFER_BYTES });
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("\n")
153
+ .split("\0")
138
154
  .filter(Boolean)
139
- .map((line) => {
140
- const [hash, date] = line.split("|");
141
- return { hash, date };
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 post-commit hook: analyze every new commit automatically
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: "0.2.0",
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 (~500 tokens) y nunca crece sin control. Cuando no cabe
30
- // todo, se recorta por prioridad: regresiones > acoplamientos > hotspots >
31
- // módulos > últimos cambios.
32
- const SYNC_BUDGET_CHARS = 2_000;
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 projectFilter = await db.projectFilter();
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(`regression_alerts?select=module,plain,created_at&created_at=gte.${since}&order=created_at.desc&limit=${MAX_ALERTS}` +
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. Antes de explorar código a mano, orienta con las tools MCP: `atlas_modules` (mapa), `atlas_recent_changes` (historia), `atlas_module_detail` (detalle + diffs).",
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: 3, title: "### Módulos", lines: moduleLines },
211
+ { key: "modules", priority: 4, title: "### Módulos", lines: moduleLines },
132
212
  {
133
- key: "couplings",
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: 2,
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: 4,
238
+ priority: 5,
153
239
  title: "### Últimos cambios",
154
240
  lines: changeLines,
155
241
  },