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 +15 -0
- package/package.json +1 -1
- package/packages/hu-board/public/app.js +123 -6
- package/packages/hu-board/public/styles.css +9 -0
- package/packages/hu-board/src/config-yaml.js +211 -13
- package/packages/hu-board/src/db.js +24 -6
- package/packages/hu-board/src/routes/api.js +26 -10
- package/packages/hu-board/src/sync.js +113 -19
- package/src/cli/register-plan.js +29 -0
- package/src/commands/plan/index.js +2 -0
- package/src/commands/plan/share.js +85 -0
- package/src/commands/plan/unshare.js +41 -0
- package/src/commands/plan.js +2 -0
- package/src/plan/plan-hu-ops.js +7 -1
- package/src/plan/plan-store.js +10 -1
- package/src/utils/shared-paths.js +41 -0
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
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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', (
|
|
1048
|
+
router.get('/config', (req, res) => {
|
|
1039
1049
|
try {
|
|
1040
|
-
const
|
|
1050
|
+
const scope = req.query?.scope || 'global';
|
|
1051
|
+
const data = readConfig({ scope });
|
|
1041
1052
|
res.json(data);
|
|
1042
1053
|
} catch (err) {
|
|
1043
|
-
res.status(
|
|
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
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
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
|
|
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
|
-
//
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
//
|
|
534
|
-
//
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
}
|
|
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
|
package/src/cli/register-plan.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/plan.js
CHANGED
package/src/plan/plan-hu-ops.js
CHANGED
|
@@ -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
|
}
|
package/src/plan/plan-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|