karajan-code 4.39.0 → 4.40.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 (39) 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/init.js +6 -2
  8. package/src/commands/rules-approve.js +59 -0
  9. package/src/commands/rules-compile.js +116 -0
  10. package/src/commands/rules-decide.js +51 -0
  11. package/src/commands/rules-review.js +44 -0
  12. package/src/commands/rules.js +169 -0
  13. package/src/config/loader.js +23 -1
  14. package/src/environment/adr.js +5 -2
  15. package/src/harden/config-templates.js +3 -0
  16. package/src/harden/human-act.js +70 -0
  17. package/src/harden/phone-sign.js +26 -0
  18. package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
  19. package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
  20. package/src/harden/sentinel/sentinel-discard.mjs +4 -2
  21. package/src/harden/sentinel/sentinel-rules.mjs +86 -0
  22. package/src/harden/sentinel/sentinel-shell.mjs +17 -0
  23. package/src/harden/sentinel/sessionstart.mjs +18 -9
  24. package/src/harden/sentinel-hooks.js +23 -2
  25. package/src/harden/supervisor-commit.js +13 -56
  26. package/src/mcp/handlers/run-handler.js +5 -0
  27. package/src/mcp/sovereignty-guard.js +16 -15
  28. package/src/orchestrator/preflight-checks.js +4 -3
  29. package/src/policy/supervisor-verify.js +12 -2
  30. package/src/privacy/scan.js +7 -0
  31. package/src/review/gate-gitignore.js +8 -0
  32. package/src/rules/approval-view.js +49 -0
  33. package/src/rules/compiled.js +108 -0
  34. package/src/rules/coverage.js +23 -0
  35. package/src/rules/evaluate.js +60 -0
  36. package/src/rules/inventory.js +118 -0
  37. package/src/sonar/config-resolver.js +19 -1
  38. package/src/utils/run-log.js +74 -2
  39. 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.40.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")
@@ -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,59 @@
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 approved these exact bytes; a touched proposal is reviewed again.
36
+ const reviewed = await checkVerdict(projectDir, text);
37
+ if (!reviewed.ok) {
38
+ const found = (reviewed.verdict?.issues ?? []).map((issue) => ` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
39
+ const why = reviewed.verdict ? "rejected by " + reviewed.verdict.reviewer : "none recorded for its exact content";
40
+ return { code: 1, lines: [`✗ this proposal has no approved cross-AI review (${why}): run \`kj rules review\``, ...found] };
41
+ }
42
+ log(`Reviewed by ${reviewed.verdict.reviewer}, a different AI from the one that wrote it: ${reviewed.verdict.summary || "approved"}`);
43
+ const { rules, local } = checked;
44
+ // KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
45
+ const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
46
+ for (const line of view) log(line);
47
+ confirmHuman(ACT, deps.confirm);
48
+ // KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
49
+ // is not versioned. Where it goes is the inventory's word, not the proposal's.
50
+ const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
51
+ for (const [name, part] of parts) {
52
+ const target = path.join(projectDir, name);
53
+ if (part.length) fs.writeFileSync(target, yaml.dump({ version: 1, rules: part }, { lineWidth: -1 }));
54
+ else fs.rmSync(target, { force: true }); // no rule left for this file: none stays behind
55
+ }
56
+ fs.rmSync(proposal, { force: true });
57
+ const [[, versioned], [, kept]] = parts;
58
+ 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)`] };
59
+ }
@@ -0,0 +1,116 @@
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` only.",
46
+ "- out-of-scope: it is about how you think or answer, and no tool call breaks it. Give the `reason`.",
47
+ "Do not stretch a rule into a condition it does not state, and do not leave a rule out: every rule gets a kind.",
48
+ ];
49
+
50
+ /**
51
+ * KJC-TSK-0953: kj writes the skeleton of the proposal, so nobody retypes the
52
+ * literal texts. It starts from what rules.yml holds minus the stale, lays the
53
+ * proposal already there over it (what is filled in stays), and adds one entry
54
+ * per pending rule that is missing, with no kind yet.
55
+ * @returns {number|null} the entries with no kind, or null when the proposal there is not YAML
56
+ */
57
+ function writeSkeleton({ projectDir, home, pending, kept, gone }) {
58
+ const file = path.join(projectDir, PROPOSAL_FILE);
59
+ let proposed = [];
60
+ if (fs.existsSync(file)) {
61
+ try { proposed = yaml.load(fs.readFileSync(file, "utf8"))?.rules; } catch { return null; }
62
+ if (!Array.isArray(proposed)) return null;
63
+ }
64
+ // The approved rules are the base: a proposal that forgot one would remove its
65
+ // gate on approval. What the proposal says about a rule wins; the stale leave.
66
+ const byId = new Map(kept.map((rule) => [rule.id, rule]));
67
+ const loose = []; // entries with no usable id: kept, for kj rules check to name
68
+ for (const entry of proposed) {
69
+ if (typeof entry?.id !== "string") loose.push(entry);
70
+ else if (!gone.has(entry.id)) byId.set(entry.id, entry);
71
+ }
72
+ for (const rule of pending) {
73
+ if (!byId.has(rule.id)) byId.set(rule.id, { id: rule.id, source: shownSource(rule.file, projectDir, home), text: rule.text });
74
+ }
75
+ const rules = [...byId.values(), ...loose];
76
+ fs.mkdirSync(path.dirname(file), { recursive: true });
77
+ fs.writeFileSync(file, yaml.dump({ version: 1, rules }, { lineWidth: -1 }));
78
+ return rules.filter((entry) => !entry?.kind).length;
79
+ }
80
+
81
+ /** @returns {{code: 0|1, lines: string[]}} */
82
+ export function rulesCompileBrief({ projectDir, home = os.homedir() }) {
83
+ const covered = rulesCoverage({ projectDir, home });
84
+ if (!covered.output) return covered;
85
+ const pending = covered.output.rows.filter((row) => row.status === "none");
86
+ const { stale } = covered.output;
87
+ if (pending.length + stale.length === 0) return { code: 0, lines: ["✓ every rule of the MD files is decided: nothing to compile"] };
88
+ const gone = new Set(stale.map((rule) => rule.id));
89
+ const kept = loadRules(projectDir).rules.filter((rule) => !gone.has(rule.id));
90
+ const undecided = writeSkeleton({ projectDir, home, pending, kept, gone });
91
+ if (undecided === null) return { code: 1, lines: [`✗ ${PROPOSAL_FILE} is not valid YAML with a rules list: fix it or delete it`] };
92
+ return {
93
+ code: 0,
94
+ lines: [
95
+ "# Compile the rules of the MD files into gates (ADR 0016)",
96
+ "",
97
+ `${PROPOSAL_FILE} is written: what ${RULES_FILE} holds today, as it is, and one entry per rule with no gate,`,
98
+ `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`,
99
+ `what that kind takes. Leave id, source and text as they are. You propose; you cannot write ${RULES_FILE}.`,
100
+ ...(stale.length ? [`Left out, their text is in no MD any more: ${stale.map((rule) => rule.id).join(", ")}`] : []),
101
+ "",
102
+ FORMAT,
103
+ "",
104
+ ...HOW,
105
+ "",
106
+ "A rule with a condition (deterministic) you write by hand, in its entry. The rest take no condition, and you",
107
+ "decide many at once: `kj rules decide <id>... --kind judgment --tool <tool>` or",
108
+ "`kj rules decide <id>... --kind out-of-scope --reason \"<why no tool call breaks it>\"`.",
109
+ "",
110
+ "## Then",
111
+ "Run `kj rules check` and fix the proposal until it holds. Then run `kj rules review`: a different AI judges",
112
+ "whether each compilation is as strong as its text, and a rule you weakened comes back to you. Only then ask your",
113
+ "user to read it and run `kj rules approve` from their own terminal: no agent session can.",
114
+ ],
115
+ };
116
+ }
@@ -0,0 +1,51 @@
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
+ return { fields: { kind, when: { tool: tools.length === 1 ? tools[0] : tools } } };
22
+ }
23
+ if (kind === "out-of-scope") {
24
+ if (!reason.trim()) return { error: "an out-of-scope rule says why no tool call breaks it: --reason <text>" };
25
+ return { fields: { kind, reason: reason.trim() } };
26
+ }
27
+ return { error: "--kind is judgment or out-of-scope; a deterministic rule is written by hand, with its condition and its examples" };
28
+ }
29
+
30
+ /**
31
+ * @param {{projectDir: string, file?: string, ids: string[], kind: string, tools?: string[], reason?: string}} opts
32
+ * @returns {{code: 0|1, lines: string[]}}
33
+ */
34
+ export function rulesDecide({ projectDir, file = PROPOSAL_FILE, ids, kind, tools, reason }) {
35
+ if (!Array.isArray(ids) || ids.length === 0) return failed("name at least one rule id (R-...)");
36
+ const { fields, error } = decision({ kind, tools, reason });
37
+ if (error) return failed(error);
38
+ const target = path.resolve(projectDir, file);
39
+ let doc;
40
+ try { doc = yaml.load(fs.readFileSync(target, "utf8")); } catch { return failed(`no readable ${file}: run \`kj rules compile\` first`); }
41
+ if (!Array.isArray(doc?.rules)) return failed(`${file} has no rules list`);
42
+ const wanted = new Set(ids);
43
+ const known = new Set(doc.rules.map((entry) => entry?.id));
44
+ const missing = ids.filter((id) => !known.has(id));
45
+ if (missing.length) return failed(`not in ${file}: ${missing.join(", ")}`);
46
+ // What identifies the rule stays; what its previous kind took goes.
47
+ doc.rules = doc.rules.map((entry) => (wanted.has(entry?.id) ? { id: entry.id, source: entry.source, text: entry.text, ...fields } : entry));
48
+ fs.writeFileSync(target, yaml.dump(doc, { lineWidth: -1 }));
49
+ const undecided = doc.rules.filter((entry) => !entry?.kind).length;
50
+ return { code: 0, lines: [`✓ ${wanted.size} rule(s) decided as ${kind}; ${undecided} entr${undecided === 1 ? "y has" : "ies have"} no kind yet`] };
51
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * kj rules review (KJC-TSK-0963, MDR-F4, ADR 0017): the agent the rules will
3
+ * watch wrote the proposal, so it is not the one who calls it good. A DIFFERENT
4
+ * AI reads it rule by rule, and its verdict is tied to the exact bytes of the
5
+ * proposal (the same store and the same primitive as `kj review`). Without an
6
+ * approved verdict for those bytes, `kj rules approve` does not offer it.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+
12
+ import { runOneShotReview } from "../review/one-shot-review.js";
13
+ import { PROPOSAL_FILE, rulesCheck } from "./rules.js";
14
+
15
+ const TASK = [
16
+ "This is NOT code: it is a proposal of compiled rules (YAML). Each entry carries the literal `text` of a rule the user",
17
+ "wrote in their MD files (already verified word for word) and what an agent compiled it into. That agent is the one these",
18
+ "rules are going to watch, so it gains from a compilation that never fires. Judge every rule on one question: does the",
19
+ "compilation enforce what the text says?",
20
+ "- kind deterministic: `when` must deny the tool calls the text forbids. Too narrow a condition (it would rarely fire), or",
21
+ " examples picked so that a weak condition passes, is a defect. So is a condition so wide that it denies honest calls.",
22
+ "- kind judgment or out-of-scope: nothing is blocked on these. If a condition over a tool name and its arguments could",
23
+ " have enforced the text, the rule has been weakened.",
24
+ "Report as a BLOCKING issue every rule that is weaker than its text, naming its id (R-...) and saying what would enforce it.",
25
+ "Approve only when no rule is weaker than its text.",
26
+ ].join("\n");
27
+
28
+ /**
29
+ * @param {{projectDir: string, file?: string, home?: string, config: object, logger?: object, deps?: object}} opts
30
+ * `deps` are the seams of runOneShotReview (hostAgent, createAgentFn, detectAgents).
31
+ * @returns {Promise<{code: 0|1, lines: string[]}>}
32
+ */
33
+ export async function rulesReview({ projectDir, file = PROPOSAL_FILE, home = os.homedir(), config, logger, deps = {} }) {
34
+ let text;
35
+ try { text = fs.readFileSync(path.resolve(projectDir, file), "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to review`] }; }
36
+ const checked = rulesCheck({ projectDir, home, text });
37
+ if (checked.code !== 0) return { code: 1, lines: checked.lines };
38
+ const record = await runOneShotReview({ diff: text, task: TASK, config, logger, projectDir, ...deps });
39
+ if (record.verdict === "approved") {
40
+ return { code: 0, lines: [`✓ APPROVED by ${record.reviewer}: ${record.summary || "the compilation enforces the rules as written"}`, "Now your user reads it and runs `kj rules approve` from their own terminal."] };
41
+ }
42
+ const issues = record.issues.map((issue) => ` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
43
+ return { code: 1, lines: [`✗ REJECTED by ${record.reviewer} — ${issues.length} rule(s) weaker than their text:`, ...issues, "Fix the proposal and run `kj rules review` again: the verdict is tied to its exact content."] };
44
+ }