karajan-code 2.29.0 → 2.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -345,6 +345,13 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.30.0 released** — Minor. **Writable config UI on HU Board** — settings modal with grouped sections, atomic-write backend, and a global vs per-project scope toggle. No more hand-editing the YAML.
349
+ >
350
+ > - **Pipeline role toggles** (PR #854): 8 nuevos booleanos en el modal (`planner`, `researcher`, `architect`, `tester`, `security`, `refactorer`, `impeccable`, `brain`) reflejan los defaults reales de `src/config/defaults.js`.
351
+ > - **RAG controls** (PR #855): `rag.preload.{enabled,topK,scope}` + `rag.embedder.provider` editables sin abrir el archivo. El dropdown del provider lista los 6 embedders (ollama / openai / voyage / cohere / mistral / onnx).
352
+ > - **Grouped sections** (PR #856): los campos se agrupan por categoría (Agentes y modelos, Roles del pipeline, RAG, Tiempos de sesión, Calidad) con iconos y orden determinista. Campos nuevos sin categoría caen en "Otros" — defensivo.
353
+ > - **Scope toggle global vs per-project** (PR #857): pill toggle en el header del modal. Switchea entre `~/.karajan/kj.config.yml` (global, default) y `<projectDir>/.karajan/kj.config.yml` (override del proyecto, se crea al guardar). Atomic-write + `.bak` aplican a ambos.
354
+ >
348
355
  > **v2.29.0 released** — Minor. **RAG quality lift** — dashboard + three new providers + metadata filter + rerank.
349
356
  >
350
357
  > - **Retrieval dashboard on HU Board** (PR #843): nuevo `/rag.html` con embedder activo, tamaño de DB, last-index, chunks por kind, chunks por proyecto. Primera pieza del config UI que llega completo en v2.30.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.29.0",
3
+ "version": "2.30.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -3272,10 +3272,14 @@ function handleRoute() {
3272
3272
  // row. `select` → <select>, `boolean` → <input type=checkbox>,
3273
3273
  // `number` → <input type=number min/max>. Help text under each
3274
3274
  // label as a small grey paragraph.
3275
- async function showConfigEditor() {
3275
+ async function showConfigEditor(scope = 'global') {
3276
+ // v2.30.0 PR4 — scope toggle. 'global' edita ~/.karajan/kj.config.yml
3277
+ // (afecta a todos los proyectos); 'project' edita
3278
+ // <projectDir>/.karajan/kj.config.yml (sólo el repo actual). El backend
3279
+ // resuelve <projectDir> desde KJ_PROJECT_DIR || cwd.
3276
3280
  let cfg;
3277
3281
  try {
3278
- const r = await fetch('/api/config');
3282
+ const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}`);
3279
3283
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
3280
3284
  cfg = await r.json();
3281
3285
  } catch (err) {
@@ -3306,14 +3310,66 @@ async function showConfigEditor() {
3306
3310
  </div>
3307
3311
  `;
3308
3312
  };
3313
+
3314
+ // v2.30.0 — agrupa los campos por `f.category`. El backend declara
3315
+ // las categorías ordenadas en `cfg.categories`; cualquier campo sin
3316
+ // categoría cae a "Otros" (defensivo, no se pierde si olvidamos
3317
+ // etiquetar uno nuevo). Mantiene el orden interno del whitelist
3318
+ // dentro de cada sección.
3319
+ const cats = Array.isArray(cfg.categories) && cfg.categories.length > 0
3320
+ ? cfg.categories
3321
+ : [{ id: 'misc', label: 'Otros', icon: '⚙', order: 999 }];
3322
+ const byCat = new Map(cats.map((c) => [c.id, []]));
3323
+ byCat.set('misc', byCat.get('misc') || []);
3324
+ for (const f of cfg.fields) {
3325
+ const id = byCat.has(f.category) ? f.category : 'misc';
3326
+ if (!byCat.has(id)) byCat.set(id, []);
3327
+ byCat.get(id).push(f);
3328
+ }
3329
+ const renderSection = (cat) => {
3330
+ const fields = byCat.get(cat.id) || [];
3331
+ if (fields.length === 0) return '';
3332
+ return `
3333
+ <section style="margin-bottom:22px;">
3334
+ <h3 style="display:flex;align-items:center;gap:8px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border);font-size:0.95rem;font-weight:700;color:var(--text);">
3335
+ <span aria-hidden="true">${esc(cat.icon || '⚙')}</span>
3336
+ <span>${esc(cat.label)}</span>
3337
+ </h3>
3338
+ ${fields.map(renderField).join('')}
3339
+ </section>
3340
+ `;
3341
+ };
3342
+ const allCats = [...cats];
3343
+ if ((byCat.get('misc') || []).length > 0 && !allCats.some((c) => c.id === 'misc')) {
3344
+ allCats.push({ id: 'misc', label: 'Otros', icon: '⚙', order: 999 });
3345
+ }
3346
+ const sectionsHtml = allCats.map(renderSection).join('');
3347
+
3348
+ // PR4 — toggle global / proyecto. Cambiarlo recarga el modal contra el
3349
+ // otro fichero. Visual: dos pills mutuamente excluyentes.
3350
+ const pill = (id, value, label, active) => `
3351
+ <button data-scope="${value}" id="${id}" type="button"
3352
+ style="padding:4px 10px;border-radius:999px;border:1px solid var(--border);
3353
+ background:${active ? 'var(--color-blue)' : 'var(--bg-primary)'};
3354
+ color:${active ? '#fff' : 'var(--text)'};font-size:0.75rem;cursor:pointer;">
3355
+ ${esc(label)}
3356
+ </button>`;
3357
+ const scopeToggle = `
3358
+ <div style="display:flex;align-items:center;gap:4px;margin-left:auto;" role="radiogroup" aria-label="Ámbito de configuración">
3359
+ ${pill('cfg-scope-global', 'global', 'Global', scope === 'global')}
3360
+ ${pill('cfg-scope-project', 'project', 'Este proyecto', scope === 'project')}
3361
+ </div>`;
3309
3362
  dlg.innerHTML = `
3310
3363
  <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
3311
3364
  <span style="font-size:1.2rem;">⚙</span>
3312
3365
  <span>Configuración de Karajan</span>
3313
- <span style="margin-left:auto;font-family:var(--font-mono,monospace);font-size:0.75rem;color:var(--text-muted);">${esc(cfg.path || '')}</span>
3366
+ ${scopeToggle}
3367
+ </div>
3368
+ <div style="padding:6px 18px 0;font-family:var(--font-mono,monospace);font-size:0.7rem;color:var(--text-muted);">
3369
+ ${esc(cfg.path || '')}${cfg.exists === false ? ' · (aún no existe, se creará al guardar)' : ''}
3314
3370
  </div>
3315
3371
  <div style="padding:14px 18px;max-height:65vh;overflow:auto;">
3316
- ${cfg.fields.map(renderField).join('')}
3372
+ ${sectionsHtml}
3317
3373
  <p style="margin:14px 0 0;font-size:0.75rem;color:var(--text-muted);">
3318
3374
  Los cambios se guardan en <code>kj.config.yml</code> de forma atómica (con copia de seguridad <code>.bak</code>).
3319
3375
  El próximo <code>kj run</code> los aplicará — no hace falta reiniciar el board.
@@ -3325,6 +3381,15 @@ async function showConfigEditor() {
3325
3381
  </div>
3326
3382
  `;
3327
3383
  if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
3384
+ // PR4 — al cambiar de pill, recargar el modal con el otro scope.
3385
+ for (const btn of dlg.querySelectorAll('[data-scope]')) {
3386
+ btn.addEventListener('click', () => {
3387
+ const next = btn.getAttribute('data-scope');
3388
+ if (next === scope) return;
3389
+ try { dlg.close(); } catch { /* ignore */ }
3390
+ showConfigEditor(next);
3391
+ }, { once: true });
3392
+ }
3328
3393
  dlg.querySelector('#config-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
3329
3394
  dlg.querySelector('#config-save')?.addEventListener('click', async () => {
3330
3395
  // Build the patch from the form, comparing against initial values
@@ -3347,7 +3412,7 @@ async function showConfigEditor() {
3347
3412
  const r = await fetch('/api/config', {
3348
3413
  method: 'PUT',
3349
3414
  headers: { 'Content-Type': 'application/json' },
3350
- body: JSON.stringify({ patch }),
3415
+ body: JSON.stringify({ patch, scope }),
3351
3416
  });
3352
3417
  if (!r.ok) {
3353
3418
  const body = await r.json().catch(() => ({}));
@@ -34,7 +34,29 @@ import { tmpdir } from 'node:os';
34
34
  import yaml from 'js-yaml';
35
35
  import { getKjHome } from './db.js';
36
36
 
37
- function configPath() {
37
+ /**
38
+ * v2.30.0 PR4 — scope resolver.
39
+ *
40
+ * 'global' → ~/.karajan/kj.config.yml (mismo path que el resto de
41
+ * Karajan; afecta a todos los proyectos).
42
+ * 'project' → <projectDir>/.karajan/kj.config.yml (override por
43
+ * proyecto; cuando existe, el loader del padre lo prefiere
44
+ * sobre el global).
45
+ *
46
+ * projectDir se resuelve desde `KJ_PROJECT_DIR` (ya usado por
47
+ * journal-parser.js) con fallback a `process.cwd()` — el board se
48
+ * arranca desde el repo del usuario, así que cwd es el proyecto.
49
+ */
50
+ export const SCOPES = ['global', 'project'];
51
+
52
+ function getProjectDir() {
53
+ return process.env.KJ_PROJECT_DIR || process.cwd();
54
+ }
55
+
56
+ function configPath(scope = 'global') {
57
+ if (scope === 'project') {
58
+ return join(getProjectDir(), '.karajan', 'kj.config.yml');
59
+ }
38
60
  // Same path the parent uses (src/utils/paths.js::getKarajanHome).
39
61
  // KJC-TSK-0420: delegate to db.js::getKjHome — gives us KARAJAN_HOME
40
62
  // priority + KJ_HOME fallback + VITEST guard from one place, instead
@@ -42,13 +64,28 @@ function configPath() {
42
64
  return join(getKjHome(), 'kj.config.yml');
43
65
  }
44
66
 
45
- function backupPath() { return `${configPath()}.bak`; }
67
+ function backupPath(scope = 'global') { return `${configPath(scope)}.bak`; }
68
+
69
+ /**
70
+ * v2.30.0 — Categorías para agrupar los campos en el modal. El backend
71
+ * sólo declara la metadata (id, label, icon, order). El front (app.js)
72
+ * itera sobre EDITABLE_FIELDS, agrupa por `f.category` y dibuja un
73
+ * <h3> por sección. Si un campo nuevo no declara categoría, cae en
74
+ * "Otros" (defensivo: no se pierde aunque se nos olvide etiquetarlo).
75
+ */
76
+ export const CATEGORIES = [
77
+ { id: 'agents', label: 'Agentes y modelos', icon: '🤖', order: 1 },
78
+ { id: 'pipeline', label: 'Roles del pipeline', icon: '🔌', order: 2 },
79
+ { id: 'rag', label: 'RAG (búsqueda de contexto)', icon: '📚', order: 3 },
80
+ { id: 'session', label: 'Tiempos de sesión', icon: '⏱', order: 4 },
81
+ { id: 'quality', label: 'Calidad', icon: '✅', order: 5 },
82
+ ];
46
83
 
47
84
  /**
48
85
  * Whitelist of fields the board's modal exposes. Everything else
49
86
  * stays untouched in the yml. Each entry says where in the yml the
50
87
  * field lives (`path`) and how to render it for the UI (`type`,
51
- * `label`, `options` for selects).
88
+ * `label`, `options` for selects, `category` for grouping).
52
89
  *
53
90
  * The path is a dotted notation we resolve manually (so we never
54
91
  * need to take a runtime dep on lodash for `_.get`/`_.set`).
@@ -57,6 +94,7 @@ export const EDITABLE_FIELDS = [
57
94
  {
58
95
  key: 'coder',
59
96
  path: 'coder',
97
+ category: 'agents',
60
98
  type: 'select',
61
99
  label: 'Agente para el rol "coder"',
62
100
  help: 'Quién escribe el código. Claude es el más fiable, Codex es más rápido.',
@@ -66,6 +104,7 @@ export const EDITABLE_FIELDS = [
66
104
  {
67
105
  key: 'reviewer',
68
106
  path: 'reviewer',
107
+ category: 'agents',
69
108
  type: 'select',
70
109
  label: 'Agente para el rol "reviewer"',
71
110
  help: 'Quién revisa el código del coder. Conviene que sea un agente distinto al coder para detectar errores.',
@@ -75,6 +114,7 @@ export const EDITABLE_FIELDS = [
75
114
  {
76
115
  key: 'modelMode',
77
116
  path: '_modelMode_virtual',
117
+ category: 'agents',
78
118
  type: 'select',
79
119
  label: 'Estrategia de modelos',
80
120
  help: 'Auto = Karajan elige el modelo según la complejidad (haiku/sonnet/opus). Sonnet siempre = barato, ideal para pruebas. Opus siempre = caro, máxima calidad.',
@@ -86,6 +126,7 @@ export const EDITABLE_FIELDS = [
86
126
  {
87
127
  key: 'maxIterationMinutes',
88
128
  path: 'session.max_iteration_minutes',
129
+ category: 'session',
89
130
  type: 'number',
90
131
  label: 'Tope de minutos por iteración',
91
132
  help: 'Si una iteración (coder + reviewer) supera este tiempo, Karajan la corta y Solomon decide qué hacer.',
@@ -96,6 +137,7 @@ export const EDITABLE_FIELDS = [
96
137
  {
97
138
  key: 'maxTotalMinutes',
98
139
  path: 'session.max_total_minutes',
140
+ category: 'session',
99
141
  type: 'number',
100
142
  label: 'Tope de minutos total por sesión',
101
143
  help: 'Tope absoluto del wall-clock de la sesión. Si se llega aquí, Karajan para aunque queden HUs.',
@@ -106,11 +148,133 @@ export const EDITABLE_FIELDS = [
106
148
  {
107
149
  key: 'sonarEnabled',
108
150
  path: 'sonarqube.enabled',
151
+ category: 'quality',
109
152
  type: 'boolean',
110
153
  label: 'Activar análisis SonarQube',
111
154
  help: 'Pasa el código por SonarQube y bloquea si el quality gate falla. Requiere SonarQube corriendo.',
112
155
  default: false,
113
156
  },
157
+ // v2.30.0 — pipeline role toggles. Cada uno controla si una etapa
158
+ // del pipeline se ejecuta. Brain está OFF por defecto porque sigue
159
+ // siendo opt-in (v2 feature); el resto reflejan los defaults de
160
+ // src/config/defaults.js.
161
+ {
162
+ key: 'plannerEnabled',
163
+ path: 'pipeline.planner.enabled',
164
+ category: 'pipeline',
165
+ type: 'boolean',
166
+ label: 'Activar rol planner',
167
+ help: 'Genera un plan de implementación antes de codificar. Útil en tareas con >2 archivos o devPoints ≥ 3.',
168
+ default: true,
169
+ },
170
+ {
171
+ key: 'researcherEnabled',
172
+ path: 'pipeline.researcher.enabled',
173
+ category: 'pipeline',
174
+ type: 'boolean',
175
+ label: 'Activar rol researcher',
176
+ help: 'Investiga la codebase antes del coder (lectura de archivos clave, patrones existentes).',
177
+ default: true,
178
+ },
179
+ {
180
+ key: 'architectEnabled',
181
+ path: 'pipeline.architect.enabled',
182
+ category: 'pipeline',
183
+ type: 'boolean',
184
+ label: 'Activar rol architect',
185
+ help: 'Diseña la arquitectura (capas, patrones, contratos) antes de codificar. Recomendado en cambios estructurales.',
186
+ default: true,
187
+ },
188
+ {
189
+ key: 'testerEnabled',
190
+ path: 'pipeline.tester.enabled',
191
+ category: 'pipeline',
192
+ type: 'boolean',
193
+ label: 'Activar rol tester',
194
+ help: 'Genera tests automáticos durante o después del coder. Refuerza la calidad.',
195
+ default: true,
196
+ },
197
+ {
198
+ key: 'securityEnabled',
199
+ path: 'pipeline.security.enabled',
200
+ category: 'pipeline',
201
+ type: 'boolean',
202
+ label: 'Activar rol security',
203
+ help: 'Audita el código contra OWASP-style issues, secret leaks, dependencias vulnerables.',
204
+ default: true,
205
+ },
206
+ {
207
+ key: 'refactorerEnabled',
208
+ path: 'pipeline.refactorer.enabled',
209
+ category: 'pipeline',
210
+ type: 'boolean',
211
+ label: 'Activar rol refactorer',
212
+ help: 'Pasa una iteración de limpieza tras el coder (extract function, rename, etc.).',
213
+ default: false,
214
+ },
215
+ {
216
+ key: 'impeccableEnabled',
217
+ path: 'pipeline.impeccable.enabled',
218
+ category: 'pipeline',
219
+ type: 'boolean',
220
+ label: 'Activar rol impeccable',
221
+ help: 'Auditoría UI/UX automática (Lighthouse, a11y, contraste). Solo útil en frontend.',
222
+ default: false,
223
+ },
224
+ {
225
+ key: 'brainEnabled',
226
+ path: 'pipeline.brain.enabled',
227
+ category: 'pipeline',
228
+ type: 'boolean',
229
+ label: 'Activar Karajan Brain (v2)',
230
+ help: 'Orquestador IA central: enriquece feedback, comprime outputs y verifica cambios reales. Opt-in.',
231
+ default: false,
232
+ },
233
+ // v2.30.0 — RAG controls. Sólo se exponen las claves que el código
234
+ // realmente lee hoy (rag.preload.* en rag-context-stage.js y
235
+ // rag.embedder.provider en embedders/factory.js). mode/alpha/rerank
236
+ // se controlan vía flags CLI; cuando se cableen en config se añadirán
237
+ // aquí (ver task #99).
238
+ {
239
+ key: 'ragPreloadEnabled',
240
+ path: 'rag.preload.enabled',
241
+ category: 'rag',
242
+ type: 'boolean',
243
+ label: 'Activar pre-carga RAG en el pipeline',
244
+ help: 'Si está activo, antes de cada iteración se inyecta contexto del vector store en el prompt del coder.',
245
+ default: false,
246
+ },
247
+ {
248
+ key: 'ragPreloadTopK',
249
+ path: 'rag.preload.topK',
250
+ category: 'rag',
251
+ type: 'number',
252
+ label: 'Nº de chunks a pre-cargar (topK)',
253
+ help: 'Cuántos fragmentos del vector store se inyectan como contexto. 3-5 funciona bien; subir gasta tokens.',
254
+ min: 1,
255
+ max: 20,
256
+ default: 5,
257
+ },
258
+ {
259
+ key: 'ragPreloadScope',
260
+ path: 'rag.preload.scope',
261
+ category: 'rag',
262
+ type: 'select',
263
+ label: 'Scope de la pre-carga RAG',
264
+ help: '"all" mezcla todo. "code" solo busca en el código indexado, "plans" en planes previos, "onboarding" en el brief del proyecto.',
265
+ options: ['all', 'code', 'plans', 'onboarding'],
266
+ default: 'all',
267
+ },
268
+ {
269
+ key: 'ragEmbedderProvider',
270
+ path: 'rag.embedder.provider',
271
+ category: 'rag',
272
+ type: 'select',
273
+ label: 'Proveedor de embeddings RAG',
274
+ help: 'Ollama local es gratis. OpenAI/Voyage/Cohere/Mistral son cloud (requieren API key). ONNX corre 100% local con @huggingface/transformers.',
275
+ options: ['ollama', 'openai', 'voyage', 'cohere', 'mistral', 'onnx'],
276
+ default: 'ollama',
277
+ },
114
278
  ];
115
279
 
116
280
  const ROLES_FOR_MODEL_MODE = [
@@ -203,8 +367,13 @@ function applyModelMode(parsed, mode) {
203
367
  *
204
368
  * Returns sane defaults when the file doesn't exist yet.
205
369
  */
206
- export function readConfig() {
207
- const p = configPath();
370
+ export function readConfig({ scope = 'global' } = {}) {
371
+ // PR4 — accept scope. Default 'global' preserves the previous behavior;
372
+ // 'project' reads <projectDir>/.karajan/kj.config.yml.
373
+ if (!SCOPES.includes(scope)) {
374
+ throw new Error(`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`);
375
+ }
376
+ const p = configPath(scope);
208
377
  let parsed = {};
209
378
  let exists = false;
210
379
  if (existsSync(p)) {
@@ -222,7 +391,11 @@ export function readConfig() {
222
391
  if (value === undefined) value = f.default;
223
392
  return { ...f, value };
224
393
  });
225
- return { path: p, exists, fields, raw: parsed };
394
+ // v2.30.0 — also expose CATEGORIES so the modal can render grouped
395
+ // sections without re-importing the constant client-side. Ordered
396
+ // by `order` for deterministic UI.
397
+ const categories = [...CATEGORIES].sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
398
+ return { path: p, scope, exists, fields, categories, raw: parsed };
226
399
  }
227
400
 
228
401
  /**
@@ -232,16 +405,20 @@ export function readConfig() {
232
405
  *
233
406
  * @returns {{ path: string, written: boolean, applied: object[], errors: string[] }}
234
407
  */
235
- export function writeConfigPatch(patch) {
408
+ export function writeConfigPatch(patch, { scope = 'global' } = {}) {
409
+ // PR4 — same shape as readConfig: scope decides target file.
410
+ if (!SCOPES.includes(scope)) {
411
+ return { path: '', scope, written: false, applied: [], errors: [`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`] };
412
+ }
236
413
  if (!patch || typeof patch !== 'object') {
237
- return { path: configPath(), written: false, applied: [], errors: ['patch debe ser un objeto'] };
414
+ return { path: configPath(scope), scope, written: false, applied: [], errors: ['patch debe ser un objeto'] };
238
415
  }
239
416
  // Read current state (or empty if file doesn't exist yet).
240
- const p = configPath();
417
+ const p = configPath(scope);
241
418
  let parsed = {};
242
419
  if (existsSync(p)) {
243
420
  try { parsed = yaml.load(readFileSync(p, 'utf8'), { json: true }) || {}; }
244
- catch (err) { return { path: p, written: false, applied: [], errors: [`No se pudo parsear el yml actual: ${err.message}`] }; }
421
+ catch (err) { return { path: p, scope, written: false, applied: [], errors: [`No se pudo parsear el yml actual: ${err.message}`] }; }
245
422
  }
246
423
  const errors = [];
247
424
  const applied = [];
@@ -268,18 +445,18 @@ export function writeConfigPatch(patch) {
268
445
  else setDeep(parsed, field.path, field.type === 'number' ? Number(val) : val);
269
446
  applied.push({ key, value: val });
270
447
  }
271
- if (errors.length > 0) return { path: p, written: false, applied, errors };
448
+ if (errors.length > 0) return { path: p, scope, written: false, applied, errors };
272
449
 
273
450
  // Write atomically: serialize → write to .tmp → rename. Backup the
274
451
  // existing file first so the user can recover.
275
452
  const dir = dirname(p);
276
453
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
277
- if (existsSync(p)) { try { copyFileSync(p, backupPath()); } catch { /* best-effort */ } }
454
+ if (existsSync(p)) { try { copyFileSync(p, backupPath(scope)); } catch { /* best-effort */ } }
278
455
  const dump = yaml.dump(parsed, { lineWidth: 120 });
279
456
  // mkstemp would be nicer but we want to stay in the same dir for
280
457
  // a guaranteed-atomic rename across the same filesystem.
281
458
  const tmpFile = join(dir, `.kj.config.yml.${process.pid}.${Date.now()}.tmp`);
282
459
  writeFileSync(tmpFile, dump, 'utf8');
283
460
  renameSync(tmpFile, p);
284
- return { path: p, written: true, applied, errors: [] };
461
+ return { path: p, scope, written: true, applied, errors: [] };
285
462
  }
@@ -1031,29 +1031,41 @@ router.post('/projects/:id/run', (req, res) => {
1031
1031
  });
1032
1032
 
1033
1033
  /**
1034
- * GET /api/config — read the editable subset of ~/.karajan/kj.config.yml.
1034
+ * GET /api/config — read the editable subset of the kj.config.yml.
1035
1035
  * Powers the board's settings modal; non-editable keys in the yml are
1036
1036
  * left untouched on PUT (see writeConfigPatch's whitelist semantics).
1037
+ *
1038
+ * v2.30.0 PR4 — supports `?scope=global|project`:
1039
+ * - `global` (default): reads ~/.karajan/kj.config.yml.
1040
+ * - `project`: reads <projectDir>/.karajan/kj.config.yml.
1041
+ * Unknown scopes are 400'd. Response includes the resolved `scope` and
1042
+ * `path` so the UI can show which file backs the current view.
1037
1043
  */
1038
- router.get('/config', (_req, res) => {
1044
+ router.get('/config', (req, res) => {
1039
1045
  try {
1040
- const data = readConfig();
1046
+ const scope = req.query?.scope || 'global';
1047
+ const data = readConfig({ scope });
1041
1048
  res.json(data);
1042
1049
  } catch (err) {
1043
- res.status(500).json({ error: err.message });
1050
+ res.status(400).json({ error: err.message });
1044
1051
  }
1045
1052
  });
1046
1053
 
1047
1054
  /**
1048
- * PUT /api/config — apply a patch to the yml. Body is `{ patch: { key: value } }`
1049
- * with the keys defined in EDITABLE_FIELDS. Unknown keys are 400'd; type
1050
- * errors are 400'd; validation errors are 400'd. Writes are atomic
1051
- * (tmp + rename) and a `.bak` is kept of the previous content.
1055
+ * PUT /api/config — apply a patch to the yml. Body is
1056
+ * `{ patch: { key: value }, scope?: 'global'|'project' }` with the keys
1057
+ * defined in EDITABLE_FIELDS. Unknown keys are 400'd; type errors are
1058
+ * 400'd; validation errors are 400'd. Writes are atomic (tmp + rename)
1059
+ * and a `.bak` is kept of the previous content.
1060
+ *
1061
+ * v2.30.0 PR4 — `scope` decides which file is patched. Default 'global'
1062
+ * preserves the previous behavior.
1052
1063
  */
1053
1064
  router.put('/config', (req, res) => {
1054
1065
  try {
1055
1066
  const patch = req.body?.patch;
1056
- const result = writeConfigPatch(patch);
1067
+ const scope = req.body?.scope || 'global';
1068
+ const result = writeConfigPatch(patch, { scope });
1057
1069
  if (!result.written) {
1058
1070
  return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
1059
1071
  }