karajan-code 4.39.0 → 4.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/audit/ai-slop-findings.js +4 -2
  3. package/src/audit/circular-deps.js +8 -7
  4. package/src/audit/webperf-input.js +3 -1
  5. package/src/cli/advanced-commands.js +1 -1
  6. package/src/cli/register-meta.js +112 -1
  7. package/src/commands/bootstrap.js +12 -41
  8. package/src/commands/env.js +8 -0
  9. package/src/commands/init.js +6 -2
  10. package/src/commands/rules-approve.js +63 -0
  11. package/src/commands/rules-compile.js +117 -0
  12. package/src/commands/rules-decide.js +52 -0
  13. package/src/commands/rules-review.js +57 -0
  14. package/src/commands/rules.js +169 -0
  15. package/src/config/loader.js +23 -1
  16. package/src/environment/adr.js +5 -2
  17. package/src/environment/contract-commit.js +79 -0
  18. package/src/harden/config-templates.js +3 -0
  19. package/src/harden/human-act.js +70 -0
  20. package/src/harden/phone-sign.js +26 -0
  21. package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
  22. package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
  23. package/src/harden/sentinel/sentinel-discard.mjs +4 -2
  24. package/src/harden/sentinel/sentinel-rules.mjs +86 -0
  25. package/src/harden/sentinel/sentinel-shell.mjs +17 -0
  26. package/src/harden/sentinel/sessionstart.mjs +18 -9
  27. package/src/harden/sentinel-hooks.js +23 -2
  28. package/src/harden/supervisor-commit.js +13 -56
  29. package/src/mcp/handlers/run-handler.js +5 -0
  30. package/src/mcp/sovereignty-guard.js +16 -15
  31. package/src/orchestrator/preflight-checks.js +4 -3
  32. package/src/policy/supervisor-verify.js +12 -2
  33. package/src/privacy/scan.js +7 -0
  34. package/src/review/gate-gitignore.js +8 -0
  35. package/src/rules/approval-view.js +54 -0
  36. package/src/rules/compiled.js +111 -0
  37. package/src/rules/coverage.js +23 -0
  38. package/src/rules/evaluate.js +60 -0
  39. package/src/rules/inventory.js +118 -0
  40. package/src/sonar/config-resolver.js +19 -1
  41. package/src/utils/run-log.js +74 -2
  42. package/src/utils/stack-detect.js +12 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.39.0",
3
+ "version": "4.41.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",
@@ -8,9 +8,11 @@ import path from "node:path";
8
8
 
9
9
  const FILE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
10
10
  const IGNORE_DIRS = new Set([
11
- "node_modules", "dist", "build", "coverage",
11
+ "node_modules", "dist", "build", "coverage", "vendor",
12
12
  ".git", ".karajan", "public", ".next", ".nuxt", ".vercel", ".cache",
13
13
  ]);
14
+ // KJC-BUG-0254 (#1902): a minified bundle is third-party output, not the project's prose.
15
+ const isMinified = (name) => /\.min\.[cm]?js$/.test(name);
14
16
  const VERB = "(?:returns?|gets?|sets?|fetches?|loads?|saves?|creates?|builds?|makes?|computes?|handles?|checks?|validates?)";
15
17
  const LINE_PATTERNS = {
16
18
  "banner-separators": /\/[/*]\s*[=*\-_#~]{5,}/,
@@ -70,7 +72,7 @@ function listSourceFiles(root) {
70
72
  if (e.isDirectory()) {
71
73
  if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue;
72
74
  stack.push(full);
73
- } else if (FILE_EXTS.has(path.extname(e.name))) out.push(full);
75
+ } else if (FILE_EXTS.has(path.extname(e.name)) && !isMinified(e.name)) out.push(full);
74
76
  }
75
77
  }
76
78
  return out;
@@ -44,11 +44,12 @@ async function findTsConfig(projectDir) {
44
44
  return undefined;
45
45
  }
46
46
 
47
- function pickEntrypoint(projectDir) {
48
- // madge needs an entrypoint. We prefer common conventions; if none
49
- // exists we fall back to scanning `src/` which covers ~95% of JS/TS
50
- // projects we've seen.
51
- return path.join(projectDir, "src");
47
+ async function pickEntrypoint(projectDir) {
48
+ // madge needs an entrypoint: `src/` covers most JS/TS projects. KJC-BUG-0267:
49
+ // a project with no src/ (a static site with its scripts in js/ or at the
50
+ // root) is scanned whole; excludeRegExp keeps node_modules and build output out.
51
+ const src = path.join(projectDir, "src");
52
+ try { await fs.access(src); return src; } catch { return projectDir; }
52
53
  }
53
54
 
54
55
  /**
@@ -65,9 +66,9 @@ export async function collectCircularDeps(projectDir, stack, config = {}, logger
65
66
  return { available: false, reason: "no JS/TS sources detected — circular-dep scan skipped" };
66
67
  }
67
68
 
68
- const entry = pickEntrypoint(projectDir);
69
+ const entry = await pickEntrypoint(projectDir);
69
70
  try { await fs.access(entry); } catch {
70
- return { available: false, reason: `entrypoint ${path.relative(projectDir, entry)} not found` };
71
+ return { available: false, reason: `project directory ${entry} not found` };
71
72
  }
72
73
 
73
74
  let madge;
@@ -93,7 +93,9 @@ export function collectWebPerfInput(stack, config = {}) {
93
93
  // a frontend layer to audit. Backend-only projects get nothing.
94
94
  const isFrontendish = !stack || stack.isFrontend === true || stack.isFullstack === true;
95
95
  if (!isFrontendish) {
96
- return { available: false, reason: "project is backend-only — no frontend-perf hints to give" };
96
+ // KJC-BUG-0267: no tier detected is not "backend-only"; say what was (not) seen.
97
+ const why = stack.isBackend ? "project is backend-only" : "no frontend layer detected (no frontend framework and no index.html)";
98
+ return { available: false, reason: `${why} — no frontend-perf hints to give` };
97
99
  }
98
100
 
99
101
  return { available: true, mode: "static-hints" };
@@ -34,7 +34,7 @@ export const ADVANCED_GROUPS = [
34
34
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
35
35
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
36
36
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
37
- { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward", "pr-size"] },
37
+ { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "claims", "steward", "pr-size"] },
38
38
  { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
39
39
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
40
40
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
@@ -43,6 +43,12 @@ import { verifySentinelScripts, resolveSentinelRoot } from "../harden/sentinel-h
43
43
  import { boardGate } from "../review/board-pending.js";
44
44
  import { panelDeviation } from "../environment/panel.js";
45
45
  import { detectHostAgent } from "../utils/agent-detect.js";
46
+ import { listRules } from "../rules/inventory.js";
47
+ import { PROPOSAL_FILE, readToolInput, rulesCheck, rulesCoverage, rulesEval, rulesTest } from "../commands/rules.js";
48
+ import { rulesApprove } from "../commands/rules-approve.js";
49
+ import { rulesDecide } from "../commands/rules-decide.js";
50
+ import { rulesReview } from "../commands/rules-review.js";
51
+ import { rulesCompileBrief } from "../commands/rules-compile.js";
46
52
 
47
53
  /**
48
54
  * Register the "meta" / single-role / housekeeping commands: pre-pipeline
@@ -296,7 +302,7 @@ export function registerMeta(program, { pkgVersion }) {
296
302
  .action(async (title, flags) => {
297
303
  await withConfig(pkgVersion, "adr", flags, async ({ config }) => {
298
304
  const res = await addAdr(config?.projectDir || process.cwd(), { title, ...flags });
299
- console.log(flags.json ? JSON.stringify(res) : `✓ ADR ${res.number} created: ${res.file} — commit it`);
305
+ console.log(flags.json ? JSON.stringify(res) : `✓ ADR ${res.number} created as ${res.status}: ${res.file} — commit it and ask your user; accepting it is theirs (Status: accepted)`);
300
306
  });
301
307
  });
302
308
  adr.command("list")
@@ -310,6 +316,111 @@ export function registerMeta(program, { pkgVersion }) {
310
316
  });
311
317
  });
312
318
 
319
+ // MDR-A2 (KJC-TSK-0942, ADR 0016): the rules of the governing MD files, each with an id.
320
+ const rules = program.command("rules").description("Rules written in the MD files that govern a session (ADR 0016)");
321
+ rules.command("list")
322
+ .option("--json", "Machine-readable output")
323
+ .action(async (flags) => {
324
+ await withConfig(pkgVersion, "rules", flags, async ({ config }) => {
325
+ const found = listRules(config?.projectDir || process.cwd());
326
+ if (flags.json) { console.log(JSON.stringify(found)); return; }
327
+ for (const r of found) console.log(`${r.id} ${r.file}:${r.line} ${r.text}`);
328
+ console.log(`${found.length} rule(s)`);
329
+ });
330
+ });
331
+ // MDR-B2 (KJC-TSK-0943): the compiled rules (.karajan/rules.yml) against one tool call.
332
+ rules.command("eval")
333
+ .description("Evaluate ONE tool call against .karajan/rules.yml: prints the verdict as JSON; exit 2 on deny, 1 if it cannot be evaluated")
334
+ .option("--tool <tool>", "Tool name (Bash, Edit, mcp__server__tool…)")
335
+ .option("--input <json>", "tool_input as JSON, or - to read it from stdin", "{}")
336
+ .action(async (flags) => {
337
+ await withConfig(pkgVersion, "rules-eval", flags, async ({ config }) => {
338
+ const res = rulesEval({ projectDir: config?.projectDir || process.cwd(), tool: flags.tool, input: await readToolInput(flags.input) });
339
+ console.log(JSON.stringify(res.output));
340
+ process.exitCode = res.code;
341
+ });
342
+ });
343
+ // MDR-B3 (KJC-TSK-0945): a compiled rule proves its compilation with its own examples.
344
+ rules.command("test")
345
+ .description("Run every compiled rule's deny/allow examples against the rule itself; exit 1 on any failure")
346
+ .action(async (flags) => {
347
+ await withConfig(pkgVersion, "rules-test", flags, async ({ config }) => {
348
+ const res = rulesTest({ projectDir: config?.projectDir || process.cwd() });
349
+ for (const line of res.lines) console.log(line);
350
+ process.exitCode = res.code;
351
+ });
352
+ });
353
+ // MDR-D1 (KJC-TSK-0950): a proposal proves itself before a human reads it.
354
+ rules.command("check")
355
+ .description("Check a proposal of compiled rules: every rule is one of the MD files, cites its literal text and passes its own examples; exit 1 otherwise")
356
+ .option("--file <path>", "The proposal", PROPOSAL_FILE)
357
+ .action(async (flags) => {
358
+ await withConfig(pkgVersion, "rules-check", flags, async ({ config }) => {
359
+ const res = rulesCheck({ projectDir: config?.projectDir || process.cwd(), file: flags.file });
360
+ for (const line of res.lines) console.log(line);
361
+ process.exitCode = res.code;
362
+ });
363
+ });
364
+ // MDR-D (KJC-TSK-0940): the brief for whoever compiles the rules with no gate.
365
+ rules.command("compile")
366
+ .description("Print the brief to compile the rules with no gate into a proposal (.karajan/rules.proposed.yml); kj calls no model")
367
+ .action(async (flags) => {
368
+ await withConfig(pkgVersion, "rules-compile", flags, async ({ config }) => {
369
+ const res = rulesCompileBrief({ projectDir: config?.projectDir || process.cwd() });
370
+ for (const line of res.lines) console.log(line);
371
+ process.exitCode = res.code;
372
+ });
373
+ });
374
+ // MDR-G (KJC-TSK-0969): most rules take no condition; their kind is decided in one go.
375
+ rules.command("decide <ids...>")
376
+ .description("Give several rules of the proposal their kind at once: judgment (with --tool) or out-of-scope (with --reason). Deterministic rules are written by hand")
377
+ .requiredOption("--kind <kind>", "judgment | out-of-scope")
378
+ .option("--tool <glob...>", "judgment: the tool(s) the rule shows on (Bash, Edit, mcp__server__tool…)")
379
+ .option("--reason <text>", "out-of-scope: why no tool call breaks the rule")
380
+ .option("--file <path>", "The proposal", PROPOSAL_FILE)
381
+ .action(async (ids, flags) => {
382
+ await withConfig(pkgVersion, "rules-decide", flags, async ({ config }) => {
383
+ const res = rulesDecide({ projectDir: config?.projectDir || process.cwd(), file: flags.file, ids, kind: flags.kind, tools: flags.tool, reason: flags.reason });
384
+ for (const line of res.lines) console.log(line);
385
+ process.exitCode = res.code;
386
+ });
387
+ });
388
+ // MDR-F4 (KJC-TSK-0963): whoever proposes the compilation does not call it good.
389
+ rules.command("review")
390
+ .description("A DIFFERENT AI reviews a checked proposal, rule by rule: is each compilation as strong as its text? The verdict is tied to the proposal's exact content")
391
+ .option("--file <path>", "The proposal", PROPOSAL_FILE)
392
+ .action(async (flags) => {
393
+ await withConfig(pkgVersion, "rules-review", flags, async ({ config, logger }) => {
394
+ const res = await rulesReview({ projectDir: config?.projectDir || process.cwd(), file: flags.file, config, logger });
395
+ for (const line of res.lines) console.log(line);
396
+ process.exitCode = res.code;
397
+ });
398
+ });
399
+ // MDR-D3 (KJC-TSK-0952): the proposal becomes rules.yml by a human act only.
400
+ rules.command("approve")
401
+ .description("HUMAN act: show a checked and cross-reviewed proposal and install it as the project's rules; no agent session runs it (ADR 0009)")
402
+ .option("--file <path>", "The proposal", PROPOSAL_FILE)
403
+ .action(async (flags) => {
404
+ await withConfig(pkgVersion, "rules-approve", flags, async ({ config }) => {
405
+ const res = await rulesApprove({ projectDir: config?.projectDir || process.cwd(), file: flags.file });
406
+ for (const line of res.lines) console.log(line);
407
+ process.exitCode = res.code;
408
+ });
409
+ });
410
+ // MDR-E (KJC-TSK-0941): which rules of the MD files have a gate, and which do not.
411
+ rules.command("coverage")
412
+ .description("Every rule of the MD files against .karajan/rules.yml: deterministic, judgment, out of scope, with no gate; and the stale compiled ones")
413
+ .option("--strict", "Exit 1 while a rule has no gate or a compiled rule is stale")
414
+ .option("--json", "Machine-readable output")
415
+ .action(async (flags) => {
416
+ await withConfig(pkgVersion, "rules-coverage", flags, async ({ config }) => {
417
+ const res = rulesCoverage({ projectDir: config?.projectDir || process.cwd(), strict: flags.strict });
418
+ if (flags.json && res.output) console.log(JSON.stringify(res.output));
419
+ else for (const line of res.lines) console.log(line);
420
+ process.exitCode = res.code;
421
+ });
422
+ });
423
+
313
424
  // AB-F (KJC-TSK-0655): self-healing — the brain files kj frictions upstream.
314
425
  program
315
426
  .command("report-issue")
@@ -15,12 +15,12 @@
15
15
  * first and a git that cannot run is the end of the line, not a warning.
16
16
  */
17
17
 
18
- import { execFileSync } from "node:child_process";
19
18
  import { existsSync } from "node:fs";
20
19
  import { join } from "node:path";
21
20
  import { ensureGitRepo } from "./init.js";
22
21
  import { initCommand } from "./init.js";
23
22
  import { envInstallCommand } from "./env.js";
23
+ import { commitContract, contractChanges } from "../environment/contract-commit.js";
24
24
  import { runStartScript, START_SCRIPT_CONTRACT } from "../start/project-script.js";
25
25
 
26
26
  const STEP_LABEL = {
@@ -31,25 +31,6 @@ const STEP_LABEL = {
31
31
  start: "arranque del proyecto",
32
32
  };
33
33
 
34
- /** What kj generates and the whole team must inherit by cloning. */
35
- const CONTRACT_PATHS = [
36
- ".gitignore",
37
- ".karajan/hooks",
38
- ".karajan/review-gate",
39
- ".karajan/adrs",
40
- ".karajan/policy.yml",
41
- ".claude",
42
- "CLAUDE.md",
43
- "AGENTS.md",
44
- "GEMINI.md",
45
- // KJC-TSK-0879: in a Rulesync repo kj's rules live in .rulesync/rules/karajan.md.
46
- ".rulesync",
47
- ];
48
- const CONTRACT_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
49
-
50
- const hasCommits = (projectDir, git) => {
51
- try { git(["rev-parse", "--verify", "HEAD"]); return true; } catch { return false; }
52
- };
53
34
 
54
35
  /**
55
36
  * @returns {Promise<{ok: boolean, pending: string|null, steps: Array<{name: string, status: "done"|"already"|"pending"}>}>}
@@ -75,6 +56,8 @@ export async function bootstrapCommand({ config = {}, logger = console, flags =
75
56
  return stop("git", "git no está disponible y las garantías de Karajan viven en sus hooks");
76
57
  }
77
58
  say("git", hadRepo ? "already" : "done", hadRepo ? "ya existía" : "creado, sin commit todavía");
59
+ // What is dirty NOW is the person's: kj commits only what it generates below.
60
+ const before = contractChanges(projectDir);
78
61
 
79
62
  // 2. The project's own configuration.
80
63
  const hasConfig = existsSync(join(projectDir, ".karajan", "kj.config.yml"));
@@ -98,27 +81,15 @@ export async function bootstrapCommand({ config = {}, logger = console, flags =
98
81
  // 4. The contract commit. project-new.md asked the USER for it, and it was
99
82
  // the commit their own freshly installed gates rejected: the review gate
100
83
  // (KJC-BUG-0165) and the branch guard (KJC-BUG-0186) both exempt it now,
101
- // so kj can make it itself instead of leaving the person to fight it.
102
- // Only what kj generated, only while the repo has no commit, never the
103
- // person's own code. This is NOT the supervisor seal, which stays a human
104
- // act with its own four layers (ADR 0009).
105
- const git = deps.gitRun ?? ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
106
- if (hasCommits(projectDir, git)) say("contract", "already", "el repositorio ya tiene historia");
107
- else {
108
- const present = CONTRACT_PATHS.filter((p) => existsSync(join(projectDir, p)));
109
- if (present.length === 0) say("contract", "already", "no hay contrato que commitear");
110
- else {
111
- try {
112
- git(["add", "--", ...present]);
113
- git(["commit", "-m", CONTRACT_MESSAGE]);
114
- } catch (err) {
115
- // Nunca explotar aquí: lo más probable es que falte la identidad del
116
- // clon, y ese cauce ya lo pide el paso anterior (KJC-BUG-0188).
117
- return stop("contract", `git no pudo commitear el contrato: ${String(err.message).split("\n")[0]}`);
118
- }
119
- say("contract", "done", `${present.length} ruta(s) del contrato`);
120
- }
121
- }
84
+ // so kj makes it itself (src/environment/contract-commit.js). Only what
85
+ // kj generated, never the person's own code, never on the base branch
86
+ // once there is history (KJC-BUG-0273). This is NOT the supervisor seal,
87
+ // which stays a human act with its own four layers (ADR 0009).
88
+ const contract = commitContract({ projectDir, before, baseBranch: config.base_branch || "main" });
89
+ if (contract.committed) say("contract", "done", `${contract.files.length} ruta(s) del contrato`);
90
+ else if (contract.reason.startsWith("nothing")) say("contract", "already", "no hay contrato que commitear");
91
+ // Lo más probable: la identidad del clon (KJC-BUG-0188) o la rama base.
92
+ else return stop("contract", contract.reason);
122
93
 
123
94
  // 5. Does the project actually run? kj verified the method; nobody verified
124
95
  // the application (BOOT-C, KJC-TSK-0862). It REPORTS, never blocks: a
@@ -21,6 +21,7 @@ import { reviewGateCommand } from "./review-gate.js";
21
21
  import { join } from "node:path";
22
22
  import { openProjectStore, projectDbPath } from "../rag/project-store.js";
23
23
  import { maybeRulesyncGenerate } from "../utils/rulesync.js";
24
+ import { commitContract, contractChanges } from "../environment/contract-commit.js";
24
25
 
25
26
  function hasRagIndex(config, projectDir) {
26
27
  // KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
@@ -52,6 +53,8 @@ export function briefCommand({ config = null, flags = {}, role = null }) {
52
53
 
53
54
  export async function envInstallCommand({ config = null, logger = null, flags = {} }) {
54
55
  const projectDir = config?.projectDir || process.cwd();
56
+ // KJC-BUG-0273: what is dirty NOW is the person's; kj commits only what it generates below.
57
+ const dirtyBefore = contractChanges(projectDir);
55
58
 
56
59
  // KJC-TSK-0709 — the board is chosen BEFORE the playbook renders (its
57
60
  // tracking line depends on the backend): interactive installs ask when
@@ -214,6 +217,11 @@ export async function envInstallCommand({ config = null, logger = null, flags =
214
217
  wizard?.close();
215
218
  } catch { /* privacy onboarding is best-effort */ }
216
219
  console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
220
+ // KJC-BUG-0273: the contract is kj's to commit. Left to the agent, its own
221
+ // PR-size rules forbid a 1200-line commit of generated files and it stops.
222
+ const contract = commitContract({ projectDir, before: dirtyBefore, baseBranch: config?.base_branch || "main" });
223
+ if (contract.committed) console.log(`✓ contract committed by kj: ${contract.files.length} generated file(s), nothing for the agent to commit`);
224
+ else if (!contract.reason.startsWith("nothing")) console.log(`⚠ the contract kj generated is NOT committed (${contract.reason}): run \`kj env install\` again once that is fixed`);
217
225
  // KJC-BUG-0133 (part 2): a mid-session install lands the playbook in
218
226
  // CLAUDE.md, but the running session loaded its context BEFORE — nobody
219
227
  // re-reads it. Print the method so it enters THIS conversation now.
@@ -414,7 +414,7 @@ async function runWizard(config, logger) {
414
414
  * @param {object} config
415
415
  */
416
416
  export async function writeInitConfig(configPath, config) {
417
- await writeConfig(configPath, config);
417
+ return writeConfig(configPath, config);
418
418
  }
419
419
 
420
420
  async function handleConfigSetup({ config, configExists, interactive, configPath, logger }) {
@@ -938,7 +938,11 @@ export async function initCommand({ logger, flags = {} }) {
938
938
  // Use writeInitConfig so the deprecated `sonarqube.enabled` key —
939
939
  // which setupSonarQube still mutates as an in-memory hint — never
940
940
  // reaches the YAML file.
941
- await writeInitConfig(configPath, config);
941
+ const written = await writeInitConfig(configPath, config);
942
+ // KJC-BUG-0253: said out loud, so nobody looks for the token where it was not written.
943
+ if (written?.strippedSecrets?.length) {
944
+ logger.info(`Kept ${written.strippedSecrets.join(", ")} out of ${configPath} (versioned with the repo); it lives in ~/.karajan/kj.config.yml`);
945
+ }
942
946
 
943
947
  // Telemetry: anonymous install event (non-blocking)
944
948
  const { readFileSync } = await import("node:fs");
@@ -0,0 +1,63 @@
1
+ /**
2
+ * kj rules approve (KJC-TSK-0952, MDR-D3, ADR 0016): a proposal of compiled rules
3
+ * becomes .karajan/rules.yml only by a HUMAN act, with the layers of the
4
+ * supervisor's seal (ADR 0009). The agent the rules will watch may propose how
5
+ * they are compiled; it does not decide.
6
+ *
7
+ * The proposal is read ONCE: what is checked, what is shown and what is
8
+ * installed are the same rules, whatever happens to the file meanwhile.
9
+ */
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ import yaml from "js-yaml";
14
+
15
+ import { confirmHuman, refuseAgentSession } from "../harden/human-act.js";
16
+ import { checkVerdict } from "../review/verdict-store.js";
17
+ import { approvalView } from "../rules/approval-view.js";
18
+ import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } from "./rules.js";
19
+
20
+ const ACT = "kj rules approve";
21
+
22
+ /**
23
+ * @param {{projectDir: string, file?: string, home?: string, env?: object, tty?: boolean,
24
+ * deps?: {confirm?: Function, ancestry?: object}, log?: (line: string) => void}} opts
25
+ * @returns {Promise<{code: 0|1, lines: string[]}>}
26
+ */
27
+ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env, tty, deps = {}, log = console.log }) {
28
+ refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
29
+ const proposal = path.resolve(projectDir, file);
30
+ let text;
31
+ try { text = fs.readFileSync(proposal, "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to approve`] }; }
32
+ const checked = rulesCheck({ projectDir, home, text });
33
+ if (checked.code !== 0) return { code: 1, lines: checked.lines };
34
+ // KJC-TSK-0963: whoever wrote the proposal does not call it good. A different
35
+ // AI must have read these exact bytes; a touched proposal is reviewed again.
36
+ const { ok, verdict } = await checkVerdict(projectDir, text);
37
+ if (!verdict) return { code: 1, lines: ["✗ this proposal has no cross-AI review of its exact content (none recorded, or it changed since): run `kj rules review`"] };
38
+ if (ok) {
39
+ log(`Reviewed by ${verdict.reviewer}, a different AI from the one that wrote it: ${verdict.summary || "approved"}`);
40
+ } else {
41
+ // KJC-TSK-0974 (ADR 0017): a rejection does not decide, the human does. What
42
+ // the defense asks is that nothing weak is approved UNSEEN: objections first.
43
+ log(`REJECTED by ${verdict.reviewer}, a different AI from the one that wrote it: ${verdict.summary || "see its objections"}`);
44
+ log("Its objections, before anything else. Approving installs the proposal as it is, objections included:");
45
+ for (const issue of verdict.issues ?? []) log(` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
46
+ }
47
+ const { rules, local } = checked;
48
+ // KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
49
+ const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
50
+ for (const line of view) log(line);
51
+ confirmHuman(ACT, deps.confirm);
52
+ // KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
53
+ // is not versioned. Where it goes is the inventory's word, not the proposal's.
54
+ const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
55
+ for (const [name, part] of parts) {
56
+ const target = path.join(projectDir, name);
57
+ if (part.length) fs.writeFileSync(target, yaml.dump({ version: 1, rules: part }, { lineWidth: -1 }));
58
+ else fs.rmSync(target, { force: true }); // no rule left for this file: none stays behind
59
+ }
60
+ fs.rmSync(proposal, { force: true });
61
+ const [[, versioned], [, kept]] = parts;
62
+ return { code: 0, lines: [`✓ ${versioned.length} rule(s) approved into ${RULES_FILE} (commit it, it travels with the repo) and ${kept.length} into ${LOCAL_RULES_FILE} (not versioned, yours alone)`] };
63
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * kj rules compile (KJC-TSK-0940, MDR-D, ADR 0016): the brief for whoever
3
+ * compiles the rules of the MD files that have no gate yet. kj calls no model:
4
+ * the host agent knows its own tools and their arguments, which is what a
5
+ * condition has to name. It proposes; `kj rules check` proves the proposal and
6
+ * the user approves it (`kj rules approve`, a human act).
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ import yaml from "js-yaml";
13
+
14
+ import { shownSource } from "../rules/inventory.js";
15
+ import { loadRules, PROPOSAL_FILE, RULES_FILE, rulesCoverage } from "./rules.js";
16
+
17
+ const FORMAT = `\`\`\`yaml
18
+ version: 1
19
+ rules:
20
+ - id: R-0a1b2c3d4e # the id below, as is
21
+ source: CLAUDE.md # the MD file the rule is written in
22
+ text: "Sprints: uno por día." # the rule's text, word for word
23
+ kind: deterministic
24
+ when: # WHEN TO DENY the call
25
+ tool: "mcp__planning-game*__create_sprint" # a glob, or a list of globs
26
+ any: # all: every condition holds; any: at least one
27
+ - { arg: allowLongSprint, equals: true }
28
+ - { arg: endDate, days_from: startDate, gt: 0 }
29
+ message: "Un sprint dura un día." # what the denied agent reads
30
+ examples: # the rule is run against them: both lists required
31
+ deny: [{ tool: mcp__planning-game-x__create_sprint, input: { startDate: "2026-10-05", endDate: "2026-10-11" } }]
32
+ allow: [{ tool: mcp__planning-game-x__create_sprint, input: { startDate: "2026-10-05", endDate: "2026-10-05" } }]
33
+ - { id: R-1a2b3c4d5e, source: CLAUDE.md, text: "No preguntes obviedades.", kind: judgment, when: { tool: AskUserQuestion } }
34
+ - { id: R-2a3b4c5d6e, source: CLAUDE.md, text: "Habla español correcto.", kind: out-of-scope, reason: "no tool call breaks it" }
35
+ \`\`\``;
36
+
37
+ const HOW = [
38
+ "A condition reads ONE argument of the tool input (`arg`, a dotted path such as updates.status) with exactly one",
39
+ "operator: equals, in (a list), matches (a regex), exists (true/false), gt, lt. With days_from, gt/lt compare the",
40
+ "calendar days from that other argument to `arg`. There is nothing else: no other keys, no free code.",
41
+ "",
42
+ "Choose the kind of each rule:",
43
+ "- deterministic: ONE tool call breaks it and the tool name and its arguments are enough to tell. Name the tools",
44
+ " as you see them (MCP tools included). Deny only what the rule forbids: a gate that denies honest calls gets removed.",
45
+ "- judgment: a tool call breaks it, but telling takes reading what the call says. Give `when.tool`, and a `reason`",
46
+ " when a condition looks possible and is not (a reviewer will ask): say what it cannot see or what it would deny.",
47
+ "- out-of-scope: it is about how you think or answer, and no tool call breaks it. Give the `reason`.",
48
+ "Do not stretch a rule into a condition it does not state, and do not leave a rule out: every rule gets a kind.",
49
+ ];
50
+
51
+ /**
52
+ * KJC-TSK-0953: kj writes the skeleton of the proposal, so nobody retypes the
53
+ * literal texts. It starts from what rules.yml holds minus the stale, lays the
54
+ * proposal already there over it (what is filled in stays), and adds one entry
55
+ * per pending rule that is missing, with no kind yet.
56
+ * @returns {number|null} the entries with no kind, or null when the proposal there is not YAML
57
+ */
58
+ function writeSkeleton({ projectDir, home, pending, kept, gone }) {
59
+ const file = path.join(projectDir, PROPOSAL_FILE);
60
+ let proposed = [];
61
+ if (fs.existsSync(file)) {
62
+ try { proposed = yaml.load(fs.readFileSync(file, "utf8"))?.rules; } catch { return null; }
63
+ if (!Array.isArray(proposed)) return null;
64
+ }
65
+ // The approved rules are the base: a proposal that forgot one would remove its
66
+ // gate on approval. What the proposal says about a rule wins; the stale leave.
67
+ const byId = new Map(kept.map((rule) => [rule.id, rule]));
68
+ const loose = []; // entries with no usable id: kept, for kj rules check to name
69
+ for (const entry of proposed) {
70
+ if (typeof entry?.id !== "string") loose.push(entry);
71
+ else if (!gone.has(entry.id)) byId.set(entry.id, entry);
72
+ }
73
+ for (const rule of pending) {
74
+ if (!byId.has(rule.id)) byId.set(rule.id, { id: rule.id, source: shownSource(rule.file, projectDir, home), text: rule.text });
75
+ }
76
+ const rules = [...byId.values(), ...loose];
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ fs.writeFileSync(file, yaml.dump({ version: 1, rules }, { lineWidth: -1 }));
79
+ return rules.filter((entry) => !entry?.kind).length;
80
+ }
81
+
82
+ /** @returns {{code: 0|1, lines: string[]}} */
83
+ export function rulesCompileBrief({ projectDir, home = os.homedir() }) {
84
+ const covered = rulesCoverage({ projectDir, home });
85
+ if (!covered.output) return covered;
86
+ const pending = covered.output.rows.filter((row) => row.status === "none");
87
+ const { stale } = covered.output;
88
+ if (pending.length + stale.length === 0) return { code: 0, lines: ["✓ every rule of the MD files is decided: nothing to compile"] };
89
+ const gone = new Set(stale.map((rule) => rule.id));
90
+ const kept = loadRules(projectDir).rules.filter((rule) => !gone.has(rule.id));
91
+ const undecided = writeSkeleton({ projectDir, home, pending, kept, gone });
92
+ if (undecided === null) return { code: 1, lines: [`✗ ${PROPOSAL_FILE} is not valid YAML with a rules list: fix it or delete it`] };
93
+ return {
94
+ code: 0,
95
+ lines: [
96
+ "# Compile the rules of the MD files into gates (ADR 0016)",
97
+ "",
98
+ `${PROPOSAL_FILE} is written: what ${RULES_FILE} holds today, as it is, and one entry per rule with no gate,`,
99
+ `with its id, source and literal text. ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet: edit each one, give it its kind and`,
100
+ `what that kind takes. Leave id, source and text as they are. You propose; you cannot write ${RULES_FILE}.`,
101
+ ...(stale.length ? [`Left out, their text is in no MD any more: ${stale.map((rule) => rule.id).join(", ")}`] : []),
102
+ "",
103
+ FORMAT,
104
+ "",
105
+ ...HOW,
106
+ "",
107
+ "A rule with a condition (deterministic) you write by hand, in its entry. The rest take no condition, and you",
108
+ "decide many at once: `kj rules decide <id>... --kind judgment --tool <tool>` or",
109
+ "`kj rules decide <id>... --kind out-of-scope --reason \"<why no tool call breaks it>\"`.",
110
+ "",
111
+ "## Then",
112
+ "Run `kj rules check` and fix the proposal until it holds. Then run `kj rules review`: a different AI judges",
113
+ "whether each compilation is as strong as its text, and a rule you weakened comes back to you. Only then ask your",
114
+ "user to read it and run `kj rules approve` from their own terminal: no agent session can.",
115
+ ],
116
+ };
117
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * kj rules decide (KJC-TSK-0969, MDR-G, ADR 0016): a real project has more than
3
+ * a hundred rules, and most take no condition. Their kind is decided in one go:
4
+ * judgment with the tool it shows on, or out of scope with the reason. kj writes
5
+ * the proposal, as it writes its skeleton. A deterministic rule is not decided
6
+ * here: its condition and its examples are written rule by rule.
7
+ */
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+
11
+ import yaml from "js-yaml";
12
+
13
+ import { PROPOSAL_FILE } from "./rules.js";
14
+
15
+ const failed = (line) => ({ code: 1, lines: [`✗ ${line}`] });
16
+
17
+ /** What each kind takes, or why it cannot be decided this way. */
18
+ function decision({ kind, tools = [], reason = "" }) {
19
+ if (kind === "judgment") {
20
+ if (tools.length === 0) return { error: "a judgment rule names the tool it shows on: --tool <glob> (repeatable)" };
21
+ // KJC-TSK-0973: why it takes no condition, when there is something to say.
22
+ return { fields: { kind, when: { tool: tools.length === 1 ? tools[0] : tools }, ...(reason.trim() ? { reason: reason.trim() } : {}) } };
23
+ }
24
+ if (kind === "out-of-scope") {
25
+ if (!reason.trim()) return { error: "an out-of-scope rule says why no tool call breaks it: --reason <text>" };
26
+ return { fields: { kind, reason: reason.trim() } };
27
+ }
28
+ return { error: "--kind is judgment or out-of-scope; a deterministic rule is written by hand, with its condition and its examples" };
29
+ }
30
+
31
+ /**
32
+ * @param {{projectDir: string, file?: string, ids: string[], kind: string, tools?: string[], reason?: string}} opts
33
+ * @returns {{code: 0|1, lines: string[]}}
34
+ */
35
+ export function rulesDecide({ projectDir, file = PROPOSAL_FILE, ids, kind, tools, reason }) {
36
+ if (!Array.isArray(ids) || ids.length === 0) return failed("name at least one rule id (R-...)");
37
+ const { fields, error } = decision({ kind, tools, reason });
38
+ if (error) return failed(error);
39
+ const target = path.resolve(projectDir, file);
40
+ let doc;
41
+ try { doc = yaml.load(fs.readFileSync(target, "utf8")); } catch { return failed(`no readable ${file}: run \`kj rules compile\` first`); }
42
+ if (!Array.isArray(doc?.rules)) return failed(`${file} has no rules list`);
43
+ const wanted = new Set(ids);
44
+ const known = new Set(doc.rules.map((entry) => entry?.id));
45
+ const missing = ids.filter((id) => !known.has(id));
46
+ if (missing.length) return failed(`not in ${file}: ${missing.join(", ")}`);
47
+ // What identifies the rule stays; what its previous kind took goes.
48
+ doc.rules = doc.rules.map((entry) => (wanted.has(entry?.id) ? { id: entry.id, source: entry.source, text: entry.text, ...fields } : entry));
49
+ fs.writeFileSync(target, yaml.dump(doc, { lineWidth: -1 }));
50
+ const undecided = doc.rules.filter((entry) => !entry?.kind).length;
51
+ return { code: 0, lines: [`✓ ${wanted.size} rule(s) decided as ${kind}; ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet`] };
52
+ }