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
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The deterministic evaluator of compiled rules (KJC-TSK-0944, MDR-B1, ADR 0016):
3
+ * one tool call against the rules parseRules accepted, with no model.
4
+ *
5
+ * A rule says when to deny. A condition over an argument the call does not
6
+ * carry, or cannot be read as the operator needs (a date, a number), does not
7
+ * hold: the rule states a fact about the call, and that fact is not there.
8
+ */
9
+ import { isObject, toolsOf } from "./compiled.js";
10
+
11
+ const DAY_MS = 86_400_000;
12
+
13
+ /** A tool glob: `*` stands for any run of characters, the rest is literal. */
14
+ const toolMatches = (glob, tool) => {
15
+ const literal = glob.split("*").map((part) => part.replaceAll(/[.+?^${}()|[\]\\]/g, String.raw`\$&`));
16
+ return new RegExp(`^${literal.join(".*")}$`).test(String(tool));
17
+ };
18
+
19
+ /** The value at a dotted path of the tool input, own properties only. */
20
+ const at = (input, dotted) =>
21
+ dotted.split(".").reduce((node, key) => (isObject(node) && Object.hasOwn(node, key) ? node[key] : undefined), input);
22
+
23
+ /** Calendar days (UTC) from one date to another: the same day is 0, whatever the hour. */
24
+ const daysBetween = (from, to) => Math.floor(Date.parse(to) / DAY_MS) - Math.floor(Date.parse(from) / DAY_MS);
25
+
26
+ function holds(cond, input) {
27
+ let value = at(input, cond.arg);
28
+ const present = value !== undefined && value !== null;
29
+ if ("exists" in cond) return present === cond.exists;
30
+ if (!present) return false;
31
+ if ("days_from" in cond) value = daysBetween(at(input, cond.days_from), value);
32
+ if ("equals" in cond) return value === cond.equals;
33
+ if ("in" in cond) return cond.in.includes(value);
34
+ if ("matches" in cond) return typeof value === "string" && new RegExp(cond.matches).test(value);
35
+ if (typeof value !== "number" || Number.isNaN(value)) return false;
36
+ return "gt" in cond ? value > cond.gt : value < cond.lt;
37
+ }
38
+
39
+ const applies = (rule, tool, input) =>
40
+ toolsOf(rule.when).some((glob) => toolMatches(glob, tool))
41
+ && (rule.when.all ?? []).every((cond) => holds(cond, input))
42
+ && (!rule.when.any?.length || rule.when.any.some((cond) => holds(cond, input)));
43
+
44
+ /**
45
+ * @param {object[]} rules what parseRules returned
46
+ * @param {{tool: string, input?: object}} call
47
+ * @returns {{decision: "allow"} | {decision: "deny", rule_id: string, source: string|null, text: string|null, message: string}}
48
+ */
49
+ export function evalRules(rules, { tool, input }) {
50
+ const args = isObject(input) ? input : {};
51
+ const hit = rules.find((rule) => rule.kind === "deterministic" && applies(rule, tool, args));
52
+ if (!hit) return { decision: "allow" };
53
+ return {
54
+ decision: "deny",
55
+ rule_id: hit.id,
56
+ source: hit.source ?? null,
57
+ text: hit.text ?? null,
58
+ message: hit.message ?? hit.text ?? `rule ${hit.id}`,
59
+ };
60
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Rule inventory (KJC-TSK-0937, MDR-A, ADR 0016). Every rule written in the MD
3
+ * files that govern a session, with a stable id, its literal text and where it
4
+ * lives, so each one can get a gate and a deny can cite it.
5
+ *
6
+ * A rule is a markdown block (a list item with the lines that continue it, or a
7
+ * paragraph), outside code blocks, that carries a normative marker. The id hashes
8
+ * the normalized text, so a rule keeps its id when it moves or is wrapped anew.
9
+ */
10
+ import { createHash } from "node:crypto";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+
15
+ const NORMATIVE = /\b(nunca|siempre|prohibido|obligatorio|jam[aá]s|never|always|must|forbidden|required|do not|don't)\b/i;
16
+
17
+ // Emphasis delimiters only: a marker opens after a blank and closes before one (or
18
+ // punctuation), so snake_case names and globs (docs/**/*.md) keep their characters.
19
+ const stripEmphasis = (text) => text
20
+ .replaceAll(/(^|[\s(])\*\*(\S[^*]*)\*\*(?=[\s).,;:!?]|$)/g, "$1$2")
21
+ .replaceAll(/(^|[\s(])__(\S[^_]*)__(?=[\s).,;:!?]|$)/g, "$1$2")
22
+ .replaceAll(/(^|[\s(])\*(\S[^*]*)\*(?=[\s).,;:!?]|$)/g, "$1$2")
23
+ .replaceAll(/(^|[\s(])_(\S[^_]*)_(?=[\s).,;:!?]|$)/g, "$1$2");
24
+
25
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s+/;
26
+
27
+ const HELD =""; // private-use char: stands in for a code span while emphasis is stripped
28
+
29
+ /**
30
+ * The text of a markdown line without its list marker, emphasis or code ticks.
31
+ * What sits inside a code span is kept as written, whatever characters it holds.
32
+ */
33
+ const plain = (line) => {
34
+ const spans = [];
35
+ const held = line
36
+ .replace(LIST_ITEM, "")
37
+ .replaceAll(/`([^`]*)`/g, (_m, code) => `${HELD}${spans.push(code) - 1}${HELD}`);
38
+ return stripEmphasis(held)
39
+ .replaceAll(new RegExp(`${HELD}(\\d+)${HELD}`, "g"), (_m, n) => spans[Number(n)])
40
+ .replaceAll(/\s+/g, " ")
41
+ .trim();
42
+ };
43
+
44
+ const ruleId = (text) => "R-" + createHash("sha256").update(text.toLowerCase()).digest("hex").slice(0, 10);
45
+
46
+ /**
47
+ * @param {string} markdown
48
+ * @param {string} file
49
+ * @returns {{id: string, text: string, file: string, line: number}[]}
50
+ */
51
+ export function extractRules(markdown, file) {
52
+ const rules = [];
53
+ let fence = null; // the open code fence (``` or ~~~, any length), closed by one as long
54
+ let comment = false; // inside an HTML comment that has not closed yet
55
+ let block = null; // the list item or paragraph being read: { line, parts }
56
+ const flush = () => {
57
+ const text = block ? plain(block.parts.join(" ")) : "";
58
+ if (text && NORMATIVE.test(text)) rules.push({ id: ruleId(text), text, file, line: block.line });
59
+ block = null;
60
+ };
61
+ const lines = markdown.split("\n");
62
+ // A memory file's YAML frontmatter is metadata, not rules.
63
+ const frontmatterEnd = lines[0]?.trim() === "---" ? lines.indexOf("---", 1) : -1;
64
+ lines.forEach((raw, i) => {
65
+ if (i <= frontmatterEnd) return;
66
+ const mark = /^\s*(`{3,}|~{3,})/.exec(raw)?.[1];
67
+ if (mark && !fence) { flush(); fence = mark; return; }
68
+ if (mark && mark[0] === fence[0] && mark.length >= fence.length && raw.trim() === mark) { fence = null; return; }
69
+ if (fence) return;
70
+ if (comment || /^\s*<!--/.test(raw)) { flush(); comment = !raw.includes("-->"); return; }
71
+ if (!raw.trim() || /^\s*#/.test(raw) || /^\s*\|/.test(raw)) { flush(); return; }
72
+ // KJC-BUG-0271: the unit is the markdown block. A list marker opens a new
73
+ // one; any other line continues the block it follows (a wrapped rule).
74
+ if (LIST_ITEM.test(raw)) flush();
75
+ block ??= { line: i + 1, parts: [] };
76
+ block.parts.push(raw.trim());
77
+ });
78
+ flush();
79
+ return rules;
80
+ }
81
+
82
+ /**
83
+ * The MD files that govern a session in projectDir (KJC-TSK-0942): its CLAUDE.md
84
+ * and AGENTS.md, the global ~/.claude/CLAUDE.md and the project's feedback memories.
85
+ * @param {string} projectDir
86
+ * @param {{home?: string}} [opts]
87
+ */
88
+ export function ruleSources(projectDir, { home = os.homedir() } = {}) {
89
+ // Claude Code names a project's folder after its path, every non-alphanumeric char as "-".
90
+ const memory = path.join(home, ".claude", "projects", path.resolve(projectDir).replaceAll(/[^A-Za-z0-9]/g, "-"), "memory");
91
+ let feedback = [];
92
+ try {
93
+ feedback = fs.readdirSync(memory).filter((f) => /^feedback_.*\.md$/.test(f)).sort().map((f) => path.join(memory, f));
94
+ } catch { /* no memories for this project */ }
95
+ return [
96
+ path.join(projectDir, "CLAUDE.md"),
97
+ path.join(projectDir, "AGENTS.md"),
98
+ path.join(home, ".claude", "CLAUDE.md"),
99
+ ...feedback,
100
+ ].filter((f) => fs.existsSync(f));
101
+ }
102
+
103
+ /** Whether an MD file is part of the project, and so as public as the repo is. */
104
+ export const inProject = (file, projectDir) => {
105
+ const rel = path.relative(projectDir, file);
106
+ return !rel.startsWith("..") && !path.isAbsolute(rel);
107
+ };
108
+
109
+ /** A source as a rules file can carry it: from the project's root, or from `~`. */
110
+ export const shownSource = (file, projectDir, home = os.homedir()) => {
111
+ if (inProject(file, projectDir)) return path.relative(projectDir, file);
112
+ return inProject(file, home) ? path.join("~", path.relative(home, file)) : file;
113
+ };
114
+
115
+ /** Every rule of every governing MD file, in source order. */
116
+ export function listRules(projectDir, opts = {}) {
117
+ return ruleSources(projectDir, opts).flatMap((file) => extractRules(fs.readFileSync(file, "utf8"), file));
118
+ }
@@ -5,7 +5,14 @@
5
5
  * Consumers: scanner.js, preflight-checks.js, api.js.
6
6
  */
7
7
 
8
+ import fs from "node:fs";
9
+ import path from "node:path";
8
10
  import { loadSonarCredentials } from "./credentials.js";
11
+ import { getKarajanHome } from "../utils/paths.js";
12
+
13
+ /** KJC-BUG-0266: the places a token is looked for, named in every "no token" message. */
14
+ export const sonarTokenSources = () =>
15
+ `KJ_SONAR_TOKEN, sonarqube.token in kj.config.yml, SONAR_TOKEN, ${path.join(getKarajanHome(), "sonar-credentials.json")} or ${sonarTokenFilePath()}`;
9
16
 
10
17
  const DEFAULT_HOST = "http://localhost:9000";
11
18
 
@@ -54,7 +61,18 @@ export async function resolveSonarTokenAsync(config = {}) {
54
61
 
55
62
  // Check credentials file for persisted token
56
63
  const fileCreds = (await loadSonarCredentials()) || {};
57
- return fileCreds.token || null;
64
+ return fileCreds.token || readSonarTokenFile() || null;
65
+ }
66
+
67
+ /** Where the token bootstrap saves the token (mode 0600), in the active Karajan home. */
68
+ export const sonarTokenFilePath = () => path.join(getKarajanHome(), "sonar.token");
69
+
70
+ /**
71
+ * KJC-BUG-0266: the token bootstrap persisted the token to <karajan home>/sonar.token
72
+ * and no resolver read it back, so a home with only that file looked tokenless.
73
+ */
74
+ export function readSonarTokenFile() {
75
+ try { return fs.readFileSync(sonarTokenFilePath(), "utf8").trim() || null; } catch { return null; }
58
76
  }
59
77
 
60
78
  /**
@@ -8,10 +8,12 @@
8
8
  * Log location: <projectDir>/.kj/run.log (overwritten each run)
9
9
  */
10
10
 
11
+ import { randomUUID } from "node:crypto";
11
12
  import fs from "node:fs";
12
13
  import path from "node:path";
13
14
 
14
15
  const LOG_FILENAME = "run.log";
16
+ const LOCK_FILENAME = "run.lock";
15
17
 
16
18
  function resolveLogDir(baseDir) {
17
19
  return path.join(baseDir || process.cwd(), ".kj");
@@ -54,8 +56,15 @@ export function createRunLog(projectDir) {
54
56
  const logDir = resolveLogDir(projectDir);
55
57
  ensureDir(logDir);
56
58
 
57
- // Truncate/create the log file
58
- fs.writeFileSync(logPath, `--- Karajan run started at ${new Date().toISOString()} ---\n`);
59
+ // KJC-BUG-0250: the run lock, taken atomically and owned by this run's token.
60
+ // close() releases it only while it is still ours (every caller closes in a
61
+ // finally); a lock whose process is gone is stale and taken over.
62
+ const startedAt = new Date().toISOString();
63
+ const lockPath = path.join(logDir, LOCK_FILENAME);
64
+ const token = randomUUID();
65
+ const owned = acquireLock(lockPath, { pid: process.pid, startedAt, token });
66
+ // Truncate/create the log file, unless another live run owns it.
67
+ if (owned) fs.writeFileSync(logPath, `--- Karajan run started at ${startedAt} ---\n`);
59
68
 
60
69
  // Audit recommendation (perf-LOW): suggested createWriteStream + queue.
61
70
  // Tried it; broke the contract. `readRunLog` (used by `kj_status`,
@@ -102,16 +111,79 @@ export function createRunLog(projectDir) {
102
111
  fd = null;
103
112
  }
104
113
  } catch { /* best-effort */ }
114
+ if (owned && readLock(lockPath)?.token === token) {
115
+ try { fs.rmSync(lockPath, { force: true }); } catch { /* best-effort */ }
116
+ }
105
117
  }
106
118
 
107
119
  return {
108
120
  logEvent,
109
121
  logText,
110
122
  close,
123
+ /** false when another live run already held the project */
124
+ owned,
111
125
  get path() { return logPath; }
112
126
  };
113
127
  }
114
128
 
129
+ const readLock = (lockPath) => {
130
+ try { return JSON.parse(fs.readFileSync(lockPath, "utf8")); } catch { return null; }
131
+ };
132
+
133
+ export const isPidAlive = (pid) => {
134
+ if (!Number.isInteger(pid) || pid <= 0) return false;
135
+ try { process.kill(pid, 0); return true; } catch (err) { return err.code === "EPERM"; }
136
+ };
137
+
138
+ const createExclusive = (file, body) => {
139
+ try { fs.writeFileSync(file, body, { flag: "wx" }); return true; } catch { return false; }
140
+ };
141
+
142
+ // The lock appears with its whole content or not at all: written to a private
143
+ // file first, then hard-linked into place (link fails if the path exists), so
144
+ // nobody can read a half-written lock and take it for stale.
145
+ function linkLock(lockPath, body, token) {
146
+ const tmp = `${lockPath}.${token}.tmp`;
147
+ try {
148
+ fs.writeFileSync(tmp, body);
149
+ fs.linkSync(tmp, lockPath);
150
+ return true;
151
+ } catch {
152
+ return false;
153
+ } finally {
154
+ try { fs.rmSync(tmp, { force: true }); } catch { /* leftover, harmless */ }
155
+ }
156
+ }
157
+
158
+ function acquireLock(lockPath, holder) {
159
+ const body = JSON.stringify(holder);
160
+ if (linkLock(lockPath, body, holder.token)) return true;
161
+ if (isPidAlive(readLock(lockPath)?.pid)) return false;
162
+ // Stale (its process is gone, or unreadable). Only one racer at a time may take
163
+ // it over: the takeover mutex is itself an exclusive create. Inside it the lock
164
+ // is read again and replaced only if it is still stale; nobody else removes it,
165
+ // and the exclusive create that follows loses to anyone who got there first.
166
+ const mutex = `${lockPath}.takeover`;
167
+ clearStaleMutex(mutex);
168
+ if (!createExclusive(mutex, holder.token)) return false;
169
+ try {
170
+ if (isPidAlive(readLock(lockPath)?.pid)) return false;
171
+ try { fs.rmSync(lockPath, { force: true }); } catch { return false; }
172
+ return linkLock(lockPath, body, holder.token);
173
+ } finally {
174
+ try { fs.rmSync(mutex, { force: true }); } catch { /* leftover, cleared when stale */ }
175
+ }
176
+ }
177
+
178
+ // A takeover takes milliseconds; a mutex older than this was left by a process
179
+ // that died inside one.
180
+ const TAKEOVER_STALE_MS = 10_000;
181
+ function clearStaleMutex(mutex) {
182
+ try {
183
+ if (Date.now() - fs.statSync(mutex).mtimeMs > TAKEOVER_STALE_MS) fs.rmSync(mutex, { force: true });
184
+ } catch { /* none */ }
185
+ }
186
+
115
187
  const KJ_TOOLS = ["kj_run", "kj_code", "kj_plan"];
116
188
 
117
189
  function detectRunStart(line, status) {
@@ -46,6 +46,9 @@ const LANGUAGE_FILE_MARKERS = [
46
46
  { file: "Package.swift", language: "swift", type: "backend" },
47
47
  ];
48
48
 
49
+ /** A page served as it is: where a static site (or the static half of an app) keeps it. */
50
+ const STATIC_SITE_MARKERS = ["index.html", path.join("public", "index.html"), path.join("src", "index.html")];
51
+
49
52
  /**
50
53
  * Maps framework names to skill names for auto-install suggestions.
51
54
  */
@@ -109,6 +112,15 @@ export async function detectProjectStack(projectDir) {
109
112
  } catch { /* file not found */ }
110
113
  }
111
114
 
115
+ // 3. KJC-BUG-0267: a static site has a frontend with no framework to show for it.
116
+ for (const page of STATIC_SITE_MARKERS) {
117
+ try {
118
+ await fs.access(path.join(projectDir, page));
119
+ hasFrontend = true;
120
+ break;
121
+ } catch { /* file not found */ }
122
+ }
123
+
112
124
  const frameworkList = Array.from(frameworks);
113
125
  const isFullstack = hasFrontend && hasBackend;
114
126