karajan-code 2.30.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,14 @@ 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
+ >
348
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.
349
357
  >
350
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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.30.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>
@@ -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);
@@ -79,6 +79,11 @@ export const CATEGORIES = [
79
79
  { id: 'rag', label: 'RAG (búsqueda de contexto)', icon: '📚', order: 3 },
80
80
  { id: 'session', label: 'Tiempos de sesión', icon: '⏱', order: 4 },
81
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 },
82
87
  ];
83
88
 
84
89
  /**
@@ -275,6 +280,22 @@ export const EDITABLE_FIELDS = [
275
280
  options: ['ollama', 'openai', 'voyage', 'cohere', 'mistral', 'onnx'],
276
281
  default: 'ollama',
277
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
+ },
278
299
  ];
279
300
 
280
301
  const ROLES_FOR_MODEL_MODE = [
@@ -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 {
@@ -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
+ }