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 +8 -0
- package/package.json +1 -1
- package/packages/hu-board/public/app.js +53 -1
- package/packages/hu-board/public/styles.css +9 -0
- package/packages/hu-board/src/config-yaml.js +21 -0
- package/packages/hu-board/src/db.js +24 -6
- package/packages/hu-board/src/routes/api.js +5 -1
- 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,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
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|