karajan-code 4.18.0 → 4.20.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.20.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 });
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { defaultEnvironment } from "../infrastructure/environment.js";
14
14
  import { buildAgentEnv } from "../utils/role-env.js";
15
+ import { isModelCompatible } from "../config/role-resolver.js";
15
16
 
16
17
  const MODEL_NOT_SUPPORTED_PATTERNS = [
17
18
  /model.{0,30}is not supported/i,
@@ -95,10 +96,19 @@ export class BaseAgent {
95
96
  * @returns {string|null}
96
97
  */
97
98
  getRoleModel(role) {
98
- const roleModel = this.config?.roles?.[role]?.model;
99
- if (roleModel) return roleModel;
100
- if (role === "reviewer") return this.config?.reviewer_options?.model || null;
101
- return this.config?.coder_options?.model || null;
99
+ const roleModel =
100
+ this.config?.roles?.[role]?.model ||
101
+ (role === "reviewer" ? this.config?.reviewer_options?.model : this.config?.coder_options?.model) ||
102
+ null;
103
+ // KJC-BUG-0144 (campo, issue #1465): un default por-rol (start: haiku)
104
+ // puede caer sobre un provider de OTRA familia (agy, codex...) — el
105
+ // modelo se descarta con aviso y el agente usa su propio default,
106
+ // nunca se le pasa un --model ajeno.
107
+ if (roleModel && !isModelCompatible(this.name, roleModel)) {
108
+ this.logger?.warn?.(`model "${roleModel}" belongs to another family — dropping it for ${this.name} (role ${role})`);
109
+ return null;
110
+ }
111
+ return roleModel;
102
112
  }
103
113
 
104
114
  /**
@@ -77,9 +77,26 @@ export class CodexAgent extends BaseAgent {
77
77
  }
78
78
 
79
79
  async _exec(task, model, role) {
80
+ // KJC-BUG-0143 (campo, issue #1471): codex exec >= 0.146 rechaza
81
+ // --full-auto ("unexpected argument"); el auto-approve moderno para un
82
+ // coder que escribe es --sandbox workspace-write. Los CLIs antiguos no
83
+ // conocen --sandbox: si lo rechazan, se reintenta con el flag legacy.
84
+ const autoApprove = role !== "reviewer" && this.isAutoApproveEnabled(role);
85
+ const res = await this._execOnce(task, model, role, { autoApprove, legacyAutoFlag: false });
86
+ if (!res.ok && autoApprove && /unexpected argument '--sandbox'/.test(res.error || "")) {
87
+ this.logger?.warn("codex CLI sin --sandbox — reintentando con el flag legacy --full-auto");
88
+ return this._execOnce(task, model, role, { autoApprove, legacyAutoFlag: true });
89
+ }
90
+ return res;
91
+ }
92
+
93
+ async _execOnce(task, model, role, { autoApprove, legacyAutoFlag }) {
80
94
  const args = ["exec", "--skip-git-repo-check"];
81
95
  if (model) args.push("--model", model);
82
- if (role !== "reviewer" && this.isAutoApproveEnabled(role)) args.push("--full-auto");
96
+ if (autoApprove) {
97
+ if (legacyAutoFlag) args.push("--full-auto");
98
+ else args.push("--sandbox", "workspace-write");
99
+ }
83
100
  args.push("-");
84
101
  const res = await this.runCommand(resolveBin("codex"), args, {
85
102
  onOutput: task.onOutput,
@@ -38,6 +38,7 @@ export function formatDeterministicSummary(ctx) {
38
38
  if (ctx.sonarFindings) lines.push(...formatSonarBlock(ctx.sonarFindings));
39
39
  if (ctx.osvFindings) lines.push(...formatOsvBlock(ctx.osvFindings));
40
40
  if (ctx.semgrepFindings) lines.push(...formatSemgrepBlock(ctx.semgrepFindings));
41
+ if (ctx.infraFindings) lines.push(...formatInfraBlock(ctx.infraFindings));
41
42
  if (ctx.circularDeps) lines.push(...formatCircularDepsBlock(ctx.circularDeps));
42
43
  if (ctx.deadExports) lines.push(...formatDeadExportsBlock(ctx.deadExports));
43
44
  if (ctx.injectionFindings) lines.push(...formatInjectionBlock(ctx.injectionFindings));
@@ -133,6 +134,25 @@ function formatSemgrepBlock(semgrepFindings) {
133
134
  return lines;
134
135
  }
135
136
 
137
+ // INF-C (KJC-TSK-0760): checkov misconfigs — same shape as the semgrep
138
+ // block; a not-applicable repo (no infra markers) declares it in one line.
139
+ function formatInfraBlock(infraFindings) {
140
+ if (!infraFindings.available) {
141
+ return ["### Infra misconfigurations (checkov)", `- Status: not available — ${infraFindings.reason || "checkov not installed"}`, ""];
142
+ }
143
+ const lines = ["### Infra misconfigurations (checkov)"];
144
+ lines.push(`- Total findings: ${infraFindings.total ?? 0}`);
145
+ const cap = MAX_SAMPLE_SEMGREP_PER_SEVERITY * 3;
146
+ for (const f of (infraFindings.findings || []).slice(0, cap)) {
147
+ const lineSuffix = f.line ? `:${f.line}` : "";
148
+ lines.push(` - [${f.severity}] ${f.file}${lineSuffix} [${f.rule}] ${f.message}${f.guideline ? ` (${f.guideline})` : ""}`);
149
+ }
150
+ const overflow = (infraFindings.total ?? 0) - cap;
151
+ if (overflow > 0) lines.push(` - ... and ${overflow} more`);
152
+ lines.push("");
153
+ return lines;
154
+ }
155
+
136
156
  function formatOsvBlock(osvFindings) {
137
157
  if (!osvFindings.available) {
138
158
  return ["### OSV Vulnerabilities", `- Status: not available — ${osvFindings.reason || "osv-scanner not installed"}`, ""];
@@ -298,6 +318,7 @@ export function deterministicContextHasFindings(ctx) {
298
318
  if (ctx.sonarFindings?.qualityGate?.status === "ERROR") return true;
299
319
  if (ctx.osvFindings?.available && (ctx.osvFindings.total ?? 0) > 0) return true;
300
320
  if (ctx.semgrepFindings?.available && (ctx.semgrepFindings.total ?? 0) > 0) return true;
321
+ if (ctx.infraFindings?.available && (ctx.infraFindings.total ?? 0) > 0) return true;
301
322
  if (ctx.circularDeps?.available && (ctx.circularDeps.total ?? 0) > 0) return true;
302
323
  if (ctx.deadExports?.available && (ctx.deadExports.total ?? 0) > 0) return true;
303
324
  if (ctx.growthDelta && (Math.abs(ctx.growthDelta.lines || 0) > 100 || Math.abs(ctx.growthDelta.deps || 0) > 0)) return true;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Checkov infra-misconfiguration collector for `kj audit` (INF-C,
3
+ * KJC-TSK-0760, epic KJC-PCS-0078).
4
+ *
5
+ * One tool covers the whole infra surface — terraform, kubernetes, helm,
6
+ * kustomize, ansible, dockerfiles — so checkov is THE infra scanner (tfsec
7
+ * and tflint stay out of scope: single dependency to learn and install).
8
+ * Findings enter the audit through the SAME best-effort channel as
9
+ * semgrep-findings (KJC-TSK-0366): no infra markers → not-applicable (zero
10
+ * noise, checkov never runs); missing binary → declared with the install
11
+ * command; non-zero exit with JSON on stdout (checkov's failed-checks
12
+ * behavior) rescued; unparseable output degrades SAYING so.
13
+ */
14
+
15
+ import { runGoverned, jobsCap } from "../utils/tool-governor.js";
16
+ import { detectInfraMarkers } from "../utils/project-detect.js";
17
+
18
+ const SCAN_TIMEOUT_MS = 120_000;
19
+ const INSTALL_HINT = "install via 'pipx install checkov' or 'pip install checkov'";
20
+
21
+ /**
22
+ * Run checkov on the project and return a structured result.
23
+ *
24
+ * @param {string} projectDir - absolute path to the project root
25
+ * @param {object} [logger]
26
+ * @returns {Promise<{available: boolean, reason?: string, total?: number, findings?: object[]}>}
27
+ */
28
+ export async function collectInfraFindings(projectDir, logger = null) {
29
+ // KJC-BUG-0122 doctrine: external scanners never fire from the unit suite.
30
+ if (process.env.VITEST && process.env.KJ_ALLOW_REAL_SCANS !== "1") {
31
+ return { available: false, reason: "real scans disabled under the test runner (set KJ_ALLOW_REAL_SCANS=1 to opt in)" };
32
+ }
33
+ if (!(await detectInfraMarkers(projectDir))) {
34
+ return { available: false, reason: "no infra markers (terraform/helm/kustomize/ansible) — not applicable" };
35
+ }
36
+ let stdout;
37
+ try {
38
+ const { stdout: out } = await runGoverned(
39
+ "checkov",
40
+ ["-d", projectDir || ".", "--output", "json", "--quiet", "--compact", `--parallel-runner-count=${jobsCap()}`],
41
+ { cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
42
+ );
43
+ stdout = out;
44
+ } catch (err) {
45
+ if (err.code === "ENOENT") {
46
+ if (logger?.warn) logger.warn(`checkov not found in PATH — ${INSTALL_HINT}`);
47
+ return { available: false, reason: `checkov not installed — ${INSTALL_HINT}` };
48
+ }
49
+ if (err.killed) {
50
+ if (logger?.warn) logger.warn(`checkov timed out after ${SCAN_TIMEOUT_MS}ms`);
51
+ return { available: false, reason: `checkov timed out after ${SCAN_TIMEOUT_MS}ms` };
52
+ }
53
+ // checkov exits non-zero when checks fail, but still emits the report.
54
+ if (typeof err.stdout === "string" && err.stdout.trim().length > 0) {
55
+ stdout = err.stdout;
56
+ } else {
57
+ if (logger?.warn) logger.warn(`checkov failed: ${err.message}`);
58
+ return { available: false, reason: `checkov failed: ${err.message}` };
59
+ }
60
+ }
61
+
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(stdout);
65
+ } catch (err) {
66
+ if (logger?.warn) logger.warn(`checkov output not parseable: ${err.message}`);
67
+ return { available: false, reason: "checkov output not parseable as JSON" };
68
+ }
69
+
70
+ return { available: true, ...parseCheckovOutput(parsed) };
71
+ }
72
+
73
+ /**
74
+ * Convert checkov's JSON (object for one framework, array for several)
75
+ * into the flat findings list the audit prompt consumes. Checkov OSS may
76
+ * omit severity — WARNING is the honest default.
77
+ *
78
+ * @param {object|object[]} raw
79
+ * @returns {{total: number, findings: object[]}}
80
+ */
81
+ export function parseCheckovOutput(raw) {
82
+ const reports = Array.isArray(raw) ? raw : [raw];
83
+ const findings = [];
84
+ for (const report of reports) {
85
+ const failed = report?.results?.failed_checks;
86
+ if (!Array.isArray(failed)) continue;
87
+ for (const c of failed) {
88
+ findings.push({
89
+ rule: c.check_id || "(unknown)",
90
+ severity: String(c.severity || "WARNING").toUpperCase(),
91
+ framework: report.check_type || "infra",
92
+ file: String(c.file_path || "").replace(/^\//, ""),
93
+ line: Array.isArray(c.file_line_range) ? (c.file_line_range[0] ?? 0) : 0,
94
+ message: c.check_name || "",
95
+ guideline: c.guideline || null,
96
+ });
97
+ }
98
+ }
99
+ return { total: findings.length, findings };
100
+ }
@@ -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;
@@ -20,8 +20,13 @@
20
20
 
21
21
  /**
22
22
  * Check if a model string is compatible with an agent provider.
23
- * Only returns false when the model clearly belongs to a DIFFERENT provider.
23
+ * Only returns false when the model clearly belongs to a DIFFERENT family.
24
24
  * Returns true if we can't determine or if the model is ambiguous.
25
+ *
26
+ * KJC-BUG-0144: exported and family-based — an agent may serve another
27
+ * family's models (agy is Google's Antigravity, gemini family), and the
28
+ * same check now guards BaseAgent.getRoleModel so per-role defaults
29
+ * (e.g. start's haiku) never reach a provider from another family.
25
30
  */
26
31
  const AGENT_MODEL_SIGNATURES = {
27
32
  claude: ["claude", "sonnet", "opus", "haiku"],
@@ -29,21 +34,26 @@ const AGENT_MODEL_SIGNATURES = {
29
34
  gemini: ["gemini", "flash-"]
30
35
  };
31
36
 
32
- function isModelCompatible(agent, model) {
33
- if (!model || !agent) return true;
34
- const lower = model.toLowerCase();
37
+ // Single-family CLIs: they only serve their own vendor's models. Agents NOT
38
+ // in this map (aider, opencode, copilot, kimi...) are multi-model hosts —
39
+ // they route to arbitrary models, so any model name is allowed there.
40
+ const AGENT_FAMILY = { claude: "claude", codex: "codex", gemini: "gemini", agy: "gemini" };
35
41
 
36
- // Check if model clearly belongs to a different provider
37
- for (const [provider, signatures] of Object.entries(AGENT_MODEL_SIGNATURES)) {
38
- if (provider === agent) continue;
39
- if (signatures.some(s => lower.includes(s))) {
40
- // Model belongs to a different provider — incompatible
41
- return false;
42
- }
42
+ function modelFamily(model) {
43
+ const lower = model.toLowerCase();
44
+ for (const [family, signatures] of Object.entries(AGENT_MODEL_SIGNATURES)) {
45
+ if (signatures.some(s => lower.includes(s))) return family;
43
46
  }
47
+ return null;
48
+ }
44
49
 
45
- // Model doesn't clearly belong to any other provider — allow it
46
- return true;
50
+ export function isModelCompatible(agent, model) {
51
+ if (!model || !agent) return true;
52
+ const agentFamily = AGENT_FAMILY[agent];
53
+ if (!agentFamily) return true; // multi-model host — bring your own model
54
+ const family = modelFamily(model);
55
+ if (!family) return true; // ambiguous/custom/local — allow it
56
+ return family === agentFamily;
47
57
  }
48
58
 
49
59
  // Roles that inherit provider/model from the coder when not explicitly configured
@@ -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\`.
@@ -35,8 +35,10 @@ import { fileURLToPath } from "node:url";
35
35
  const here = dirname(fileURLToPath(import.meta.url));
36
36
  export const STATE = join(here, "sentinel-state.json");
37
37
  export const ROOT = join(here, "..", "..");
38
- export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$/;
39
- export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp)$/;
38
+ // INF-A (KJC-TSK-0758): infra IS source — terraform/k8s/shell/Docker edits
39
+ // engage the method like app code; *_test.* files (terratest) are tests.
40
+ export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$|_test\\.[a-z]+$/;
41
+ export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp|tf|hcl|ya?ml|sh)$|(^|\\/)(Dockerfile|Makefile|Vagrantfile)$/;
40
42
  export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
41
43
  export const BASE_BRANCHES = new Set(["main", "master"]);
42
44
  export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
@@ -226,6 +228,43 @@ process.stdin.on("end", () => {
226
228
  process.exit(2);
227
229
  }
228
230
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
231
+ // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
232
+ // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
233
+ // prefijo jamas llegaba a process.env y el escape era inalcanzable
234
+ // (deadlock real del gate de release: la landing solo se pone verde
235
+ // tras el deploy y el deploy estaba bloqueado por el mismo gate). Un
236
+ // escape es una excepcion CONSCIENTE y auditada, no un boundary de
237
+ // seguridad (security sigue sin escape y la autoprotecion corre antes),
238
+ // asi que el TEXTO del comando es fuente valida: solo asignaciones
239
+ // LITERALES al inicio (tokens VAR=VAL antes del primer verbo) y solo
240
+ // sobre un comando SIMPLE — con ;|& $() backtick o salto de linea el
241
+ // prefijo shell no alcanzaria a los comandos posteriores (catch de
242
+ // codex: "X=1 true && npm publish" escaparia sin serlo), asi que el
243
+ // escape por texto se rechaza y queda la via env de siempre.
244
+ const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
245
+ const ESC_TAB = String.fromCharCode(9);
246
+ const ESC_NL = String.fromCharCode(10);
247
+ const ESC_BT = String.fromCharCode(96);
248
+ const escOn = (name) => {
249
+ if (process.env[name] === "1") return true;
250
+ let found = false;
251
+ let i = 0;
252
+ while (i < CMD_TEXT.length) {
253
+ while (i < CMD_TEXT.length && (CMD_TEXT[i] === " " || CMD_TEXT[i] === ESC_TAB)) i += 1;
254
+ const start = i;
255
+ while (i < CMD_TEXT.length && CMD_TEXT[i] !== " " && CMD_TEXT[i] !== ESC_TAB && CMD_TEXT[i] !== ESC_NL) i += 1;
256
+ const tok = CMD_TEXT.slice(start, i);
257
+ const eq = tok.indexOf("=");
258
+ if (eq <= 0 || !/^[A-Z][A-Z0-9_]*$/.test(tok.slice(0, eq))) break;
259
+ if (tok === name + "=1") found = true;
260
+ }
261
+ if (!found) return false;
262
+ for (const c of CMD_TEXT) {
263
+ if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT) return false;
264
+ if (c === "$" || c === "(" || c === ")") return false;
265
+ }
266
+ return true;
267
+ };
229
268
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
230
269
  // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
231
270
  const laneOf = (p) => {
@@ -243,7 +282,7 @@ process.stdin.on("end", () => {
243
282
  return d ? foreignLane(d) : null;
244
283
  };
245
284
  const laneDeny = (what, lane) => {
246
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
285
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
247
286
  console.error("kj sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Cruce deliberado: KJ_ALLOW_CROSS_LANE=1, queda registrado.");
248
287
  return true;
249
288
  };
@@ -270,7 +309,7 @@ process.stdin.on("end", () => {
270
309
  // directorio se deniega conservadoramente — el remedio es no
271
310
  // necesitarlo (git -C, npm --prefix, rutas absolutas).
272
311
  if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
273
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
312
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
274
313
  else {
275
314
  console.error("kj sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd (o KJ_ALLOW_CROSS_LANE=1, queda registrado).");
276
315
  process.exit(2);
@@ -289,7 +328,7 @@ process.stdin.on("end", () => {
289
328
  // de tokens de abajo; el residuo (expansion anidada en segmentos
290
329
  // runner) queda documentado como en la regla de ficheros PROTECTED.
291
330
  if (/>{1,2}[ \\t]*["']?[\\$\`]/.test(cmd)) {
292
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
331
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
293
332
  else {
294
333
  console.error("kj sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado).");
295
334
  process.exit(2);
@@ -299,8 +338,63 @@ process.stdin.on("end", () => {
299
338
  // en cualquier posicion, y una ruta entrecomillada con espacios es
300
339
  // invisible al tokenizador: ambas => deny conservador (endpoint
301
340
  // pedido por el reviewer; friccion asumida, el remedio es literal).
302
- if (/\\$\\(|\`/.test(cmd) || /["'][^"'\\n]*\\/[^"'\\n]* [^"'\\n]*["']/.test(cmd)) {
303
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
341
+ // KJC-BUG-0140: pares de comillas REALES, no regex sobre el texto —
342
+ // la comilla de CIERRE de un string + un redirect literal + la de
343
+ // apertura del siguiente creaban la ilusion de ruta con espacios y
344
+ // denegaban el flujo estandar commit -m "..." > log && echo "ok".
345
+ // Se recorre alternando estado (doctrina del parseCommand del motor):
346
+ // solo el CONTENIDO realmente entrecomillado con slash y espacio
347
+ // deniega — incluidas comillas a mitad de token (scp host:"/a b/x")
348
+ // y la comilla sin cerrar (no verificable).
349
+ // ...y por TOKEN completo, no por span: spans adyacentes concatenan
350
+ // ("dir with"" spaces/file" es UNA ruta) — catch de codex.
351
+ const quotedPathWithSpaces = (s) => {
352
+ // Un backslash pegado a comilla o blanco (comilla escapada,
353
+ // espacio escapado) altera donde CIERRA el string o esconde
354
+ // espacios: opaco => deny (doctrina del motor) — catch de codex.
355
+ // Caracteres construidos con fromCharCode: CERO escaping en este
356
+ // template — fuente y artefacto identicos, nada que malinterpretar
357
+ // (el doble-escaping confundio cuatro reviews; solomon desestimo
358
+ // la primera y las demas se volvieron ilegibles igual).
359
+ // El punto escapado de un sed y similares siguen libres.
360
+ const BS = String.fromCharCode(92);
361
+ const TAB = String.fromCharCode(9);
362
+ const NL = String.fromCharCode(10);
363
+ for (let i = s.indexOf(BS); i !== -1; i = s.indexOf(BS, i + 1)) {
364
+ const n = s[i + 1] || "";
365
+ if (n === '"' || n === "'" || n === " " || n === TAB) return true;
366
+ }
367
+ let q = null;
368
+ let tok = "";
369
+ let hadQuote = false;
370
+ const flush = () => {
371
+ const bad = hadQuote && tok.includes("/") && tok.includes(" ");
372
+ tok = "";
373
+ hadQuote = false;
374
+ return bad;
375
+ };
376
+ for (const ch of s) {
377
+ if (q) {
378
+ if (ch === q) q = null;
379
+ else tok += ch;
380
+ continue;
381
+ }
382
+ if (ch === '"' || ch === "'") {
383
+ q = ch;
384
+ hadQuote = true;
385
+ continue;
386
+ }
387
+ if (ch === " " || ch === TAB || ch === NL || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">" || ch === "(" || ch === ")") {
388
+ if (flush()) return true;
389
+ continue;
390
+ }
391
+ tok += ch;
392
+ }
393
+ if (q !== null) return true; // comilla sin cerrar: no verificable
394
+ return flush();
395
+ };
396
+ if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
397
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
304
398
  else {
305
399
  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).");
306
400
  process.exit(2);
@@ -309,7 +403,7 @@ process.stdin.on("end", () => {
309
403
  const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
310
404
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
311
405
  if (!segs.every((s) => !/[\\$]/.test(s) || SAFE_EXP_SEG.test(s))) {
312
- if (process.env.KJ_ALLOW_CROSS_LANE === "1") { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
406
+ if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
313
407
  else {
314
408
  console.error("kj sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado).");
315
409
  process.exit(2);
@@ -344,20 +438,20 @@ process.stdin.on("end", () => {
344
438
  const actorRole = process.env.KJ_POLICY_ROLE || "coder";
345
439
  const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
346
440
  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.");
441
+ 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
442
  process.exit(2);
349
443
  }
350
444
  if (pres.status === 2) {
351
445
  let v = null;
352
446
  try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
353
447
  const secure = !!(v && v.class === "security");
354
- if (!secure && process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
448
+ if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
355
449
  else {
356
450
  console.error("kj sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)"));
357
451
  process.exit(2);
358
452
  }
359
453
  } else if (pres.status !== 0) {
360
- if (process.env.KJ_ALLOW_POLICY === "1") { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
454
+ if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
361
455
  else {
362
456
  console.error("kj sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada).");
363
457
  process.exit(2);
@@ -371,7 +465,7 @@ process.stdin.on("end", () => {
371
465
  const branch = branchOf();
372
466
  const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
373
467
  if (why) {
374
- if (process.env.KJ_ALLOW_NO_CARD === "1") { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
468
+ if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
375
469
  console.error(why === "base"
376
470
  ? "kj sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)"
377
471
  : "kj sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)");
@@ -382,7 +476,7 @@ process.stdin.on("end", () => {
382
476
  if (tool === "Bash") {
383
477
  const cmd = String(input.command || "");
384
478
  if (PUBLISH.test(cmd)) {
385
- if (process.env.KJ_ALLOW_RELEASE === "1") { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
479
+ if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
386
480
  const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
387
481
  if (res.error || res.status === null) process.exit(0);
388
482
  if (res.status !== 0) {
@@ -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
+ }
@@ -6,6 +6,7 @@ import { collectSonarFindings } from "../audit/sonar-findings.js";
6
6
  import { collectWebPerfInput } from "../audit/webperf-input.js";
7
7
  import { collectOsvFindings } from "../audit/osv-findings.js";
8
8
  import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
9
+ import { collectInfraFindings } from "../audit/infra-findings.js";
9
10
  import { collectCircularDeps } from "../audit/circular-deps.js";
10
11
  import { collectDeadExports } from "../audit/dead-exports.js";
11
12
  import { collectInjectionFindings } from "../audit/injection-findings.js";
@@ -58,6 +59,7 @@ export class AuditRole extends AgentRole {
58
59
  const noMadge = (typeof input === "object" ? Boolean(input?.noMadge) : false) || securityOnly;
59
60
  const noKnip = (typeof input === "object" ? Boolean(input?.noKnip) : false) || securityOnly;
60
61
  const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
62
+ const noInfraScan = typeof input === "object" ? Boolean(input?.noInfraScan) : false;
61
63
  const noAiSlop = (typeof input === "object" ? Boolean(input?.noAiSlop) : false) || securityOnly;
62
64
  const projectDir = this.config?.projectDir || process.cwd();
63
65
  let basalCost = null;
@@ -70,6 +72,7 @@ export class AuditRole extends AgentRole {
70
72
  let circularDeps = null;
71
73
  let deadExports = null;
72
74
  let injectionFindings = null;
75
+ let infraFindings = null;
73
76
  let aiSlop = null;
74
77
  if (!securityOnly) {
75
78
  try {
@@ -127,6 +130,14 @@ export class AuditRole extends AgentRole {
127
130
  injectionFindings = await collectInjectionFindings(projectDir, this.logger);
128
131
  } catch { /* injection scan is best-effort (KJC-TSK-0498) */ }
129
132
  }
133
+ // Checkov infra misconfigs — INF-C (KJC-TSK-0760). Security surface, so
134
+ // it ALSO runs in securityOnly. Best-effort: no infra markers or missing
135
+ // checkov return available:false declaring why.
136
+ if (!noInfraScan) {
137
+ try {
138
+ infraFindings = await collectInfraFindings(projectDir, this.logger);
139
+ } catch { /* checkov is best-effort */ }
140
+ }
130
141
  // AI-slop tells (KJC-TSK-0503). Deterministic, offline, regex-based.
131
142
  // Local equivalent of Deslopify-style review. Best-effort.
132
143
  if (!noAiSlop) {
@@ -134,7 +145,7 @@ export class AuditRole extends AgentRole {
134
145
  aiSlop = await collectAiSlop(projectDir);
135
146
  } catch { /* ai-slop scan is best-effort */ }
136
147
  }
137
- return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, aiSlop };
148
+ return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings, infraFindings, aiSlop };
138
149
  }
139
150
 
140
151
  /**
@@ -24,7 +24,14 @@ const COVERAGE_COMMANDS = {
24
24
  rspec: "bundle exec rspec 2>&1",
25
25
  phpunit: "vendor/bin/phpunit --coverage-text 2>&1",
26
26
  "dotnet-test": "dotnet test --collect:\"XPlat Code Coverage\" 2>&1",
27
- "dart-test": "dart test 2>&1"
27
+ "dart-test": "dart test 2>&1",
28
+ // INF-B: the basic suite ALWAYS runs first; checkov is the additive deep
29
+ // scan. If checkov is missing this chain fails and the brief falls back to
30
+ // the basic suite alone (declared degradation, never green-by-omission).
31
+ "terraform-validate": "terraform init -backend=false -input=false 2>&1 && terraform validate 2>&1 && checkov -d . --compact --quiet 2>&1",
32
+ "helm-lint": "helm lint . 2>&1 && checkov -d . --compact --quiet 2>&1",
33
+ "kustomize-build": "kustomize build . > /dev/null && checkov -d . --compact --quiet 2>&1",
34
+ "ansible-lint": "ansible-lint 2>&1 && checkov -d . --compact --quiet 2>&1"
28
35
  };
29
36
 
30
37
  const TEST_COMMANDS = {
@@ -39,7 +46,12 @@ const TEST_COMMANDS = {
39
46
  rspec: "bundle exec rspec 2>&1",
40
47
  phpunit: "vendor/bin/phpunit 2>&1",
41
48
  "dotnet-test": "dotnet test 2>&1",
42
- "dart-test": "dart test 2>&1"
49
+ "dart-test": "dart test 2>&1",
50
+ // INF-B (KJC-TSK-0759): infra suites — validity IS the test floor.
51
+ "terraform-validate": "terraform init -backend=false -input=false 2>&1 && terraform validate 2>&1",
52
+ "helm-lint": "helm lint . 2>&1",
53
+ "kustomize-build": "kustomize build . > /dev/null && echo kustomize build: OK",
54
+ "ansible-lint": "ansible-lint 2>&1"
43
55
  };
44
56
 
45
57
  export class TesterRole extends AgentRole {
@@ -82,9 +94,14 @@ export class TesterRole extends AgentRole {
82
94
  if (detection.hasTests && detection.framework) {
83
95
  const coverageCmd = COVERAGE_COMMANDS[detection.framework];
84
96
  const testCmd = TEST_COMMANDS[detection.framework];
97
+ // INF-B: infra suites have no node_modules — the step-0 contract is
98
+ // "the tool exists or you SAY which one is missing", never a fake green.
99
+ const step0 = detection.language === "infra"
100
+ ? "**Step 0**: Verify the SUITE tool is installed (`which terraform`/`helm`/`kustomize`/`ansible-lint`). If the suite tool is MISSING, do NOT fake a green: report it in `failures` with the exact install command and set tests_pass: false. `checkov` (the deep scan below) is OPTIONAL: if it is missing, run the basic suite as the fallback and SAY the deep scan was skipped (degraded, never green-by-omission)."
101
+ : "**Step 0**: If node_modules/ does not exist, run `npm install` (or `pnpm install`) first.";
85
102
  sections.push(
86
103
  `## Detected test framework: ${detection.framework} (${detection.language})`,
87
- "**Step 0**: If node_modules/ does not exist, run `npm install` (or `pnpm install`) first.",
104
+ step0,
88
105
  `**Step 1**: Run the test suite with coverage:`,
89
106
  "```bash",
90
107
  coverageCmd || testCmd,
@@ -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)),
@@ -42,6 +42,18 @@ const LANGUAGE_MARKERS = [
42
42
  { file: "pubspec.yaml", framework: "dart-test", language: "dart" },
43
43
  ];
44
44
 
45
+ // --- Infra (INF-B, KJC-TSK-0759) ---
46
+ // Checked AFTER app-language markers: a mixed repo keeps its app framework.
47
+ // Only unequivocal markers — a stray .yaml never classifies as infra.
48
+ const INFRA_MARKERS = [
49
+ { file: "*.tf", glob: true, framework: "terraform-validate", language: "infra" },
50
+ { file: "terraform", framework: "terraform-validate", language: "infra" },
51
+ { file: "Chart.yaml", framework: "helm-lint", language: "infra" },
52
+ { file: "kustomization.yaml", framework: "kustomize-build", language: "infra" },
53
+ { file: "kustomization.yml", framework: "kustomize-build", language: "infra" },
54
+ { file: "ansible.cfg", framework: "ansible-lint", language: "infra" },
55
+ ];
56
+
45
57
  /** Test file patterns per language (used by TDD policy). */
46
58
  export const TEST_PATTERNS_BY_LANGUAGE = {
47
59
  javascript: ["/tests/", "/__tests__/", ".test.", ".spec."],
@@ -55,6 +67,7 @@ export const TEST_PATTERNS_BY_LANGUAGE = {
55
67
  php: ["/tests/", "/test/", "Test.php"],
56
68
  swift: ["/Tests/", "Tests.swift"],
57
69
  dart: ["/test/", "_test.dart"],
70
+ infra: ["/tests/", "/test/", "_test."],
58
71
  };
59
72
 
60
73
  function frameworkFromConfig(filename) {
@@ -93,7 +106,29 @@ export async function detectTestFramework(cwd = process.cwd()) {
93
106
  }
94
107
 
95
108
  // 3. Multi-language: check project markers
96
- for (const marker of LANGUAGE_MARKERS) {
109
+ const lang = await matchMarkers(cwd, LANGUAGE_MARKERS);
110
+ if (lang) return lang;
111
+
112
+ // 4. Infra (INF-B): terraform/helm/kustomize/ansible have their own suite.
113
+ const infra = await matchMarkers(cwd, INFRA_MARKERS);
114
+ if (infra) return infra;
115
+
116
+ return { hasTests: false, framework: null, language: null };
117
+ }
118
+
119
+ /**
120
+ * True when the project carries unequivocal infra markers (terraform, helm,
121
+ * kustomize, ansible) — regardless of the app framework the repo may also
122
+ * have. Used by INF-C's audit collector to gate the checkov scan.
123
+ * @param {string} cwd
124
+ * @returns {Promise<boolean>}
125
+ */
126
+ export async function detectInfraMarkers(cwd = process.cwd()) {
127
+ return Boolean(await matchMarkers(cwd, INFRA_MARKERS));
128
+ }
129
+
130
+ async function matchMarkers(cwd, markers) {
131
+ for (const marker of markers) {
97
132
  try {
98
133
  if (marker.glob) {
99
134
  const entries = await fs.readdir(cwd);
@@ -107,8 +142,7 @@ export async function detectTestFramework(cwd = process.cwd()) {
107
142
  }
108
143
  } catch { /* not found */ }
109
144
  }
110
-
111
- return { hasTests: false, framework: null, language: null };
145
+ return null;
112
146
  }
113
147
 
114
148
  /**
@@ -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);