karajan-code 4.35.1 → 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 (42) 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/register-meta.js +15 -4
  12. package/src/commands/board.js +20 -8
  13. package/src/commands/bootstrap.js +2 -0
  14. package/src/commands/env.js +8 -4
  15. package/src/commands/harden.js +26 -4
  16. package/src/commands/init.js +2 -1
  17. package/src/commands/plan/generate.js +4 -6
  18. package/src/commands/rag.js +69 -10
  19. package/src/commands/review-gate.js +14 -5
  20. package/src/commands/watch.js +13 -10
  21. package/src/config/defaults.js +3 -2
  22. package/src/environment/playbook.js +10 -3
  23. package/src/harden/guidelines-engine.js +24 -7
  24. package/src/harden/sentinel-hooks.js +68 -7
  25. package/src/lang/registry.js +2 -1
  26. package/src/mcp/handlers/rag-handler.js +14 -4
  27. package/src/orchestrator/drivers/post-loop.js +13 -11
  28. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
  29. package/src/orchestrator/stages/rag-context-stage.js +5 -2
  30. package/src/rag/auto-update.js +5 -3
  31. package/src/rag/coverage.js +13 -12
  32. package/src/rag/indexable.js +97 -0
  33. package/src/rag/indexer.js +31 -13
  34. package/src/rag/library.js +19 -13
  35. package/src/rag/migrate.js +73 -0
  36. package/src/rag/onnx-fallback.js +5 -3
  37. package/src/rag/project-store.js +48 -0
  38. package/src/rag/watcher.js +25 -14
  39. package/src/review/kj-provenance.js +26 -0
  40. package/src/review/verdict-store.js +6 -1
  41. package/src/start/sweep.js +4 -3
  42. package/src/utils/rulesync.js +61 -0
@@ -1,19 +1,49 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isAbsolute, join, relative } from "node:path";
1
3
  // KJC-PCS-0049 Step 6 — `kj rag` command group. Two subcommands:
2
4
  // kj rag index [--project <slug>] [--with-sources]
3
5
  // kj rag query <text> [--scope plans|code|onboarding|all] [--top-k N] [--json]
4
6
  // Closes the v2.22.0 RAG MVP end-to-end from the terminal.
5
- import { openVecStore, countChunks, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
7
+ import { countChunks, deleteChunksBySource, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
6
8
  import { makeGovernedEmbedder } from "../rag/governed-embedder.js";
7
- import { indexProject, indexProjectDelta } from "../rag/indexer.js";
9
+ import { indexFile, indexProject, indexProjectDelta, sourceKind } from "../rag/indexer.js";
10
+ import { indexableReason, ragExclude } from "../rag/indexable.js";
8
11
  import { fileIndexState } from "../rag/coverage.js";
9
12
  import { query } from "../rag/retriever.js";
10
13
  import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
11
14
  import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
12
15
  import { loadGoldenQueries, runEval } from "../rag/eval.js";
13
16
  import { getKarajanHome } from "../utils/paths.js";
17
+ import { countProjectChunks, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
18
+ import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
14
19
 
20
+ // KJC-TSK-0882 (ADR 0011): el indice es del proyecto, no de la maquina.
15
21
  function openDb(config) {
16
- return openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
22
+ return openProjectStore({ projectDir: config?.projectDir || process.cwd(), dim: config?.rag?.embedder?.dim || 768 });
23
+ }
24
+
25
+ async function indexFiles({ files, projectDir, db, embedder, slug, logger, json, exclude }) {
26
+ const totals = { indexed: 0, failed: 0, files: 0, deleted: 0 };
27
+ for (const file of files) {
28
+ const abs = isAbsolute(file) ? file : join(projectDir, file);
29
+ if (!existsSync(abs)) {
30
+ totals.deleted += deleteChunksBySource(db, abs); // gone from disk: gone from the index
31
+ continue;
32
+ }
33
+ // KJC-TSK-0891: the same criterion and kind as the full walk, or the
34
+ // remedy rag-first names would index nothing for a .mdx or a .astro.
35
+ const out = indexableReason(relative(projectDir, abs), abs, { exclude, projectDir });
36
+ if (out) {
37
+ logger.warn?.(`[rag] ${out}: not indexed`);
38
+ continue;
39
+ }
40
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
41
+ totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
42
+ }
43
+ const deleted = totals.deleted ? `, ${totals.deleted} chunks deleted` : "";
44
+ if (json) process.stdout.write(`${JSON.stringify(totals)}\n`);
45
+ else logger.info(`[rag] indexed ${totals.indexed} chunk(s) across ${totals.files} file(s) (${totals.failed} failed${deleted})`);
46
+ return totals;
17
47
  }
18
48
 
19
49
  export async function ragIndexCommand({ config, logger, flags = {} }) {
@@ -22,6 +52,9 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
22
52
  try {
23
53
  const slug = projectSlug(projectDir);
24
54
  const embedder = makeGovernedEmbedder(config);
55
+ // KJC-TSK-0891: exactly these files. The remedy rag-first names for a file
56
+ // missing from the index: `--since auto` only reaches what changed.
57
+ if (flags.file?.length) return await indexFiles({ files: flags.file, projectDir, db, embedder, slug, logger, json: flags.json, exclude: ragExclude(config) });
25
58
  const sinceFlag = flags.since;
26
59
  // KJC-TSK-0455 — `--since auto` resolves to the last commit we indexed;
27
60
  // an explicit ref is honoured as-is. Without a baseline (first-time
@@ -35,7 +68,7 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
35
68
  let totals;
36
69
  if (since) {
37
70
  try {
38
- totals = await indexProjectDelta(projectDir, { db, embedder, since, logger });
71
+ totals = await indexProjectDelta(projectDir, { db, embedder, since, logger, exclude: ragExclude(config) });
39
72
  } catch (err) {
40
73
  logger.warn?.(`[rag] delta index failed (${err.message}); falling back to full index`);
41
74
  totals = null;
@@ -46,13 +79,17 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
46
79
  db, embedder,
47
80
  karajanHome: getKarajanHome(), logger,
48
81
  withSources: Boolean(flags.withSources),
82
+ exclude: ragExclude(config),
49
83
  });
50
84
  }
51
85
  if (totals.head) setLastIndexedCommit(db, slug, totals.head);
52
86
  // KJC-TSK-0697 — the library corpus refreshes on every index run.
53
87
  // Best-effort and cheap: a handful of cards, content-hash dedup.
88
+ // KJC-TSK-0883: el canon de la maquina a su base; las fichas del proyecto, a la suya.
54
89
  try {
55
- await indexLibrary({ db, embedder, logger, projectDir });
90
+ const lib = openLibraryStore({ dim: config?.rag?.embedder?.dim || 768 });
91
+ try { await indexLibrary({ db: lib, embedder, logger, scope: "machine" }); } finally { lib.close(); }
92
+ await indexLibrary({ db, embedder, logger, projectDir, scope: "project", project: slug });
56
93
  } catch (err) {
57
94
  logger.warn?.(`[rag] library index failed: ${err.message}`);
58
95
  }
@@ -87,7 +124,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
87
124
  // (--no-rag-update / config.rag.autoUpdate); failures degrade to a warn
88
125
  // inside maybeAutoUpdate and the query proceeds with the current index.
89
126
  await maybeAutoUpdate({ projectDir: config?.projectDir || process.cwd(), config, logger, flags });
90
- const db = openDb(config);
127
+ // KJC-TSK-0883: --library abre la base del canon, no la del proyecto.
128
+ const db = flags.library ? openLibraryStore({ dim: config?.rag?.embedder?.dim || 768 }) : openDb(config);
91
129
  try {
92
130
  const topK = Math.max(1, Number(flags.topK) || 5);
93
131
  const scope = flags.scope || "all";
@@ -107,9 +145,12 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
107
145
  // deterministic recovery signal. The CLI was emitting just `[]`, which
108
146
  // is indistinguishable from "query returned zero hits over a populated
109
147
  // store" and forced consumers to parse stderr.
110
- if (countChunks(db) === 0) {
111
- logger.warn("[rag] No chunks indexed yet. Run 'kj rag index' first.");
112
- if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
148
+ // KJC-TSK-0882: cuenta lo de ESTE proyecto; chunks ajenos no son respuesta.
149
+ if (countChunks(db) === 0 || (project && countProjectChunks(db, project) === 0)) {
150
+ // La library se rellena en cada `kj rag index`; migrar la llevaria al proyecto.
151
+ const remedy = library ? "kj rag index" : emptyIndexRemedy({ slug: project, legacyPath: join(getKarajanHome(), "rag.db"), dim: config?.rag?.embedder?.dim || 768 });
152
+ logger.warn(`[rag] ${library ? "the library index" : "this project's index"} holds no chunks of ${project || "any project"}: this is not an answer, run ${remedy}`);
153
+ if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope, remedy })}\n`);
113
154
  return [];
114
155
  }
115
156
  const mode = flags.mode || "hybrid";
@@ -183,7 +224,7 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
183
224
  const projectDir = config?.projectDir || process.cwd();
184
225
  const db = openDb(config);
185
226
  try {
186
- const state = fileIndexState(projectDir, file, { db });
227
+ const state = fileIndexState(projectDir, file, { db, exclude: ragExclude(config) });
187
228
  if (flags.json) process.stdout.write(`${JSON.stringify(state)}\n`);
188
229
  else process.stdout.write(`${state.state}: ${state.reason}\n`);
189
230
  if (!state.canAnswer) process.exitCode = 1;
@@ -192,3 +233,21 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
192
233
  db.close();
193
234
  }
194
235
  }
236
+
237
+ /**
238
+ * KJC-TSK-0888 (RAG-P1a, ADR 0011) — `kj rag migrate`: copia los chunks de este
239
+ * proyecto desde la base global a su propio indice, con sus embeddings, sin
240
+ * volver a embeber. La base global no se toca.
241
+ */
242
+ export async function ragMigrateCommand({ config, flags = {} }) {
243
+ const projectDir = config?.projectDir || process.cwd();
244
+ const res = migrateProjectIndex({
245
+ slug: projectSlug(projectDir),
246
+ legacyPath: join(getKarajanHome(), "rag.db"),
247
+ targetPath: projectDbPath(projectDir),
248
+ dim: config?.rag?.embedder?.dim || 768,
249
+ });
250
+ if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
251
+ else process.stdout.write(`rag migrate: ${res.state} — ${res.reason}\n`);
252
+ return res;
253
+ }
@@ -188,6 +188,14 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
188
188
  // KJC-TSK-0687 (MG-B): the verifiable half of TDD — sources without a
189
189
  // single test change warn (block via method_gates.tests_with_code).
190
190
  const changedFiles = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
191
+ // KJC-BUG-0229: un fichero BORRADO no es una fuente que el scan pueda indexar
192
+ // ni sobre la que el RAG pueda responder, asi que exigirlo era pedir lo
193
+ // imposible: una PR que retiraba 45 ficheros se paro dos veces y acumulo tres
194
+ // excepciones por la misma causa mecanica. Los requisitos de Sonar y de RAG
195
+ // se evaluan sobre lo que sigue VIVO tras el cambio (A, C, M, R); policy, LOC
196
+ // y tests-with-code siguen viendo el diff entero, porque ahi un borrado si
197
+ // cuenta. El borrado danino lo cubre la review cruzada, que lee todo el diff.
198
+ const liveFiles = (await rawDiff(flags.range, ["--name-only", "--diff-filter=ACMR"])).split("\n").map((f) => f.trim()).filter(Boolean);
191
199
 
192
200
  // KJC-TSK-0688 (MG-C) + KJC-TSK-0691 (MG-E): oversized-diff policy.
193
201
  // Field case: a 522-line PR sailed past the warning with a
@@ -357,6 +365,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
357
365
  }
358
366
  }
359
367
  let res = await checkVerdict(projectDir, diff);
368
+ if (res.note) console.log(`ℹ ${res.note}`);
360
369
  // KJC-TSK-0838: a reviewed diff with code enters only if the verdict's
361
370
  // sonar block proves the analysis ran and covered every staged source.
362
371
  // The headless pipeline stamps its verdict after its own sonar stage and
@@ -371,7 +380,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
371
380
  res = { ok: false, verdict: res.verdict, reason: `Sonar is mandatory for code — ${why}` };
372
381
  }
373
382
  } else if (res.ok) {
374
- const req = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
383
+ const req = checkSonarRequirement({ config, stagedFiles: liveFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
375
384
  if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
376
385
  else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
377
386
  }
@@ -387,7 +396,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
387
396
  if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
388
397
  } else if (res.ok) {
389
398
  const { harness, verified, mismatched, drift } = readRagLedger(projectDir);
390
- const rreq = checkRagVerdict({ config, stagedFiles: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, drift, standingExceptions: std.standing });
399
+ const rreq = checkRagVerdict({ config, stagedFiles: liveFiles, rag: res.verdict.rag, harness, verified, mismatched, drift, standingExceptions: std.standing });
391
400
  if (!rreq.ok) res = { ok: false, verdict: res.verdict, reason: rreq.reason };
392
401
  else if (rreq.mode === "granted" && rreq.grant) console.log(formatRagGrant(rreq.grant));
393
402
  }
@@ -414,7 +423,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
414
423
  if (flags.sonar !== false) {
415
424
  // KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
416
425
  const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
417
- const pre = await runSonarPregate({ config, stagedFiles: changedFiles, touchedLines, logger });
426
+ const pre = await runSonarPregate({ config, stagedFiles: liveFiles, touchedLines, logger });
418
427
  if (!pre.available) {
419
428
  // KJC-BUG-0156 (issue #1543): a scan that FAILED must not read like one
420
429
  // more config warning — it hid a dead quality gate for 8 straight PRs.
@@ -459,7 +468,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
459
468
  // KJC-TSK-0838: fail-CLOSED for code. Sonar disabled, down, skipped by
460
469
  // flag, or blind to a staged source — the diff does not reach the reviewer.
461
470
  // Docs-only diffs pass; a live human grant on the rule is the only escape.
462
- const sonarReq = checkSonarRequirement({ config, stagedFiles: changedFiles, sonar: sonarRecord, standingExceptions: std.standing });
471
+ const sonarReq = checkSonarRequirement({ config, stagedFiles: liveFiles, sonar: sonarRecord, standingExceptions: std.standing });
463
472
  if (!sonarReq.ok) {
464
473
  console.log(`✗ ${sonarReq.reason}`);
465
474
  process.exitCode = 1;
@@ -476,7 +485,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
476
485
  // the 16-sep bugs were exactly the twin nobody touched.
477
486
  const ledger = readRagLedger(projectDir);
478
487
  const newFiles = (await rawDiff(flags.range, ["--name-only", "--diff-filter=A"])).split("\n").map((f) => f.trim()).filter(Boolean);
479
- const ragReq = checkRagRequirement({ config, stagedFiles: changedFiles, newFiles, ledger, standingExceptions: std.standing });
488
+ const ragReq = checkRagRequirement({ config, stagedFiles: liveFiles, newFiles, ledger, standingExceptions: std.standing });
480
489
  if (!ragReq.ok) {
481
490
  console.log(`✗ ${ragReq.reason}`);
482
491
  process.exitCode = 1;
@@ -8,13 +8,13 @@ const HERE = dirname(fileURLToPath(import.meta.url));
8
8
 
9
9
  export async function watchStartCommand({ config, logger, flags = {} }) {
10
10
  const projectDir = config?.projectDir || process.cwd();
11
- const existing = readPidFile();
11
+ const existing = readPidFile(projectDir);
12
12
  if (existing && isPidAlive(existing)) { logger.info(`Watcher already running (pid ${existing}).`); return { pid: existing, started: false }; }
13
- if (existing) clearPidFile();
13
+ if (existing) clearPidFile(projectDir);
14
14
  if (flags.foreground) {
15
- writePidFile();
15
+ writePidFile(projectDir);
16
16
  const stop = startWatcher({ projectDir, config, logger, withSources: !!flags.withSources });
17
- const shutdown = async () => { await stop(); clearPidFile(); process.exit(0); };
17
+ const shutdown = async () => { await stop(); clearPidFile(projectDir); process.exit(0); };
18
18
  process.on("SIGINT", shutdown).on("SIGTERM", shutdown);
19
19
  logger.info(`Watcher started in foreground (pid ${process.pid}). Ctrl+C to stop.`);
20
20
  return { pid: process.pid, started: true };
@@ -26,18 +26,21 @@ export async function watchStartCommand({ config, logger, flags = {} }) {
26
26
  return { pid: child.pid, started: true };
27
27
  }
28
28
 
29
- export async function watchStopCommand({ logger }) {
30
- const pid = readPidFile();
29
+ // KJC-TSK-0883: stop/status son del watcher de ESTE proyecto, resuelto como en start.
30
+ export async function watchStopCommand({ config, logger }) {
31
+ const projectDir = config?.projectDir || process.cwd();
32
+ const pid = readPidFile(projectDir);
31
33
  if (!pid) { logger.info("No watcher running."); return { stopped: false }; }
32
- if (!isPidAlive(pid)) { clearPidFile(); logger.info(`Stale PID file removed (process ${pid} was not alive).`); return { stopped: false, stale: true }; }
34
+ if (!isPidAlive(pid)) { clearPidFile(projectDir); logger.info(`Stale PID file removed (process ${pid} was not alive).`); return { stopped: false, stale: true }; }
33
35
  try { process.kill(pid, "SIGTERM"); } catch (err) { logger.warn(`Could not signal pid ${pid}: ${err.message}`); }
34
- clearPidFile();
36
+ clearPidFile(projectDir);
35
37
  logger.info(`Watcher (pid ${pid}) stopped.`);
36
38
  return { stopped: true, pid };
37
39
  }
38
40
 
39
- export async function watchStatusCommand({ logger }) {
40
- const pid = readPidFile();
41
+ export async function watchStatusCommand({ config, logger }) {
42
+ const projectDir = config?.projectDir || process.cwd();
43
+ const pid = readPidFile(projectDir);
41
44
  const alive = pid && isPidAlive(pid);
42
45
  if (alive) logger.info(`Watcher running (pid ${pid}).`);
43
46
  else if (pid) logger.warn(`Stale PID file (process ${pid} not alive). Run \`kj watch stop\` to clean up.`);
@@ -179,10 +179,11 @@ const DEFAULTS = {
179
179
  test_inclusions: "**/*.test.js,**/*.spec.js,**/tests/**,**/__tests__/**"
180
180
  }
181
181
  },
182
+ // KJC-TSK-0884 (ADR 0011): on by default, like Sonar; a declared false opts out.
182
183
  hu_board: {
183
- enabled: false,
184
+ enabled: true,
184
185
  port: 4000,
185
- auto_start: false
186
+ auto_start: true
186
187
  },
187
188
  language: "en",
188
189
  hu_language: "en",
@@ -12,6 +12,7 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { upsertManagedBlock } from "../utils/managed-markers.js";
15
+ import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed } from "../utils/rulesync.js";
15
16
  import { panelLine } from "./panel.js";
16
17
 
17
18
  // AB-A (KJC-TSK-0650): any agent can be the brain. AGENTS.md is the
@@ -114,15 +115,21 @@ export async function installPlaybook({ projectDir, target = "all", version = "1
114
115
  throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
115
116
  }
116
117
  const files = [];
117
- for (const file of TARGET_FILES[target]) {
118
+ // KJC-TSK-0879: in a Rulesync repo its rule source is the only file of truth;
119
+ // `rulesync generate` would erase a block written into the agent files.
120
+ const rulesync = detectRulesync(projectDir);
121
+ for (const file of rulesync ? [RULESYNC_RULE] : TARGET_FILES[target]) {
118
122
  const fullPath = path.join(projectDir, file);
119
- let source = "";
123
+ let source = rulesync ? rulesyncRuleSeed() : "";
120
124
  try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
121
125
  const { content, action } = upsertManagedBlock({
122
126
  source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName, config }), style: "html",
123
127
  note: "do not edit: regenerated by kj env install",
124
128
  });
125
- if (action !== "unchanged") await fs.writeFile(fullPath, content);
129
+ if (action !== "unchanged") {
130
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
131
+ await fs.writeFile(fullPath, content);
132
+ }
126
133
  files.push(file);
127
134
  }
128
135
  return { files, target };
@@ -5,10 +5,11 @@
5
5
  * content the user keeps outside the markers. Idempotent.
6
6
  */
7
7
 
8
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { dirname, join } from "node:path";
10
10
 
11
11
  import { upsertManagedBlock } from "../utils/managed-markers.js";
12
+ import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed } from "../utils/rulesync.js";
12
13
  import { guidelinesBody } from "./guidelines-templates.js";
13
14
 
14
15
  const BLOCK_VERSION = 1;
@@ -28,7 +29,12 @@ export function stripDevHooksBlock(source) {
28
29
  return { content: `${before}${joiner}${after}`, migrated: true };
29
30
  }
30
31
 
31
- const countLines = (text) => String(text || "").split("\n").filter((l) => l.trim()).length;
32
+ const nonEmpty = (text) => String(text || "").split("\n").map((l) => l.trim()).filter(Boolean);
33
+ const countLines = (text) => nonEmpty(text).length;
34
+
35
+ // KJC-BUG-0232: "do not edit" se leia como "no toques esto", no como "lo que
36
+ // escribas aqui se borra". Solo este bloque: los hooks sellados no cambian.
37
+ const GUIDELINES_NOTE = "regenerated by kj harden: anything written inside is lost; keep your own rules outside this block";
32
38
 
33
39
  export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
34
40
  // KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
@@ -36,9 +42,13 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
36
42
  // the configs; the guidelines just never asked.
37
43
  const body = guidelinesBody(language);
38
44
  const results = [];
39
- for (const file of TARGETS) {
45
+ // KJC-TSK-0879: with Rulesync, its source is the file of truth; writing into
46
+ // CLAUDE.md/AGENTS.md would be erased by the next `rulesync generate`.
47
+ const rulesync = detectRulesync(projectDir);
48
+ for (const file of rulesync ? [RULESYNC_RULE] : TARGETS) {
40
49
  const target = join(projectDir, file);
41
- const raw = existsSync(target) ? readFileSync(target, "utf8") : "";
50
+ const seed = rulesync ? rulesyncRuleSeed() : "";
51
+ const raw = existsSync(target) ? readFileSync(target, "utf8") : seed;
42
52
  const { content: source, migrated } = stripDevHooksBlock(raw);
43
53
  const { content, action } = upsertManagedBlock({
44
54
  source,
@@ -46,13 +56,20 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
46
56
  version: BLOCK_VERSION,
47
57
  body,
48
58
  style: "html",
59
+ note: GUIDELINES_NOTE,
49
60
  });
50
61
  // KJC-BUG-0206: harden removing rules is a fact worth saying. It silently
51
62
  // dropped six lines from this repo's own files because the detected language
52
63
  // had no block, and "updated" read like an improvement.
53
64
  const shrank = countLines(content) < countLines(raw);
54
- if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
55
- results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true } : {}) });
65
+ // KJC-BUG-0232: decir CUALES se van, o el aviso obliga a salir a git diff.
66
+ const kept = new Set(nonEmpty(content));
67
+ const removed = shrank ? nonEmpty(raw).filter((l) => !kept.has(l) && !l.includes("kj:managed:")) : [];
68
+ if (!dryRun && (migrated || action !== "unchanged")) {
69
+ mkdirSync(dirname(target), { recursive: true });
70
+ writeFileSync(target, content);
71
+ }
72
+ results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true, removed } : {}) });
56
73
  }
57
74
  return { dryRun, guidelines: results };
58
75
  }
@@ -8,7 +8,7 @@
8
8
  * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
9
  */
10
10
 
11
- import { readFileSync } from "node:fs";
11
+ import { readFileSync, writeFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { join } from "node:path";
@@ -131,6 +131,7 @@ const POST_BODY = `#!/usr/bin/env node
131
131
  // kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
132
132
  // Records deterministic method facts per session; never blocks, never fails
133
133
  // a tool call (PostToolUse, always exit 0).
134
+ import console from "node:console";
134
135
  import process from "node:process";
135
136
  import { relative } from "node:path";
136
137
  import { spawnSync } from "node:child_process";
@@ -256,7 +257,16 @@ process.stdin.on("end", () => {
256
257
  const state = load();
257
258
  const s = session(state, sid);
258
259
  const ref = CARD.exec(headRef || branchOf() || "");
259
- (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
260
+ // KJC-BUG-0230: una EPICA (…-PCS-…) no tiene estado que mover: el PG
261
+ // la deja en To Do por mas status que reciba y kj hu move no la
262
+ // conoce. Registrarla como pendiente era un bloqueo sin cauce. Se dice
263
+ // en el momento, para que no parezca que el board dejo de mirar.
264
+ const epic = ref && /-PCS-/i.test(ref[0]);
265
+ if (epic) {
266
+ console.error("karajan sentinel: board-sync — la PR #" + mergedPr + " pertenece a la epica " + ref[0].toUpperCase() + ", que no tiene estado que mover: no queda pendiente (su cierre va en la descripcion de la epica)");
267
+ } else {
268
+ (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
269
+ }
260
270
  save(state);
261
271
  }
262
272
  const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
@@ -980,9 +990,19 @@ process.stdin.on("end", () => {
980
990
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
981
991
  const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
982
992
  let blind = null;
983
- try { const c = JSON.parse(String(cov.stdout || "")); if (c.state === "not-indexable") blind = c.reason; } catch { blind = null; }
993
+ let absent = null;
994
+ try {
995
+ const c = JSON.parse(String(cov.stdout || ""));
996
+ if (c.state === "not-indexable") blind = c.reason;
997
+ // KJC-TSK-0891: si el indice no tiene el fichero, consultar no puede
998
+ // devolverlo; el remedio es indexarlo, y se nombra.
999
+ if (c.state === "stale" || c.state === "index-empty") absent = c.reason;
1000
+ } catch { blind = null; }
984
1001
  if (blind) {
985
1002
  console.error("karajan sentinel: rag-first — " + blind + " — no se te exige consultar lo que el indice no puede tener; el gate lo anota y sigue" + doc("rag-first"));
1003
+ } else if (absent) {
1004
+ console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
1005
+ process.exit(2);
986
1006
  } else {
987
1007
  console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
988
1008
  process.exit(2);
@@ -1159,7 +1179,33 @@ function sealedByPath(projectDir, gitShowFn) {
1159
1179
  }
1160
1180
 
1161
1181
  /**
1162
- * @returns {{ok: boolean, mismatched: string[], drift?: string[], reason?: string}}
1182
+ * KJC-BUG-0224: la procedencia PROPIA del harness, lo que `kj harden` escribio
1183
+ * (sha256 por guardia). Distinto del sello humano: no concede nada, solo dice
1184
+ * "esto lo puso kj", y lo unico que habilita es regenerar desde el kj
1185
+ * instalado. Forjarlo, por tanto, solo consigue que el guardia se restaure.
1186
+ */
1187
+ const INSTALLED_RECORD = "installed.json";
1188
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
1189
+
1190
+ function readInstalledRecord(dir) {
1191
+ try {
1192
+ const files = JSON.parse(readFileSync(join(dir, INSTALLED_RECORD), "utf8"))?.files;
1193
+ return files && typeof files === "object" ? files : {};
1194
+ } catch {
1195
+ return {};
1196
+ }
1197
+ }
1198
+
1199
+ function writeInstalledRecord(dir) {
1200
+ const files = {};
1201
+ for (const name of Object.keys(SCRIPT_BODIES)) {
1202
+ try { files[name] = sha256(readFileSync(join(dir, name), "utf8")); } catch { /* sin fichero no hay nada que registrar */ }
1203
+ }
1204
+ writeFileSync(join(dir, INSTALLED_RECORD), `${JSON.stringify({ files }, null, 2)}\n`);
1205
+ }
1206
+
1207
+ /**
1208
+ * @returns {{ok: boolean, mismatched: string[], drift?: string[], regenerated?: string[], reason?: string}}
1163
1209
  * `mismatched` is TAMPERING: a guard holds content nobody sealed, and that is
1164
1210
  * what the hooks block on. `drift` is a sealed guard whose templates have
1165
1211
  * moved on, which `kj harden` applies and which is not a defect.
@@ -1185,16 +1231,30 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1185
1231
  // cannot be borrowed by also editing a template: that was the hole in the
1186
1232
  // first attempt at this fix, and the review was right to reject it.
1187
1233
  const sealed = sealedByPath(root, gitShowFn);
1234
+ const ownRecord = readInstalledRecord(dir);
1188
1235
  const drift = [];
1189
1236
  const tampered = [];
1237
+ const regenerated = [];
1190
1238
  for (const name of mismatched) {
1191
1239
  const text = installed.get(name);
1192
- const sealedHash = sealed.get(`.karajan/harness/${name}`);
1193
- if (text !== undefined && sealedHash && sealedHash === createHash("sha256").update(text).digest("hex")) drift.push(name);
1240
+ const hash = text === undefined ? null : sha256(text);
1241
+ if (hash && sealed.get(`.karajan/harness/${name}`) === hash) drift.push(name);
1242
+ else if (hash && ownRecord[name] === hash) regenerated.push(name);
1194
1243
  else tampered.push(name);
1195
1244
  }
1196
- if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}) };
1245
+ // KJC-BUG-0224 caso 1: lo que kj escribio y nadie sello ni toco se pone al
1246
+ // dia solo. Es lo que hacia el ritual de `kj harden`, hecho por la maquina.
1247
+ if (regenerated.length > 0) {
1248
+ for (const name of regenerated) writeHarnessScript(root, name, SCRIPT_BODIES[name]);
1249
+ writeInstalledRecord(dir);
1250
+ }
1251
+ const done = regenerated.length ? { regenerated } : {};
1252
+ if (tampered.length > 0) return { ok: false, mismatched: tampered, ...(drift.length ? { drift } : {}), ...done };
1253
+ if (drift.length === 0) {
1254
+ return { ok: true, mismatched: [], ...done, reason: `kj avanzó y estos guardias eran los que kj escribió: regenerados (${regenerated.join(", ")})` };
1255
+ }
1197
1256
  return {
1257
+ ...done,
1198
1258
  ok: true,
1199
1259
  mismatched: [],
1200
1260
  drift,
@@ -1243,6 +1303,7 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1243
1303
  }
1244
1304
  return writeHarnessScript(projectDir, name, body);
1245
1305
  });
1306
+ writeInstalledRecord(join(projectDir, ".karajan", "harness"));
1246
1307
  if (deferred.length > 0) {
1247
1308
  logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
1248
1309
  }
@@ -14,7 +14,8 @@ import { RUST_ADAPTER } from "./rust.js";
14
14
  // Segmentos siempre excluidos del walk, independientes de stack: VCS,
15
15
  // outputs de build, scratch de Karajan, sandbox del audit. Lo que viva
16
16
  // aquí jamás se indexa.
17
- const COMMON_SKIP_SEGMENTS = [".git", "dist", "build", "coverage", ".karajan", ".kj", "_diet"];
17
+ // KJC-BUG-0231: `.claude` guarda worktrees de subagentes, copias enteras del repo.
18
+ const COMMON_SKIP_SEGMENTS = [".git", "dist", "build", "coverage", ".karajan", ".kj", "_diet", ".claude"];
18
19
 
19
20
  const REGISTRY = [JS_ADAPTER, PYTHON_ADAPTER, RUST_ADAPTER, GO_ADAPTER, JAVA_ADAPTER];
20
21
 
@@ -1,9 +1,13 @@
1
1
  // KJC-PCS-0049 Step 7 — MCP handlers calling src/rag/* directly. Imports
2
2
  // from src/commands/* are forbidden by the layer-boundaries test
3
3
  // (MCP and CLI are peer layers); we hit the pure rag/* modules instead.
4
- import { countChunks, openVecStore, projectSlug } from "../../rag/vec-store.js";
4
+ import { join } from "node:path";
5
+ import { countChunks, projectSlug } from "../../rag/vec-store.js";
6
+ import { openProjectStore } from "../../rag/project-store.js";
7
+ import { countProjectChunks, emptyIndexRemedy } from "../../rag/migrate.js";
5
8
  import { makeGovernedEmbedder } from "../../rag/governed-embedder.js";
6
9
  import { indexProject } from "../../rag/indexer.js";
10
+ import { ragExclude } from "../../rag/indexable.js";
7
11
  import { query } from "../../rag/retriever.js";
8
12
  import { getKarajanHome } from "../../utils/paths.js";
9
13
  import { resolveProjectDir, buildConfig, responseText, failPayload } from "../shared-helpers.js";
@@ -28,9 +32,14 @@ export async function handleRagQuery(args, server) {
28
32
  // projectDir, `project: "all"` disables the filter, `project: <slug>`
29
33
  // overrides it.
30
34
  const project = args?.project === "all" ? null : (args?.project || projectSlug(projectDir) || null);
31
- const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
35
+ const dim = config?.rag?.embedder?.dim || 768;
36
+ // KJC-TSK-0883 (ADR 0011): el indice del proyecto, como la CLI (0882).
37
+ const db = openProjectStore({ projectDir, dim });
32
38
  try {
33
- if (countChunks(db) === 0) return responseText({ hits: [], empty: true, topK, scope, project });
39
+ if (countChunks(db) === 0 || (project && countProjectChunks(db, project) === 0)) {
40
+ const remedy = emptyIndexRemedy({ slug: project, legacyPath: join(getKarajanHome(), "rag.db"), dim });
41
+ return responseText({ hits: [], empty: true, topK, scope, project, remedy });
42
+ }
34
43
  const hits = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project });
35
44
  return responseText({ hits, empty: false, topK, scope, project });
36
45
  } finally { db.close(); }
@@ -43,12 +52,13 @@ export async function handleRagIndex(args, server) {
43
52
  try {
44
53
  const projectDir = await resolveProjectDir(server, args?.projectDir);
45
54
  const config = await buildConfig({ ...args, projectDir }, "rag-index");
46
- const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
55
+ const db = openProjectStore({ projectDir, dim: config?.rag?.embedder?.dim || 768 });
47
56
  try {
48
57
  const totals = await indexProject(projectDir, {
49
58
  db, embedder: makeGovernedEmbedder(config),
50
59
  karajanHome: getKarajanHome(), logger: silentLogger,
51
60
  withSources: Boolean(args?.withSources),
61
+ exclude: ragExclude(config), // KJC-TSK-0891
52
62
  });
53
63
  return responseText(totals);
54
64
  } finally { db.close(); }
@@ -24,7 +24,7 @@
24
24
  import { generateDiff } from "../../review/diff-generator.js";
25
25
  import { finalizeGitAutomation } from "../../git/automation.js";
26
26
  import { listCommitsBetween, listFilesChangedSince } from "../../utils/git.js";
27
- import { loadPlan, projectSlug as slugFor } from "../../plan/plan-store.js";
27
+ import { loadPlan } from "../../plan/plan-store.js";
28
28
  import { computePlanAdherenceScore } from "../../audit/plan-adherence.js";
29
29
  import { saveSession, markSessionStatus } from "../../session/store.js";
30
30
  import {
@@ -334,22 +334,24 @@ export async function handleMaxIterationsReached({ session, budgetSummary, emitt
334
334
  return { approved: false, sessionId: session.id, reason: "max_iterations" };
335
335
  }
336
336
 
337
+ /**
338
+ * KJC-TSK-0884 (ADR 0011): the board starts by itself, like Sonar. Only a
339
+ * declared false on either switch opts out; nothing declared means on.
340
+ */
341
+ export const boardAutoStartWanted = (config) => config?.hu_board?.auto_start !== false && config?.hu_board?.enabled !== false;
342
+
337
343
  export async function tryAutoStartBoard(config, logger, emitter, eventBase) {
338
- // TSK-0273: gate on hu_board.auto_start alone. The previous double-gate
339
- // (enabled && auto_start) was confusing because `enabled` was never the
340
- // user-facing switch — only auto_start was. This matches the AC: "kj run
341
- // auto-inicia el board server si hu_board.auto_start es true".
342
- if (!config.hu_board?.auto_start) return;
344
+ if (!boardAutoStartWanted(config)) return;
343
345
  // Never auto-start during vitest: races the PID file and starts a detached
344
346
  // process that outlives the test run.
345
347
  if (process.env.VITEST || process.env.NODE_ENV === "test") return;
346
348
 
347
349
  try {
348
- const { startBoard, renderBoardBanner } = await import("../../commands/board.js");
349
- const boardPort = config.hu_board.port || 4000;
350
- // Scope the board URL to the current run's project (`#board/<slug>`)
351
- // so the user lands on a filtered view, not the global dashboard.
352
- const slug = config.projectDir ? slugFor(config.projectDir) : null;
350
+ const { startBoard, renderBoardBanner, boardSlug } = await import("../../commands/board.js");
351
+ const boardPort = config.hu_board?.port || 4000;
352
+ // KJC-TSK-0884: the project's scoped view (/p/<slug>), the parent repo's
353
+ // even from a worktree lane.
354
+ const slug = config.projectDir ? boardSlug(config.projectDir) : null;
353
355
  const boardResult = await startBoard(boardPort, { projectSlug: slug });
354
356
  const status = boardResult.alreadyRunning ? "already running" : "started";
355
357
  // Highlighted URL box — visible regardless of log level (matches the
@@ -122,9 +122,10 @@ export async function maybeGenerateAutoHuBatch({
122
122
  // node process around after the suite (TSK-0273).
123
123
  if (process.env.VITEST || process.env.NODE_ENV === "test") return;
124
124
  try {
125
- const { startBoard, renderBoardBanner } = await import("../../../commands/board.js");
125
+ const { startBoard, renderBoardBanner, boardSlug } = await import("../../../commands/board.js");
126
126
  const desiredPort = session.config_snapshot?.hu_board?.port ?? 4000;
127
- const boardResult = await startBoard(desiredPort);
127
+ // KJC-TSK-0884: its project's scoped view, not the global dashboard.
128
+ const boardResult = await startBoard(desiredPort, { projectSlug: projectDir ? boardSlug(projectDir) : null });
128
129
  const url = boardResult.url;
129
130
  const status = boardResult.alreadyRunning ? "already running" : "started";
130
131
  const projectName = batch.projectName || "Auto-generated HUs";