karajan-code 4.38.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 (53) hide show
  1. package/package.json +1 -1
  2. package/packages/ai-trash/src/destructive-parser.js +34 -5
  3. package/packages/ai-trash/src/hook.js +28 -6
  4. package/src/audit/ai-slop-findings.js +4 -2
  5. package/src/audit/circular-deps.js +8 -7
  6. package/src/audit/webperf-input.js +3 -1
  7. package/src/checks/method.js +1 -1
  8. package/src/checks/release-check.js +3 -1
  9. package/src/cli/advanced-commands.js +1 -1
  10. package/src/cli/register-meta.js +112 -1
  11. package/src/cli/register-pipeline.js +2 -1
  12. package/src/commands/init.js +6 -2
  13. package/src/commands/review-gate.js +21 -22
  14. package/src/commands/rules-approve.js +59 -0
  15. package/src/commands/rules-compile.js +116 -0
  16. package/src/commands/rules-decide.js +51 -0
  17. package/src/commands/rules-review.js +44 -0
  18. package/src/commands/rules.js +169 -0
  19. package/src/config/loader.js +23 -1
  20. package/src/environment/adr.js +5 -2
  21. package/src/harden/config-templates.js +3 -0
  22. package/src/harden/harness-hooks.js +7 -85
  23. package/src/harden/hook-templates.js +6 -5
  24. package/src/harden/human-act.js +70 -0
  25. package/src/harden/phone-sign.js +26 -0
  26. package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
  27. package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
  28. package/src/harden/sentinel/sentinel-discard.mjs +33 -4
  29. package/src/harden/sentinel/sentinel-rules.mjs +86 -0
  30. package/src/harden/sentinel/sentinel-shell.mjs +34 -2
  31. package/src/harden/sentinel/sessionstart.mjs +18 -9
  32. package/src/harden/sentinel-hooks.js +111 -194
  33. package/src/harden/supervisor-commit.js +13 -56
  34. package/src/mcp/handlers/run-handler.js +5 -0
  35. package/src/mcp/sovereignty-guard.js +16 -15
  36. package/src/orchestrator/preflight-checks.js +4 -3
  37. package/src/policy/supervisor-verify.js +20 -6
  38. package/src/privacy/scan.js +7 -0
  39. package/src/review/card-first.js +3 -5
  40. package/src/review/gate-gitignore.js +8 -0
  41. package/src/review/policy-gate.js +4 -6
  42. package/src/review/sonar-pregate.js +3 -3
  43. package/src/review/tests-with-code.js +3 -6
  44. package/src/rules/approval-view.js +49 -0
  45. package/src/rules/compiled.js +108 -0
  46. package/src/rules/coverage.js +23 -0
  47. package/src/rules/evaluate.js +60 -0
  48. package/src/rules/inventory.js +118 -0
  49. package/src/sonar/api.js +21 -0
  50. package/src/sonar/config-resolver.js +19 -1
  51. package/src/sonar/scanner.js +22 -3
  52. package/src/utils/run-log.js +74 -2
  53. package/src/utils/stack-detect.js +12 -0
@@ -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
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * kj rules eval (KJC-TSK-0943, MDR-B2, ADR 0016): the hook's contract, the same
3
+ * as `kj policy eval --strict`. One tool call in, a JSON verdict out, exit
4
+ * 0 = allow, 2 = deny, 1 = it could not be evaluated (invalid or unreadable
5
+ * rules.yml, unreadable input).
6
+ */
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ import { isObject, parseRules } from "../rules/compiled.js";
12
+ import { coverage, NO_GATE } from "../rules/coverage.js";
13
+ import { evalRules } from "../rules/evaluate.js";
14
+ import { inProject, listRules, shownSource } from "../rules/inventory.js";
15
+
16
+ export const RULES_FILE = path.join(".karajan", "rules.yml");
17
+ /** ADR 0017: rules whose source is outside the project. Never versioned. */
18
+ export const LOCAL_RULES_FILE = path.join(".karajan", "rules.local.yml");
19
+
20
+ /**
21
+ * The compiled rules of a project. A file that does not exist is no rules; a
22
+ * file that exists and cannot be read is an error (rules silently off would be
23
+ * every call allowed).
24
+ */
25
+ function loadRulesFile(projectDir, file) {
26
+ let text;
27
+ try {
28
+ text = fs.readFileSync(path.resolve(projectDir, file), "utf8");
29
+ } catch (err) {
30
+ if (err.code === "ENOENT") return { present: false, rules: [], errors: [] };
31
+ return { present: true, rules: [], errors: [`cannot read ${file}: ${err.code || err.message}`] };
32
+ }
33
+ return { present: true, ...parseRules(text) };
34
+ }
35
+
36
+ /**
37
+ * With a `file`, that file alone (a proposal). Without one, what governs the
38
+ * project (KJC-TSK-0954, ADR 0017): the versioned rules and the local ones, as
39
+ * one rule set. An error in either leaves no rules; a rule lives in one file.
40
+ */
41
+ export function loadRules(projectDir, file) {
42
+ if (file) return loadRulesFile(projectDir, file);
43
+ const loaded = [RULES_FILE, LOCAL_RULES_FILE].map((name) => ({ name, ...loadRulesFile(projectDir, name) }));
44
+ const errors = loaded.flatMap(({ name, errors: found }) => found.map((e) => `${name}: ${e}`));
45
+ const rules = loaded.flatMap((part) => part.rules);
46
+ const ids = rules.map((rule) => rule.id);
47
+ errors.push(...new Set(ids.filter((id, i) => ids.indexOf(id) !== i).map((id) => `${id}: in both ${RULES_FILE} and ${LOCAL_RULES_FILE}, a rule lives in one file`)));
48
+ return { present: loaded.some((part) => part.present), rules: errors.length ? [] : rules, errors };
49
+ }
50
+
51
+ const failed = (...errors) => ({ code: 1, output: { errors } });
52
+
53
+ /**
54
+ * KJC-TSK-0946: `--input -` reads the tool input from stdin. The hook passes it
55
+ * that way, because a large Write does not fit in one argument. Read as a
56
+ * stream: a synchronous read of a pipe fails (EAGAIN) once the input is large.
57
+ */
58
+ export async function readToolInput(flag, stdin = process.stdin) {
59
+ if (flag !== "-") return flag;
60
+ let text = "";
61
+ for await (const chunk of stdin) text += chunk;
62
+ return text;
63
+ }
64
+
65
+ /** @returns {{code: 0|1|2, output: object}} */
66
+ export function rulesEval({ projectDir, tool, input = "{}" }) {
67
+ if (typeof tool !== "string" || !tool) return failed("--tool is required");
68
+ let args;
69
+ try { args = JSON.parse(input); } catch { return failed("--input must be valid JSON"); }
70
+ if (!isObject(args)) return failed("--input must be a JSON object (the tool input)");
71
+ const { rules, errors } = loadRules(projectDir);
72
+ if (errors.length) return failed(...errors);
73
+ const output = evalRules(rules, { tool, input: args });
74
+ return { code: output.decision === "deny" ? 2 : 0, output };
75
+ }
76
+
77
+ const isCall = (c) => isObject(c) && typeof c.tool === "string" && c.tool !== "" && (c.input === undefined || isObject(c.input));
78
+ const isCallList = (list) => Array.isArray(list) && list.length > 0 && list.every(isCall);
79
+
80
+ /** What a rule's examples say against the rule itself, alone. */
81
+ function exampleFailures(rule) {
82
+ const { deny, allow } = isObject(rule.examples) ? rule.examples : {};
83
+ if (!isCallList(deny) || !isCallList(allow)) return [`${rule.id}: needs examples.deny and examples.allow, each a list of { tool, input } calls`];
84
+ const verdict = (call) => evalRules([rule], call).decision;
85
+ return [
86
+ ...deny.flatMap((call, i) => (verdict(call) === "deny" ? [] : [`${rule.id}: deny example #${i + 1} was allowed`])),
87
+ ...allow.flatMap((call, i) => (verdict(call) === "deny" ? [`${rule.id}: allow example #${i + 1} was denied`] : [])),
88
+ ];
89
+ }
90
+
91
+ /**
92
+ * kj rules test (KJC-TSK-0945, MDR-B3): every deterministic rule against its own
93
+ * deny/allow examples. A rule with no examples is untested, and that fails too.
94
+ * @returns {{code: 0|1, lines: string[]}}
95
+ */
96
+ export function rulesTest({ projectDir }) {
97
+ const { present, rules, errors } = loadRules(projectDir);
98
+ if (errors.length) return { code: 1, lines: errors.map((e) => `✗ ${e}`) };
99
+ if (!present) return { code: 0, lines: [`no ${RULES_FILE}: nothing to test`] };
100
+ const tested = rules.filter((rule) => rule.kind === "deterministic");
101
+ const failures = tested.flatMap(exampleFailures);
102
+ if (failures.length) return { code: 1, lines: failures.map((f) => `✗ ${f}`) };
103
+ const examples = tested.reduce((n, rule) => n + rule.examples.deny.length + rule.examples.allow.length, 0);
104
+ return { code: 0, lines: [`✓ ${tested.length} rule(s), ${examples} example(s)`] };
105
+ }
106
+
107
+ export const PROPOSAL_FILE = path.join(".karajan", "rules.proposed.yml");
108
+
109
+ /**
110
+ * What a proposed rule says against the MD files: it is one of their rules, word
111
+ * for word, and its source is where they write it (KJC-TSK-0961: the source
112
+ * decides whether a rule is versioned, so the proposal does not get to choose it).
113
+ * @param {object} rule
114
+ * @param {object[]|undefined} written every place the inventory found this rule
115
+ * @param {(file: string) => string} shown
116
+ */
117
+ function inventoryFailures(rule, written, shown) {
118
+ if (!written) return [`${rule.id}: no rule of the MD files has this id (kj rules list)`];
119
+ const [first] = written;
120
+ if (rule.text !== first.text) return [`${rule.id}: text is not what the MD says (${first.file}:${first.line}): ${first.text}`];
121
+ const sources = written.map((found) => shown(found.file));
122
+ return sources.includes(rule.source) ? [] : [`${rule.id}: source is not where the MD files write it (${sources.join(", ")})`];
123
+ }
124
+
125
+ /**
126
+ * kj rules check (KJC-TSK-0950, MDR-D1): a proposal of compiled rules proves
127
+ * itself before a human reads it. Every rule is one of the MD files and cites
128
+ * its literal text; every deterministic one passes its own examples.
129
+ * With `text`, that content is checked and the file is not read again: whoever
130
+ * installs a proposal installs the bytes that were checked.
131
+ * `local` holds the ids written in no file of the project: those are not versioned.
132
+ * @returns {{code: 0|1, lines: string[], rules?: object[], local?: Set<string>}}
133
+ */
134
+ export function rulesCheck({ projectDir, file = PROPOSAL_FILE, home = os.homedir(), text }) {
135
+ const { present, rules, errors } = text === undefined ? loadRules(projectDir, file) : { present: true, ...parseRules(text) };
136
+ if (!present) return { code: 1, lines: [`✗ no ${file}: nothing to check`] };
137
+ if (errors.length) return { code: 1, lines: errors.map((e) => `✗ ${e}`) };
138
+ const inventory = Map.groupBy(listRules(projectDir, { home }), (rule) => rule.id);
139
+ const shown = (found) => shownSource(found, projectDir, home);
140
+ const failures = rules.flatMap((rule) => [
141
+ ...inventoryFailures(rule, inventory.get(rule.id), shown),
142
+ ...(rule.kind === "deterministic" ? exampleFailures(rule) : []),
143
+ ]);
144
+ if (failures.length) return { code: 1, lines: failures.map((f) => `✗ ${f}`) };
145
+ const count = (kind) => rules.filter((rule) => rule.kind === kind).length;
146
+ const local = new Set(rules.filter((rule) => !inventory.get(rule.id).some((found) => inProject(found.file, projectDir))).map((rule) => rule.id));
147
+ return { code: 0, rules, local, lines: [`✓ ${rules.length} rule(s) hold: ${count("deterministic")} deterministic, ${count("judgment")} judgment, ${count("out-of-scope")} out of scope`] };
148
+ }
149
+
150
+ /**
151
+ * kj rules coverage (KJC-TSK-0941, MDR-E): every rule of the governing MD files
152
+ * against rules.yml. `strict` fails while a rule has no gate or a compiled rule
153
+ * is stale, so a rule nobody decided about cannot go unseen.
154
+ * @returns {{code: 0|1, lines: string[], output?: object}}
155
+ */
156
+ export function rulesCoverage({ projectDir, strict = false, home }) {
157
+ const { rules, errors } = loadRules(projectDir);
158
+ if (errors.length) return { code: 1, lines: errors.map((e) => `✗ ${e}`) };
159
+ const { rows, stale } = coverage(listRules(projectDir, home ? { home } : {}), rules);
160
+ const count = (status) => rows.filter((row) => row.status === status).length;
161
+ const counts = { deterministic: count("deterministic"), judgment: count("judgment"), "out-of-scope": count("out-of-scope"), [NO_GATE]: count(NO_GATE), stale: stale.length };
162
+ const ungated = rows.filter((row) => row.status === NO_GATE);
163
+ const lines = [
164
+ ...ungated.map((r) => `✗ no gate ${r.id} ${r.file}:${r.line} ${r.text}`),
165
+ ...stale.map((r) => `✗ stale ${r.id} ${r.source ?? "?"} ${r.text ?? ""} (its text is in no MD any more: compile it again)`),
166
+ `${rows.length} rule(s): ${counts.deterministic} deterministic, ${counts.judgment} judgment, ${counts["out-of-scope"]} out of scope, ${ungated.length} with no gate; ${stale.length} stale`,
167
+ ];
168
+ return { code: strict && ungated.length + stale.length > 0 ? 1 : 0, lines, output: { counts, rows, stale } };
169
+ }
@@ -194,10 +194,32 @@ function stripRuntimeOnlyKeys(config) {
194
194
  return out;
195
195
  }
196
196
 
197
+ // KJC-BUG-0253: a project's .karajan/kj.config.yml is versioned with the repo,
198
+ // so the SonarQube credentials never go there; they live in the global config
199
+ // (~/.karajan), where the token bootstrap already saves them.
200
+ const SECRET_KEYS = ["token", "admin_password"];
201
+ const isProjectConfig = (configPath) =>
202
+ path.basename(configPath) === "kj.config.yml"
203
+ && path.basename(path.dirname(configPath)) === ".karajan"
204
+ && path.resolve(configPath) !== path.resolve(getConfigPath());
205
+
206
+ function stripSecrets(config) {
207
+ if (!config?.sonarqube || typeof config.sonarqube !== "object") return { out: config, stripped: [] };
208
+ const stripped = SECRET_KEYS.filter((k) => config.sonarqube[k] != null);
209
+ if (!stripped.length) return { out: config, stripped };
210
+ const sonarqube = { ...config.sonarqube };
211
+ for (const k of stripped) delete sonarqube[k];
212
+ return { out: { ...config, sonarqube }, stripped: stripped.map((k) => `sonarqube.${k}`) };
213
+ }
214
+
215
+ /** @returns {Promise<{strippedSecrets: string[]}>} the secret keys kept out of a project config */
197
216
  export async function writeConfig(configPath, config) {
198
217
  await ensureDir(path.dirname(configPath));
199
- const sanitized = stripRuntimeOnlyKeys(config);
218
+ let sanitized = stripRuntimeOnlyKeys(config);
219
+ let strippedSecrets = [];
220
+ if (isProjectConfig(configPath)) ({ out: sanitized, stripped: strippedSecrets } = stripSecrets(sanitized));
200
221
  await fs.writeFile(configPath, yaml.dump(sanitized, { lineWidth: 120 }), "utf8");
222
+ return { strippedSecrets };
201
223
  }
202
224
 
203
225
  // Declarative mappings for applyRunOverrides to reduce cognitive complexity.
@@ -8,6 +8,9 @@ import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
9
 
10
10
  const ADR_DIR = path.join(".karajan", "adrs");
11
+ // KJC-BUG-0272: whoever records a decision proposes it. Accepting is the user's:
12
+ // they set `Status: accepted` in the file. Born accepted, nobody had decided.
13
+ const NEW_STATUS = "proposed";
11
14
 
12
15
  const slugify = (t) => t.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "").slice(0, 60);
13
16
 
@@ -34,12 +37,12 @@ export async function addAdr(projectDir, { title, decision, context = "", conseq
34
37
  const file = path.join(ADR_DIR, `${String(number).padStart(4, "0")}-${slugify(title)}.md`);
35
38
  const body = [
36
39
  `# ${title}`, "",
37
- `Status: accepted`, `Date: ${new Date().toISOString().slice(0, 10)}`, "",
40
+ `Status: ${NEW_STATUS}`, `Date: ${new Date().toISOString().slice(0, 10)}`, "",
38
41
  ...(context ? ["## Context", "", context, ""] : []),
39
42
  "## Decision", "", decision, "",
40
43
  ...(consequences ? ["## Consequences", "", consequences, ""] : []),
41
44
  ].join("\n");
42
45
  await fs.mkdir(path.join(projectDir, ADR_DIR), { recursive: true });
43
46
  await fs.writeFile(path.join(projectDir, file), body);
44
- return { number, file, title };
47
+ return { number, file, title, status: NEW_STATUS };
45
48
  }
@@ -42,6 +42,9 @@ export const COMMITLINT_BODY = [
42
42
  // project has no eslint config of its own.
43
43
  export const ESLINT_BODY = [
44
44
  "export default [",
45
+ // KJC-BUG-0254 (#1902): third-party and generated code is not the project's to
46
+ // lint (a vendored tf.min.js gave 6607 false no-var errors).
47
+ ' { ignores: ["vendor/**", "dist/**", "build/**", "coverage/**", ".scannerwork/**", "**/*.min.{js,mjs,cjs}"] },',
45
48
  " {",
46
49
  ' files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],',
47
50
  ' languageOptions: { ecmaVersion: 2025, sourceType: "module" },',
@@ -3,8 +3,9 @@
3
3
  * Field case 2026-08-02: text rules were not enough — the environment must
4
4
  * impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
5
5
  * it into the project's `.claude/settings.json` (merged, never clobbered):
6
- * Write over an existing file blocks ("use Edit", KJ_ALLOW_WRITE=1 escapes);
7
- * Bash that reserializes whole JSON files blocks (KJ_ALLOW_REWRITE=1).
6
+ * Write over an existing file blocks ("use Edit"), with no escape (ADR 0015).
7
+ * Writes through Bash are the Sentinel's bash-write guard (KJC-BUG-0237), which
8
+ * replaced the JSON-reserialization guard that lived here.
8
9
  * Claude-only v1 — the abstraction arrives with the second host that
9
10
  * supports tool hooks.
10
11
  */
@@ -19,94 +20,15 @@ const SCRIPT_BODY = `#!/usr/bin/env node
19
20
  // so a gate bug never bricks the session.
20
21
  import console from "node:console";
21
22
  import process from "node:process";
22
- import { existsSync, realpathSync } from "node:fs";
23
- import { dirname, join, relative, resolve } from "node:path";
24
- import { fileURLToPath } from "node:url";
25
- // El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
26
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
23
+ import { existsSync } from "node:fs";
27
24
  let raw = "";
28
25
  process.stdin.on("data", (d) => { raw += d; });
29
26
  process.stdin.on("end", () => {
30
27
  try {
31
28
  const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
32
- if (tool === "Write" && process.env.KJ_ALLOW_WRITE !== "1") {
33
- if (input.file_path && existsSync(input.file_path)) {
34
- console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes (KJ_ALLOW_WRITE=1 to override consciously).");
35
- process.exit(2);
36
- }
37
- }
38
- if (tool === "Bash") {
39
- const cmd = String(input.command || "");
40
- // KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
41
- // resto de guardias. Mirando solo process.env el agente no tenia salida:
42
- // un export dentro del comando jamas llega a este proceso.
43
- // Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
44
- // aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
45
- // un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
46
- const toks = cmd.trim().split(/\\s+/);
47
- let i = 0;
48
- let named = false;
49
- while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
50
- if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
51
- i += 1;
52
- }
53
- // Una asignacion delante solo alcanza a SU comando, asi que el escape del
54
- // comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
55
- // o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
56
- // que va entre comillas no encadena nada, asi que no cuenta.
57
- // Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
58
- // hereda la asignacion (catch de la review). Mismo criterio que el resto
59
- // de guardias del Sentinel.
60
- // Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
61
- // encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
62
- const noSingle = cmd.replaceAll(/'[^']*'/g, "");
63
- const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
64
- const bare = noSingle.replaceAll(/"[^"]*"/g, "");
65
- const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
66
- const cmdEscape = named && simple && i < toks.length;
67
- const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
68
- // Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
69
- if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
70
- const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
71
- // KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
72
- // temporal del scratchpad no es asunto de este guard, y bloquearlo
73
- // ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
74
- const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
75
- // Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
76
- // fuera del arbol. Una relativa depende del cwd del shell, que este hook
77
- // no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
78
- // dentro del repo (catch de la review), asi que se sigue vigilando.
79
- // Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
80
- // (catch de la review). Se resuelve el padre real antes de clasificar, y
81
- // si no se puede resolver, se vigila.
82
- // Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
83
- // <repo>/..config.json esta dentro (catch de la review).
84
- const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
85
- const outsideForSure = (t) => {
86
- if (!t.startsWith("/")) return false;
87
- const abs = resolve(t);
88
- try {
89
- const root = realpathSync(ROOT);
90
- // El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
91
- // asi que se resuelve entero cuando existe; si aun no existe, manda su
92
- // directorio real.
93
- if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
94
- const parts = abs.split("/");
95
- const name = parts.pop();
96
- return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
97
- } catch {
98
- return false;
99
- }
100
- };
101
- // Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
102
- // deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
103
- // de la review). Ante la duda se vigila.
104
- const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
105
- const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
106
- if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
107
- console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
108
- process.exit(2);
109
- }
29
+ if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
30
+ console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
31
+ process.exit(2);
110
32
  }
111
33
  } catch { /* fail open */ }
112
34
  process.exit(0);
@@ -62,10 +62,11 @@ function baseBranchGuard(baseBranch) {
62
62
  return [
63
63
  "# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
64
64
  "# the bootstrap commit is exempt, there is no commit to branch from yet.",
65
- 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then',
65
+ "# No escape (ADR 0015): an env prefix on git commit was one the agent could raise.",
66
+ "if git rev-parse --verify HEAD >/dev/null 2>&1; then",
66
67
  ' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
67
68
  ` if [ "$current_branch" = "${baseBranch}" ]; then`,
68
- ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
69
+ ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR'; exit 1`,
69
70
  " fi",
70
71
  "fi",
71
72
  ];
@@ -78,7 +79,7 @@ function baseBranchGuard(baseBranch) {
78
79
  function identityGuard(hook) {
79
80
  const head = [
80
81
  "# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
81
- 'if [ "$KJ_ALLOW_IDENTITY" != "1" ] && [ -f .karajan/identity.local.yml ]; then',
82
+ "if [ -f .karajan/identity.local.yml ]; then",
82
83
  ];
83
84
  const tail = [
84
85
  "elif [ ! -f .karajan/identity.local.yml ]; then",
@@ -92,7 +93,7 @@ function identityGuard(hook) {
92
93
  " kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
93
94
  " kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
94
95
  ' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
95
- ' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show; KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
96
+ ' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show)"; exit 1',
96
97
  " fi",
97
98
  ...tail,
98
99
  ];
@@ -103,7 +104,7 @@ function identityGuard(hook) {
103
104
  ' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
104
105
  " kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
105
106
  ' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
106
- ' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh (KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
107
+ ' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh"; exit 1',
107
108
  " fi",
108
109
  ...tail,
109
110
  ];