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
@@ -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) {
@@ -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
  }
@@ -386,8 +395,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
386
395
  console.log(`⚠ rag [${RAG_RULE_ID}]: the pipeline verdict carries no session ledger — the headless pipeline does not consult the RAG yet; sealed as a warn`);
387
396
  if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
388
397
  } else if (res.ok) {
389
- const { harness, verified, mismatched } = readRagLedger(projectDir);
390
- const rreq = checkRagVerdict({ config, stagedFiles: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, standingExceptions: std.standing });
398
+ const { harness, verified, mismatched, drift } = readRagLedger(projectDir);
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 };
@@ -118,6 +118,17 @@ export const GO_CONFIGS = [{ file: ".golangci.yml", blockId: "golangci", style:
118
118
  export const PHPSTAN_BODY = ["parameters:", " level: 6", " paths:", " - src", " - tests"].join("\n");
119
119
  export const PHP_CONFIGS = [{ file: "phpstan.neon", blockId: "phpstan", style: "hash", body: PHPSTAN_BODY }];
120
120
 
121
+
122
+ /**
123
+ * KJC-BUG-0221 — los nombres que kj ESCRIBE en el proyecto. Lo consume la
124
+ * cobertura del RAG para no denunciar como drift un fichero que kj acaba de
125
+ * generar. Se deriva de las tablas de arriba: anadir un config aqui no obliga
126
+ * a acordarse de una segunda lista en otro modulo.
127
+ */
128
+ export const GENERATED_CONFIG_FILES = new Set(
129
+ [...UNIVERSAL_CONFIGS, ...JS_CONFIGS, ...PY_CONFIGS, ...GO_CONFIGS, ...PHP_CONFIGS].map((c) => c.file),
130
+ );
131
+
121
132
  /** Lint/format configs by detected language. Unknown ⇒ universal only. */
122
133
  export const CONFIGS_BY_LANGUAGE = {
123
134
  javascript: JS_CONFIGS,
@@ -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
  }
@@ -9,6 +9,7 @@
9
9
  * supports tool hooks.
10
10
  */
11
11
 
12
+ import { execFileSync } from "node:child_process";
12
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
14
  import { join } from "node:path";
14
15
 
@@ -18,7 +19,11 @@ const SCRIPT_BODY = `#!/usr/bin/env node
18
19
  // so a gate bug never bricks the session.
19
20
  import console from "node:console";
20
21
  import process from "node:process";
21
- import { existsSync } from "node:fs";
22
+ import { existsSync, realpathSync } from "node:fs";
23
+ import { dirname, join, relative, resolve } from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+ // El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
26
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
22
27
  let raw = "";
23
28
  process.stdin.on("data", (d) => { raw += d; });
24
29
  process.stdin.on("end", () => {
@@ -30,10 +35,75 @@ process.stdin.on("end", () => {
30
35
  process.exit(2);
31
36
  }
32
37
  }
33
- if (tool === "Bash" && process.env.KJ_ALLOW_REWRITE !== "1") {
38
+ if (tool === "Bash") {
34
39
  const cmd = String(input.command || "");
40
+ // KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
41
+ // resto de guardias. Mirando solo process.env el agente no tenia salida:
42
+ // un export dentro del comando jamas llega a este proceso.
43
+ // Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
44
+ // aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
45
+ // un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
46
+ const toks = cmd.trim().split(/\\s+/);
47
+ let i = 0;
48
+ let named = false;
49
+ while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
50
+ if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
51
+ i += 1;
52
+ }
53
+ // Una asignacion delante solo alcanza a SU comando, asi que el escape del
54
+ // comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
55
+ // o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
56
+ // que va entre comillas no encadena nada, asi que no cuenta.
57
+ // Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
58
+ // hereda la asignacion (catch de la review). Mismo criterio que el resto
59
+ // de guardias del Sentinel.
60
+ // Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
61
+ // encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
62
+ const noSingle = cmd.replaceAll(/'[^']*'/g, "");
63
+ const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
64
+ const bare = noSingle.replaceAll(/"[^"]*"/g, "");
65
+ const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
66
+ const cmdEscape = named && simple && i < toks.length;
67
+ const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
68
+ // Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
69
+ if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
35
70
  const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
36
- if (/json\\.dumps?\\s*\\(/.test(cmd) && writes) {
71
+ // KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
72
+ // temporal del scratchpad no es asunto de este guard, y bloquearlo
73
+ // ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
74
+ const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
75
+ // Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
76
+ // fuera del arbol. Una relativa depende del cwd del shell, que este hook
77
+ // no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
78
+ // dentro del repo (catch de la review), asi que se sigue vigilando.
79
+ // Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
80
+ // (catch de la review). Se resuelve el padre real antes de clasificar, y
81
+ // si no se puede resolver, se vigila.
82
+ // Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
83
+ // <repo>/..config.json esta dentro (catch de la review).
84
+ const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
85
+ const outsideForSure = (t) => {
86
+ if (!t.startsWith("/")) return false;
87
+ const abs = resolve(t);
88
+ try {
89
+ const root = realpathSync(ROOT);
90
+ // El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
91
+ // asi que se resuelve entero cuando existe; si aun no existe, manda su
92
+ // directorio real.
93
+ if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
94
+ const parts = abs.split("/");
95
+ const name = parts.pop();
96
+ return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
97
+ } catch {
98
+ return false;
99
+ }
100
+ };
101
+ // Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
102
+ // deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
103
+ // de la review). Ante la duda se vigila.
104
+ const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
105
+ const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
106
+ if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
37
107
  console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
38
108
  process.exit(2);
39
109
  }
@@ -100,8 +170,27 @@ export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
100
170
  return { wired: true };
101
171
  }
102
172
 
173
+ /**
174
+ * La raiz del proyecto: el toplevel de git, o el directorio dado si no hay git.
175
+ * Vive aqui y no se importa de sentinel-hooks porque ese modulo importa de
176
+ * este: el ciclo rompe la carga.
177
+ */
178
+ function resolveProjectRoot(dir) {
179
+ try {
180
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir;
181
+ } catch {
182
+ return dir;
183
+ }
184
+ }
185
+
103
186
  /** Write the script and merge the PreToolUse entries into .claude/settings.json. */
104
187
  export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
188
+ // KJC-BUG-0223: el harness es del proyecto y vive en su raiz. Instalado desde
189
+ // un subdirectorio sembraba ahi un segundo `.karajan/harness` que no gobierna
190
+ // nada, y el gate seguia mirando otro sitio.
191
+ const root = resolveProjectRoot(projectDir);
192
+ if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
193
+ projectDir = root;
105
194
  const scriptAbs = writeHarnessScript(projectDir, "pretooluse.mjs", SCRIPT_BODY);
106
195
  const { wired } = mergeClaudeHooks({
107
196
  projectDir,