karajan-code 4.34.0 → 4.35.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.34.0",
3
+ "version": "4.35.1",
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",
@@ -34,6 +34,28 @@ export class BaseAgent {
34
34
  this.environment = environment;
35
35
  }
36
36
 
37
+ /**
38
+ * KJC-BUG-0217 (issue #1409) — los flags con los que kj invoca el CLI de un
39
+ * provider eran una constante del codigo, y hay versiones que rechazan la
40
+ * combinacion: kimi-code 0.27.0 responde "Cannot combine --prompt with
41
+ * --yolo" y dejaba `kj agent run` inutilizable, sin mas salida que parchear
42
+ * kj. Ahora el proyecto puede declararlos:
43
+ *
44
+ * agents: { kimi: { task_args: [], review_args: [] } }
45
+ *
46
+ * Sin declaracion valen los de siempre. Una declaracion que no sea una lista
47
+ * de cadenas se ignora: la config no puede romper la invocacion.
48
+ *
49
+ * @param {"task"|"review"} kind
50
+ * @param {string[]} fallback
51
+ * @returns {string[]}
52
+ */
53
+ declaredArgs(kind, fallback) {
54
+ const declared = this.config?.agents?.[this.name]?.[`${kind}_args`];
55
+ if (!Array.isArray(declared) || declared.some((a) => typeof a !== "string")) return fallback;
56
+ return declared;
57
+ }
58
+
37
59
  /**
38
60
  * Run a shell command through the injected CommandRunner. Subclasses should
39
61
  * call this instead of importing runCommand directly so tests can swap in
@@ -1,6 +1,9 @@
1
1
  import { BaseAgent } from "./base-agent.js";
2
2
  import { resolveBin } from "./resolve-bin.js";
3
3
 
4
+ /** El rechazo de kimi-code 0.27.0 a `-p` junto con `-y`, en su forma literal. */
5
+ const REJECTS_COMBINATION = /cannot combine .* with /i;
6
+
4
7
  /**
5
8
  * Kimi Code (Moonshot, binary `kimi`) — KJC-TSK-0729 fase 2: the eighth
6
9
  * built-in agent and the free-tier reserve of the reviewer/solomon panel.
@@ -17,7 +20,7 @@ import { resolveBin } from "./resolve-bin.js";
17
20
  export class KimiAgent extends BaseAgent {
18
21
  async runTask(task) {
19
22
  // Coder mode IS an agentic run: file edits and shell need approval.
20
- return this._exec(task, this.getRoleModel(task.role || "coder"), ["-y"]);
23
+ return this._exec(task, this.getRoleModel(task.role || "coder"), this.declaredArgs("task", ["-y"]));
21
24
  }
22
25
 
23
26
  async reviewTask(task) {
@@ -27,7 +30,7 @@ export class KimiAgent extends BaseAgent {
27
30
  return this._exec(
28
31
  { ...task, prompt: `Do NOT use any tools. Answer directly from this prompt only.\n\n${task.prompt}` },
29
32
  this.getRoleModel(task.role || "reviewer"),
30
- [],
33
+ this.declaredArgs("review", []),
31
34
  );
32
35
  }
33
36
 
@@ -40,6 +43,12 @@ export class KimiAgent extends BaseAgent {
40
43
  timeout: task.timeoutMs,
41
44
  cwd: task.cwd,
42
45
  });
43
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode };
46
+ // KJC-BUG-0217 (issue #1409): el CLI puede rechazar la COMBINACION de flags
47
+ // (kimi-code 0.27.0: "Cannot combine --prompt with --yolo"). Ese error crudo
48
+ // no dice que hacer; el de kj si.
49
+ const error = REJECTS_COMBINATION.test(String(res.stderr || ""))
50
+ ? `${res.stderr}\n\nkimi rechaza la combinacion de flags con la que kj lo invoca. Declara los que acepta esta version: agents.kimi.task_args (una lista vacia deja el prompt solo, sin auto-aprobado).`
51
+ : res.stderr;
52
+ return { ok: res.exitCode === 0, output: res.stdout, error, exitCode: res.exitCode };
44
53
  }
45
54
  }
@@ -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` : "";
@@ -41,7 +41,10 @@ export function createRagCoverageCheck({ openStore = (dim) => openVecStore({ dim
41
41
  const sample = gaps.slice(0, 3).join(", ") + (gaps.length > 3 ? ", …" : "");
42
42
  return { ok: false, severity: "fail", detail: `${c.missing.length} source(s) outside the RAG index, ${c.stale.length} stale (${sample}) — ${fix}` };
43
43
  }
44
- return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}` };
44
+ // KJC-BUG-0221: lo que kj genera no se exige indexado, pero se dice —
45
+ // una exencion que no se ve es indistinguible de un gate que dejo de mirar.
46
+ const generated = c.generated?.length ? `, ${c.generated.length} generated by kj (not required)` : "";
47
+ return { ok: true, severity: "info", detail: `${c.indexed}/${c.total} sources indexed at ${c.lastIndexedCommit.slice(0, 9)}${generated}` };
45
48
  } finally {
46
49
  db.close();
47
50
  }
@@ -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 } from "../commands/rag.js";
14
+ import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand, ragCoversCommand } 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";
@@ -532,6 +532,16 @@ export function registerMeta(program, { pkgVersion }) {
532
532
  await ragIndexCommand({ config, logger, flags });
533
533
  });
534
534
  });
535
+ // KJC-BUG-0216: el gate rag-first no puede pedir lo imposible. Esto responde
536
+ // si el RAG PUEDE hablar de un fichero, y si no, por que.
537
+ rag.command("covers <file>")
538
+ .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
+ .option("--json", "Emit the state as JSON")
540
+ .action(async (file, flags) => {
541
+ await withConfig(pkgVersion, "rag-covers", flags, async ({ config, logger }) => {
542
+ await ragCoversCommand({ file, config, logger, flags });
543
+ });
544
+ });
535
545
  rag.command("install-hooks")
536
546
  .description("Install the post-merge git hook so the RAG index auto-refreshes after pulls/merges (KJC-TSK-0455)")
537
547
  .option("--json", "Output the result as JSON")
@@ -5,6 +5,7 @@
5
5
  import { openVecStore, countChunks, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
6
6
  import { makeGovernedEmbedder } from "../rag/governed-embedder.js";
7
7
  import { indexProject, indexProjectDelta } from "../rag/indexer.js";
8
+ import { fileIndexState } from "../rag/coverage.js";
8
9
  import { query } from "../rag/retriever.js";
9
10
  import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
10
11
  import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
@@ -171,3 +172,23 @@ export async function ragEvalCommand({ config, logger, flags = {} }) {
171
172
  db.close();
172
173
  }
173
174
  }
175
+
176
+ /**
177
+ * KJC-BUG-0216 (issue #1807) — el hecho comprobable que el gate rag-first
178
+ * necesita antes de exigir una consulta: PUEDE el RAG responder sobre este
179
+ * fichero. Exit 0 = si; exit 1 = no, y la razon (sin adapter, segmento
180
+ * excluido, indice vacio o por detras) va en la salida.
181
+ */
182
+ export async function ragCoversCommand({ file, config, flags = {} }) {
183
+ const projectDir = config?.projectDir || process.cwd();
184
+ const db = openDb(config);
185
+ try {
186
+ const state = fileIndexState(projectDir, file, { db });
187
+ if (flags.json) process.stdout.write(`${JSON.stringify(state)}\n`);
188
+ else process.stdout.write(`${state.state}: ${state.reason}\n`);
189
+ if (!state.canAnswer) process.exitCode = 1;
190
+ return state;
191
+ } finally {
192
+ db.close();
193
+ }
194
+ }
@@ -386,8 +386,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
386
386
  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
387
  if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
388
388
  } 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 });
389
+ 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 });
391
391
  if (!rreq.ok) res = { ok: false, verdict: res.verdict, reason: rreq.reason };
392
392
  else if (rreq.mode === "granted" && rreq.grant) console.log(formatRagGrant(rreq.grant));
393
393
  }
@@ -68,7 +68,10 @@ const DEFAULTS = {
68
68
  state_backend: "hu-board",
69
69
  // verify_cmd (KJC-TSK-0732): user adapter that checks a card ref against
70
70
  // the real tracker — prints {exists, live, status} JSON; {ref} substituted.
71
- board: { name: null, verify_cmd: null },
71
+ // unverifiable (KJC-TSK-0878): que hacer cuando el adaptador no puede
72
+ // responder (sin red, sin credenciales, JSON invalido). "warn" degrada a
73
+ // branch-ref diciendolo; "block" se niega a aprobar lo que no ha comprobado.
74
+ board: { name: null, verify_cmd: null, unverifiable: "warn" },
72
75
  // Method gates (KJC-PCS-0068): rules climb from playbook text to
73
76
  // deterministic enforcement. card_first: "auto" = block with hu-board
74
77
  // (fully verifiable), warn with planning-game/external (presence only);
@@ -91,6 +94,11 @@ const DEFAULTS = {
91
94
  coder_rules: "./.karajan/coder-rules.md",
92
95
  base_branch: "main",
93
96
  coder_options: { model: null, auto_approve: true, fallback_coder: null },
97
+ // KJC-BUG-0217: los flags con los que kj invoca el CLI de cada provider son
98
+ // los que su version acepta, y eso cambia con el CLI. Aqui se declaran por
99
+ // agente cuando los de serie no valen: { kimi: { task_args: [] } } deja el
100
+ // prompt solo, sin auto-aprobado, para una version que rechaza la combinacion.
101
+ agents: {},
94
102
  reviewer_options: {
95
103
  output_format: "json",
96
104
  require_schema: true,
@@ -256,6 +264,10 @@ const DEFAULTS = {
256
264
  enabled: true,
257
265
  patterns: [],
258
266
  protected_files: [],
267
+ // KJC-BUG-0215: cauce declarado para el falso positivo de las reglas que
268
+ // acusan por el NOMBRE del identificador. Entradas `NAME` (todo el repo)
269
+ // o `path/to/file:NAME` (solo ese fichero, que es lo preferible).
270
+ non_secret_assignments: [],
259
271
  on_violation: "block"
260
272
  },
261
273
  perf: {
@@ -12,7 +12,7 @@ const DESTRUCTIVE_PATTERNS = [
12
12
  const CREDENTIAL_PATTERNS = [
13
13
  { id: "aws-key", pattern: /AKIA[0-9A-Z]{16}/, severity: "critical", message: "AWS access key exposed" },
14
14
  { id: "private-key", pattern: /-----BEGIN\s+(RSA\s+)?PRIVATE\s+KEY-----/, severity: "critical", message: "Private key exposed" },
15
- { id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "Hardcoded secret detected. Use .env file + process.env instead" },
15
+ { id: "generic-secret", pattern: /(password|secret|token|api_key|apikey)\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "Hardcoded secret detected. Move it to the environment or a secrets manager" },
16
16
  { id: "github-token", pattern: /gh[pousr]_[A-Za-z0-9_]{36,}/, severity: "critical", message: "GitHub token exposed" },
17
17
  { id: "npm-token", pattern: /npm_[A-Za-z0-9]{36,}/, severity: "critical", message: "npm token exposed" },
18
18
  { id: "openai-key", pattern: /sk-[A-Za-z0-9]{20,}/, severity: "critical", message: "OpenAI API key exposed" },
@@ -24,7 +24,7 @@ const CREDENTIAL_PATTERNS = [
24
24
  { id: "slack-token", pattern: /xox[bpors]-[A-Za-z0-9-]{10,}/, severity: "critical", message: "Slack token exposed" },
25
25
  { id: "jwt-secret", pattern: /jwt[_-]?secret\s*[:=]\s*["'][^"']{8,}["']/i, severity: "critical", message: "JWT secret hardcoded. Use .env file" },
26
26
  { id: "database-url", pattern: /(mongodb|postgres|mysql|redis):\/\/[^"'\s]+:[^"'\s]+@/i, severity: "critical", message: "Database URL with credentials exposed. Use .env file" },
27
- { id: "hardcoded-key-assignment", pattern: /(?:const|let|var)\s+\w*(?:key|secret|token|password)\w*\s*=\s*["'][A-Za-z0-9_-]{16,}["']/i, severity: "critical", message: "Hardcoded key in variable. Use process.env or .env file" },
27
+ { id: "hardcoded-key-assignment", pattern: /(?:const|let|var)\s+\w*(?:key|secret|token|password)\w*\s*=\s*["'][A-Za-z0-9_-]{16,}["']/i, severity: "critical", message: "Hardcoded key in an assignment. Move it to the environment or a secrets manager" },
28
28
  ];
29
29
 
30
30
  // Default protected files (block if these appear in added/modified lines)
@@ -115,6 +115,49 @@ export function checkProtectedFiles(diff, protectedFiles) {
115
115
  return violations;
116
116
  }
117
117
 
118
+ /**
119
+ * KJC-BUG-0215 (issue #1733): estas dos reglas acusan por el NOMBRE del
120
+ * identificador, no por la forma del valor, asi que un prefijo de cache llamado
121
+ * EXPERIMENTS_HANDOFF_TOKENS_NAMESPACE bloqueo una ejecucion entera con
122
+ * severidad critica y sin salida. Las demas reglas miran el valor (AKIA…,
123
+ * sk_live_…) y esas aciertan.
124
+ *
125
+ * El arreglo NO es adivinar: la review tumbo tres heuristicas seguidas y con
126
+ * razon, porque `const dbPassword = "db-password"` tambien tiene pinta inocente
127
+ * y es una credencial. Lo que faltaba era CAUCE: que el proyecto pueda declarar
128
+ * la excepcion en su configuracion versionada, donde se revisa en la PR como
129
+ * cualquier otra linea, y que el guard diga como hacerlo cuando bloquea.
130
+ */
131
+ const NAME_BASED_RULES = new Set(["hardcoded-key-assignment", "generic-secret"]);
132
+
133
+ const HOW_TO_DECLARE = "If it is not a secret, declare it in guards.output.non_secret_assignments as NAME or path/to/file:NAME";
134
+
135
+ /** El identificador al que se asigna, cuando la linea deja leerlo. */
136
+ const assignedName = (content) => /([A-Za-z_$][\w$]*)\s*[:=]\s*["']/.exec(content)?.[1] ?? null;
137
+
138
+ /** Una entrada declarada se lee como `NAME` o `path/to/file:NAME`. */
139
+ const splitEntry = (entry) => {
140
+ const cut = entry.lastIndexOf(":");
141
+ return cut === -1 ? { path: null, name: entry } : { path: entry.slice(0, cut), name: entry.slice(cut + 1) };
142
+ };
143
+
144
+ /**
145
+ * Excepcion DECLARADA, no inferida: el proyecto afirma por escrito que esta
146
+ * asignacion no guarda un secreto. Sin `file` la declaracion vale para todo el
147
+ * repositorio; con el, solo para ese fichero, que es lo estrecho y preferible.
148
+ */
149
+ export function declaredNonSecret(configGuards, file, content) {
150
+ const declared = configGuards?.output?.non_secret_assignments;
151
+ if (!Array.isArray(declared) || declared.length === 0) return false;
152
+ const name = assignedName(content);
153
+ if (!name) return false;
154
+ return declared.some(entry => {
155
+ if (typeof entry !== "string") return false;
156
+ const target = splitEntry(entry.trim());
157
+ return target.name === name && (target.path === null || target.path === file);
158
+ });
159
+ }
160
+
118
161
  /**
119
162
  * Scan a diff for pattern violations.
120
163
  * Returns { pass: boolean, violations: Array<{id, severity, file, line, message, matchedContent}> }
@@ -133,8 +176,17 @@ export function scanDiff(diff, config = {}) {
133
176
  // Check patterns against added lines
134
177
  for (const { file, line, content } of addedLines) {
135
178
  for (const { id, pattern, severity, message } of patterns) {
136
- if (pattern.test(content)) {
137
- violations.push({ id, severity, file, line, message, matchedContent: content.trim().slice(0, 200) });
179
+ if (!pattern.test(content)) continue;
180
+ const matchedContent = content.trim().slice(0, 200);
181
+ // KJC-BUG-0215: una regla que acusa por el nombre necesita un cauce
182
+ // declarado para el falso positivo, y decirlo cuando bloquea.
183
+ if (!NAME_BASED_RULES.has(id)) {
184
+ violations.push({ id, severity, file, line, message, matchedContent });
185
+ } else if (declaredNonSecret(configGuards, file, content)) {
186
+ violations.push({ id, severity: "warning", file, line, matchedContent,
187
+ message: `${message}. Declared as non-secret in guards.output.non_secret_assignments` });
188
+ } else {
189
+ violations.push({ id, severity, file, line, matchedContent, message: `${message}. ${HOW_TO_DECLARE}` });
138
190
  }
139
191
  }
140
192
  }
@@ -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,
@@ -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,
@@ -971,8 +971,22 @@ process.stdin.on("end", () => {
971
971
  // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
972
972
  if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
973
973
  } else {
974
- 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"));
975
- process.exit(2);
974
+ // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
975
+ // sin adapter NO puede estar en el indice, asi que exigir una
976
+ // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
977
+ // dejar KJ_ALLOW_NO_RAG puesta para siempre. La exencion es SOLO
978
+ // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
979
+ // y sigue bloqueando, porque si no el gate desapareceria en
980
+ // cualquier proyecto sin indexar. Nada legible = se bloquea.
981
+ const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
982
+ let blind = null;
983
+ try { const c = JSON.parse(String(cov.stdout || "")); if (c.state === "not-indexable") blind = c.reason; } catch { blind = null; }
984
+ if (blind) {
985
+ 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"));
986
+ } else {
987
+ 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
+ process.exit(2);
989
+ }
976
990
  }
977
991
  }
978
992
  }
@@ -1184,7 +1198,11 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1184
1198
  ok: true,
1185
1199
  mismatched: [],
1186
1200
  drift,
1187
- reason: "el harness instalado está sellado y las plantillas de este árbol van por delante (kj harden lo pone al día) — no es manipulación",
1201
+ // KJC-BUG-0222: `kj harden` NO pone al dia un guard sellado, lo deja en paz
1202
+ // a proposito (KJC-BUG-0193), asi que mandarlo era un bucle: ejecutas,
1203
+ // sale "unchanged" y el gate repite lo mismo. El que resuelve ESTE estado
1204
+ // es `kj harden --commit`, y es acto del humano.
1205
+ reason: "el harness instalado está sellado y las plantillas de este árbol van por delante — no es manipulación, y `kj harden` lo deja como está a propósito: quien lo avanza es el humano con `kj harden --commit`",
1188
1206
  };
1189
1207
  }
1190
1208
 
@@ -1200,6 +1218,13 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1200
1218
  * guard is still the right move.
1201
1219
  */
1202
1220
  export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
1221
+ // KJC-BUG-0223: instalado desde un subdirectorio, esto sembraba un segundo
1222
+ // `.karajan/harness` ahi, y el gate (que mira otro sitio) pasaba a verlo. El
1223
+ // ritual quedaba "arreglado" y el arbol se llenaba de arneses huerfanos que
1224
+ // no gobiernan nada. El harness es del proyecto: va a su raiz.
1225
+ const root = resolveSentinelRoot(projectDir);
1226
+ if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
1227
+ projectDir = root;
1203
1228
  const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
1204
1229
  const deferred = [];
1205
1230
  const keepsItsSeal = (name) => {