karajan-code 2.30.0 → 2.32.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 +17 -0
- package/package.json +6 -1
- package/packages/hu-board/public/app.js +57 -2
- package/packages/hu-board/public/styles.css +9 -0
- package/packages/hu-board/src/config-yaml.js +61 -5
- package/packages/hu-board/src/db.js +24 -6
- package/packages/hu-board/src/routes/api.js +5 -1
- package/packages/hu-board/src/routes/rag.js +8 -2
- package/packages/hu-board/src/sync.js +113 -19
- package/scripts/ai-attribution-guard.yml +9 -2
- package/src/cli/register-meta.js +12 -2
- 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/orchestrator/stages/rag-context-stage.js +20 -2
- package/src/plan/plan-hu-ops.js +7 -1
- package/src/plan/plan-store.js +10 -1
- package/src/spec-review/refine-loop.js +1 -1
- package/src/utils/shared-paths.js +41 -0
package/README.md
CHANGED
|
@@ -345,6 +345,23 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
+
> **v2.32.0 released** — Minor. **AI Harness Scorecard hardening (KJC-PCS-0051)** — Plan A closes five FAILs from the external scorecard audit in one sprint: Prettier `--check`, Coverage v8 reports, Conventional Commits enforcement, nightly drift detection, and an unsafe-code lint policy. Plus two bug fixes shipped alongside.
|
|
349
|
+
>
|
|
350
|
+
> - **Prettier `--check` CI job** (PR #868, KJC-TSK-0464): new `format` job blocks PRs whose formatting drifts from `.prettierrc.json`. Scope narrow at first (`.github/workflows/`, root config); future PRs fold in more dirs under the shrink-budget cap.
|
|
351
|
+
> - **Coverage v8 + CI artifact** (PR #870, KJC-TSK-0465): `vitest.config.js` emits `text + html + lcov` via `@vitest/coverage-v8`. New `coverage` job uploads `coverage/` (14-day retention). Per-glob thresholds enforced when the user opts in; `src/mcp/handlers/**` floor ratcheted to `70/60` to lock the current state — follow-up tracked to climb back to 80/80.
|
|
352
|
+
> - **Conventional Commits on PR head** (PR #872, KJC-TSK-0466): `wagoid/commitlint-github-action@v6` checks every PR commit against `.commitlintrc.json`. CI-side enforcement on top of the pre-commit local hook — bypassing husky no longer escapes the gate.
|
|
353
|
+
> - **Nightly drift workflow** (PR #873, KJC-TSK-0467): new `.github/workflows/nightly.yml` runs the full CI suite every night at 04:17 UTC against `main`. Failures auto-file/update a tracking issue tagged `drift` via `actions/github-script@v8`, so a flaky dep or upstream regression surfaces within 24 h instead of on the next unrelated PR.
|
|
354
|
+
> - **`eslint-plugin-security` policy** (PR #874, KJC-TSK-0468): `eslint.config.js` now blocks `eval`, `new Function`, dynamic `require`, `pseudoRandomBytes` and `mustache`-escape disabling as hard errors; flags `detect-non-literal-regexp` as warn. Noisy members of the recommended preset are intentionally NOT enabled.
|
|
355
|
+
> - **42 tests on `main` repaired** (PR #869, KJC-BUG-0065) and **`await openEditor` race in spec-reviewer refine-loop fixed** (PR #871, KJC-BUG-0066). The hardening sprint sits on a clean `main` again.
|
|
356
|
+
>
|
|
357
|
+
> **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.
|
|
358
|
+
>
|
|
359
|
+
> - **`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.
|
|
360
|
+
> - **`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.
|
|
361
|
+
> - **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.
|
|
362
|
+
> - **`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`.
|
|
363
|
+
> - **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.
|
|
364
|
+
>
|
|
348
365
|
> **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
366
|
>
|
|
350
367
|
> - **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.
|
|
3
|
+
"version": "2.32.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",
|
|
@@ -55,9 +55,12 @@
|
|
|
55
55
|
"setup": "node scripts/install.js",
|
|
56
56
|
"test": "vitest run",
|
|
57
57
|
"test:watch": "vitest",
|
|
58
|
+
"test:coverage": "vitest run --coverage",
|
|
58
59
|
"lint": "eslint src/",
|
|
59
60
|
"lint:fix": "eslint src/ --fix",
|
|
60
61
|
"lint:syntax": "((command -v rg >/dev/null 2>&1 && rg --files src tests | rg '\\.js$') || find src tests -type f -name '*.js') | while IFS= read -r f; do node --check \"$f\"; done",
|
|
62
|
+
"format:check": "prettier --check .",
|
|
63
|
+
"format:fix": "prettier --write .",
|
|
61
64
|
"typecheck": "npx -p typescript@5 tsc --noEmit -p tsconfig.json",
|
|
62
65
|
"validate": "npm run lint && npm run lint:syntax && npm test",
|
|
63
66
|
"mcp": "node src/mcp/server.js"
|
|
@@ -90,9 +93,11 @@
|
|
|
90
93
|
"esbuild": "^0.28.0",
|
|
91
94
|
"eslint": "^10.2.1",
|
|
92
95
|
"eslint-plugin-import-x": "^4.16.2",
|
|
96
|
+
"eslint-plugin-security": "^4.0.0",
|
|
93
97
|
"globals": "^17.5.0",
|
|
94
98
|
"lint-staged": "^16.4.0",
|
|
95
99
|
"postject": "^1.0.0-alpha.6",
|
|
100
|
+
"prettier": "^3.4.2",
|
|
96
101
|
"simple-git-hooks": "^2.13.1",
|
|
97
102
|
"vitest": "^4.0.0"
|
|
98
103
|
}
|
|
@@ -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>
|
|
@@ -3300,7 +3352,10 @@ async function showConfigEditor(scope = 'global') {
|
|
|
3300
3352
|
<span style="font-size:0.85rem;color:var(--text-muted);">${f.value ? 'activado' : 'desactivado'}</span>
|
|
3301
3353
|
</label>`;
|
|
3302
3354
|
} else if (f.type === 'number') {
|
|
3303
|
-
|
|
3355
|
+
// KJC-BUG-0069 — honour the schema's `step` (e.g. 'any' for floats like
|
|
3356
|
+
// rag.search.alpha=0.6). HTML5 number inputs default to step=1, which
|
|
3357
|
+
// marks any fractional value as stepMismatch and refuses to commit.
|
|
3358
|
+
input = `<input type="number" id="${id}" data-key="${esc(f.key)}" value="${esc(String(f.value))}"${f.min != null ? ` min="${f.min}"` : ''}${f.max != null ? ` max="${f.max}"` : ''}${f.step != null ? ` step="${esc(String(f.step))}"` : ''} style="width:120px;padding:6px 8px;background:var(--bg-primary);border:1px solid var(--border);color:var(--text);border-radius:var(--radius-sm);">`;
|
|
3304
3359
|
}
|
|
3305
3360
|
return `
|
|
3306
3361
|
<div style="margin-bottom:14px;">
|
|
@@ -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
|
/**
|
|
@@ -230,11 +235,10 @@ export const EDITABLE_FIELDS = [
|
|
|
230
235
|
help: 'Orquestador IA central: enriquece feedback, comprime outputs y verifica cambios reales. Opt-in.',
|
|
231
236
|
default: false,
|
|
232
237
|
},
|
|
233
|
-
// v2.30.0 — RAG controls.
|
|
234
|
-
//
|
|
235
|
-
// rag.embedder.provider
|
|
236
|
-
//
|
|
237
|
-
// aquí (ver task #99).
|
|
238
|
+
// v2.30.0 — RAG controls. Cubren los tres ejes que afectan al
|
|
239
|
+
// retrieval en tiempo de pipeline: pre-carga (rag.preload.*),
|
|
240
|
+
// proveedor de embeddings (rag.embedder.provider) y parámetros de
|
|
241
|
+
// búsqueda (rag.search.*, cableados al stage en KJC-TSK-0463).
|
|
238
242
|
{
|
|
239
243
|
key: 'ragPreloadEnabled',
|
|
240
244
|
path: 'rag.preload.enabled',
|
|
@@ -275,6 +279,58 @@ export const EDITABLE_FIELDS = [
|
|
|
275
279
|
options: ['ollama', 'openai', 'voyage', 'cohere', 'mistral', 'onnx'],
|
|
276
280
|
default: 'ollama',
|
|
277
281
|
},
|
|
282
|
+
// KJC-TSK-0463 — search-time knobs ahora cableados al stage.
|
|
283
|
+
// mode/alpha controlan el fuse semantic vs keyword; rerank activa el
|
|
284
|
+
// cross-encoder opcional (más calidad, más latencia).
|
|
285
|
+
{
|
|
286
|
+
key: 'ragSearchMode',
|
|
287
|
+
path: 'rag.search.mode',
|
|
288
|
+
category: 'rag',
|
|
289
|
+
type: 'select',
|
|
290
|
+
label: 'Modo de búsqueda RAG',
|
|
291
|
+
help: '"hybrid" mezcla semántico + BM25 (recomendado). "semantic" sólo embeddings. "keyword" sólo BM25 (útil si los embeddings están desafinados).',
|
|
292
|
+
options: ['hybrid', 'semantic', 'keyword'],
|
|
293
|
+
default: 'hybrid',
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
key: 'ragSearchAlpha',
|
|
297
|
+
path: 'rag.search.alpha',
|
|
298
|
+
category: 'rag',
|
|
299
|
+
type: 'number',
|
|
300
|
+
label: 'Alpha del modo hybrid (0-1)',
|
|
301
|
+
help: 'Peso del scoring semántico vs keyword en modo hybrid. 1.0 = sólo semántico, 0.0 = sólo BM25, 0.6 (default) = inclinado a semántico.',
|
|
302
|
+
min: 0,
|
|
303
|
+
max: 1,
|
|
304
|
+
// KJC-BUG-0069 — number input defaults to step=1 (HTML5), so any
|
|
305
|
+
// non-integer default fails validity. Allow any fractional value here.
|
|
306
|
+
step: 'any',
|
|
307
|
+
default: 0.6,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
key: 'ragSearchRerank',
|
|
311
|
+
path: 'rag.search.rerank',
|
|
312
|
+
category: 'rag',
|
|
313
|
+
type: 'boolean',
|
|
314
|
+
label: 'Activar rerank cross-encoder',
|
|
315
|
+
help: 'Re-puntúa los topK supervivientes con un cross-encoder. Mejora calidad pero añade latencia y requiere modelo extra. Opt-in.',
|
|
316
|
+
default: false,
|
|
317
|
+
},
|
|
318
|
+
// KJC-PRP-0002 PR5: when the board scans plans, it visits both
|
|
319
|
+
// ~/.karajan/plans/<slug>/ (per-user cache) and .karajan-shared/plans/
|
|
320
|
+
// (team copy). If a planId lives in both, the second pass overwrites
|
|
321
|
+
// the first. Before this knob the order was hard-coded shared-wins
|
|
322
|
+
// (per-user → shared) — fine in a team but unsafe for a solo dev who
|
|
323
|
+
// forgets a stale `.karajan-shared/` exists in the repo.
|
|
324
|
+
{
|
|
325
|
+
key: 'huBoardSharedConflictPolicy',
|
|
326
|
+
path: 'huBoard.sharedConflictPolicy',
|
|
327
|
+
category: 'huBoard',
|
|
328
|
+
type: 'select',
|
|
329
|
+
label: 'Política ante plan compartido y local',
|
|
330
|
+
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.',
|
|
331
|
+
options: ['shared-wins', 'local-wins'],
|
|
332
|
+
default: 'shared-wins',
|
|
333
|
+
},
|
|
278
334
|
];
|
|
279
335
|
|
|
280
336
|
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 {
|
|
@@ -24,12 +24,18 @@ router.get("/stats", (_req, res) => {
|
|
|
24
24
|
if (!existsSync(path)) return res.json({ ok: true, initialized: false, message: "rag.db not initialized — run `kj rag index <dir>` to start indexing.", embedder: embedder() });
|
|
25
25
|
try {
|
|
26
26
|
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
27
|
+
// KJC-BUG-0068 — readonly handler cannot run the project_slug migration
|
|
28
|
+
// (KJC-TSK-0438). Legacy DBs created pre-v2.27 lack the column; detect
|
|
29
|
+
// and fall back so the dashboard renders instead of crashing with 500.
|
|
30
|
+
const hasProjectSlug = db.prepare("PRAGMA table_info(chunks)").all().some((c) => c.name === "project_slug");
|
|
27
31
|
const total_chunks = db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n;
|
|
28
32
|
const by_kind = db.prepare("SELECT kind, COUNT(*) AS n FROM chunks GROUP BY kind").all();
|
|
29
|
-
const by_project =
|
|
33
|
+
const by_project = hasProjectSlug
|
|
34
|
+
? db.prepare("SELECT COALESCE(project_slug, '(no slug)') AS project, COUNT(*) AS n FROM chunks GROUP BY project_slug ORDER BY n DESC LIMIT 20").all()
|
|
35
|
+
: [];
|
|
30
36
|
const last_indexed = db.prepare("SELECT MAX(created_at) AS ts FROM chunks").get().ts;
|
|
31
37
|
db.close();
|
|
32
|
-
res.json({ ok: true, initialized: true, db_path: path, db_size_bytes: statSync(path).size, total_chunks, by_kind, by_project, last_indexed, embedder: embedder() });
|
|
38
|
+
res.json({ ok: true, initialized: true, db_path: path, db_size_bytes: statSync(path).size, total_chunks, by_kind, by_project, last_indexed, embedder: embedder(), schema_legacy: !hasProjectSlug });
|
|
33
39
|
} catch (err) { res.status(500).json({ ok: false, error: String(err.message || err) }); }
|
|
34
40
|
});
|
|
35
41
|
|
|
@@ -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
|
|
@@ -20,8 +20,12 @@ jobs:
|
|
|
20
20
|
fetch-depth: 0
|
|
21
21
|
|
|
22
22
|
- name: Check PR diff for AI attribution
|
|
23
|
+
# KJC-BUG-0072 — never interpolate ${{ github.* }} directly inside a
|
|
24
|
+
# `run:` block on pull_request workflows. Pin to env vars instead.
|
|
25
|
+
env:
|
|
26
|
+
BASE_REF: ${{ github.base_ref }}
|
|
23
27
|
run: |
|
|
24
|
-
DIFF=$(git diff origin/${
|
|
28
|
+
DIFF=$(git diff "origin/${BASE_REF}...HEAD" \
|
|
25
29
|
-- ':!*.png' ':!*.jpg' ':!*.svg' ':!*.ico' \
|
|
26
30
|
':!*.lock' ':!package-lock.json' ':!yarn.lock' \
|
|
27
31
|
':!*.min.js' ':!*.min.css' \
|
|
@@ -54,8 +58,11 @@ jobs:
|
|
|
54
58
|
echo "AI attribution guard: clean"
|
|
55
59
|
|
|
56
60
|
- name: Check commit messages
|
|
61
|
+
# KJC-BUG-0072 — same hardening as the diff scan: env-pin the ref.
|
|
62
|
+
env:
|
|
63
|
+
BASE_REF: ${{ github.base_ref }}
|
|
57
64
|
run: |
|
|
58
|
-
COMMITS=$(git log --format='%s%n%b' origin/${
|
|
65
|
+
COMMITS=$(git log --format='%s%n%b' "origin/${BASE_REF}...HEAD")
|
|
59
66
|
|
|
60
67
|
COAUTH='Co-Authored-By:.*\b(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)\b'
|
|
61
68
|
ATTR='(Generated|Created|Written|Built|Authored) (by|with|using) .*(Claude|Anthropic|OpenAI|ChatGPT|Copilot|Gemini|Cursor|Windsurf|Codeium)'
|
package/src/cli/register-meta.js
CHANGED
|
@@ -115,15 +115,25 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
115
115
|
.option("--scope <scope>", "plans | code | onboarding | all (default: all)", "all")
|
|
116
116
|
.option("--top-k <n>", "Number of hits to return (default: 5)", "5")
|
|
117
117
|
.option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project. Default: cwd basename")
|
|
118
|
-
|
|
118
|
+
// KJC-BUG-0070 — the global `--mode` flag maps to `review_mode` in
|
|
119
|
+
// src/config/overrides.js. We expose the rag-specific knob under
|
|
120
|
+
// `--rag-mode` to avoid the collision (which crashed the command on
|
|
121
|
+
// first call). Internally we rebind to `flags.mode` so the handler
|
|
122
|
+
// in src/commands/rag.js stays untouched.
|
|
123
|
+
.option("--rag-mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
|
|
119
124
|
.option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
|
|
120
125
|
.option("--where <clause>", "Metadata filter, e.g. 'symbol=loadConfig' or 'hu_id=HU-003 AND kind=plan'")
|
|
121
126
|
.option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
|
|
122
127
|
.option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
|
|
123
128
|
.option("--json", "Output the hits as JSON")
|
|
124
129
|
.action(async (text, flags) => {
|
|
130
|
+
// Stash the rag-specific mode and remove it from the flags object so
|
|
131
|
+
// applyScalarAndBooleanOverrides cannot see a `flags.mode` and write
|
|
132
|
+
// out.review_mode = "hybrid" — that would fail the schema validator.
|
|
133
|
+
const ragMode = flags.ragMode;
|
|
134
|
+
delete flags.ragMode;
|
|
125
135
|
await withConfig(pkgVersion, "rag-query", flags, async ({ config, logger }) => {
|
|
126
|
-
await ragQueryCommand({ text, config, logger, flags });
|
|
136
|
+
await ragQueryCommand({ text, config, logger, flags: { ...flags, mode: ragMode } });
|
|
127
137
|
});
|
|
128
138
|
});
|
|
129
139
|
|
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
|
@@ -11,8 +11,25 @@ import { query } from "../../rag/retriever.js";
|
|
|
11
11
|
|
|
12
12
|
const DEFAULT_TOP_K = 5;
|
|
13
13
|
const DEFAULT_SCOPE = "all";
|
|
14
|
+
const DEFAULT_SEARCH_MODE = "hybrid";
|
|
15
|
+
const DEFAULT_SEARCH_ALPHA = 0.6;
|
|
14
16
|
const MAX_CHUNK_CHARS = 600;
|
|
15
17
|
|
|
18
|
+
// KJC-TSK-0463 — pull search-time knobs from config so the HU Board
|
|
19
|
+
// modal (rag.search.{mode,alpha,rerank}) actually steers retrieval at
|
|
20
|
+
// pipeline time. Returns the args object query() expects; unset fields
|
|
21
|
+
// fall back to the retriever's own defaults.
|
|
22
|
+
export function resolveSearchOpts(config) {
|
|
23
|
+
const s = config?.rag?.search ?? {};
|
|
24
|
+
const out = {
|
|
25
|
+
mode: typeof s.mode === "string" ? s.mode : DEFAULT_SEARCH_MODE,
|
|
26
|
+
alpha: Number.isFinite(s.alpha) ? s.alpha : DEFAULT_SEARCH_ALPHA,
|
|
27
|
+
};
|
|
28
|
+
if (s.rerank && typeof s.rerank === "object") out.rerankOpts = s.rerank;
|
|
29
|
+
else if (s.rerank === true) out.rerankOpts = {};
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
16
33
|
/**
|
|
17
34
|
* Runs the RAG preload stage. Returns:
|
|
18
35
|
* { skipped: true, reason } — when disabled / empty / error
|
|
@@ -36,7 +53,8 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
|
|
|
36
53
|
const topK = preload.topK || DEFAULT_TOP_K;
|
|
37
54
|
const scope = preload.scope || DEFAULT_SCOPE;
|
|
38
55
|
const embedder = makeEmbedder(config);
|
|
39
|
-
const
|
|
56
|
+
const searchOpts = resolveSearchOpts(config);
|
|
57
|
+
const hits = await query(db, embedder, task, { topK, scope, ...searchOpts });
|
|
40
58
|
if (hits.length === 0) return { skipped: true, reason: "no-hits" };
|
|
41
59
|
const lines = hits.map((h) => {
|
|
42
60
|
const label = h.metadata?.hu_id || h.metadata?.symbol || h.metadata?.headingPath?.join(" > ") || "block";
|
|
@@ -46,7 +64,7 @@ export async function runRagContextStage({ config, logger, emitter, eventBase, t
|
|
|
46
64
|
const contextBlock = `## Prior context from RAG (auto-retrieved, top ${hits.length})\n\n${lines.join("\n\n")}`;
|
|
47
65
|
emitProgress(emitter, makeEvent("rag-preload:hits", { ...eventBase, stage: "rag-preload" }, {
|
|
48
66
|
message: `RAG preload: injected ${hits.length} chunk(s) into the task context`,
|
|
49
|
-
detail: { topK, scope, hits: hits.length },
|
|
67
|
+
detail: { topK, scope, hits: hits.length, mode: searchOpts.mode, alpha: searchOpts.alpha, rerank: Boolean(searchOpts.rerankOpts) },
|
|
50
68
|
}));
|
|
51
69
|
return { skipped: false, contextBlock, hits };
|
|
52
70
|
} finally {
|
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 {
|
|
@@ -50,7 +50,7 @@ export async function runRefinePass({ spec, findings, role, projectDir, taskFile
|
|
|
50
50
|
} catch (err) { return { ok: false, error: `persist failed: ${err.message}`, hashChanged: false }; }
|
|
51
51
|
|
|
52
52
|
const before = sha256(refineRes.refinedSpec);
|
|
53
|
-
openEditor(v2Path, { logger, openEditorFn });
|
|
53
|
+
await openEditor(v2Path, { logger, openEditorFn });
|
|
54
54
|
let after;
|
|
55
55
|
try { after = await _fs.readFile(v2Path, "utf8"); }
|
|
56
56
|
catch (err) { return { ok: false, error: `read v2 failed: ${err.message}`, hashChanged: false }; }
|
|
@@ -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
|
+
}
|