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
@@ -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