karajan-code 4.18.0 → 4.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.18.0",
3
+ "version": "4.19.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dual-publish (MIG-A, KJC-TSK-0751, épica KJC-PCS-0077, ADR 0004) — el
4
+ * MISMO contenido sale bajo dos nombres npm durante la migración al scope:
5
+ * karajan-code (legacy, publish normal) y @karajan-family/code (scoped).
6
+ *
7
+ * Uso:
8
+ * node scripts/dual-publish.mjs --pack-only # genera+verifica el tarball scoped
9
+ * node scripts/dual-publish.mjs --publish --otp=XXX # publica el scoped (tras publicar el legacy)
10
+ *
11
+ * El swap del name es EN SITIO con restauración en finally: un crash a mitad
12
+ * no deja el manifest renombrado. La verificación es el MISMO verify-pack
13
+ * (name-agnóstico desde la parte 1): empaqueta, instala aislado y corre kj.
14
+ * El PRIMER publish del scoped lo hace el usuario (cuenta karajan-family,
15
+ * security key); los siguientes van con la sesión normal.
16
+ */
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+ import { execFileSync } from "node:child_process";
19
+ import { join, dirname, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ export const LEGACY_NAME = "karajan-code";
23
+ export const SCOPED_NAME = "@karajan-family/code";
24
+
25
+ /**
26
+ * Ejecuta `fn` con el manifest renombrado al scoped (+ publishConfig public,
27
+ * obligatorio para scoped) y lo RESTAURA byte a byte pase lo que pase.
28
+ */
29
+ export async function withScopedName(repoRoot, fn) {
30
+ const pkgPath = join(repoRoot, "package.json");
31
+ const original = readFileSync(pkgPath, "utf8");
32
+ const pkg = JSON.parse(original);
33
+ if (pkg.name !== LEGACY_NAME) {
34
+ throw new Error(`dual-publish: el manifest dice "${pkg.name}" y solo se renombra desde ${LEGACY_NAME} — jamás a ciegas`);
35
+ }
36
+ // publishConfig se MERGEA, no se pisa (catch de codex): un registry/tag
37
+ // preexistente debe sobrevivir al publish scoped.
38
+ writeFileSync(pkgPath, `${JSON.stringify({ ...pkg, name: SCOPED_NAME, publishConfig: { ...pkg.publishConfig, access: "public" } }, null, 2)}\n`, "utf8");
39
+ try {
40
+ return await fn();
41
+ } finally {
42
+ writeFileSync(pkgPath, original, "utf8");
43
+ }
44
+ }
45
+
46
+ // argv[1] normalizado (catch de codex): node lo absolutiza hoy, pero el
47
+ // guard no debe depender de ese detalle de implementación.
48
+ const isMain = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
49
+ if (isMain) {
50
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
51
+ const args = process.argv.slice(2);
52
+ const publish = args.includes("--publish");
53
+ const otp = args.find((a) => a.startsWith("--otp="))?.slice(6);
54
+ await withScopedName(repoRoot, async () => {
55
+ console.log(`dual-publish: manifest → ${SCOPED_NAME} (temporal)`);
56
+ // La E2E real: empaqueta el scoped, instálalo aislado y corre kj.
57
+ execFileSync("node", [join(repoRoot, "scripts", "verify-pack.mjs")], { cwd: repoRoot, stdio: "inherit" });
58
+ if (publish) {
59
+ const pubArgs = ["publish", "--ignore-scripts", ...(otp ? [`--otp=${otp}`] : [])];
60
+ console.log(`dual-publish: npm ${pubArgs.join(" ").replace(/--otp=\S+/, "--otp=***")}`);
61
+ execFileSync("npm", pubArgs, { cwd: repoRoot, stdio: "inherit" });
62
+ } else {
63
+ console.log("dual-publish: --pack-only — verificado y SIN publicar (usa --publish en la release)");
64
+ }
65
+ });
66
+ console.log(`dual-publish: manifest restaurado a ${LEGACY_NAME} ✓`);
67
+ }
@@ -27,6 +27,9 @@ import { fileURLToPath } from "node:url";
27
27
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
28
28
  const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
29
29
  const expectedVersion = pkg.version;
30
+ // MIG-A (KJC-TSK-0751): el nombre sale del manifest — el MISMO verify-pack
31
+ // valida el tarball unscoped y el @karajan-family/* del dual-publish.
32
+ const pkgName = pkg.name;
30
33
 
31
34
  // Subprocess env: strip CLAUDECODE (Claude Code blocks nested non-interactive
32
35
  // runs otherwise) and force a non-interactive, quiet npm.
@@ -54,13 +57,13 @@ let gTmp = null;
54
57
  let pnpmTmp = null;
55
58
  let qsTmp = null;
56
59
  try {
57
- console.log(`verify-pack: packing karajan-code@${expectedVersion}…`);
60
+ console.log(`verify-pack: packing ${pkgName}@${expectedVersion}…`);
58
61
  // --json gives us the exact filename without parsing human output.
59
62
  const packOut = run("npm", ["pack", "--json", "--silent"], { cwd: repoRoot });
60
63
  const filename = JSON.parse(packOut)[0]?.filename;
61
64
  if (!filename) fail("npm pack did not report a filename", packOut);
62
- // npm normalizes scoped names in --json but karajan-code is unscoped;
63
- // the file lands in cwd under the reported name.
65
+ // npm --json reports the exact filename (scoped names normalized to
66
+ // scope-name-x.y.z.tgz); the file lands in cwd under that name.
64
67
  tgzPath = path.join(repoRoot, filename);
65
68
  if (!fs.existsSync(tgzPath)) fail(`packed tarball not found at ${tgzPath}`);
66
69
 
@@ -93,7 +96,7 @@ try {
93
96
  const { scanPaths, loadPrivacyList } = await import(
94
97
  path.join(repoRoot, "src", "privacy", "scan.js")
95
98
  );
96
- const shippedDir = path.join(tmpDir, "node_modules", "karajan-code");
99
+ const shippedDir = path.join(tmpDir, "node_modules", ...pkgName.split("/"));
97
100
  const pFindings = scanPaths([shippedDir], { list: loadPrivacyList() });
98
101
  const pBlocks = pFindings.filter((f) => f.severity === "block");
99
102
  if (pBlocks.length > 0) {
@@ -265,7 +268,7 @@ try {
265
268
  );
266
269
  }
267
270
 
268
- console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
271
+ console.log(`\n✓ verify-pack: ${pkgName}@${expectedVersion} installs clean and runs.`);
269
272
  } finally {
270
273
  if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
271
274
  if (tmpDir && fs.existsSync(tmpDir)) fs.rmSync(tmpDir, { recursive: true, force: true });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Guarantee level en runtime (KJC-TSK-0756, hallazgo de campo de Pedro):
3
+ * la doctrina "kj jamás finge una supervisión que no puede aplicar" vivía
4
+ * solo en el README — ahora kj doctor la DECLARA para el host actual, con
5
+ * el porqué. El acoplamiento a Claude es UNA capa (tier A: hooks síncronos
6
+ * bloqueantes, que hoy solo ese harness expone) y es decisión de ADR
7
+ * (v4.13), no accidente; el tier B (git gates) es el suelo agnóstico.
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { detectHostAgent } from "../utils/agent-detect.js";
12
+
13
+ /** Pure: computes the three tiers from observable facts. */
14
+ export function guaranteeLevel({ host, harnessWired, policyDeclared, policyWorkflow }) {
15
+ const tierA = host === "claude" && harnessWired
16
+ ? { active: true, reason: "Claude Code expone hooks síncronos bloqueantes y el harness del Sentinel está instalado (kj harden)" }
17
+ : host === "claude"
18
+ ? { active: false, reason: "host Claude sin harness instalado — corre kj harden para activar la supervisión en el turno" }
19
+ : { active: false, reason: `el host ${host ?? "desconocido"} no expone hooks síncronos bloqueantes — no se finge la supervisión; el tier B sigue siendo el suelo de garantía` };
20
+ const tierB = { active: true, reason: "los gates viven en el repositorio, no en el harness: cualquier host, el diff violador no entra" };
21
+ const tierC = policyDeclared && policyWorkflow
22
+ ? { active: true, reason: "kj-policy.yml re-verifica el PR contra la policy declarada (merge-blocking)" }
23
+ : policyDeclared
24
+ ? { active: false, reason: "hay policy declarada pero sin workflow de CI — corre kj harden para sembrar kj-policy.yml" }
25
+ : { active: false, reason: "sin .karajan/policy.yml declarada — el tier C solo existe donde hay policy que verificar" };
26
+ return { host: host ?? null, tierA, tierB, tierC };
27
+ }
28
+
29
+ /** Facts from disk + env; injectable for tests. */
30
+ export function collectGuaranteeFacts({ projectDir = process.cwd(), deps = {} } = {}) {
31
+ const { detectHost = detectHostAgent, exists = existsSync } = deps;
32
+ return guaranteeLevel({
33
+ host: detectHost(),
34
+ harnessWired: exists(join(projectDir, ".karajan", "harness", "pretooluse-sentinel.mjs")),
35
+ policyDeclared: exists(join(projectDir, ".karajan", "policy.yml")),
36
+ policyWorkflow: exists(join(projectDir, ".github", "workflows", "kj-policy.yml")),
37
+ });
38
+ }
@@ -286,7 +286,7 @@ export function registerMeta(program, { pkgVersion }) {
286
286
  });
287
287
 
288
288
  // KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
289
- const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado) — PL-A: modo warn");
289
+ const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
290
290
  policyCmd.command("eval")
291
291
  .description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
292
292
  .option("--role <role>", "Rol del agente", "coder")
@@ -1,5 +1,5 @@
1
1
  import { rolesCommand } from "../commands/roles.js";
2
- import { agentsCommand } from "../commands/agents.js";
2
+ import { agentsCommand, ASSIGNABLE_ROLES, VALID_PROVIDERS } from "../commands/agents.js";
3
3
  import { withConfig } from "./_shared.js";
4
4
 
5
5
  /**
@@ -30,6 +30,22 @@ export function registerRolesSkills(program, { pkgVersion }) {
30
30
  .command("agents [subcommand] [role] [provider]")
31
31
  .description("List or change AI agent assignments per role (e.g. kj agents set coder gemini)")
32
32
  .option("--global", "Persist change to kj.config.yml (default for CLI)")
33
+ // KJC-TSK-0755: el --help enumera valores REALES (de las constantes, no
34
+ // duplicados a mano) — la firma genérica obligaba a preguntar.
35
+ .addHelpText("after", [
36
+ "",
37
+ "Subcommands:",
38
+ " list (default) Show the provider assigned to each role",
39
+ " set Change one: kj agents set <role> <provider>",
40
+ "",
41
+ `Roles: ${ASSIGNABLE_ROLES.join(", ")}`,
42
+ `Providers: ${VALID_PROVIDERS.join(", ")} (kj doctor shows which are installed)`,
43
+ "",
44
+ "Examples:",
45
+ " kj agents # current assignments",
46
+ " kj agents set reviewer codex",
47
+ " kj agents set solomon agy --global",
48
+ ].join("\n"))
33
49
  .action(async (subcommand, role, provider, flags) => {
34
50
  await withConfig(pkgVersion, "agents", {}, async ({ config }) => {
35
51
  await agentsCommand({ config, subcommand: subcommand || "list", role, provider, global: flags.global });
@@ -1,12 +1,14 @@
1
1
  import { loadConfig, writeConfig, getConfigPath, getProjectConfigPath, loadProjectConfig, resolveRole } from "../config.js";
2
2
  import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
3
3
 
4
- const ASSIGNABLE_ROLES = [
4
+ // Exportados para que el --help se autoalimente de las constantes reales
5
+ // (KJC-TSK-0755, hallazgo de campo: la firma genérica obligaba a preguntar).
6
+ export const ASSIGNABLE_ROLES = [
5
7
  "coder", "reviewer", "planner", "refactorer", "triage",
6
8
  "researcher", "tester", "security", "solomon"
7
9
  ];
8
10
 
9
- const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
11
+ export const VALID_PROVIDERS = KNOWN_AGENTS.map((a) => a.name);
10
12
 
11
13
  export function listAgents(config, sessionOverrides = {}, projectConfig = null) {
12
14
  return ASSIGNABLE_ROLES.map((role) => {
@@ -13,6 +13,7 @@
13
13
 
14
14
  import readline from "node:readline";
15
15
  import { runChecks as runCheckPipeline, toLegacyShape } from "../checks/runner.js";
16
+ import { collectGuaranteeFacts } from "../checks/guarantee-level.js";
16
17
  import { STATUS } from "../checks/types.js";
17
18
  import { getSystemChecks } from "../checks/system.js";
18
19
  import { getConfigFileChecks } from "../checks/config-files.js";
@@ -140,16 +141,31 @@ export async function runChecks({ config }) {
140
141
  */
141
142
  export async function doctorCommand({ config, checkOnly = false, yes = false, json = false, verbose = false, projectOnly = false }) {
142
143
  const report = await runDoctor({ config, checkOnly, yes, projectOnly });
144
+ // KJC-TSK-0756 (campo, Pedro): el guarantee level del host actual se
145
+ // DECLARA en runtime — la doctrina no vive solo en el README.
146
+ report.guarantee = collectGuaranteeFacts({ projectDir: config?.projectDir || process.cwd() });
143
147
 
144
148
  if (json) {
145
149
  console.log(JSON.stringify(report, null, 2));
146
150
  } else {
147
151
  printHuman(report, { verbose });
152
+ printGuaranteeLevel(report.guarantee);
148
153
  }
149
154
 
150
155
  return hasBlockingFailure(report) ? 1 : 0;
151
156
  }
152
157
 
158
+ // KJC-TSK-0756: la seccion de guarantee level — consola es el medio del
159
+ // doctor (excepcion no-console de commands).
160
+ function printGuaranteeLevel(g) {
161
+ const mark = (t) => (t.active ? "ACTIVE " : "OFF ");
162
+ console.log();
163
+ console.log(`Guarantee level (host: ${g.host ?? "desconocido"}) — kj jamas finge una supervision que no puede aplicar:`);
164
+ console.log(` ${mark(g.tierA)} A tool-time (Sentinel en el turno): ${g.tierA.reason}`);
165
+ console.log(` ${mark(g.tierB)} B commit-time (git gates, el SUELO): ${g.tierB.reason}`);
166
+ console.log(` ${mark(g.tierC)} C merge-time (re-check en CI): ${g.tierC.reason}`);
167
+ }
168
+
153
169
  function hasBlockingFailure(report) {
154
170
  return report.checks.some((c) => c.status === STATUS.FAIL || c.status === STATUS.TIMEOUT);
155
171
  }
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
13
13
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
14
14
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
16
+ import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
16
17
  import { checkCardFirst } from "../review/card-first.js";
17
18
  import { checkTestsWithCode } from "../review/tests-with-code.js";
18
19
  import { loadPrivacyList, scanText } from "../privacy/scan.js";
@@ -325,6 +326,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
325
326
  }
326
327
  }
327
328
 
329
+ // MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
330
+ // SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
331
+ // el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
332
+ // cierra ANTES de gastar reviewer; warn viaja como advisory en su task.
333
+ const mut = flags.staged && !flags.range ? await runMutationPregate({ config, projectDir }) : { enabled: false, ok: true };
334
+ if (mut.enabled) {
335
+ if (mut.available === false) {
336
+ console.log(`⚠ mutation pre-gate no disponible: ${mut.reason} — degradado avisando (method_gates.mutation)`);
337
+ } else if (mut.survived.length > 0) {
338
+ console.log(`Mutantes supervivientes en el diff — score ${mut.score ?? "n/a"}%:`);
339
+ for (const m of mut.survived) console.log(` ✗ ${formatSurvivor(m)}`);
340
+ if (!mut.ok) {
341
+ console.log("✗ mutation gate: la suite verde no PRUEBA el cambio — mata los supervivientes antes del review (method_gates.mutation: block).");
342
+ process.exitCode = 1;
343
+ return { verdict: "rejected", reviewer: "mutation", issues: mut.survived.map((m) => ({ severity: "high", file: m.file, description: formatSurvivor(m) })) };
344
+ }
345
+ task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n`
346
+ + `Surviving mutants on the changed lines (weak asserts — fold into your review):\n`
347
+ + mut.survived.map((m) => `- ${formatSurvivor(m)}`).join("\n");
348
+ }
349
+ }
350
+
328
351
  const record = await runOneShotReview({ diff, task, config, logger, projectDir });
329
352
  printVerdict(record);
330
353
  process.exitCode = record.verdict === "approved" ? 0 : 1;
@@ -71,6 +71,9 @@ const B = {
71
71
  [
72
72
  ...(tdd ? ["TDD is ON: the failing test exists FIRST, then the code."] : []),
73
73
  "The suite is never left red, and never goes green by deleting or skipping tests.",
74
+ ...(config?.method_gates?.mutation
75
+ ? ["Green is not proof: run `kj mutate --since <base>` after the suite passes and BEFORE review — kill surviving mutants (weak asserts) first."]
76
+ : []),
74
77
  ],
75
78
  "green suite + the list of new/changed tests and what each one pins.");
76
79
  },
@@ -61,7 +61,9 @@ Invariants (the git gates enforce these — they are not suggestions):
61
61
  \`kj_rag_query\` tool: reach for it before grepping by hand.
62
62
  - ${trackingLine(stateBackend, boardName)}
63
63
  - Tests prove behavior: the failing test exists first (TDD), and the suite
64
- is never left red.
64
+ is never left red. Green is not proof: with \`method_gates.mutation\`
65
+ declared, run \`kj mutate --since <base>\` after green and BEFORE the
66
+ review — a surviving mutant is a weak assert; kill it first.
65
67
  - Every diff is reviewed by a DIFFERENT AI before it is committed
66
68
  (\`kj review --staged\`): verdicts bind to the exact diff — change the code
67
69
  and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
@@ -299,7 +299,62 @@ process.stdin.on("end", () => {
299
299
  // en cualquier posicion, y una ruta entrecomillada con espacios es
300
300
  // invisible al tokenizador: ambas => deny conservador (endpoint
301
301
  // pedido por el reviewer; friccion asumida, el remedio es literal).
302
- if (/\\$\\(|\`/.test(cmd) || /["'][^"'\\n]*\\/[^"'\\n]* [^"'\\n]*["']/.test(cmd)) {
302
+ // KJC-BUG-0140: pares de comillas REALES, no regex sobre el texto —
303
+ // la comilla de CIERRE de un string + un redirect literal + la de
304
+ // apertura del siguiente creaban la ilusion de ruta con espacios y
305
+ // denegaban el flujo estandar commit -m "..." > log && echo "ok".
306
+ // Se recorre alternando estado (doctrina del parseCommand del motor):
307
+ // solo el CONTENIDO realmente entrecomillado con slash y espacio
308
+ // deniega — incluidas comillas a mitad de token (scp host:"/a b/x")
309
+ // y la comilla sin cerrar (no verificable).
310
+ // ...y por TOKEN completo, no por span: spans adyacentes concatenan
311
+ // ("dir with"" spaces/file" es UNA ruta) — catch de codex.
312
+ const quotedPathWithSpaces = (s) => {
313
+ // Un backslash pegado a comilla o blanco (comilla escapada,
314
+ // espacio escapado) altera donde CIERRA el string o esconde
315
+ // espacios: opaco => deny (doctrina del motor) — catch de codex.
316
+ // Caracteres construidos con fromCharCode: CERO escaping en este
317
+ // template — fuente y artefacto identicos, nada que malinterpretar
318
+ // (el doble-escaping confundio cuatro reviews; solomon desestimo
319
+ // la primera y las demas se volvieron ilegibles igual).
320
+ // El punto escapado de un sed y similares siguen libres.
321
+ const BS = String.fromCharCode(92);
322
+ const TAB = String.fromCharCode(9);
323
+ const NL = String.fromCharCode(10);
324
+ for (let i = s.indexOf(BS); i !== -1; i = s.indexOf(BS, i + 1)) {
325
+ const n = s[i + 1] || "";
326
+ if (n === '"' || n === "'" || n === " " || n === TAB) return true;
327
+ }
328
+ let q = null;
329
+ let tok = "";
330
+ let hadQuote = false;
331
+ const flush = () => {
332
+ const bad = hadQuote && tok.includes("/") && tok.includes(" ");
333
+ tok = "";
334
+ hadQuote = false;
335
+ return bad;
336
+ };
337
+ for (const ch of s) {
338
+ if (q) {
339
+ if (ch === q) q = null;
340
+ else tok += ch;
341
+ continue;
342
+ }
343
+ if (ch === '"' || ch === "'") {
344
+ q = ch;
345
+ hadQuote = true;
346
+ continue;
347
+ }
348
+ if (ch === " " || ch === TAB || ch === NL || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">" || ch === "(" || ch === ")") {
349
+ if (flush()) return true;
350
+ continue;
351
+ }
352
+ tok += ch;
353
+ }
354
+ if (q !== null) return true; // comilla sin cerrar: no verificable
355
+ return flush();
356
+ };
357
+ if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
303
358
  if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
304
359
  else {
305
360
  console.error("kj sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado).");
@@ -344,7 +399,7 @@ process.stdin.on("end", () => {
344
399
  const actorRole = process.env.KJ_POLICY_ROLE || "coder";
345
400
  const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
346
401
  if (pres.error || pres.status === null) {
347
- console.error("kj sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g karajan-code) o retira la policy conscientemente.");
402
+ console.error("kj sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g @karajan-family/code, o el nombre legacy karajan-code) o retira la policy conscientemente.");
348
403
  process.exit(2);
349
404
  }
350
405
  if (pres.status === 2) {
@@ -19,18 +19,18 @@ import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkf
19
19
  // npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
20
20
  // bundle SEA no hay package.json junto al módulo y una lectura eager a
21
21
  // nivel de módulo tumbaba el binario entero al cargar (ENOENT /package.json).
22
- let _ownVersion;
23
- function ownVersion() {
24
- if (_ownVersion === undefined) {
22
+ let _ownPkg;
23
+ function ownPkg() {
24
+ if (_ownPkg === undefined) {
25
25
  try {
26
- _ownVersion = JSON.parse(
26
+ _ownPkg = JSON.parse(
27
27
  readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"),
28
- ).version;
28
+ );
29
29
  } catch {
30
- _ownVersion = null;
30
+ _ownPkg = null;
31
31
  }
32
32
  }
33
- return _ownVersion;
33
+ return _ownPkg;
34
34
  }
35
35
 
36
36
  const BLOCK_VERSION = 1;
@@ -81,9 +81,9 @@ export function installWorkflows({
81
81
  // PL-C: el tier C solo existe donde el proyecto DECLARA policy. Versión a
82
82
  // pinear: la plumbeada por el CLI (SEA) o la propia; "latest" es último
83
83
  // recurso inalcanzable en los caminos reales.
84
- const pinned = kjVersion ?? ownVersion() ?? "latest";
84
+ const pinned = kjVersion ?? ownPkg()?.version ?? "latest";
85
85
  const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
86
- ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned) }
86
+ ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
87
87
  : null;
88
88
  const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : [])];
89
89
  const results = [];
@@ -57,7 +57,7 @@ export const WORKFLOWS = [
57
57
  // PL-C (KJC-TSK-0735) — tier C del ADR 0001: re-check merge-blocking en CI
58
58
  // (cubre el hook local manipulado). Solo se siembra con policy declarada;
59
59
  // npx PINEADO a la versión del kj que corrió harden — jamás @latest en CI.
60
- export const policyWorkflowFor = (kjVersion) => [
60
+ export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
61
61
  "name: Policy",
62
62
  "on:",
63
63
  " pull_request:",
@@ -77,7 +77,7 @@ export const policyWorkflowFor = (kjVersion) => [
77
77
  " env:",
78
78
  " BASE_REF: ${{ github.base_ref }}",
79
79
  " run: |",
80
- ` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes karajan-code@${kjVersion}"; fi`,
80
+ ` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes ${kjName}@${kjVersion}"; fi`,
81
81
  ' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
82
82
  "", // línea en blanco pre-marcador: prettier la exige tras un block scalar
83
83
  ].join("\n");
@@ -89,8 +89,12 @@ export function buildScope({ language, files, ranges }) {
89
89
  * the injectable git runner (defaults to `runCommand`).
90
90
  * @param {{since: string, language: string, toRef?: string, run?: Function}} params
91
91
  */
92
- export async function getDiffScope({ since, language, toRef = "HEAD", run = runCommand }) {
93
- const result = await run("git", ["diff", "--unified=0", `${since}..${toRef}`]).catch(
92
+ export async function getDiffScope({ since, language, toRef = "HEAD", staged = false, run = runCommand }) {
93
+ // MUT-A (KJC-TSK-0716): staged=true scopea al ÍNDICE (git diff --cached) —
94
+ // lo que el review gate evalúa. since..HEAD con since=HEAD sería un diff
95
+ // VACÍO y el pre-gate no mutaría nada (catch de codex, agravado).
96
+ const args = staged ? ["diff", "--unified=0", "--cached"] : ["diff", "--unified=0", `${since}..${toRef}`];
97
+ const result = await run("git", args).catch(
94
98
  () => null,
95
99
  );
96
100
  if (!result || result.exitCode !== 0) {
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Mutation pre-gate (MUT-A, KJC-TSK-0716) — audita "tests prove behavior":
3
+ * asserts flojos dejan mutantes vivos con la suite verde. OPT-IN
4
+ * (method_gates.mutation: warn|block — cuesta minutos) y SOLO en --staged:
5
+ * supervivientes como advisory al reviewer; block cierra ANTES con la
6
+ * lista exacta. Indisponible = degrada avisando, nunca en silencio.
7
+ */
8
+ import { runMutation } from "../mutate/runner.js";
9
+ import { getDiffScope } from "../mutate/diff-scope.js";
10
+ import { detectProjectStack } from "../utils/stack-detect.js";
11
+ import { getMutationTool } from "../mutate/tool-registry.js";
12
+
13
+ export const formatSurvivor = (s) =>
14
+ `${s.file}:${s.line} (${s.mutator ?? s.status ?? "?"}) — mátalo con un assert que distinga el cambio`;
15
+
16
+ async function defaultMutate({ projectDir }) {
17
+ const { language } = await detectProjectStack(projectDir);
18
+ const tool = getMutationTool(language);
19
+ if (!tool.supported) throw new Error(`lenguaje no soportado (${language ?? "desconocido"}) — ${tool.reason}`);
20
+ const scope = await getDiffScope({ staged: true, language });
21
+ if (scope.empty) return { result: { score: 100, killed: 0, total: 0, survived: [] } };
22
+ const outcome = await runMutation({ binary: tool.binary, args: scope.args, cwd: projectDir });
23
+ if (!outcome.result) throw new Error(`la herramienta ${tool.id} no produjo un informe legible`);
24
+ return outcome;
25
+ }
26
+
27
+ /**
28
+ * @returns {Promise<{enabled: boolean, ok: boolean, available?: boolean, mode?: string, survived?: object[], score?: number, reason?: string}>}
29
+ */
30
+ export async function runMutationPregate({ config = {}, projectDir = process.cwd(), deps = {} } = {}) {
31
+ const mode = config?.method_gates?.mutation;
32
+ if (mode !== "warn" && mode !== "block") return { enabled: false, ok: true };
33
+ const { mutateFn = defaultMutate } = deps;
34
+ let outcome;
35
+ try {
36
+ outcome = await mutateFn({ projectDir });
37
+ } catch (err) {
38
+ // Sin herramienta no hay medición: se degrada DICIENDO qué red cayó.
39
+ return { enabled: true, ok: true, available: false, mode, reason: err.message };
40
+ }
41
+ // Payload malformado del runner = misma degradación que el error (catch
42
+ // de codex): jamás crashear el review gate por un informe roto.
43
+ if (!outcome?.result || !Array.isArray(outcome.result.survived ?? [])) {
44
+ return { enabled: true, ok: true, available: false, mode, reason: "el runner de mutación devolvió un informe ilegible" };
45
+ }
46
+ const { survived = [], score } = outcome.result;
47
+ return { enabled: true, available: true, mode, survived, score, ok: mode === "warn" || survived.length === 0 };
48
+ }
@@ -7,6 +7,8 @@
7
7
  // brief+tests; existing/legacy = + drift, harden advisory, rag + qmd status.
8
8
  // Collaborators are injected (opts.deps) so it stays trivially testable.
9
9
  import { existsSync } from "node:fs";
10
+ import { readdir } from "node:fs/promises";
11
+ import { join, relative } from "node:path";
10
12
  import { execFileSync } from "node:child_process";
11
13
 
12
14
  import { collectAll } from "../onboarder/collectors/index.js";
@@ -23,11 +25,50 @@ const CODE_EXT = new Set([
23
25
  ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".go",
24
26
  ".rs", ".java", ".php", ".rb", ".c", ".cc", ".cpp", ".cs",
25
27
  ]);
28
+ // KJC-BUG-0141 — infra projects (k8s/terraform/shell/Docker) ARE source code,
29
+ // and code nested deeper than the display tree's maxDepth must still count.
30
+ const INFRA_EXT = new Set([".yaml", ".yml", ".tf", ".hcl", ".sh"]);
31
+ const INFRA_NAMES = new Set(["Dockerfile", "Makefile", "Vagrantfile"]);
32
+ const SCAN_IGNORED = new Set([
33
+ "node_modules", "dist", "build", "out", "vendor", "coverage", "target", "__pycache__",
34
+ ]);
35
+ const SCAN_MAX_DEPTH = 6;
26
36
  const SCAFFOLD_MAX_FILES = 3;
27
37
  const CI_MARKERS = [".github/workflows", ".gitlab-ci.yml"];
28
38
 
29
39
  const safe = async (fn) => { try { return await fn(); } catch { return null; } };
30
40
 
41
+ const isTestPath = (p) => {
42
+ const posix = p.replaceAll("\\", "/");
43
+ return /\.(test|spec)\./.test(posix) || /(^|\/)(test|tests|__tests__)\//.test(posix);
44
+ };
45
+
46
+ /**
47
+ * Deep source counter with its own walk: the display tree stops at depth 2,
48
+ * which made backend/app/… projects look empty (KJC-BUG-0141).
49
+ * @returns {Promise<{code: number, infra: number}>}
50
+ */
51
+ export async function deepSourceScan(projectDir, { maxDepth = SCAN_MAX_DEPTH } = {}) {
52
+ const counts = { code: 0, infra: 0 };
53
+ const walk = async (dir, depth) => {
54
+ const entries = await readdir(dir, { withFileTypes: true });
55
+ for (const ent of entries) {
56
+ if (ent.name.startsWith(".") || SCAN_IGNORED.has(ent.name)) continue;
57
+ const full = join(dir, ent.name);
58
+ if (ent.isDirectory()) {
59
+ if (depth < maxDepth) await walk(full, depth + 1);
60
+ continue;
61
+ }
62
+ if (!ent.isFile() || isTestPath(relative(projectDir, full))) continue;
63
+ const ext = ent.name.slice(ent.name.lastIndexOf("."));
64
+ if (CODE_EXT.has(ext)) counts.code += 1;
65
+ else if (INFRA_EXT.has(ext) || INFRA_NAMES.has(ent.name)) counts.infra += 1;
66
+ }
67
+ };
68
+ await walk(projectDir, 0);
69
+ return counts;
70
+ }
71
+
31
72
  /** Count real source files in a tree bundle, skipping tests. */
32
73
  export function countCodeFiles(tree = []) {
33
74
  let n = 0;
@@ -35,8 +76,7 @@ export function countCodeFiles(tree = []) {
35
76
  if (node.kind === "dir") n += countCodeFiles(node.children);
36
77
  else if (node.kind === "file") {
37
78
  const ext = node.path.slice(node.path.lastIndexOf("."));
38
- const isTest = /\.(test|spec)\./.test(node.path) || /(^|\/)(test|tests|__tests__)\//.test(node.path);
39
- if (CODE_EXT.has(ext) && !isTest) n += 1;
79
+ if (CODE_EXT.has(ext) && !isTestPath(node.path)) n += 1;
40
80
  }
41
81
  }
42
82
  return n;
@@ -73,20 +113,22 @@ function readRagStatus(projectDir) {
73
113
  */
74
114
  export async function runReadOnlySweep(projectDir, { declared = null, profile = "standard", deps = {} } = {}) {
75
115
  const d = {
76
- collectAll, detectTestFramework, checkHarden, compareHarden,
116
+ collectAll, detectTestFramework, checkHarden, compareHarden, sourceScan: deepSourceScan,
77
117
  detectQmd, ragStatus: readRagStatus, gitAgeDays: lastCommitAgeDays, ...deps,
78
118
  };
79
119
 
80
120
  const brief = await safe(() => d.collectAll(projectDir));
81
121
  const tests = await safe(() => d.detectTestFramework(projectDir));
122
+ const scan = await safe(() => d.sourceScan(projectDir));
82
123
 
83
124
  const tree = brief?.tree ?? [];
84
125
  const present = brief?.configs?.present ?? [];
85
- const codeFiles = countCodeFiles(tree);
126
+ const codeFiles = scan ? scan.code + scan.infra : countCodeFiles(tree);
86
127
  const commitCount = brief?.git?.commitCount ?? 0;
87
128
 
88
129
  const signals = {
89
130
  hasSourceCode: codeFiles > 0,
131
+ infraFiles: scan?.infra ?? 0,
90
132
  scaffoldingOnly: codeFiles > 0 && codeFiles <= SCAFFOLD_MAX_FILES && commitCount <= 1,
91
133
  hasTests: Boolean(tests?.hasTests),
92
134
  hasCI: CI_MARKERS.some((m) => present.includes(m)),
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import { readFileSync } from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { isSea } from "node:sea";
@@ -8,7 +9,25 @@ const CACHE_FILE = "update-check.json";
8
9
  // KJC-TSK-0690: 6h — kj can ship several releases a day; a session should
9
10
  // not work a full day blind. KJ_NO_UPDATE_CHECK=1 opts out (CI).
10
11
  const CACHE_TTL_MS = 6 * 60 * 60 * 1000;
11
- const PACKAGE_NAME = "karajan-code";
12
+ // MIG-A (KJC-TSK-0751): el nombre npm se lee del PROPIO manifest — con el
13
+ // dual-publish del scope, el mismo código vive como karajan-code y como
14
+ // @karajan-family/code y cada instalación se auto-actualiza por SU nombre.
15
+ // Lazy + fallback: en el bundle SEA no hay package.json junto al módulo.
16
+ let _pkgName;
17
+ export function packageName() {
18
+ if (_pkgName) return _pkgName;
19
+ try {
20
+ _pkgName = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).name || "karajan-code";
21
+ } catch {
22
+ _pkgName = "karajan-code";
23
+ }
24
+ return _pkgName;
25
+ }
26
+
27
+ // URL del registro con el nombre CODIFICADO — el scoped @karajan-family/code
28
+ // lleva @ y / que rompen la ruta sin encode (catch de codex).
29
+ export const registryLatestUrl = (name = packageName()) =>
30
+ `https://registry.npmjs.org/${encodeURIComponent(name)}/latest`;
12
31
  const CHANGELOG_URL = "https://raw.githubusercontent.com/manufosela/karajan-code/main/CHANGELOG.md";
13
32
  const HIGHLIGHT_MAX = 160;
14
33
 
@@ -55,10 +74,10 @@ export function updateInstruction({ channel, platform = process.platform }) {
55
74
  : `Re-run the installer: curl -fsSL ${INSTALL_SH_URL} | sh`;
56
75
  }
57
76
  if (channel === "npm") {
58
- return `Run: npm install -g ${PACKAGE_NAME}`;
77
+ return `Run: npm install -g ${packageName()}`;
59
78
  }
60
79
  // Channel unknown — offer both paths, never silently pick a wrong one.
61
- return `Update: npm install -g ${PACKAGE_NAME} (or re-run the binary installer — see README)`;
80
+ return `Update: npm install -g ${packageName()} (or re-run the binary installer — see README)`;
62
81
  }
63
82
 
64
83
  /**
@@ -100,7 +119,7 @@ export async function checkForUpdate(currentVersion) {
100
119
  // Fetch from npm (timeout 3s, don't block)
101
120
  const controller = new AbortController();
102
121
  const timeout = setTimeout(() => controller.abort(), 3000);
103
- const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
122
+ const res = await fetch(registryLatestUrl(), {
104
123
  signal: controller.signal,
105
124
  headers: { "Accept": "application/json" },
106
125
  });
@@ -174,7 +193,7 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
174
193
  try {
175
194
  // Registry over HTTP, not `npm view` — standalone-binary machines may
176
195
  // not have npm at all.
177
- const res = await fetchFn(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
196
+ const res = await fetchFn(registryLatestUrl(), {
178
197
  headers: { Accept: "application/json" },
179
198
  });
180
199
  if (!res.ok) throw new Error(`registry responded ${res.status}`);
@@ -213,7 +232,7 @@ export async function performSelfUpdate({ currentVersion, exec, logger = console
213
232
  }
214
233
  } else {
215
234
  // No stdio:inherit — capture and drop npm's warnings on the success path.
216
- await run("npm", ["install", "-g", `${PACKAGE_NAME}@latest`]);
235
+ await run("npm", ["install", "-g", `${packageName()}@latest`]);
217
236
  }
218
237
  } catch (err) {
219
238
  if (err.stdout) logger.error(err.stdout);