karajan-code 2.29.0 → 2.31.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,21 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.31.0 released** — Minor. **Team-shared HU Board** — the full KJC-PRP-0002: opt-in HUs into a `.karajan-shared/` cohort, the board surfaces them with a `shared` badge, and per-HU `assignee` lets multiple machines work the same plan without trampling each other.
349
+ >
350
+ > - **`kj plan share` / `kj plan unshare`** (PRs #860, #862): mueve plans entre `~/.karajan/plans/` (local) y `<projectDir>/.karajan-shared/plans/` (compartido). Idempotente. El loader probará ambos automáticamente.
351
+ > - **`share --only HU-001,HU-003` / `--exclude HU-005`** (PR #863): selective sharing — comparte solo lo que quieres, deja los WIP privados sin partir el plan.
352
+ > - **Board scanner + `shared` badge** (PRs #861, #862): el watcher recorre los dos roots, mete las HUs compartidas en la misma tabla con `is_shared = 1`, y el modal pinta un pill `shared` junto al título del plan.
353
+ > - **`sharedConflictPolicy` escape hatch** (PR #864): `prompt | local-wins | shared-wins` en kj.config.yml. Cuando dos máquinas editan la misma HU compartida, la política decide sin intervención. Log en `~/.karajan/board-conflicts.log`.
354
+ > - **Per-HU `assignee`** (PR #865): handle libre (`@manufosela`, `dev_016`, `becaria`…) por HU. Visible y editable en el modal **solo** en proyectos shared. Migración sqlite idempotente.
355
+ >
356
+ > **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.
357
+ >
358
+ > - **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`.
359
+ > - **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).
360
+ > - **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.
361
+ > - **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.
362
+ >
348
363
  > **v2.29.0 released** — Minor. **RAG quality lift** — dashboard + three new providers + metadata filter + rerank.
349
364
  >
350
365
  > - **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.31.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",
@@ -193,6 +193,10 @@ async function triggerSync() {
193
193
 
194
194
  const projectInitialsCache = {};
195
195
  const projectNameCache = {};
196
+ // KJC-PRP-0002 PR6: cache project.is_shared so the HU modal can gate the
197
+ // "Asignado a" field without re-fetching /api/projects/:id each open.
198
+ // 0/1 mirror what sqlite stores; undefined = not yet hydrated.
199
+ const projectIsSharedCache = {};
196
200
 
197
201
  /**
198
202
  * Humanise a slug-ish project id so the board can show
@@ -251,11 +255,13 @@ async function resolveProjectMeta(projectId) {
251
255
  const initials = deriveInitialsFromName(rawName);
252
256
  projectInitialsCache[projectId] = initials;
253
257
  projectNameCache[projectId] = rawName;
258
+ projectIsSharedCache[projectId] = project.is_shared === 1 ? 1 : 0;
254
259
  return { initials, name: rawName };
255
260
  } catch {
256
261
  const fallbackName = humaniseProjectName(projectId);
257
262
  projectInitialsCache[projectId] = deriveInitialsFromName(fallbackName);
258
263
  projectNameCache[projectId] = fallbackName;
264
+ projectIsSharedCache[projectId] = 0;
259
265
  return { initials: projectInitialsCache[projectId], name: fallbackName };
260
266
  }
261
267
  }
@@ -452,7 +458,7 @@ async function renderDashboard() {
452
458
  <button class="project-card__delete" title="Delete project (cascade)" data-project-id="${esc(p.id)}" data-project-name="${esc(p.name || p.id)}">🗑️</button>
453
459
  <button class="project-card__is-test" title="${esc(isTestTitle(p))}" data-project-id="${esc(p.id)}" data-is-test="${p.is_test === null || p.is_test === undefined ? '' : p.is_test}">${isTestIcon(p)}</button>
454
460
  <div class="project-card__body" onclick="selectProject('${esc(p.id)}')">
455
- <div class="project-card__name">${esc(p.name || p.id)}</div>
461
+ <div class="project-card__name">${p.is_shared === 1 ? '<span class="project-card__shared-badge" title="Team-shared via .karajan-shared/">🔗</span> ' : ''}${esc(p.name || p.id)}</div>
456
462
  <div class="project-card__stats">
457
463
  <div class="project-card__stat">
458
464
  <div class="project-card__stat-value">${p.story_count || 0}</div>
@@ -1668,6 +1674,31 @@ window.saveHuModels = async function saveHuModels(storyId) {
1668
1674
  }
1669
1675
  };
1670
1676
 
1677
+ // KJC-PRP-0002 PR6: persist `hu.assignee` (free-form handle of whoever owns
1678
+ // this HU on a team-shared board). Empty string → null so the API knows to
1679
+ // clear, not store "". The modal only renders the input when the project is
1680
+ // `is_shared = 1`, so non-team boards never trigger this path.
1681
+ window.saveHuAssignee = async function saveHuAssignee(storyId) {
1682
+ const input = document.getElementById('hu-assignee');
1683
+ const value = input?.value?.trim() || null;
1684
+ try {
1685
+ const res = await fetch(`/api/stories/${encodeURIComponent(storyId)}`, {
1686
+ method: 'PATCH',
1687
+ headers: { 'Content-Type': 'application/json' },
1688
+ body: JSON.stringify({ assignee: value }),
1689
+ });
1690
+ if (!res.ok) {
1691
+ const msg = (await res.json().catch(() => ({}))).error || `HTTP ${res.status}`;
1692
+ await showError(msg, { title: 'No se pudo guardar el asignado' });
1693
+ return;
1694
+ }
1695
+ await fetch('/api/sync', { method: 'POST' }).catch(() => {});
1696
+ await renderBoard();
1697
+ } catch (err) {
1698
+ await showError(err.message || String(err), { title: 'Fallo guardando asignado' });
1699
+ }
1700
+ };
1701
+
1671
1702
  // KJC-TSK-0408: deshace los cambios de una HU restaurando el snapshot
1672
1703
  // git tomado pre-run. ATENCIÓN: es destructivo (git reset --hard) —
1673
1704
  // confirmamos siempre antes de invocar.
@@ -2435,6 +2466,27 @@ async function showStoryDetail(storyId) {
2435
2466
  </div>
2436
2467
  ` : ''}
2437
2468
 
2469
+ ${projectIsSharedCache[story.project_id] === 1 ? `
2470
+ <!-- KJC-PRP-0002 PR6: per-HU assignee — only surfaced when the
2471
+ project is team-shared. Free-form string; no entity table. -->
2472
+ <div class="modal__section">
2473
+ <div class="modal__section-title">Asignado a</div>
2474
+ <div class="modal__field-value" style="font-family:monospace">${esc(story.assignee || '—')}</div>
2475
+ ${canEdit ? `
2476
+ <div style="margin-top:6px;display:flex;gap:6px;align-items:center">
2477
+ <input id="hu-assignee" type="text" placeholder="@manu, dev_016, becaria…"
2478
+ value="${esc(story.assignee || '')}"
2479
+ style="flex:1;padding:4px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;font-family:monospace;font-size:0.8rem">
2480
+ <button onclick="saveHuAssignee('${esc(story.id)}')" class="control-btn"
2481
+ style="padding:4px 10px;background:var(--bg-primary);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:0.8rem">
2482
+ 💾 Save
2483
+ </button>
2484
+ </div>
2485
+ <div style="margin-top:4px;color:var(--text-muted);font-size:0.75rem">vacío → sin asignar</div>
2486
+ ` : ''}
2487
+ </div>
2488
+ ` : ''}
2489
+
2438
2490
  ${story.certified_as ? `
2439
2491
  <div class="modal__section">
2440
2492
  <div class="modal__section-title">Certified Story</div>
@@ -3272,10 +3324,14 @@ function handleRoute() {
3272
3324
  // row. `select` → <select>, `boolean` → <input type=checkbox>,
3273
3325
  // `number` → <input type=number min/max>. Help text under each
3274
3326
  // label as a small grey paragraph.
3275
- async function showConfigEditor() {
3327
+ async function showConfigEditor(scope = 'global') {
3328
+ // v2.30.0 PR4 — scope toggle. 'global' edita ~/.karajan/kj.config.yml
3329
+ // (afecta a todos los proyectos); 'project' edita
3330
+ // <projectDir>/.karajan/kj.config.yml (sólo el repo actual). El backend
3331
+ // resuelve <projectDir> desde KJ_PROJECT_DIR || cwd.
3276
3332
  let cfg;
3277
3333
  try {
3278
- const r = await fetch('/api/config');
3334
+ const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}`);
3279
3335
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
3280
3336
  cfg = await r.json();
3281
3337
  } catch (err) {
@@ -3306,14 +3362,66 @@ async function showConfigEditor() {
3306
3362
  </div>
3307
3363
  `;
3308
3364
  };
3365
+
3366
+ // v2.30.0 — agrupa los campos por `f.category`. El backend declara
3367
+ // las categorías ordenadas en `cfg.categories`; cualquier campo sin
3368
+ // categoría cae a "Otros" (defensivo, no se pierde si olvidamos
3369
+ // etiquetar uno nuevo). Mantiene el orden interno del whitelist
3370
+ // dentro de cada sección.
3371
+ const cats = Array.isArray(cfg.categories) && cfg.categories.length > 0
3372
+ ? cfg.categories
3373
+ : [{ id: 'misc', label: 'Otros', icon: '⚙', order: 999 }];
3374
+ const byCat = new Map(cats.map((c) => [c.id, []]));
3375
+ byCat.set('misc', byCat.get('misc') || []);
3376
+ for (const f of cfg.fields) {
3377
+ const id = byCat.has(f.category) ? f.category : 'misc';
3378
+ if (!byCat.has(id)) byCat.set(id, []);
3379
+ byCat.get(id).push(f);
3380
+ }
3381
+ const renderSection = (cat) => {
3382
+ const fields = byCat.get(cat.id) || [];
3383
+ if (fields.length === 0) return '';
3384
+ return `
3385
+ <section style="margin-bottom:22px;">
3386
+ <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);">
3387
+ <span aria-hidden="true">${esc(cat.icon || '⚙')}</span>
3388
+ <span>${esc(cat.label)}</span>
3389
+ </h3>
3390
+ ${fields.map(renderField).join('')}
3391
+ </section>
3392
+ `;
3393
+ };
3394
+ const allCats = [...cats];
3395
+ if ((byCat.get('misc') || []).length > 0 && !allCats.some((c) => c.id === 'misc')) {
3396
+ allCats.push({ id: 'misc', label: 'Otros', icon: '⚙', order: 999 });
3397
+ }
3398
+ const sectionsHtml = allCats.map(renderSection).join('');
3399
+
3400
+ // PR4 — toggle global / proyecto. Cambiarlo recarga el modal contra el
3401
+ // otro fichero. Visual: dos pills mutuamente excluyentes.
3402
+ const pill = (id, value, label, active) => `
3403
+ <button data-scope="${value}" id="${id}" type="button"
3404
+ style="padding:4px 10px;border-radius:999px;border:1px solid var(--border);
3405
+ background:${active ? 'var(--color-blue)' : 'var(--bg-primary)'};
3406
+ color:${active ? '#fff' : 'var(--text)'};font-size:0.75rem;cursor:pointer;">
3407
+ ${esc(label)}
3408
+ </button>`;
3409
+ const scopeToggle = `
3410
+ <div style="display:flex;align-items:center;gap:4px;margin-left:auto;" role="radiogroup" aria-label="Ámbito de configuración">
3411
+ ${pill('cfg-scope-global', 'global', 'Global', scope === 'global')}
3412
+ ${pill('cfg-scope-project', 'project', 'Este proyecto', scope === 'project')}
3413
+ </div>`;
3309
3414
  dlg.innerHTML = `
3310
3415
  <div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
3311
3416
  <span style="font-size:1.2rem;">⚙</span>
3312
3417
  <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>
3418
+ ${scopeToggle}
3419
+ </div>
3420
+ <div style="padding:6px 18px 0;font-family:var(--font-mono,monospace);font-size:0.7rem;color:var(--text-muted);">
3421
+ ${esc(cfg.path || '')}${cfg.exists === false ? ' · (aún no existe, se creará al guardar)' : ''}
3314
3422
  </div>
3315
3423
  <div style="padding:14px 18px;max-height:65vh;overflow:auto;">
3316
- ${cfg.fields.map(renderField).join('')}
3424
+ ${sectionsHtml}
3317
3425
  <p style="margin:14px 0 0;font-size:0.75rem;color:var(--text-muted);">
3318
3426
  Los cambios se guardan en <code>kj.config.yml</code> de forma atómica (con copia de seguridad <code>.bak</code>).
3319
3427
  El próximo <code>kj run</code> los aplicará — no hace falta reiniciar el board.
@@ -3325,6 +3433,15 @@ async function showConfigEditor() {
3325
3433
  </div>
3326
3434
  `;
3327
3435
  if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
3436
+ // PR4 — al cambiar de pill, recargar el modal con el otro scope.
3437
+ for (const btn of dlg.querySelectorAll('[data-scope]')) {
3438
+ btn.addEventListener('click', () => {
3439
+ const next = btn.getAttribute('data-scope');
3440
+ if (next === scope) return;
3441
+ try { dlg.close(); } catch { /* ignore */ }
3442
+ showConfigEditor(next);
3443
+ }, { once: true });
3444
+ }
3328
3445
  dlg.querySelector('#config-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
3329
3446
  dlg.querySelector('#config-save')?.addEventListener('click', async () => {
3330
3447
  // Build the patch from the form, comparing against initial values
@@ -3347,7 +3464,7 @@ async function showConfigEditor() {
3347
3464
  const r = await fetch('/api/config', {
3348
3465
  method: 'PUT',
3349
3466
  headers: { 'Content-Type': 'application/json' },
3350
- body: JSON.stringify({ patch }),
3467
+ body: JSON.stringify({ patch, scope }),
3351
3468
  });
3352
3469
  if (!r.ok) {
3353
3470
  const body = await r.json().catch(() => ({}));
@@ -770,6 +770,15 @@ a:hover {
770
770
  margin-bottom: 8px;
771
771
  }
772
772
 
773
+ /* KJC-PRP-0002 PR3: 🔗 badge for projects with a plan in .karajan-shared/. */
774
+ .project-card__shared-badge {
775
+ display: inline-block;
776
+ font-size: 0.85em;
777
+ margin-right: 4px;
778
+ cursor: help;
779
+ vertical-align: baseline;
780
+ }
781
+
773
782
  .project-card__stats {
774
783
  display: grid;
775
784
  grid-template-columns: repeat(3, 1fr);
@@ -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,33 @@ 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
+ // KJC-PRP-0002 PR5: team-shared HU board controls. Today only carries
83
+ // the conflict-resolution policy when a planId lives in both
84
+ // ~/.karajan/plans/ (per-user) and <projectDir>/.karajan-shared/plans/
85
+ // (team-committed). Will grow as more board-level multi-user knobs land.
86
+ { id: 'huBoard', label: 'HU Board (equipo)', icon: '🔗', order: 6 },
87
+ ];
46
88
 
47
89
  /**
48
90
  * Whitelist of fields the board's modal exposes. Everything else
49
91
  * stays untouched in the yml. Each entry says where in the yml the
50
92
  * field lives (`path`) and how to render it for the UI (`type`,
51
- * `label`, `options` for selects).
93
+ * `label`, `options` for selects, `category` for grouping).
52
94
  *
53
95
  * The path is a dotted notation we resolve manually (so we never
54
96
  * need to take a runtime dep on lodash for `_.get`/`_.set`).
@@ -57,6 +99,7 @@ export const EDITABLE_FIELDS = [
57
99
  {
58
100
  key: 'coder',
59
101
  path: 'coder',
102
+ category: 'agents',
60
103
  type: 'select',
61
104
  label: 'Agente para el rol "coder"',
62
105
  help: 'Quién escribe el código. Claude es el más fiable, Codex es más rápido.',
@@ -66,6 +109,7 @@ export const EDITABLE_FIELDS = [
66
109
  {
67
110
  key: 'reviewer',
68
111
  path: 'reviewer',
112
+ category: 'agents',
69
113
  type: 'select',
70
114
  label: 'Agente para el rol "reviewer"',
71
115
  help: 'Quién revisa el código del coder. Conviene que sea un agente distinto al coder para detectar errores.',
@@ -75,6 +119,7 @@ export const EDITABLE_FIELDS = [
75
119
  {
76
120
  key: 'modelMode',
77
121
  path: '_modelMode_virtual',
122
+ category: 'agents',
78
123
  type: 'select',
79
124
  label: 'Estrategia de modelos',
80
125
  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 +131,7 @@ export const EDITABLE_FIELDS = [
86
131
  {
87
132
  key: 'maxIterationMinutes',
88
133
  path: 'session.max_iteration_minutes',
134
+ category: 'session',
89
135
  type: 'number',
90
136
  label: 'Tope de minutos por iteración',
91
137
  help: 'Si una iteración (coder + reviewer) supera este tiempo, Karajan la corta y Solomon decide qué hacer.',
@@ -96,6 +142,7 @@ export const EDITABLE_FIELDS = [
96
142
  {
97
143
  key: 'maxTotalMinutes',
98
144
  path: 'session.max_total_minutes',
145
+ category: 'session',
99
146
  type: 'number',
100
147
  label: 'Tope de minutos total por sesión',
101
148
  help: 'Tope absoluto del wall-clock de la sesión. Si se llega aquí, Karajan para aunque queden HUs.',
@@ -106,11 +153,149 @@ export const EDITABLE_FIELDS = [
106
153
  {
107
154
  key: 'sonarEnabled',
108
155
  path: 'sonarqube.enabled',
156
+ category: 'quality',
109
157
  type: 'boolean',
110
158
  label: 'Activar análisis SonarQube',
111
159
  help: 'Pasa el código por SonarQube y bloquea si el quality gate falla. Requiere SonarQube corriendo.',
112
160
  default: false,
113
161
  },
162
+ // v2.30.0 — pipeline role toggles. Cada uno controla si una etapa
163
+ // del pipeline se ejecuta. Brain está OFF por defecto porque sigue
164
+ // siendo opt-in (v2 feature); el resto reflejan los defaults de
165
+ // src/config/defaults.js.
166
+ {
167
+ key: 'plannerEnabled',
168
+ path: 'pipeline.planner.enabled',
169
+ category: 'pipeline',
170
+ type: 'boolean',
171
+ label: 'Activar rol planner',
172
+ help: 'Genera un plan de implementación antes de codificar. Útil en tareas con >2 archivos o devPoints ≥ 3.',
173
+ default: true,
174
+ },
175
+ {
176
+ key: 'researcherEnabled',
177
+ path: 'pipeline.researcher.enabled',
178
+ category: 'pipeline',
179
+ type: 'boolean',
180
+ label: 'Activar rol researcher',
181
+ help: 'Investiga la codebase antes del coder (lectura de archivos clave, patrones existentes).',
182
+ default: true,
183
+ },
184
+ {
185
+ key: 'architectEnabled',
186
+ path: 'pipeline.architect.enabled',
187
+ category: 'pipeline',
188
+ type: 'boolean',
189
+ label: 'Activar rol architect',
190
+ help: 'Diseña la arquitectura (capas, patrones, contratos) antes de codificar. Recomendado en cambios estructurales.',
191
+ default: true,
192
+ },
193
+ {
194
+ key: 'testerEnabled',
195
+ path: 'pipeline.tester.enabled',
196
+ category: 'pipeline',
197
+ type: 'boolean',
198
+ label: 'Activar rol tester',
199
+ help: 'Genera tests automáticos durante o después del coder. Refuerza la calidad.',
200
+ default: true,
201
+ },
202
+ {
203
+ key: 'securityEnabled',
204
+ path: 'pipeline.security.enabled',
205
+ category: 'pipeline',
206
+ type: 'boolean',
207
+ label: 'Activar rol security',
208
+ help: 'Audita el código contra OWASP-style issues, secret leaks, dependencias vulnerables.',
209
+ default: true,
210
+ },
211
+ {
212
+ key: 'refactorerEnabled',
213
+ path: 'pipeline.refactorer.enabled',
214
+ category: 'pipeline',
215
+ type: 'boolean',
216
+ label: 'Activar rol refactorer',
217
+ help: 'Pasa una iteración de limpieza tras el coder (extract function, rename, etc.).',
218
+ default: false,
219
+ },
220
+ {
221
+ key: 'impeccableEnabled',
222
+ path: 'pipeline.impeccable.enabled',
223
+ category: 'pipeline',
224
+ type: 'boolean',
225
+ label: 'Activar rol impeccable',
226
+ help: 'Auditoría UI/UX automática (Lighthouse, a11y, contraste). Solo útil en frontend.',
227
+ default: false,
228
+ },
229
+ {
230
+ key: 'brainEnabled',
231
+ path: 'pipeline.brain.enabled',
232
+ category: 'pipeline',
233
+ type: 'boolean',
234
+ label: 'Activar Karajan Brain (v2)',
235
+ help: 'Orquestador IA central: enriquece feedback, comprime outputs y verifica cambios reales. Opt-in.',
236
+ default: false,
237
+ },
238
+ // v2.30.0 — RAG controls. Sólo se exponen las claves que el código
239
+ // realmente lee hoy (rag.preload.* en rag-context-stage.js y
240
+ // rag.embedder.provider en embedders/factory.js). mode/alpha/rerank
241
+ // se controlan vía flags CLI; cuando se cableen en config se añadirán
242
+ // aquí (ver task #99).
243
+ {
244
+ key: 'ragPreloadEnabled',
245
+ path: 'rag.preload.enabled',
246
+ category: 'rag',
247
+ type: 'boolean',
248
+ label: 'Activar pre-carga RAG en el pipeline',
249
+ help: 'Si está activo, antes de cada iteración se inyecta contexto del vector store en el prompt del coder.',
250
+ default: false,
251
+ },
252
+ {
253
+ key: 'ragPreloadTopK',
254
+ path: 'rag.preload.topK',
255
+ category: 'rag',
256
+ type: 'number',
257
+ label: 'Nº de chunks a pre-cargar (topK)',
258
+ help: 'Cuántos fragmentos del vector store se inyectan como contexto. 3-5 funciona bien; subir gasta tokens.',
259
+ min: 1,
260
+ max: 20,
261
+ default: 5,
262
+ },
263
+ {
264
+ key: 'ragPreloadScope',
265
+ path: 'rag.preload.scope',
266
+ category: 'rag',
267
+ type: 'select',
268
+ label: 'Scope de la pre-carga RAG',
269
+ help: '"all" mezcla todo. "code" solo busca en el código indexado, "plans" en planes previos, "onboarding" en el brief del proyecto.',
270
+ options: ['all', 'code', 'plans', 'onboarding'],
271
+ default: 'all',
272
+ },
273
+ {
274
+ key: 'ragEmbedderProvider',
275
+ path: 'rag.embedder.provider',
276
+ category: 'rag',
277
+ type: 'select',
278
+ label: 'Proveedor de embeddings RAG',
279
+ help: 'Ollama local es gratis. OpenAI/Voyage/Cohere/Mistral son cloud (requieren API key). ONNX corre 100% local con @huggingface/transformers.',
280
+ options: ['ollama', 'openai', 'voyage', 'cohere', 'mistral', 'onnx'],
281
+ default: 'ollama',
282
+ },
283
+ // KJC-PRP-0002 PR5: when the board scans plans, it visits both
284
+ // ~/.karajan/plans/<slug>/ (per-user cache) and .karajan-shared/plans/
285
+ // (team copy). If a planId lives in both, the second pass overwrites
286
+ // the first. Before this knob the order was hard-coded shared-wins
287
+ // (per-user → shared) — fine in a team but unsafe for a solo dev who
288
+ // forgets a stale `.karajan-shared/` exists in the repo.
289
+ {
290
+ key: 'huBoardSharedConflictPolicy',
291
+ path: 'huBoard.sharedConflictPolicy',
292
+ category: 'huBoard',
293
+ type: 'select',
294
+ label: 'Política ante plan compartido y local',
295
+ help: '"shared-wins" (recomendado en equipo): la copia en .karajan-shared/ pisa la local. "local-wins" (solo dev individual): tu copia en ~/.karajan/plans/ pisa la compartida — útil si todavía estás iterando antes de publicarla.',
296
+ options: ['shared-wins', 'local-wins'],
297
+ default: 'shared-wins',
298
+ },
114
299
  ];
115
300
 
116
301
  const ROLES_FOR_MODEL_MODE = [
@@ -203,8 +388,13 @@ function applyModelMode(parsed, mode) {
203
388
  *
204
389
  * Returns sane defaults when the file doesn't exist yet.
205
390
  */
206
- export function readConfig() {
207
- const p = configPath();
391
+ export function readConfig({ scope = 'global' } = {}) {
392
+ // PR4 — accept scope. Default 'global' preserves the previous behavior;
393
+ // 'project' reads <projectDir>/.karajan/kj.config.yml.
394
+ if (!SCOPES.includes(scope)) {
395
+ throw new Error(`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`);
396
+ }
397
+ const p = configPath(scope);
208
398
  let parsed = {};
209
399
  let exists = false;
210
400
  if (existsSync(p)) {
@@ -222,7 +412,11 @@ export function readConfig() {
222
412
  if (value === undefined) value = f.default;
223
413
  return { ...f, value };
224
414
  });
225
- return { path: p, exists, fields, raw: parsed };
415
+ // v2.30.0 — also expose CATEGORIES so the modal can render grouped
416
+ // sections without re-importing the constant client-side. Ordered
417
+ // by `order` for deterministic UI.
418
+ const categories = [...CATEGORIES].sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
419
+ return { path: p, scope, exists, fields, categories, raw: parsed };
226
420
  }
227
421
 
228
422
  /**
@@ -232,16 +426,20 @@ export function readConfig() {
232
426
  *
233
427
  * @returns {{ path: string, written: boolean, applied: object[], errors: string[] }}
234
428
  */
235
- export function writeConfigPatch(patch) {
429
+ export function writeConfigPatch(patch, { scope = 'global' } = {}) {
430
+ // PR4 — same shape as readConfig: scope decides target file.
431
+ if (!SCOPES.includes(scope)) {
432
+ return { path: '', scope, written: false, applied: [], errors: [`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`] };
433
+ }
236
434
  if (!patch || typeof patch !== 'object') {
237
- return { path: configPath(), written: false, applied: [], errors: ['patch debe ser un objeto'] };
435
+ return { path: configPath(scope), scope, written: false, applied: [], errors: ['patch debe ser un objeto'] };
238
436
  }
239
437
  // Read current state (or empty if file doesn't exist yet).
240
- const p = configPath();
438
+ const p = configPath(scope);
241
439
  let parsed = {};
242
440
  if (existsSync(p)) {
243
441
  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}`] }; }
442
+ catch (err) { return { path: p, scope, written: false, applied: [], errors: [`No se pudo parsear el yml actual: ${err.message}`] }; }
245
443
  }
246
444
  const errors = [];
247
445
  const applied = [];
@@ -268,18 +466,18 @@ export function writeConfigPatch(patch) {
268
466
  else setDeep(parsed, field.path, field.type === 'number' ? Number(val) : val);
269
467
  applied.push({ key, value: val });
270
468
  }
271
- if (errors.length > 0) return { path: p, written: false, applied, errors };
469
+ if (errors.length > 0) return { path: p, scope, written: false, applied, errors };
272
470
 
273
471
  // Write atomically: serialize → write to .tmp → rename. Backup the
274
472
  // existing file first so the user can recover.
275
473
  const dir = dirname(p);
276
474
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
277
- if (existsSync(p)) { try { copyFileSync(p, backupPath()); } catch { /* best-effort */ } }
475
+ if (existsSync(p)) { try { copyFileSync(p, backupPath(scope)); } catch { /* best-effort */ } }
278
476
  const dump = yaml.dump(parsed, { lineWidth: 120 });
279
477
  // mkstemp would be nicer but we want to stay in the same dir for
280
478
  // a guaranteed-atomic rename across the same filesystem.
281
479
  const tmpFile = join(dir, `.kj.config.yml.${process.pid}.${Date.now()}.tmp`);
282
480
  writeFileSync(tmpFile, dump, 'utf8');
283
481
  renameSync(tmpFile, p);
284
- return { path: p, written: true, applied, errors: [] };
482
+ return { path: p, scope, written: true, applied, errors: [] };
285
483
  }
@@ -304,6 +304,12 @@ export function initDb() {
304
304
  try { db.exec('ALTER TABLE stories ADD COLUMN reviewer_model TEXT'); } catch { /* already migrated */ }
305
305
  try { db.exec('ALTER TABLE stories ADD COLUMN coder_provider TEXT'); } catch { /* already migrated */ }
306
306
  try { db.exec('ALTER TABLE stories ADD COLUMN reviewer_provider TEXT'); } catch { /* already migrated */ }
307
+ // KJC-PRP-0002 PR6: free-form handle of the human (or dev_XXX AI) who
308
+ // owns this HU on a team-shared board. NULL = unassigned. Only surfaced
309
+ // in the UI when the parent project is `is_shared = 1`, but stored
310
+ // unconditionally so solo devs who later promote a plan don't lose
311
+ // values they typed pre-share.
312
+ try { db.exec('ALTER TABLE stories ADD COLUMN assignee TEXT'); } catch { /* already migrated */ }
307
313
  try { db.exec('CREATE INDEX IF NOT EXISTS idx_stories_plan ON stories(plan_id)'); } catch { /* ignore */ }
308
314
 
309
315
  // is_test (KJC-TSK-0371 — board polish #3): per-project flag that
@@ -313,6 +319,11 @@ export function initDb() {
313
319
  // The UI surfaces this as a toggle on each project card.
314
320
  try { db.exec('ALTER TABLE projects ADD COLUMN is_test INTEGER'); } catch { /* already migrated */ }
315
321
 
322
+ // is_shared (KJC-PRP-0002 PR3): 1 when at least one plan of the project came
323
+ // from `<projectDir>/.karajan-shared/plans/`. Surfaced as a 🔗 badge on the
324
+ // board so the user can tell at a glance which projects are team-tracked.
325
+ try { db.exec('ALTER TABLE projects ADD COLUMN is_shared INTEGER'); } catch { /* already migrated */ }
326
+
316
327
  return db;
317
328
  }
318
329
 
@@ -330,13 +341,17 @@ export function getDb() {
330
341
  * @param {{ id: string, name?: string, first_seen?: string, last_activity?: string, total_stories?: number }} project
331
342
  */
332
343
  export function upsertProject(project) {
344
+ // is_shared (KJC-PRP-0002 PR3) is set by sync when a plan came from
345
+ // `.karajan-shared/`. COALESCE with the existing column keeps the badge
346
+ // once it's been promoted — a second non-shared plan won't unset it.
333
347
  const stmt = getDb().prepare(`
334
- INSERT INTO projects (id, name, first_seen, last_activity, total_stories)
335
- VALUES (@id, @name, @first_seen, @last_activity, @total_stories)
348
+ INSERT INTO projects (id, name, first_seen, last_activity, total_stories, is_shared)
349
+ VALUES (@id, @name, @first_seen, @last_activity, @total_stories, @is_shared)
336
350
  ON CONFLICT(id) DO UPDATE SET
337
351
  name = COALESCE(@name, name),
338
352
  last_activity = COALESCE(@last_activity, last_activity),
339
- total_stories = COALESCE(@total_stories, total_stories)
353
+ total_stories = COALESCE(@total_stories, total_stories),
354
+ is_shared = COALESCE(@is_shared, is_shared)
340
355
  `);
341
356
  stmt.run({
342
357
  id: project.id,
@@ -344,6 +359,7 @@ export function upsertProject(project) {
344
359
  first_seen: project.first_seen || new Date().toISOString(),
345
360
  last_activity: project.last_activity || new Date().toISOString(),
346
361
  total_stories: project.total_stories || 0,
362
+ is_shared: project.is_shared ?? null,
347
363
  });
348
364
  }
349
365
 
@@ -360,7 +376,7 @@ export function upsertStory(story) {
360
376
  antipatterns, ac_format, acceptance_criteria,
361
377
  created_at, updated_at, certified_at, plan_id,
362
378
  ac_count, test_count, blocked_by, acceptance_tests, plan_order, spec_section, outcome, result,
363
- coder_model, reviewer_model, coder_provider, reviewer_provider
379
+ coder_model, reviewer_model, coder_provider, reviewer_provider, assignee
364
380
  ) VALUES (
365
381
  @id, @project_id, @session_id, @status, @title, @original_text,
366
382
  @certified_as, @certified_want, @certified_so_that,
@@ -368,7 +384,7 @@ export function upsertStory(story) {
368
384
  @antipatterns, @ac_format, @acceptance_criteria,
369
385
  @created_at, @updated_at, @certified_at, @plan_id,
370
386
  @ac_count, @test_count, @blocked_by, @acceptance_tests, @plan_order, @spec_section, @outcome, @result,
371
- @coder_model, @reviewer_model, @coder_provider, @reviewer_provider
387
+ @coder_model, @reviewer_model, @coder_provider, @reviewer_provider, @assignee
372
388
  )
373
389
  ON CONFLICT(id) DO UPDATE SET
374
390
  status = @status,
@@ -401,7 +417,8 @@ export function upsertStory(story) {
401
417
  coder_model = COALESCE(@coder_model, coder_model),
402
418
  reviewer_model = COALESCE(@reviewer_model, reviewer_model),
403
419
  coder_provider = COALESCE(@coder_provider, coder_provider),
404
- reviewer_provider = COALESCE(@reviewer_provider, reviewer_provider)
420
+ reviewer_provider = COALESCE(@reviewer_provider, reviewer_provider),
421
+ assignee = COALESCE(@assignee, assignee)
405
422
  `);
406
423
  stmt.run({
407
424
  id: story.id,
@@ -439,6 +456,7 @@ export function upsertStory(story) {
439
456
  reviewer_model: story.reviewer_model || null,
440
457
  coder_provider: story.coder_provider || null,
441
458
  reviewer_provider: story.reviewer_provider || null,
459
+ assignee: story.assignee || null,
442
460
  });
443
461
  }
444
462
 
@@ -557,7 +557,11 @@ const ALLOWED_STORY_STATUSES = new Set(['pending', 'certified', 'done', 'blocked
557
557
  // KJC-TSK-0406: coder_model y reviewer_model son editables desde el
558
558
  // modal del board. Independientes — el usuario puede subir el reviewer
559
559
  // y bajar el coder sin afectar al resto del plan.
560
- const EDITABLE_HU_FIELDS = ['title', 'scope', 'task_type', 'acceptance_criteria', 'acceptance_tests', 'blocked_by', 'coder_model', 'reviewer_model', 'coder_provider', 'reviewer_provider'];
560
+ // KJC-PRP-0002 PR6: `assignee` is editable from the board modal when the
561
+ // project is team-shared. The whitelist itself is project-agnostic — the
562
+ // modal hides the input on non-shared projects, but accepting the field
563
+ // here doesn't hurt (sync just stores it).
564
+ const EDITABLE_HU_FIELDS = ['title', 'scope', 'task_type', 'acceptance_criteria', 'acceptance_tests', 'blocked_by', 'coder_model', 'reviewer_model', 'coder_provider', 'reviewer_provider', 'assignee'];
561
565
 
562
566
  router.patch('/stories/:id', (req, res) => {
563
567
  try {
@@ -1031,29 +1035,41 @@ router.post('/projects/:id/run', (req, res) => {
1031
1035
  });
1032
1036
 
1033
1037
  /**
1034
- * GET /api/config — read the editable subset of ~/.karajan/kj.config.yml.
1038
+ * GET /api/config — read the editable subset of the kj.config.yml.
1035
1039
  * Powers the board's settings modal; non-editable keys in the yml are
1036
1040
  * left untouched on PUT (see writeConfigPatch's whitelist semantics).
1041
+ *
1042
+ * v2.30.0 PR4 — supports `?scope=global|project`:
1043
+ * - `global` (default): reads ~/.karajan/kj.config.yml.
1044
+ * - `project`: reads <projectDir>/.karajan/kj.config.yml.
1045
+ * Unknown scopes are 400'd. Response includes the resolved `scope` and
1046
+ * `path` so the UI can show which file backs the current view.
1037
1047
  */
1038
- router.get('/config', (_req, res) => {
1048
+ router.get('/config', (req, res) => {
1039
1049
  try {
1040
- const data = readConfig();
1050
+ const scope = req.query?.scope || 'global';
1051
+ const data = readConfig({ scope });
1041
1052
  res.json(data);
1042
1053
  } catch (err) {
1043
- res.status(500).json({ error: err.message });
1054
+ res.status(400).json({ error: err.message });
1044
1055
  }
1045
1056
  });
1046
1057
 
1047
1058
  /**
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.
1059
+ * PUT /api/config — apply a patch to the yml. Body is
1060
+ * `{ patch: { key: value }, scope?: 'global'|'project' }` with the keys
1061
+ * defined in EDITABLE_FIELDS. Unknown keys are 400'd; type errors are
1062
+ * 400'd; validation errors are 400'd. Writes are atomic (tmp + rename)
1063
+ * and a `.bak` is kept of the previous content.
1064
+ *
1065
+ * v2.30.0 PR4 — `scope` decides which file is patched. Default 'global'
1066
+ * preserves the previous behavior.
1052
1067
  */
1053
1068
  router.put('/config', (req, res) => {
1054
1069
  try {
1055
1070
  const patch = req.body?.patch;
1056
- const result = writeConfigPatch(patch);
1071
+ const scope = req.body?.scope || 'global';
1072
+ const result = writeConfigPatch(patch, { scope });
1057
1073
  if (!result.written) {
1058
1074
  return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
1059
1075
  }
@@ -14,6 +14,36 @@ import {
14
14
  removeTombstone,
15
15
  } from './db.js';
16
16
  import { publish as publishEvent } from './event-bus.js';
17
+ import { getSharedPlansDir, SHARED_DIR_NAME, isSharedPath } from '../../../src/utils/shared-paths.js';
18
+ import { readConfig } from './config-yaml.js';
19
+
20
+ /**
21
+ * KJC-PRP-0002 PR5: resolve the conflict policy that decides who wins
22
+ * when the same planId is present in both the per-user cache
23
+ * (~/.karajan/plans/<slug>/) and the shared dir (.karajan-shared/plans/).
24
+ *
25
+ * Implementation is "order of last write": `syncPlanFile` upserts by
26
+ * planId, so whichever scan runs second overwrites whatever the first
27
+ * one wrote. We just swap the visit order based on policy.
28
+ *
29
+ * shared-wins (default) → per-user first, shared second
30
+ * local-wins → shared first, per-user second
31
+ *
32
+ * Reads from kj.config.yml live; absent / malformed config falls back to
33
+ * the safe team default. Project scope wins over global scope (same
34
+ * precedence the rest of the config UI honours).
35
+ */
36
+ function getSharedConflictPolicy() {
37
+ const pluck = (cfg) => cfg?.raw?.huBoard?.sharedConflictPolicy;
38
+ const valid = (v) => v === 'local-wins' || v === 'shared-wins';
39
+ try {
40
+ const projectCfg = readConfig({ scope: 'project' });
41
+ if (valid(pluck(projectCfg))) return pluck(projectCfg);
42
+ const globalCfg = readConfig({ scope: 'global' });
43
+ if (valid(pluck(globalCfg))) return pluck(globalCfg);
44
+ } catch { /* swallow — config UI handles surfacing errors */ }
45
+ return 'shared-wins';
46
+ }
17
47
 
18
48
  /**
19
49
  * Skip + best-effort fs cleanup when the resource is tombstoned. Returns
@@ -381,11 +411,17 @@ export function syncPlanFile(filePath) {
381
411
  || slugToTitle(data.task || '')
382
412
  || projectId;
383
413
 
414
+ // KJC-PRP-0002 PR3: stamp `is_shared` from the path (authoritative — works
415
+ // even for legacy plan files without the `shared:true` envelope marker).
416
+ // OR with existing value so a single shared plan promotes the project,
417
+ // while unsharing the LAST shared plan still leaves the flag set (we
418
+ // clear it lazily only when the user explicitly tombstones / re-creates).
384
419
  upsertProject({
385
420
  id: projectId,
386
421
  name: projectName,
387
422
  last_activity: data.updatedAt || data.createdAt || new Date().toISOString(),
388
423
  total_stories: data.hus.length,
424
+ is_shared: isSharedPath(filePath) || data.shared === true ? 1 : undefined,
389
425
  });
390
426
 
391
427
  for (let i = 0; i < data.hus.length; i += 1) {
@@ -468,6 +504,10 @@ export function syncPlanFile(filePath) {
468
504
  reviewer_model: hu.reviewer_model || null,
469
505
  coder_provider: hu.coder_provider || null,
470
506
  reviewer_provider: hu.reviewer_provider || null,
507
+ // KJC-PRP-0002 PR6: optional handle of the owner of this HU
508
+ // (free-form string). Only surfaced in UI when project is_shared,
509
+ // but stored unconditionally so values survive share/unshare flips.
510
+ assignee: typeof hu.assignee === 'string' && hu.assignee.trim() ? hu.assignee.trim() : null,
471
511
  });
472
512
  }
473
513
 
@@ -527,32 +567,72 @@ export function fullScan() {
527
567
  }
528
568
  }
529
569
 
530
- // Scan v2 plans (`kj plan` writes to `~/.karajan/plans/<slug>/` after
531
- // KJC-PCS-0047 PR 3). `KJ_PLANS_DIR` keeps overriding for test
532
- // isolation. A second pass picks up the legacy `~/.kj/plans/`
533
- // location for users whose CLI hasn't yet run the migrator (e.g.
534
- // the board was started before any `kj` command on a fresh upgrade).
535
- const kjDir = process.env.KJ_PLANS_DIR || join(homedir(), '.karajan', 'plans');
536
- const legacyKjDir = process.env.KJ_PLANS_DIR ? null : join(homedir(), '.kj', 'plans');
537
- for (const root of [kjDir, legacyKjDir]) {
538
- if (!root || !existsSync(root)) continue;
539
- const projectDirs = readdirSync(root);
540
- for (const projDir of projectDirs) {
541
- const projPath = join(root, projDir);
542
- try {
543
- const files = readdirSync(projPath);
544
- for (const file of files) {
545
- if (file.startsWith('plan-') && file.endsWith('.json')) {
546
- syncPlanFile(join(projPath, file));
570
+ // KJC-PRP-0002 PR5: order plans scans by conflict policy. `syncPlanFile`
571
+ // upserts by planId, so whichever pass runs LAST wins. Default
572
+ // shared-wins matches what teams expect (the committed copy is canonical);
573
+ // local-wins is the escape hatch for a solo dev still iterating before
574
+ // publishing. Same projectDir scan, only the order changes.
575
+ const scanPerUserPlans = () => {
576
+ const kjDir = process.env.KJ_PLANS_DIR || join(homedir(), '.karajan', 'plans');
577
+ const legacyKjDir = process.env.KJ_PLANS_DIR ? null : join(homedir(), '.kj', 'plans');
578
+ for (const root of [kjDir, legacyKjDir]) {
579
+ if (!root || !existsSync(root)) continue;
580
+ const projectDirs = readdirSync(root);
581
+ for (const projDir of projectDirs) {
582
+ const projPath = join(root, projDir);
583
+ try {
584
+ const files = readdirSync(projPath);
585
+ for (const file of files) {
586
+ if (file.startsWith('plan-') && file.endsWith('.json')) {
587
+ syncPlanFile(join(projPath, file));
588
+ }
547
589
  }
548
- }
549
- } catch { /* skip */ }
590
+ } catch { /* skip */ }
591
+ }
550
592
  }
593
+ };
594
+
595
+ if (getSharedConflictPolicy() === 'local-wins') {
596
+ scanSharedPlans();
597
+ scanPerUserPlans();
598
+ } else {
599
+ scanPerUserPlans();
600
+ scanSharedPlans();
551
601
  }
552
602
 
553
603
  console.log('[sync] Full scan completed');
554
604
  }
555
605
 
606
+ /**
607
+ * Scans `<projectDir>/.karajan-shared/plans/` for every known projectDir
608
+ * and feeds each plan into `syncPlanFile`. Discovery sources:
609
+ * 1. `process.cwd()` — covers the canonical case of running `kj board start`
610
+ * from a project root with a freshly cloned `.karajan-shared/` and zero
611
+ * local plans yet.
612
+ * 2. `KJ_SHARED_PROJECT_DIRS` env var (colon-separated paths) — escape
613
+ * hatch for users running the board outside the project root or
614
+ * tracking several repos with one board instance.
615
+ */
616
+ function scanSharedPlans() {
617
+ const dirs = new Set([process.cwd()]);
618
+ const extra = process.env.KJ_SHARED_PROJECT_DIRS;
619
+ if (extra) {
620
+ for (const p of extra.split(':')) if (p) dirs.add(p);
621
+ }
622
+ for (const projectDir of dirs) {
623
+ const sharedDir = getSharedPlansDir(projectDir);
624
+ if (!existsSync(sharedDir)) continue;
625
+ try {
626
+ const files = readdirSync(sharedDir);
627
+ for (const file of files) {
628
+ if (file.startsWith('plan-') && file.endsWith('.json')) {
629
+ syncPlanFile(join(sharedDir, file));
630
+ }
631
+ }
632
+ } catch { /* skip */ }
633
+ }
634
+ }
635
+
556
636
  /**
557
637
  * Starts the file watcher for live sync.
558
638
  * @returns {import('chokidar').FSWatcher}
@@ -576,6 +656,17 @@ export function startWatcher() {
576
656
 
577
657
  const watchTargets = [storiesGlob, sessionsGlob, plansGlob, promptsGlob];
578
658
  if (legacyPlansGlob) watchTargets.push(legacyPlansGlob);
659
+ // KJC-PRP-0002 PR2: live-watch shared plans for the board's cwd + any
660
+ // dir listed in KJ_SHARED_PROJECT_DIRS. Without this, `kj plan share`
661
+ // updates would only land via the next fullScan / manual 🔄.
662
+ const sharedDirs = new Set([process.cwd()]);
663
+ const extraShared = process.env.KJ_SHARED_PROJECT_DIRS;
664
+ if (extraShared) {
665
+ for (const p of extraShared.split(':')) if (p) sharedDirs.add(p);
666
+ }
667
+ for (const d of sharedDirs) {
668
+ watchTargets.push(join(d, SHARED_DIR_NAME, 'plans', 'plan-*.json'));
669
+ }
579
670
  const watcher = watch(watchTargets, {
580
671
  ignoreInitial: true,
581
672
  awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
@@ -585,6 +676,9 @@ export function startWatcher() {
585
676
  if (p.includes('hu-stories')) syncStoryFile(p);
586
677
  else if (p.includes('sessions')) syncSessionFile(p);
587
678
  else if (p.includes(`${plansRoot}${plansRoot.endsWith('/') ? '' : '/'}`) || p.includes('/plans/')) {
679
+ // Catches both per-user `~/.karajan/plans/<slug>/plan-*.json` and
680
+ // team-shared `<projectDir>/.karajan-shared/plans/plan-*.json` — the
681
+ // path-segment match `/plans/` is the same for both.
588
682
  syncPlanFile(p);
589
683
  } else if (p.includes('/prompts/')) {
590
684
  // Prompt files are RPC packets the runner writes when it needs
@@ -9,6 +9,8 @@ import {
9
9
  planRemoveHuCommand,
10
10
  planMigrateResultCommand,
11
11
  planFixCommand,
12
+ planShareCommand,
13
+ planUnshareCommand,
12
14
  } from "../commands/plan.js";
13
15
  import { parseCanvasMarkdown } from "../canvas/parse-md.js";
14
16
  import { validateCanvas } from "../canvas/validate.js";
@@ -140,6 +142,33 @@ export function registerPlan(program, { pkgVersion }) {
140
142
  });
141
143
  });
142
144
 
145
+ // KJC-PRP-0002 / v2.31.0 PR1: promote a local plan into the project's
146
+ // `.karajan-shared/plans/` so teammates picking up the repo (or the same
147
+ // dev on another machine) see it in their HU Board without re-running
148
+ // the planner. The shared dir is meant to be committed to git.
149
+ plan.command("share")
150
+ .description("Promote a local plan to <projectDir>/.karajan-shared/ so the team can see it")
151
+ .argument("<planId>")
152
+ .option("--only <ids>", "Comma-separated HU IDs to include (share a subset)")
153
+ .option("--exclude <ids>", "Comma-separated HU IDs to omit from the shared copy")
154
+ .action(async (planId, flags) => {
155
+ await withConfig(pkgVersion, "plan", flags, async ({ config, logger }) => {
156
+ await planShareCommand({ config, planId, logger, only: flags.only, exclude: flags.exclude });
157
+ });
158
+ });
159
+
160
+ // KJC-PRP-0002 / v2.31.0 PR3: inverse of `kj plan share`. Removes the plan
161
+ // from `.karajan-shared/plans/` but keeps the per-user copy intact, so the
162
+ // dev still has the plan locally while the team stops seeing it.
163
+ plan.command("unshare")
164
+ .description("Remove a plan from <projectDir>/.karajan-shared/ (keeps local copy)")
165
+ .argument("<planId>")
166
+ .action(async (planId, flags) => {
167
+ await withConfig(pkgVersion, "plan", flags, async ({ config, logger }) => {
168
+ await planUnshareCommand({ config, planId, logger });
169
+ });
170
+ });
171
+
143
172
  plan.command("add-hu").description("Add HU to plan").argument("<planId>")
144
173
  .option("--title <text>", "HU title")
145
174
  .option("--type <type>", "Task type: sw|infra|doc|add-tests|refactor|nocode", "sw")
@@ -9,3 +9,5 @@ export { planAddHuCommand } from "./add-hu.js";
9
9
  export { planRemoveHuCommand } from "./remove-hu.js";
10
10
  export { planMigrateResultCommand } from "./migrate-result.js";
11
11
  export { planFixCommand } from "./fix.js";
12
+ export { planShareCommand } from "./share.js";
13
+ export { planUnshareCommand } from "./unshare.js";
@@ -0,0 +1,85 @@
1
+ /**
2
+ * kj plan share <planId>
3
+ *
4
+ * Promotes a local plan (under `~/.karajan/plans/<projectSlug>/`) to the
5
+ * project's shared dir (`<projectDir>/.karajan-shared/plans/`) so teammates
6
+ * pulling the repo see the same HUs in their HU Board. KJC-PRP-0002 / PR1.
7
+ */
8
+
9
+ import fs from "node:fs/promises";
10
+ import path from "node:path";
11
+ import { writeJsonAtomic } from "../../utils/atomic-write.js";
12
+ import { getSharedPlansDir } from "../../utils/shared-paths.js";
13
+ import { loadPlan } from "../../plan/plan-store.js";
14
+
15
+ function parseIdList(raw) {
16
+ if (!raw) return null;
17
+ const ids = String(raw).split(",").map((s) => s.trim()).filter(Boolean);
18
+ return ids.length ? ids : null;
19
+ }
20
+
21
+ export async function planShareCommand({ config, planId, logger, only, exclude }) {
22
+ const log = logger || console;
23
+ const projectDir = config.projectDir || process.cwd();
24
+
25
+ if (!planId) {
26
+ log.error?.("kj plan share <planId> — planId is required");
27
+ process.exitCode = 1;
28
+ return;
29
+ }
30
+
31
+ const onlyIds = parseIdList(only);
32
+ const excludeIds = parseIdList(exclude);
33
+ if (onlyIds && excludeIds) {
34
+ log.error?.("kj plan share — --only and --exclude are mutually exclusive");
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+
39
+ const plan = await loadPlan(projectDir, planId);
40
+ if (!plan) {
41
+ log.error?.(`Plan not found: ${planId}`);
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+
46
+ // KJC-PRP-0002 PR4: optional per-HU filter. Lets a dev share a curated
47
+ // subset of HUs without splitting the plan or hand-editing the JSON.
48
+ let hus = plan.hus || [];
49
+ let filterSummary = "";
50
+ if (onlyIds) {
51
+ hus = hus.filter((h) => onlyIds.includes(h.id));
52
+ filterSummary = ` [only: ${onlyIds.join(",")}]`;
53
+ } else if (excludeIds) {
54
+ hus = hus.filter((h) => !excludeIds.includes(h.id));
55
+ filterSummary = ` [excluding: ${excludeIds.join(",")}]`;
56
+ }
57
+ if ((onlyIds || excludeIds) && hus.length === 0) {
58
+ log.error?.(`No HUs left after applying filter — aborting share.`);
59
+ process.exitCode = 1;
60
+ return;
61
+ }
62
+
63
+ const sharedDir = getSharedPlansDir(projectDir);
64
+ await fs.mkdir(sharedDir, { recursive: true });
65
+ const targetFile = path.join(sharedDir, `${plan.planId}.json`);
66
+
67
+ // Stamp a `shared: true` marker so consumers (board sync, audit, etc.)
68
+ // can tell promoted copies from local ones without inspecting the path.
69
+ const sharedPlan = {
70
+ ...plan,
71
+ hus,
72
+ shared: true,
73
+ sharedAt: new Date().toISOString(),
74
+ };
75
+ if (onlyIds || excludeIds) {
76
+ sharedPlan.sharedFilter = onlyIds
77
+ ? { only: onlyIds }
78
+ : { exclude: excludeIds };
79
+ }
80
+ await writeJsonAtomic(targetFile, sharedPlan);
81
+
82
+ const rel = path.relative(projectDir, targetFile);
83
+ log.info?.(`✓ Shared plan ${plan.planId} → ${rel}${filterSummary}`);
84
+ log.info?.(` Commit '.karajan-shared/' so your team picks it up.`);
85
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * kj plan unshare <planId>
3
+ *
4
+ * Inverse of `kj plan share`. Removes the plan from the project's shared dir
5
+ * (`<projectDir>/.karajan-shared/plans/`). The per-user copy at
6
+ * `~/.karajan/plans/<projectSlug>/<planId>.json` stays untouched, so the dev
7
+ * keeps the plan locally while the team stops seeing it. KJC-PRP-0002 / PR3.
8
+ */
9
+
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { getSharedPlansDir } from "../../utils/shared-paths.js";
13
+
14
+ export async function planUnshareCommand({ config, planId, logger }) {
15
+ const log = logger || console;
16
+ const projectDir = config.projectDir || process.cwd();
17
+
18
+ if (!planId) {
19
+ log.error?.("kj plan unshare <planId> — planId is required");
20
+ process.exitCode = 1;
21
+ return;
22
+ }
23
+
24
+ const sharedDir = getSharedPlansDir(projectDir);
25
+ const targetFile = path.join(sharedDir, `${planId}.json`);
26
+
27
+ try {
28
+ await fs.unlink(targetFile);
29
+ } catch (err) {
30
+ if (err.code === "ENOENT") {
31
+ log.error?.(`Plan not shared: ${planId} (no file at .karajan-shared/plans/)`);
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ throw err;
36
+ }
37
+
38
+ const rel = path.relative(projectDir, targetFile);
39
+ log.info?.(`✓ Unshared plan ${planId} (removed ${rel})`);
40
+ log.info?.(` Commit '.karajan-shared/' so your team stops seeing it.`);
41
+ }
@@ -12,4 +12,6 @@ export {
12
12
  planRemoveHuCommand,
13
13
  planMigrateResultCommand,
14
14
  planFixCommand,
15
+ planShareCommand,
16
+ planUnshareCommand,
15
17
  } from "./plan/index.js";
@@ -68,6 +68,12 @@ export function addHu(plan, huData) {
68
68
  coder_provider: huData.coder_provider ?? null,
69
69
  reviewer_model: huData.reviewer_model ?? null,
70
70
  reviewer_provider: huData.reviewer_provider ?? null,
71
+ // KJC-PRP-0002 PR6: optional handle of the human (or AI dev_XXX) who
72
+ // owns this HU in a team-shared board. null = unassigned. Free-form
73
+ // string — the board prints it as-is; no entity table is enforced
74
+ // because team rosters are out of scope (and would force every solo
75
+ // dev to register themselves).
76
+ assignee: huData.assignee ?? null,
71
77
  createdAt: new Date().toISOString(),
72
78
  updatedAt: new Date().toISOString()
73
79
  };
@@ -106,7 +112,7 @@ export function updateHu(plan, huId, patch) {
106
112
  // KJC-TSK-0405: coder_model y reviewer_model son override per-HU del
107
113
  // routing automático del triage. Independientes — el usuario puede
108
114
  // subir el reviewer sin tocar el coder, o viceversa.
109
- const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "reuse", "spec_section", "result", "short_id", "coder_model", "reviewer_model", "coder_provider", "reviewer_provider"];
115
+ const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "reuse", "spec_section", "result", "short_id", "coder_model", "reviewer_model", "coder_provider", "reviewer_provider", "assignee"];
110
116
  for (const key of allowed) {
111
117
  if (patch[key] !== undefined) hu[key] = patch[key];
112
118
  }
@@ -8,6 +8,7 @@ import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import { writeJsonAtomic } from "../utils/atomic-write.js";
10
10
  import { resolveHome } from "../utils/paths.js";
11
+ import { getSharedPlansDir } from "../utils/shared-paths.js";
11
12
  import { generatePlanId, normaliseAlias } from "./plan-id.js";
12
13
 
13
14
  /**
@@ -154,7 +155,15 @@ async function resolveUniqueAlias(dir, ownPlanId, base) {
154
155
  */
155
156
  export async function loadPlan(projectDir, ref) {
156
157
  if (!ref) return null;
157
- const dir = plansDir(projectDir);
158
+ // Local plans take precedence — if a teammate's shared copy and your
159
+ // working copy share a planId, your local edits win until you re-share.
160
+ const local = await loadPlanFromDir(plansDir(projectDir), ref);
161
+ if (local) return local;
162
+ // Fallback: shared plans committed by a teammate. KJC-PRP-0002 PR1.
163
+ return loadPlanFromDir(getSharedPlansDir(projectDir), ref);
164
+ }
165
+
166
+ async function loadPlanFromDir(dir, ref) {
158
167
  // 1. Match exacto por planId.json (camino caliente).
159
168
  const direct = path.join(dir, `${ref}.json`);
160
169
  try {
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Team-shared HU Board paths — KJC-PRP-0002 / v2.31.0 PR1.
3
+ *
4
+ * The shared root lives INSIDE the project, not in `~/.karajan/`, so a team
5
+ * can commit it to git (or sync via Drive/SyncThing) and share plans + HU
6
+ * stories without each member regenerating them locally.
7
+ *
8
+ * <projectDir>/.karajan-shared/
9
+ * plans/<planId>.json ← `kj plan share <planId>` copies here
10
+ * hu-stories/<huId>.md ← reserved for PR2+ (board reads it)
11
+ *
12
+ * Keep the dir name in lockstep with the HU Board sync layer
13
+ * (`packages/hu-board/src/sync.js`) — diverging the constant breaks the
14
+ * watcher silently. One source of truth lives here.
15
+ */
16
+
17
+ import path from "node:path";
18
+
19
+ export const SHARED_DIR_NAME = ".karajan-shared";
20
+
21
+ export function getSharedRoot(projectDir) {
22
+ return path.join(projectDir, SHARED_DIR_NAME);
23
+ }
24
+
25
+ export function getSharedPlansDir(projectDir) {
26
+ return path.join(getSharedRoot(projectDir), "plans");
27
+ }
28
+
29
+ export function getSharedHuStoriesDir(projectDir) {
30
+ return path.join(getSharedRoot(projectDir), "hu-stories");
31
+ }
32
+
33
+ /**
34
+ * Quick check used by callers that decide whether a plan came from local
35
+ * (`~/.karajan/plans/`) or from the shared dir. The HU Board uses this to
36
+ * stamp a `source` column on each record so the UI can render a 🌐 badge.
37
+ */
38
+ export function isSharedPath(p) {
39
+ if (!p) return false;
40
+ return p.split(path.sep).includes(SHARED_DIR_NAME);
41
+ }