karajan-code 4.35.1 → 4.37.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/packages/hu-board/public/utils/board-view.js +2 -1
  3. package/packages/hu-board/public/utils/config-editor.js +5 -2
  4. package/packages/hu-board/public/utils/terminal-panel.js +2 -1
  5. package/packages/hu-board/src/config-yaml.js +11 -9
  6. package/packages/hu-board/src/project-dir.js +42 -0
  7. package/packages/hu-board/src/routes/api.js +11 -3
  8. package/packages/hu-board/src/terminal-wire.js +5 -1
  9. package/packages/hu-board/src/terminal.js +8 -3
  10. package/src/checks/rag-coverage.js +5 -4
  11. package/src/cli/advanced-commands.js +1 -1
  12. package/src/cli/register-meta.js +26 -4
  13. package/src/commands/board.js +20 -8
  14. package/src/commands/bootstrap.js +2 -0
  15. package/src/commands/env.js +8 -4
  16. package/src/commands/harden.js +26 -4
  17. package/src/commands/init.js +2 -1
  18. package/src/commands/plan/generate.js +4 -6
  19. package/src/commands/pr-size.js +45 -0
  20. package/src/commands/rag.js +69 -10
  21. package/src/commands/review-gate.js +47 -6
  22. package/src/commands/watch.js +13 -10
  23. package/src/config/defaults.js +3 -2
  24. package/src/environment/playbook.js +10 -3
  25. package/src/harden/guidelines-engine.js +24 -7
  26. package/src/harden/hook-templates.js +7 -0
  27. package/src/harden/phone-sign.js +28 -11
  28. package/src/harden/sentinel-hooks.js +82 -7
  29. package/src/lang/registry.js +2 -1
  30. package/src/mcp/handlers/rag-handler.js +14 -4
  31. package/src/orchestrator/drivers/post-loop.js +13 -11
  32. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
  33. package/src/orchestrator/stages/rag-context-stage.js +5 -2
  34. package/src/rag/auto-update.js +5 -3
  35. package/src/rag/coverage.js +13 -12
  36. package/src/rag/indexable.js +128 -0
  37. package/src/rag/indexer.js +31 -13
  38. package/src/rag/library.js +19 -13
  39. package/src/rag/migrate.js +73 -0
  40. package/src/rag/onnx-fallback.js +5 -3
  41. package/src/rag/project-store.js +48 -0
  42. package/src/rag/watcher.js +25 -14
  43. package/src/review/comment-only.js +93 -0
  44. package/src/review/kj-provenance.js +26 -0
  45. package/src/review/tests-with-code.js +5 -2
  46. package/src/review/verdict-store.js +6 -1
  47. package/src/start/sweep.js +4 -3
  48. package/src/utils/rulesync.js +61 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.35.1",
3
+ "version": "4.37.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",
@@ -267,7 +267,8 @@ async function renderBoard() {
267
267
  try {
268
268
  const res = await fetch('/api/rag/query', {
269
269
  method: 'POST', headers: { 'Content-Type': 'application/json' },
270
- body: JSON.stringify({ text, topK: 5, scope: ragScope?.value || 'all' }),
270
+ // KJC-TSK-0885: the scoped view searches its own project's index.
271
+ body: JSON.stringify({ text, topK: 5, scope: ragScope?.value || 'all', ...(scopedProjectSlug ? { project: scopedProjectSlug } : {}) }),
271
272
  });
272
273
  const body = await res.json();
273
274
  if (!res.ok) { ragResults.innerHTML = `<span style="color:var(--color-red);">Error: ${esc(body.error || res.status)}</span>`; return; }
@@ -27,7 +27,10 @@ async function showConfigEditor(scope = 'global') {
27
27
  // resuelve <projectDir> desde KJ_PROJECT_DIR || cwd.
28
28
  let cfg;
29
29
  try {
30
- const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}`);
30
+ // KJC-TSK-0885: a scoped view (/p/<slug>, app.js) names its project, so
31
+ // 'project' is THAT project's file, not the daemon's cwd.
32
+ const project = scopedProjectSlug ? `&project=${encodeURIComponent(scopedProjectSlug)}` : '';
33
+ const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}${project}`);
31
34
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
32
35
  cfg = await r.json();
33
36
  } catch (err) {
@@ -163,7 +166,7 @@ async function showConfigEditor(scope = 'global') {
163
166
  const r = await fetch('/api/config', {
164
167
  method: 'PUT',
165
168
  headers: { 'Content-Type': 'application/json' },
166
- body: JSON.stringify({ patch, scope }),
169
+ body: JSON.stringify({ patch, scope, ...(scopedProjectSlug ? { project: scopedProjectSlug } : {}) }),
167
170
  });
168
171
  if (!r.ok) {
169
172
  const body = await r.json().catch(() => ({}));
@@ -28,7 +28,8 @@ async function openConversationPanel() {
28
28
  headers: { 'Content-Type': 'application/json' },
29
29
  // Sin agente en el body: decide el servidor (kj go --window fija el
30
30
  // elegido por env; sin él, claude).
31
- body: JSON.stringify({}),
31
+ // KJC-TSK-0885: la vista scoped (/p/<slug>) abre la terminal en SU proyecto.
32
+ body: JSON.stringify(scopedProjectSlug ? { project: scopedProjectSlug } : {}),
32
33
  });
33
34
  started = await res.json();
34
35
  if (!res.ok) throw new Error(started.error || `HTTP ${res.status}`);
@@ -52,9 +52,11 @@ function getProjectDir() {
52
52
  return process.env.KJ_PROJECT_DIR || process.cwd();
53
53
  }
54
54
 
55
- function configPath(scope = 'global') {
55
+ // KJC-TSK-0885: a scoped view passes its project's dir; only without one does
56
+ // the daemon's KJ_PROJECT_DIR || cwd stand in (dashboard, old clients).
57
+ function configPath(scope = 'global', projectDir = null) {
56
58
  if (scope === 'project') {
57
- return join(getProjectDir(), '.karajan', 'kj.config.yml');
59
+ return join(projectDir || getProjectDir(), '.karajan', 'kj.config.yml');
58
60
  }
59
61
  // Same path the parent uses (src/utils/paths.js::getKarajanHome).
60
62
  // KJC-TSK-0420: delegate to db.js::getKjHome — gives us KARAJAN_HOME
@@ -63,7 +65,7 @@ function configPath(scope = 'global') {
63
65
  return join(getKjHome(), 'kj.config.yml');
64
66
  }
65
67
 
66
- function backupPath(scope = 'global') { return `${configPath(scope)}.bak`; }
68
+ function backupPath(scope = 'global', projectDir = null) { return `${configPath(scope, projectDir)}.bak`; }
67
69
 
68
70
  /**
69
71
  * v2.30.0 — Categorías para agrupar los campos en el modal. El backend
@@ -433,13 +435,13 @@ function applyModelMode(parsed, mode) {
433
435
  *
434
436
  * Returns sane defaults when the file doesn't exist yet.
435
437
  */
436
- export function readConfig({ scope = 'global' } = {}) {
438
+ export function readConfig({ scope = 'global', projectDir = null } = {}) {
437
439
  // PR4 — accept scope. Default 'global' preserves the previous behavior;
438
440
  // 'project' reads <projectDir>/.karajan/kj.config.yml.
439
441
  if (!SCOPES.includes(scope)) {
440
442
  throw new Error(`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`);
441
443
  }
442
- const p = configPath(scope);
444
+ const p = configPath(scope, projectDir);
443
445
  let parsed = {};
444
446
  let exists = false;
445
447
  if (existsSync(p)) {
@@ -471,16 +473,16 @@ export function readConfig({ scope = 'global' } = {}) {
471
473
  *
472
474
  * @returns {{ path: string, written: boolean, applied: object[], errors: string[] }}
473
475
  */
474
- export function writeConfigPatch(patch, { scope = 'global' } = {}) {
476
+ export function writeConfigPatch(patch, { scope = 'global', projectDir = null } = {}) {
475
477
  // PR4 — same shape as readConfig: scope decides target file.
476
478
  if (!SCOPES.includes(scope)) {
477
479
  return { path: '', scope, written: false, applied: [], errors: [`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`] };
478
480
  }
479
481
  if (!patch || typeof patch !== 'object') {
480
- return { path: configPath(scope), scope, written: false, applied: [], errors: ['patch debe ser un objeto'] };
482
+ return { path: configPath(scope, projectDir), scope, written: false, applied: [], errors: ['patch debe ser un objeto'] };
481
483
  }
482
484
  // Read current state (or empty if file doesn't exist yet).
483
- const p = configPath(scope);
485
+ const p = configPath(scope, projectDir);
484
486
  let parsed = {};
485
487
  if (existsSync(p)) {
486
488
  try { parsed = yaml.load(readFileSync(p, 'utf8'), { json: true }) || {}; }
@@ -517,7 +519,7 @@ export function writeConfigPatch(patch, { scope = 'global' } = {}) {
517
519
  // existing file first so the user can recover.
518
520
  const dir = dirname(p);
519
521
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
520
- if (existsSync(p)) { try { copyFileSync(p, backupPath(scope)); } catch { /* best-effort */ } }
522
+ if (existsSync(p)) { try { copyFileSync(p, backupPath(scope, projectDir)); } catch { /* best-effort */ } }
521
523
  const dump = yaml.dump(parsed, { lineWidth: 120 });
522
524
  // mkstemp would be nicer but we want to stay in the same dir for
523
525
  // a guaranteed-atomic rename across the same filesystem.
@@ -0,0 +1,42 @@
1
+ /**
2
+ * KJC-TSK-0885 (BRD-P2, ADR 0011): the project behind a scoped view.
3
+ *
4
+ * The daemon's cwd is not the project (one board serves every project), so a
5
+ * `/p/<slug>` view sends its slug and the server resolves the directory. It
6
+ * never takes a path from the client: it reads the `projectDir` the project's
7
+ * own plans recorded, and accepts it only if it exists and its slug is the one
8
+ * asked for, so a plan cannot point the board at another directory.
9
+ */
10
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+
13
+ import { getHuBoardPlansDirs } from './db.js';
14
+ import { deriveProjectIdFromDir } from './sync.js';
15
+
16
+ const SLUG_RE = /^[a-zA-Z0-9_.-]+$/;
17
+
18
+ /** @returns {string|null} the project's directory, or null when unresolvable. */
19
+ export function projectDirForSlug(slug, { plansDirs = getHuBoardPlansDirs() } = {}) {
20
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.startsWith('.')) return null;
21
+ for (const root of plansDirs) {
22
+ let names;
23
+ try { names = readdirSync(join(root, slug)); } catch { continue; }
24
+ for (const name of names.filter((n) => n.endsWith('.json'))) {
25
+ let dir;
26
+ try { dir = JSON.parse(readFileSync(join(root, slug, name), 'utf8'))?.projectDir; } catch { continue; }
27
+ if (typeof dir === 'string' && deriveProjectIdFromDir(dir) === slug && existsSync(dir)) return dir;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * The project a request is about: none when it names none (dashboard), its
35
+ * directory when it resolves, and an Error that says why when it does not.
36
+ */
37
+ export function requestProjectDir(project) {
38
+ if (project === undefined || project === null || project === '') return null;
39
+ const dir = projectDirForSlug(project);
40
+ if (!dir) throw new Error(`project "${project}" has no plan recording where it lives: run a kj command in it once`);
41
+ return dir;
42
+ }
@@ -49,6 +49,7 @@ import { subscribe as subscribeEvents } from '../event-bus.js';
49
49
  import { runKjCommand, listSupportedCommands } from '../command-runner.js';
50
50
  import { runPreflight } from '../preflight.js';
51
51
  import { readConfig, writeConfigPatch } from '../config-yaml.js';
52
+ import { requestProjectDir } from '../project-dir.js';
52
53
  import { BOOT_TIME, PKG_VERSION } from '../boot-info.js';
53
54
  import { addTombstone, getDb, isTombstoned as isTombstonedFn, removeTombstone, listTombstones } from '../db.js';
54
55
  import { publish as publishEvent } from '../event-bus.js';
@@ -1095,7 +1096,8 @@ router.post('/projects/:id/run', (req, res) => {
1095
1096
  router.get('/config', (req, res) => {
1096
1097
  try {
1097
1098
  const scope = req.query?.scope || 'global';
1098
- const data = readConfig({ scope });
1099
+ // KJC-TSK-0885: a scoped view names its project; the daemon's cwd is not it.
1100
+ const data = readConfig({ scope, projectDir: requestProjectDir(req.query?.project) });
1099
1101
  res.json(data);
1100
1102
  } catch (err) {
1101
1103
  res.status(400).json({ error: err.message });
@@ -1116,7 +1118,9 @@ router.put('/config', (req, res) => {
1116
1118
  try {
1117
1119
  const patch = req.body?.patch;
1118
1120
  const scope = req.body?.scope || 'global';
1119
- const result = writeConfigPatch(patch, { scope });
1121
+ let projectDir;
1122
+ try { projectDir = requestProjectDir(req.body?.project); } catch (err) { return res.status(400).json({ error: err.message }); }
1123
+ const result = writeConfigPatch(patch, { scope, projectDir });
1120
1124
  if (!result.written) {
1121
1125
  return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
1122
1126
  }
@@ -1353,13 +1357,17 @@ router.post('/rag/query', async (req, res) => {
1353
1357
  if (typeof text !== 'string' || text.length === 0) {
1354
1358
  return res.status(400).json({ error: "'text' is required (non-empty string)" });
1355
1359
  }
1360
+ // KJC-TSK-0885 (ADR 0011): a scoped view searches ITS project's index; the
1361
+ // machine-wide store (no filter) returned chunks of any repo.
1362
+ let projectDir;
1363
+ try { projectDir = requestProjectDir(req.body?.project); } catch (err) { return res.status(400).json({ error: err.message }); }
1356
1364
  try {
1357
1365
  const { openVecStore, countChunks } = await import('karajan-core/vec-store');
1358
1366
  // KJC-TSK-0632: resolved from karajan-core — the board carries zero
1359
1367
  // relative imports into the CLI src tree (see no-cli-imports test).
1360
1368
  const { makeEmbedder } = await import('karajan-core/rag/embedders/factory');
1361
1369
  const { query } = await import('karajan-core/rag/retriever');
1362
- const db = openVecStore({ dim: 768 });
1370
+ const db = openVecStore({ dim: 768, ...(projectDir ? { path: path.join(projectDir, '.karajan', 'rag.db') } : {}) });
1363
1371
  try {
1364
1372
  if (countChunks(db) === 0) return res.json({ hits: [], empty: true, topK, scope });
1365
1373
  const embedder = makeEmbedder({});
@@ -9,6 +9,8 @@
9
9
  import { Router } from 'express';
10
10
  import { WebSocket, WebSocketServer } from 'ws';
11
11
 
12
+ import { requestProjectDir } from './project-dir.js';
13
+
12
14
  const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
13
15
  const WS_PATH = '/api/terminal/ws';
14
16
 
@@ -20,7 +22,9 @@ export function terminalRouter(manager, { defaultAgent = 'claude' } = {}) {
20
22
  const router = Router();
21
23
  router.post('/start', (req, res) => {
22
24
  try {
23
- res.json(manager.start({ agent: req.body?.agent ?? defaultAgent }));
25
+ // KJC-TSK-0885: la vista scoped nombra su proyecto; el servidor resuelve el dir.
26
+ const projectDir = requestProjectDir(req.body?.project);
27
+ res.json(manager.start({ agent: req.body?.agent ?? defaultAgent, ...(projectDir ? { cwd: projectDir } : {}) }));
24
28
  } catch (err) {
25
29
  res.status(400).json({ error: err.message });
26
30
  }
@@ -47,8 +47,12 @@ export function createTerminalManager({ spawnPty, cwd, env = process.env, prompt
47
47
 
48
48
  return {
49
49
  /** Arranca (o devuelve) LA sesión — reutilizar la viva evita que cada
50
- * recarga multiplique agentes. @param {{agent: string}} params */
51
- start({ agent }) {
50
+ * recarga multiplique agentes. KJC-TSK-0885: `cwd` es el proyecto de la
51
+ * vista; sin él, el del daemon. @param {{agent: string, cwd?: string}} params */
52
+ start({ agent, cwd: wanted = cwd }) {
53
+ if (session && session.cwd !== wanted) {
54
+ throw new Error(`terminal: ya hay una sesión viva en ${session.cwd}; termínala antes de abrir otra en ${wanted}.`);
55
+ }
52
56
  if (session) return { termId: session.termId, token: session.token, reused: true };
53
57
  // hasOwn: sin él, "constructor"/"__proto__" indexarían el prototipo.
54
58
  const spec = Object.hasOwn(TERMINAL_AGENTS, agent) ? TERMINAL_AGENTS[agent] : null;
@@ -65,10 +69,11 @@ export function createTerminalManager({ spawnPty, cwd, env = process.env, prompt
65
69
  name: 'xterm-256color',
66
70
  cols: 120,
67
71
  rows: 32,
68
- cwd,
72
+ cwd: wanted,
69
73
  env: cleanEnv,
70
74
  });
71
75
  const current = {
76
+ cwd: wanted,
72
77
  termId: `term-${randomBytes(8).toString('hex')}`,
73
78
  token: randomBytes(16).toString('hex'),
74
79
  pty,
@@ -4,21 +4,22 @@
4
4
  * Red only when an index EXISTS and is incomplete or stale: a machine with
5
5
  * no index yet (CI, a fresh clone) gets the command, not a failure.
6
6
  */
7
- import { openVecStore } from "../rag/vec-store.js";
7
+ import { openProjectStore } from "../rag/project-store.js";
8
8
  import { ragIndexCoverage } from "../rag/coverage.js";
9
+ import { ragExclude } from "../rag/indexable.js";
9
10
  import { STRATEGY } from "./types.js";
10
11
 
11
- export function createRagCoverageCheck({ openStore = (dim) => openVecStore({ dim }) } = {}) {
12
+ export function createRagCoverageCheck({ openStore = (dim, projectDir) => openProjectStore({ projectDir, dim }) } = {}) {
12
13
  return {
13
14
  name: "rag-coverage",
14
15
  label: "RAG index coverage (ADR 0010)",
15
16
  strategy: STRATEGY.NONE,
16
17
  async detect({ config = {}, projectDir = process.cwd() } = {}) {
17
- const db = openStore(config?.rag?.embedder?.dim || 768);
18
+ const db = openStore(config?.rag?.embedder?.dim || 768, projectDir);
18
19
  try {
19
20
  let c;
20
21
  try {
21
- c = await ragIndexCoverage(projectDir, { db });
22
+ c = await ragIndexCoverage(projectDir, { db, exclude: ragExclude(config) });
22
23
  } catch (err) {
23
24
  // Corrupt store, unreachable stamp, git failure: a failing check,
24
25
  // never a silent pass. The only tolerated non-red state is "no index".
@@ -34,7 +34,7 @@ export const ADVANCED_GROUPS = [
34
34
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
35
35
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
36
36
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
37
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward"] },
37
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward", "pr-size"] },
38
38
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
39
39
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
40
40
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -11,7 +11,7 @@ import { identityCommand } from "../commands/identity.js";
11
11
  import { policyCommand } from "../commands/policy.js";
12
12
  import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
13
13
  import { stewardSweepCommand } from "../commands/steward.js";
14
- import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand, ragCoversCommand } from "../commands/rag.js";
14
+ import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand, ragCoversCommand, ragMigrateCommand } from "../commands/rag.js";
15
15
  import { qmdQueryCommand } from "../commands/qmd.js";
16
16
  import { ragMcpCommand } from "../commands/rag-mcp.js";
17
17
  import { watchStartCommand, watchStopCommand, watchStatusCommand } from "../commands/watch.js";
@@ -480,6 +480,7 @@ export function registerMeta(program, { pkgVersion }) {
480
480
  // KJC-BUG-0197: manipulación y desfase son hechos distintos y se leen
481
481
  // distinto. Un desfase no es un defecto: los guardias que corren están
482
482
  // sellados, solo son más viejos que las plantillas de este árbol.
483
+ else if (res.regenerated?.length && res.ok && !res.drift?.length) console.log(`sentinel verify: ${res.reason}`);
483
484
  else if (res.drift?.length && res.ok) console.log(`sentinel verify: scripts sellados, con desfase (${res.drift.join(", ")}) — ${res.reason}`);
484
485
  else console.log(res.ok ? "sentinel verify: scripts intactos" : `sentinel verify: modificados fuera de kj harden: ${res.mismatched.join(", ")} — restaura con kj harden`);
485
486
  process.exitCode = res.ok ? 0 : 1;
@@ -521,11 +522,23 @@ export function registerMeta(program, { pkgVersion }) {
521
522
  await privacyScanCommand({ paths, flags });
522
523
  });
523
524
 
525
+ // KJC-TSK-0910: the branch's size while it is written, same budget as CI.
526
+ program.command("pr-size")
527
+ .description("Lines this branch adds against its base, with the CI budget (committed, uncommitted and new files)")
528
+ .option("--json", "Machine-readable result")
529
+ .action(async (flags) => {
530
+ await withConfig(pkgVersion, "pr-size", flags, async ({ config, logger }) => {
531
+ const { prSizeCommand } = await import("../commands/pr-size.js");
532
+ await prSizeCommand({ config, logger, flags });
533
+ });
534
+ });
535
+
524
536
  const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
525
537
  rag.command("index")
526
538
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
527
539
  .option("--with-sources", "Also index the projectDir's JS/TS files")
528
540
  .option("--since <ref>", "Only re-index files changed since <ref> ('auto' uses the last indexed commit; falls back to full index if unset). KJC-TSK-0455.")
541
+ .option("--file <paths...>", "Index exactly these files (a missing one leaves the index): the remedy for a file absent from the index")
529
542
  .option("--json", "Output the totals as JSON")
530
543
  .action(async (flags) => {
531
544
  await withConfig(pkgVersion, "rag-index", flags, async ({ config, logger }) => {
@@ -534,6 +547,15 @@ export function registerMeta(program, { pkgVersion }) {
534
547
  });
535
548
  // KJC-BUG-0216: el gate rag-first no puede pedir lo imposible. Esto responde
536
549
  // si el RAG PUEDE hablar de un fichero, y si no, por que.
550
+ // KJC-TSK-0888 (ADR 0011): el paso al indice por proyecto, sin reindexar.
551
+ rag.command("migrate")
552
+ .description("Copy this project's chunks from the machine-wide index into its own .karajan/rag.db, embeddings included (no re-embedding); the global index is left untouched")
553
+ .option("--json", "Emit the result as JSON")
554
+ .action(async (flags) => {
555
+ await withConfig(pkgVersion, "rag-migrate", flags, async ({ config }) => {
556
+ await ragMigrateCommand({ config, flags });
557
+ });
558
+ });
537
559
  rag.command("covers <file>")
538
560
  .description("Whether the RAG can answer about a file: exit 0 when indexed, 1 with the reason it cannot (no adapter, skipped path, empty or stale index)")
539
561
  .option("--json", "Emit the state as JSON")
@@ -564,7 +586,7 @@ export function registerMeta(program, { pkgVersion }) {
564
586
  .option("--rag-mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
565
587
  .option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
566
588
  .option("--where <clause>", "Metadata filter, e.g. 'symbol=loadConfig' or 'hu_id=HU-003 AND kind=plan'")
567
- .option("--library", "Query the distilled engineering canon (pattern cards shipped with kj + ~/.karajan/library + project .karajan/library) instead of the project corpus. Cards carry problem signatures, when-NOT-to-apply and canonical citations (KJC-TSK-0697).")
589
+ .option("--library", "Query the distilled engineering canon (pattern cards shipped with kj + ~/.karajan/library, in ~/.karajan/library.db) instead of the project corpus; a project's own .karajan/library cards live in its index and answer normal queries. Cards carry problem signatures, when-NOT-to-apply and canonical citations (KJC-TSK-0697).")
568
590
  .option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
569
591
  .option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
570
592
  .option("--json", "Output the hits as JSON")
@@ -627,10 +649,10 @@ export function registerMeta(program, { pkgVersion }) {
627
649
  await withConfig(pkgVersion, "watch:start", flags, async ({ config, logger }) => watchStartCommand({ config, logger, flags }));
628
650
  });
629
651
  watch.command("stop").description("Stop the watcher daemon").action(async () => {
630
- await withConfig(pkgVersion, "watch:stop", {}, async ({ logger }) => watchStopCommand({ logger }));
652
+ await withConfig(pkgVersion, "watch:stop", {}, async ({ config, logger }) => watchStopCommand({ config, logger }));
631
653
  });
632
654
  watch.command("status").description("Report watcher daemon state").action(async () => {
633
- await withConfig(pkgVersion, "watch:status", {}, async ({ logger }) => watchStatusCommand({ logger }));
655
+ await withConfig(pkgVersion, "watch:status", {}, async ({ config, logger }) => watchStatusCommand({ config, logger }));
634
656
  });
635
657
 
636
658
  program
@@ -2,8 +2,9 @@ import fs from "node:fs";
2
2
  import http from "node:http";
3
3
  import path from "node:path";
4
4
  import net from "node:net";
5
- import { spawn } from "node:child_process";
5
+ import { execFileSync, spawn } from "node:child_process";
6
6
  import { getKarajanHome } from "../utils/paths.js";
7
+ import { projectSlug as slugOfDir } from "../plan/plan-store.js";
7
8
  import { getPortOccupant } from "../utils/port-occupant.js";
8
9
 
9
10
  const BOARD_DIR = path.resolve(import.meta.dirname, "../../packages/hu-board");
@@ -76,18 +77,29 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
76
77
  }
77
78
 
78
79
  /**
79
- * Build the board URL. When `projectSlug` is provided, the URL points at
80
- * the per-project view via the SPA hash route (`#board/<slug>`) so the
81
- * caller's project shows up pre-filtered instead of "All projects". The
82
- * frontend router is hash-based (see public/utils/init-listeners.js); a
83
- * real `/p/<slug>` path is not a route and silently falls back to the
84
- * global dashboard. KJC-BUG-0093.
80
+ * Build the board URL. KJC-TSK-0884 (ADR 0011, board scoped): with a project,
81
+ * the scoped view `/p/<slug>`, one project with the multi-project affordances
82
+ * hidden (app.js scoped-mode, served by the SPA fallback; checked in a browser).
83
+ * It replaces KJC-BUG-0093's `#board/<slug>`, which kept the global dashboard.
85
84
  * @param {number} port
86
85
  * @param {string|null} [projectSlug]
87
86
  * @returns {string}
88
87
  */
89
88
  export function buildBoardUrl(port, projectSlug) {
90
- return projectSlug ? boardUrl(port, `/#board/${projectSlug}`) : boardUrl(port);
89
+ return projectSlug ? boardUrl(port, `/p/${encodeURIComponent(projectSlug)}`) : boardUrl(port);
90
+ }
91
+
92
+ /**
93
+ * KJC-TSK-0884: the board slug of a project is its PARENT repo's, even from a
94
+ * `kj worktree` lane, or the lane opens an empty view.
95
+ */
96
+ export function boardSlug(projectDir) {
97
+ let dir = projectDir;
98
+ try {
99
+ const common = execFileSync("git", ["-C", projectDir, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
100
+ if (path.basename(common) === ".git") dir = path.dirname(common);
101
+ } catch { /* not a git repo: the dir is the project */ }
102
+ return slugOfDir(dir);
91
103
  }
92
104
 
93
105
  /**
@@ -42,6 +42,8 @@ const CONTRACT_PATHS = [
42
42
  "CLAUDE.md",
43
43
  "AGENTS.md",
44
44
  "GEMINI.md",
45
+ // KJC-TSK-0879: in a Rulesync repo kj's rules live in .rulesync/rules/karajan.md.
46
+ ".rulesync",
45
47
  ];
46
48
  const CONTRACT_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
47
49
 
@@ -7,7 +7,7 @@
7
7
  import { installPlaybook, renderPlaybook } from "../environment/playbook.js";
8
8
  import { renderBrief, listBriefs } from "../environment/briefs.js";
9
9
  import { existsSync } from "node:fs";
10
- import { openVecStore, projectSlug, getLastIndexedCommit, dbPath } from "../rag/vec-store.js";
10
+ import { projectSlug, getLastIndexedCommit } from "../rag/vec-store.js";
11
11
  import { ragIndexCommand } from "./rag.js";
12
12
  import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
13
13
  import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
@@ -19,13 +19,15 @@ import { createWizard } from "../utils/wizard.js";
19
19
  import { hardenCommand } from "./harden.js";
20
20
  import { reviewGateCommand } from "./review-gate.js";
21
21
  import { join } from "node:path";
22
+ import { openProjectStore, projectDbPath } from "../rag/project-store.js";
23
+ import { maybeRulesyncGenerate } from "../utils/rulesync.js";
22
24
 
23
25
  function hasRagIndex(config, projectDir) {
24
26
  // KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
25
27
  // the DDL, and a table born at the default dim (768) breaks the ONNX
26
28
  // fallback (384) later. No file → no index, without side effects.
27
- if (!existsSync(dbPath())) return false;
28
- const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
29
+ if (!existsSync(projectDbPath(projectDir))) return false;
30
+ const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
29
31
  try { return Boolean(getLastIndexedCommit(db, projectSlug(projectDir))); }
30
32
  finally { db.close(); }
31
33
  }
@@ -74,6 +76,8 @@ export async function envInstallCommand({ config = null, logger = null, flags =
74
76
  config, // KJC-TSK-0865: the panel travels with the method
75
77
  });
76
78
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
79
+ // KJC-TSK-0879: in a Rulesync repo, spread the rules only if the project opted in.
80
+ maybeRulesyncGenerate({ projectDir, config, logger: console });
77
81
 
78
82
  // KJC-TSK-0711 — RAG as a native tool: agents use what is in their
79
83
  // toolbox, so the official path must be cheaper than the grep shortcut.
@@ -169,7 +173,7 @@ export async function envInstallCommand({ config = null, logger = null, flags =
169
173
  // KJC-BUG-0128: an empty store may carry a vec table at the wrong
170
174
  // dim (the old probe created it at 768) — reset it so ONNX (384)
171
175
  // can index. A store with data is never touched.
172
- if (!resetEmptyStore()) {
176
+ if (!resetEmptyStore(projectDir)) {
173
177
  console.log(" the vector store already has data at another dimension — not switching automatically");
174
178
  return false;
175
179
  }
@@ -17,6 +17,7 @@ import { createWizard, isTTY } from "../utils/wizard.js";
17
17
  import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
18
18
  import { installConfigsForRoots } from "../harden/config-engine.js";
19
19
  import { installGuidelines } from "../harden/guidelines-engine.js";
20
+ import { maybeRulesyncGenerate } from "../utils/rulesync.js";
20
21
  import { commandsForLanguage } from "../harden/hook-commands.js";
21
22
  import { installHooks } from "../harden/harden-engine.js";
22
23
  import { installHarnessHooks } from "../harden/harness-hooks.js";
@@ -76,6 +77,18 @@ export async function resolveCmds(projectDir, language) {
76
77
  return cmds;
77
78
  }
78
79
 
80
+ /**
81
+ * KJC-BUG-0233: loadConfig returns `{ config, ... }`; reading `base_branch` off
82
+ * the result was always undefined, so the guard always protected "main".
83
+ */
84
+ export async function resolveBaseBranch(projectDir, load = loadConfig) {
85
+ try {
86
+ return (await load(projectDir))?.config?.base_branch || "main";
87
+ } catch {
88
+ return "main"; // no kj config: the default stands
89
+ }
90
+ }
91
+
79
92
  export async function hardenCommand({
80
93
  projectDir = process.cwd(),
81
94
  profile = "standard",
@@ -139,10 +152,7 @@ export async function hardenCommand({
139
152
  // KJC-TSK-0648: branch-first guard — the base branch only moves via PR.
140
153
  // Resolved from the project's kj config (default "main"); best-effort so
141
154
  // harden keeps working on repos that never ran kj init.
142
- let baseBranch = "main";
143
- try {
144
- baseBranch = (await loadConfig(projectDir))?.base_branch || "main";
145
- } catch { /* no kj config — default stands */ }
155
+ const baseBranch = await resolveBaseBranch(projectDir);
146
156
  let result;
147
157
  try {
148
158
  result = await installHooks({ projectDir, profile, cmds, dryRun, baseBranch });
@@ -177,6 +187,12 @@ export async function hardenCommand({
177
187
  // KJC-BUG-0199 (issue #1773): the same detected language the workflows and
178
188
  // the configs already use — a Python project must not be told to use `const`.
179
189
  const gl = withGuidelines ? installGuidelines({ projectDir, language: roots[0]?.language ?? null, dryRun }) : null;
190
+ // KJC-TSK-0879: in a Rulesync repo, spread the rules only if the project opted in.
191
+ if (withGuidelines && !dryRun) {
192
+ let kjConfig = null;
193
+ try { kjConfig = (await loadConfig(projectDir))?.config; } catch { /* no kj config: not opted in */ }
194
+ maybeRulesyncGenerate({ projectDir, config: kjConfig, logger });
195
+ }
180
196
  const out = {
181
197
  ok: true,
182
198
  ...result,
@@ -202,6 +218,12 @@ export async function hardenCommand({
202
218
  logger.info?.(` • ${g.file}: ${g.action}`);
203
219
  // KJC-BUG-0206: quitar reglas no puede leerse como "updated" y nada más.
204
220
  if (g.shrank) logger.warn?.(` ⚠ ${g.file} queda con MENOS reglas que antes — revisa si el lenguaje detectado es el que crees (kj check lo dice)`);
221
+ // KJC-BUG-0232: cuales. Si una es tuya, vivia dentro del bloque: va fuera.
222
+ if (g.removed?.length) {
223
+ for (const line of g.removed.slice(0, 10)) logger.warn?.(` - ${line.length > 120 ? `${line.slice(0, 117)}...` : line}`);
224
+ if (g.removed.length > 10) logger.warn?.(` (+${g.removed.length - 10} más)`);
225
+ logger.warn?.(" Si alguna es vuestra, estaba dentro del bloque gestionado: recuperadla de git y ponedla fuera de él.");
226
+ }
205
227
  }
206
228
  if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
207
229
  // IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
@@ -344,7 +344,8 @@ async function runWizard(config, logger) {
344
344
  config.sonarqube.enabled = enableSonar;
345
345
  logger.info(` -> SonarQube: ${enableSonar ? "enabled" : "disabled"}`);
346
346
 
347
- const enableHuBoard = await wizard.confirm("Enable the HU Board — a local web UI to track user stories (HUs)?", false);
347
+ // KJC-TSK-0884: on by default, like Sonar; answering no is a declared opt-out.
348
+ const enableHuBoard = await wizard.confirm("Enable the HU Board — a local web UI to track user stories (HUs)?", true);
348
349
  config.hu_board = config.hu_board || {};
349
350
  config.hu_board.enabled = enableHuBoard;
350
351
  if (enableHuBoard) {
@@ -512,13 +512,11 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
512
512
  // vitest / test mode, and never when `--json` is active.
513
513
  if (plan.hus.length > 0 && !process.env.VITEST && process.env.NODE_ENV !== "test") {
514
514
  try {
515
- const { startBoard, renderBoardBanner } = await import("../board.js");
516
- const { projectSlug: slugFor } = await import("../../plan/plan-store.js");
515
+ const { startBoard, renderBoardBanner, boardSlug } = await import("../board.js");
517
516
  const boardPort = config?.hu_board?.port ?? 4000;
518
- // `projectSlug` scopes the board URL to this project's view
519
- // (`#board/<slug>`) instead of the global "All projects" dashboard.
520
- // The hash route opens the board pre-filtered to that project.
521
- const boardResult = await startBoard(boardPort, { projectSlug: slugFor(projectDir) });
517
+ // KJC-TSK-0884: this project's scoped view (/p/<slug>), the parent
518
+ // repo's even from a worktree lane, never the global dashboard.
519
+ const boardResult = await startBoard(boardPort, { projectSlug: boardSlug(projectDir) });
522
520
  const status = boardResult.alreadyRunning ? "already running" : "started";
523
521
  console.log(renderBoardBanner({ url: boardResult.url, status, projectName: plan.name }));
524
522
  } catch (err) {
@@ -0,0 +1,45 @@
1
+ /**
2
+ * KJC-TSK-0910 — `kj pr-size`: the branch's size while it is being written,
3
+ * with the SAME budget the CI gate applies (loc-budget.js). It counts what is
4
+ * committed, what is not yet, and new files not yet added, against the merge
5
+ * base with the base branch, so the Sentinel can warn before the gate blocks.
6
+ */
7
+ import { execFileSync } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { join } from "node:path";
10
+
11
+ import { budgetedAdded } from "../review/loc-budget.js";
12
+
13
+ const git = (projectDir, args) =>
14
+ execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
15
+
16
+ function mergeBase(projectDir, base) {
17
+ for (const ref of [`origin/${base}`, base]) {
18
+ try { return git(projectDir, ["merge-base", "HEAD", ref]).trim(); } catch { /* try the next ref */ }
19
+ }
20
+ throw new Error(`pr-size: no merge base with ${base} (nor origin/${base})`);
21
+ }
22
+
23
+ /** @returns {Promise<{added: number, exempt: number, testAdded: number, base: string}>} */
24
+ export async function branchSize({ projectDir = process.cwd(), base = "main" } = {}) {
25
+ const from = mergeBase(projectDir, base);
26
+ // Working tree against the merge base: committed and uncommitted together.
27
+ let numstat = git(projectDir, ["diff", "--numstat", from]);
28
+ // New files not yet added are where a branch grows while it is written.
29
+ for (const file of git(projectDir, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter(Boolean)) {
30
+ let text;
31
+ try { text = readFileSync(join(projectDir, file), "utf8"); } catch { continue; }
32
+ if (text.includes("\0")) continue; // binary
33
+ // Lines as git counts them: an empty file adds none.
34
+ const n = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
35
+ numstat += `${n}\t0\t${file}\n`;
36
+ }
37
+ return { ...budgetedAdded(numstat), base };
38
+ }
39
+
40
+ export async function prSizeCommand({ config, logger = console, flags = {} }) {
41
+ const s = await branchSize({ projectDir: config?.projectDir || process.cwd(), base: config?.base_branch || "main" });
42
+ if (flags.json) process.stdout.write(`${JSON.stringify(s)}\n`);
43
+ else logger.info(`pr-size: ${s.added} line(s) added against ${s.base} (${s.testAdded} in tests, ${s.exempt} exempt)`);
44
+ return s;
45
+ }