karajan-code 4.35.0 → 4.36.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 (56) 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/audit/harness-scorecard.js +8 -1
  11. package/src/audit/harness-section.js +13 -2
  12. package/src/checks/rag-coverage.js +9 -5
  13. package/src/checks/runner.js +27 -5
  14. package/src/checks/skills.js +10 -0
  15. package/src/checks/types.js +5 -1
  16. package/src/cli/register-meta.js +15 -4
  17. package/src/commands/board.js +20 -8
  18. package/src/commands/bootstrap.js +2 -0
  19. package/src/commands/env.js +8 -4
  20. package/src/commands/harden.js +26 -4
  21. package/src/commands/init.js +2 -1
  22. package/src/commands/plan/generate.js +4 -6
  23. package/src/commands/rag.js +69 -10
  24. package/src/commands/review-gate.js +15 -6
  25. package/src/commands/watch.js +13 -10
  26. package/src/config/defaults.js +3 -2
  27. package/src/environment/playbook.js +10 -3
  28. package/src/harden/config-templates.js +11 -0
  29. package/src/harden/guidelines-engine.js +24 -7
  30. package/src/harden/harness-hooks.js +92 -3
  31. package/src/harden/sentinel-hooks.js +80 -8
  32. package/src/lang/registry.js +2 -1
  33. package/src/mcp/handlers/rag-handler.js +14 -4
  34. package/src/orchestrator/drivers/post-loop.js +13 -11
  35. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
  36. package/src/orchestrator/drivers/pre-loop.js +1 -1
  37. package/src/orchestrator/stages/rag-context-stage.js +5 -2
  38. package/src/rag/auto-update.js +5 -3
  39. package/src/rag/coverage.js +36 -13
  40. package/src/rag/indexable.js +97 -0
  41. package/src/rag/indexer.js +31 -13
  42. package/src/rag/library.js +19 -13
  43. package/src/rag/migrate.js +73 -0
  44. package/src/rag/onnx-fallback.js +5 -3
  45. package/src/rag/project-store.js +48 -0
  46. package/src/rag/watcher.js +25 -14
  47. package/src/review/gate-gitignore.js +23 -1
  48. package/src/review/kj-provenance.js +68 -0
  49. package/src/review/rag-ledger.js +12 -4
  50. package/src/review/rag-requirement.js +17 -3
  51. package/src/review/verdict-store.js +6 -1
  52. package/src/sonar/api.js +11 -2
  53. package/src/sonar/project-key.js +33 -0
  54. package/src/sonar/scanner.js +37 -2
  55. package/src/start/sweep.js +4 -3
  56. 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.0",
3
+ "version": "4.36.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,
@@ -40,9 +40,16 @@ export function hintForReason(reason, image) {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * KJC-BUG-0228 (issue #1840) — OPT-IN. La imagen por defecto es de un tercero y
45
+ * dejo de existir en el registro publico, asi que el paso venia activo, fallaba
46
+ * con "pull access denied" y se saltaba en cada `kj audit`. kj no puede depender
47
+ * por defecto de algo que otro puede borrar: quien quiera el scorecard lo pide,
48
+ * y quien tenga su propia imagen la declara en `audit.harness.image`.
49
+ */
43
50
  export function normalizeHarnessConfig(h = {}) {
44
51
  return {
45
- enabled: h.enabled !== false,
52
+ enabled: h?.enabled === true,
46
53
  image: h.image || D.image,
47
54
  pullTimeoutMs: pos(h.pull_timeout_ms, D.pullTimeoutMs),
48
55
  assessTimeoutMs: pos(h.assess_timeout_ms, D.assessTimeoutMs),
@@ -20,7 +20,12 @@ const ts = () => new Date().toISOString().replaceAll(/[:.]/g, "-");
20
20
 
21
21
  export async function runHarnessSection({ projectDir, harnessConfig = null, runAssess = runHarnessAssess } = {}) {
22
22
  const cfg = normalizeHarnessConfig(harnessConfig || {});
23
- if (!cfg.enabled) return { ok: true, skipped: true, reason: "disabled" };
23
+ // KJC-BUG-0228: desactivado por defecto, y DICHO. Un paso que desaparece en
24
+ // silencio del informe se lee como un paso aprobado.
25
+ if (!cfg.enabled) {
26
+ const asked = harnessConfig?.enabled === false;
27
+ return { ok: true, skipped: true, reason: asked ? "disabled" : "opt-in" };
28
+ }
24
29
  const res = await runAssess(projectDir, cfg);
25
30
  if (!res.ok) return { ok: false, error: res.error, reason: res.reason, hint: res.hint, image: cfg.image };
26
31
  const outDir = path.join(projectDir, ".karajan", "audit-runs", ts());
@@ -38,7 +43,13 @@ export async function runHarnessSection({ projectDir, harnessConfig = null, runA
38
43
  }
39
44
 
40
45
  export function formatHarnessSection(summary) {
41
- if (!summary || summary.skipped) return "";
46
+ if (!summary) return "";
47
+ // KJC-BUG-0228: cuando no corre porque nadie lo pidio, se dice como pedirlo.
48
+ // Callarlo dejaba un informe que parecia completo sin serlo.
49
+ if (summary.reason === "opt-in") {
50
+ return "## Harness Scorecard\n\n_not run: it is opt-in (`audit.harness.enabled: true`), and it needs a scorecard image you can pull — declare yours in `audit.harness.image`._\n";
51
+ }
52
+ if (summary.skipped) return "";
42
53
  if (!summary.ok) {
43
54
  const reason = summary.reason ? ` (${summary.reason})` : "";
44
55
  const hint = summary.hint ? `\n\n${summary.hint}\n` : "";
@@ -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".
@@ -41,7 +42,10 @@ export function createRagCoverageCheck({ openStore = (dim) => openVecStore({ dim
41
42
  const sample = gaps.slice(0, 3).join(", ") + (gaps.length > 3 ? ", …" : "");
42
43
  return { ok: false, severity: "fail", detail: `${c.missing.length} source(s) outside the RAG index, ${c.stale.length} stale (${sample}) — ${fix}` };
43
44
  }
44
- return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}` };
45
+ // KJC-BUG-0221: lo que kj genera no se exige indexado, pero se dice —
46
+ // una exencion que no se ve es indistinguible de un gate que dejo de mirar.
47
+ const generated = c.generated?.length ? `, ${c.generated.length} generated by kj (not required)` : "";
48
+ return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}${generated}` };
45
49
  } finally {
46
50
  db.close();
47
51
  }
@@ -92,8 +92,14 @@ export async function runChecks(checks, ctx, options = {}) {
92
92
  entries.map(async (entry) => {
93
93
  if (entry.status === STATUS.SKIPPED) return;
94
94
  const start = Date.now();
95
+ // Per-check override: checks that spawn slow tools (e.g. `npx <pkg>` cold
96
+ // start) declare detectTimeoutMs so the blanket pipeline timeout doesn't
97
+ // kill them. Falls back to the pipeline-wide timeoutMs.
98
+ const detectTimeoutMs = Number.isFinite(entry.check.detectTimeoutMs)
99
+ ? entry.check.detectTimeoutMs
100
+ : timeoutMs;
95
101
  try {
96
- const result = await withTimeout(entry.check.detect({ config, signal }), timeoutMs, entry.check.name);
102
+ const result = await withTimeout(entry.check.detect({ config, signal }), detectTimeoutMs, entry.check.name);
97
103
  entry.detectResult = result;
98
104
  entry.runMs = Date.now() - start;
99
105
  entry.status = resolveStatusFromDetect(entry.check, result);
@@ -112,9 +118,21 @@ export async function runChecks(checks, ctx, options = {}) {
112
118
  } catch (err) {
113
119
  entry.runMs = Date.now() - start;
114
120
  if (err instanceof TimeoutError) {
115
- entry.detectResult = { ok: false, severity: "warn", detail: `Timeout after ${timeoutMs}ms` };
116
- entry.status = STATUS.TIMEOUT;
117
- entry.detail = entry.detectResult.detail;
121
+ entry.detectResult = { ok: false, severity: "warn", detail: `Timeout after ${detectTimeoutMs}ms` };
122
+ // A timeout on a degradable (optional) check must not block the
123
+ // pipeline: degrade to WARN and disable its feature flags, same as
124
+ // a regular failure (KJC-BUG-0049 semantics).
125
+ if (entry.check.degradable) {
126
+ for (const dotPath of entry.check.degradable.disables || []) {
127
+ setDotPath(overrides, dotPath, false);
128
+ }
129
+ entry.status = STATUS.WARN;
130
+ entry.detail = `${entry.detectResult.detail} → ${entry.check.degradable.warn}`;
131
+ entry.degraded = true;
132
+ } else {
133
+ entry.status = STATUS.TIMEOUT;
134
+ entry.detail = entry.detectResult.detail;
135
+ }
118
136
  } else {
119
137
  entry.detectResult = { ok: false, severity: "fail", detail: `Detection error: ${err.message}` };
120
138
  entry.status = STATUS.FAIL;
@@ -172,7 +190,11 @@ export async function runChecks(checks, ctx, options = {}) {
172
190
  entries.filter((e) => e.remediated).map(async (entry) => {
173
191
  const start = Date.now();
174
192
  try {
175
- const result = await withTimeout(entry.check.detect({ config: verifyConfig, signal }), timeoutMs, `${entry.check.name}:reverify`);
193
+ const result = await withTimeout(
194
+ entry.check.detect({ config: verifyConfig, signal }),
195
+ Number.isFinite(entry.check.detectTimeoutMs) ? entry.check.detectTimeoutMs : timeoutMs,
196
+ `${entry.check.name}:reverify`
197
+ );
176
198
  entry.runMs += Date.now() - start;
177
199
  if (!result.ok) {
178
200
  entry.status = STATUS.FAIL;
@@ -23,6 +23,16 @@ export function createOpenSkillsCheck() {
23
23
  strategy: STRATEGY.PROMPT,
24
24
  applies: skillsEnabled,
25
25
  describe: "Install OpenSkills globally: npm install -g openskills",
26
+ // Detection shells out to `npx openskills --version`; a cold npx cache
27
+ // downloads the package first (~8s observed), well above the pipeline's
28
+ // 3s blanket timeout. Must exceed openskills-client's internal 15s timeout.
29
+ detectTimeoutMs: 20_000,
30
+ // Optional feature: even if detection times out, degrade instead of
31
+ // blocking the whole preflight (skill injection is skipped for the run).
32
+ degradable: {
33
+ disables: ["skills.enabled"],
34
+ warn: "skill auto-install disabled for this run",
35
+ },
26
36
  async detect() {
27
37
  const available = await isOpenSkillsAvailable();
28
38
  if (available) {
@@ -59,7 +59,11 @@
59
59
  * @property {DegradableSpec} [degradable] - KJC-BUG-0049: si está definido y el check falla, en lugar de abortar
60
60
  * el preflight, se desactivan los flags listados en `disables` y se emite
61
61
  * WARN con `warn`. Útil para features opcionales (auto_pr, auto_push,
62
- * sonar advisory, etc.) que no son blockers absolutos.
62
+ * sonar advisory, etc.) que no son blockers absolutos. También aplica
63
+ * cuando el detect agota su timeout (timeout → WARN, no TIMEOUT).
64
+ * @property {number} [detectTimeoutMs] - per-check detect timeout override. Use for checks that spawn slow
65
+ * tools (e.g. `npx <pkg>` cold start) where the pipeline-wide
66
+ * timeoutMs would kill an otherwise healthy detect.
63
67
  */
64
68
 
65
69
  /**
@@ -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;
@@ -526,6 +527,7 @@ export function registerMeta(program, { pkgVersion }) {
526
527
  .description("Index plans + onboarding (and optionally project sources) into the local vector store")
527
528
  .option("--with-sources", "Also index the projectDir's JS/TS files")
528
529
  .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.")
530
+ .option("--file <paths...>", "Index exactly these files (a missing one leaves the index): the remedy for a file absent from the index")
529
531
  .option("--json", "Output the totals as JSON")
530
532
  .action(async (flags) => {
531
533
  await withConfig(pkgVersion, "rag-index", flags, async ({ config, logger }) => {
@@ -534,6 +536,15 @@ export function registerMeta(program, { pkgVersion }) {
534
536
  });
535
537
  // KJC-BUG-0216: el gate rag-first no puede pedir lo imposible. Esto responde
536
538
  // si el RAG PUEDE hablar de un fichero, y si no, por que.
539
+ // KJC-TSK-0888 (ADR 0011): el paso al indice por proyecto, sin reindexar.
540
+ rag.command("migrate")
541
+ .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")
542
+ .option("--json", "Emit the result as JSON")
543
+ .action(async (flags) => {
544
+ await withConfig(pkgVersion, "rag-migrate", flags, async ({ config }) => {
545
+ await ragMigrateCommand({ config, flags });
546
+ });
547
+ });
537
548
  rag.command("covers <file>")
538
549
  .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
550
  .option("--json", "Emit the state as JSON")
@@ -564,7 +575,7 @@ export function registerMeta(program, { pkgVersion }) {
564
575
  .option("--rag-mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
565
576
  .option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
566
577
  .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).")
578
+ .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
579
  .option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
569
580
  .option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
570
581
  .option("--json", "Output the hits as JSON")
@@ -627,10 +638,10 @@ export function registerMeta(program, { pkgVersion }) {
627
638
  await withConfig(pkgVersion, "watch:start", flags, async ({ config, logger }) => watchStartCommand({ config, logger, flags }));
628
639
  });
629
640
  watch.command("stop").description("Stop the watcher daemon").action(async () => {
630
- await withConfig(pkgVersion, "watch:stop", {}, async ({ logger }) => watchStopCommand({ logger }));
641
+ await withConfig(pkgVersion, "watch:stop", {}, async ({ config, logger }) => watchStopCommand({ config, logger }));
631
642
  });
632
643
  watch.command("status").description("Report watcher daemon state").action(async () => {
633
- await withConfig(pkgVersion, "watch:status", {}, async ({ logger }) => watchStatusCommand({ logger }));
644
+ await withConfig(pkgVersion, "watch:status", {}, async ({ config, logger }) => watchStatusCommand({ config, logger }));
634
645
  });
635
646
 
636
647
  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
  }