karajan-code 2.8.0 → 2.10.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 (64) hide show
  1. package/README.md +14 -1
  2. package/package.json +1 -1
  3. package/src/audit/agent-readiness.js +21 -3
  4. package/src/audit/basal-cost.js +92 -9
  5. package/src/audit/deterministic-summary.js +200 -0
  6. package/src/audit/osv-findings.js +162 -0
  7. package/src/audit/semgrep-findings.js +117 -0
  8. package/src/audit/sonar-findings.js +81 -0
  9. package/src/audit/webperf-input.js +136 -0
  10. package/src/canvas/schema.js +1 -1
  11. package/src/checks/binaries.js +2 -2
  12. package/src/checks/ci.js +2 -2
  13. package/src/checks/config-files.js +6 -6
  14. package/src/checks/dir-setup.js +4 -0
  15. package/src/checks/mcp-health.js +6 -0
  16. package/src/checks/ports.js +8 -0
  17. package/src/checks/rtk.js +1 -1
  18. package/src/checks/skills.js +5 -1
  19. package/src/checks/sonar.js +1 -1
  20. package/src/checks/system.js +2 -2
  21. package/src/checks/tokens.js +1 -1
  22. package/src/checks/types.js +0 -12
  23. package/src/cli/register-meta.js +40 -2
  24. package/src/cli/register-pipeline.js +1 -0
  25. package/src/commands/audit.js +239 -28
  26. package/src/commands/board.js +7 -4
  27. package/src/commands/doctor.js +1 -1
  28. package/src/commands/webperf.js +164 -0
  29. package/src/config/defaults.js +1 -0
  30. package/src/config/overrides.js +1 -0
  31. package/src/config/schema.js +1 -0
  32. package/src/config/test-harness.js +0 -1
  33. package/src/mcp/handlers/run-handler.js +2 -2
  34. package/src/mcp/run-kj.js +1 -0
  35. package/src/mcp/sovereignty-guard.js +1 -1
  36. package/src/mcp/tools.js +1 -0
  37. package/src/orchestrator/ci-integration.js +1 -1
  38. package/src/orchestrator/config-init.js +3 -1
  39. package/src/orchestrator/direct-actions.js +1 -1
  40. package/src/orchestrator/drivers/error-recovery.js +2 -2
  41. package/src/orchestrator/drivers/iteration-loop.js +3 -13
  42. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
  43. package/src/orchestrator/drivers/pre-loop.js +1 -4
  44. package/src/orchestrator/feedback-queue.js +0 -1
  45. package/src/orchestrator/integrations.js +0 -7
  46. package/src/orchestrator/role-output-compressor.js +0 -1
  47. package/src/orchestrator/session-journal.js +5 -41
  48. package/src/orchestrator/stages/perf-stage.js +140 -0
  49. package/src/orchestrator/stages/stage-classes.js +0 -2
  50. package/src/plan/plan-store.js +0 -14
  51. package/src/planning-game/client.js +0 -14
  52. package/src/planning-game/pipeline-adapter.js +1 -1
  53. package/src/prompts/audit.js +206 -6
  54. package/src/prompts/rtk-snippet.js +1 -1
  55. package/src/roles/audit-role.js +125 -13
  56. package/src/roles/perf-role.js +122 -0
  57. package/src/skills/addyosmani-role-map.js +6 -0
  58. package/src/sonar/api.js +1 -1
  59. package/src/utils/display/event-handlers.js +1 -1
  60. package/src/utils/display/header.js +1 -1
  61. package/src/utils/display/session-summary.js +3 -3
  62. package/src/utils/display/solomon.js +1 -1
  63. package/src/utils/role-assigner.js +1 -1
  64. package/src/webperf/scanner.js +209 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Semgrep SAST collector for `kj audit` (KJC-TSK-0366).
3
+ *
4
+ * Wraps `semgrep --config auto --json` and feeds structured static-
5
+ * analysis findings into the audit prompt. Equivalent to the SAST half
6
+ * of `snyk code` but free for OSS, no account, runs locally. Semgrep
7
+ * has 2000+ built-in rules across JS/TS/Python/Go/etc. covering OWASP
8
+ * Top 10, taint analysis, hardcoded secrets, regex misuse and language-
9
+ * specific anti-patterns.
10
+ *
11
+ * Same best-effort pattern as sonar-findings.js and osv-findings.js:
12
+ * - Missing binary or timeout returns {available: false, reason}.
13
+ * - 120s timeout (semgrep can be slow on large repos with --config auto).
14
+ * - Severity-grouped (ERROR / WARNING / INFO) and capped at
15
+ * MAX_SEMGREP_FINDINGS_IN_PROMPT (50) total entries with overflow
16
+ * markers.
17
+ * - The LLM is told these are deterministic and asked to fold them
18
+ * into the `security` dimension using the rule ID as the rule field.
19
+ */
20
+
21
+ import { execFile } from "node:child_process";
22
+ import { promisify } from "node:util";
23
+
24
+ const execFileAsync = promisify(execFile);
25
+
26
+ const SCAN_TIMEOUT_MS = 120_000;
27
+ const SEVERITY_ORDER = ["ERROR", "WARNING", "INFO"];
28
+
29
+ /**
30
+ * Run semgrep on the project and return a structured result.
31
+ *
32
+ * @param {string} projectDir - absolute path to the project root
33
+ * @param {object} [logger]
34
+ * @returns {Promise<{available: boolean, reason?: string, total?: number, findings?: object[]}>}
35
+ */
36
+ export async function collectSemgrepFindings(projectDir, logger = null) {
37
+ let stdout;
38
+ try {
39
+ const { stdout: out } = await execFileAsync(
40
+ "semgrep",
41
+ ["--config", "auto", "--json", "--quiet", `--timeout=${Math.floor(SCAN_TIMEOUT_MS / 1000)}`, projectDir || "."],
42
+ { cwd: projectDir, timeout: SCAN_TIMEOUT_MS + 5000, maxBuffer: 32 * 1024 * 1024 }
43
+ );
44
+ stdout = out;
45
+ } catch (err) {
46
+ if (err.code === "ENOENT") {
47
+ if (logger?.warn) logger.warn("semgrep not found in PATH — install via 'pipx install semgrep' or 'brew install semgrep'");
48
+ return { available: false, reason: "semgrep not installed" };
49
+ }
50
+ if (err.killed) {
51
+ if (logger?.warn) logger.warn(`semgrep timed out after ${SCAN_TIMEOUT_MS}ms`);
52
+ return { available: false, reason: `semgrep timed out after ${SCAN_TIMEOUT_MS}ms` };
53
+ }
54
+ // semgrep exits 1 when it finds blocking rules (ERROR severity), but
55
+ // still emits a valid JSON report on stdout. Rescue that case.
56
+ if (typeof err.stdout === "string" && err.stdout.trim().length > 0) {
57
+ stdout = err.stdout;
58
+ } else {
59
+ if (logger?.warn) logger.warn(`semgrep failed: ${err.message}`);
60
+ return { available: false, reason: `semgrep failed: ${err.message}` };
61
+ }
62
+ }
63
+
64
+ let parsed;
65
+ try {
66
+ parsed = JSON.parse(stdout);
67
+ } catch (err) {
68
+ if (logger?.warn) logger.warn(`semgrep output not parseable: ${err.message}`);
69
+ return { available: false, reason: "semgrep output not parseable as JSON" };
70
+ }
71
+
72
+ return { available: true, ...parseSemgrepOutput(parsed, projectDir) };
73
+ }
74
+
75
+ /**
76
+ * Convert semgrep's JSON shape into the flat findings list the prompt
77
+ * builder consumes. Semgrep emits results[] with rule id, severity,
78
+ * path, start/end line, message, fix hints.
79
+ *
80
+ * @param {object} raw - parsed semgrep --json output
81
+ * @param {string} [projectDir] - used to make paths repo-relative
82
+ * @returns {{total: number, findings: object[]}}
83
+ */
84
+ export function parseSemgrepOutput(raw, projectDir = "") {
85
+ const results = Array.isArray(raw?.results) ? raw.results : [];
86
+ const findings = [];
87
+ for (const r of results) {
88
+ const file = r.path && projectDir
89
+ ? r.path.startsWith(projectDir + "/") ? r.path.slice(projectDir.length + 1) : r.path
90
+ : r.path || "";
91
+ findings.push({
92
+ rule: r.check_id || "(unknown)",
93
+ severity: String(r.extra?.severity || "INFO").toUpperCase(),
94
+ file,
95
+ line: r.start?.line ?? 0,
96
+ endLine: r.end?.line ?? 0,
97
+ message: r.extra?.message || "",
98
+ fix: r.extra?.fix || r.extra?.fix_regex || null,
99
+ cwe: r.extra?.metadata?.cwe || null,
100
+ owasp: r.extra?.metadata?.owasp || null,
101
+ });
102
+ }
103
+ return { total: findings.length, findings };
104
+ }
105
+
106
+ /**
107
+ * Group findings by severity in canonical order (ERROR → WARNING → INFO).
108
+ */
109
+ export function groupFindingsBySeverity(findings) {
110
+ const groups = Object.fromEntries(SEVERITY_ORDER.map(s => [s, []]));
111
+ for (const f of findings || []) {
112
+ const sev = (f.severity || "INFO").toUpperCase();
113
+ if (groups[sev]) groups[sev].push(f);
114
+ else groups.INFO.push(f);
115
+ }
116
+ return groups;
117
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Sonar findings collector for `kj audit` (KJC-TSK-0361).
3
+ *
4
+ * The audit pipeline already pays for SonarQube to exist (kj run + the
5
+ * SonarStage drive a full scan and gate the pipeline on its result). When
6
+ * the user runs `kj audit`, we don't want to pay for *another* full scan
7
+ * (~1-3 min) — we want to read whatever Sonar already knows. This module
8
+ * does exactly that: ping the configured Sonar host, and if it's
9
+ * reachable, fetch the current open-issue list + quality-gate status via
10
+ * the existing sonar/api.js helpers. Both results are best-effort: a
11
+ * sonar-down host or a never-scanned project quietly returns
12
+ * `{available: false, reason: "..."}` and the audit continues without
13
+ * the section.
14
+ *
15
+ * Why this matters: the LLM auditor was previously asked to find issues
16
+ * by reading source code, but Sonar already produces them with rule IDs
17
+ * (squid:S1234), severity, and line precision. Feeding those in lets the
18
+ * LLM cross-reference its own findings (high-confidence overlap) and
19
+ * focus its tokens on patterns Sonar can't see (architecture, naming,
20
+ * API design).
21
+ */
22
+
23
+ import { isSonarReachable } from "../sonar/manager.js";
24
+ import { getOpenIssues, getQualityGateStatus } from "../sonar/api.js";
25
+
26
+ /**
27
+ * Collect deterministic Sonar findings for the audit prompt.
28
+ *
29
+ * @param {object} config - resolved Karajan config (must have config.sonarqube.host)
30
+ * @param {object} [logger] - optional logger for warn-level traces
31
+ * @returns {Promise<{available: boolean, reason?: string, issues?: object[], total?: number, qualityGate?: object}>}
32
+ */
33
+ export async function collectSonarFindings(config, logger = null) {
34
+ const sonarConfig = config?.sonarqube || {};
35
+ const host = sonarConfig.host || "http://localhost:9000";
36
+ const healthcheckSeconds = sonarConfig.timeouts?.healthcheckSeconds ?? 5;
37
+
38
+ // Ping first — avoids surfacing a 30-second timeout into audit runtime
39
+ // when Docker is simply down or the user opted out of Sonar.
40
+ const reachable = await isSonarReachable(host, healthcheckSeconds).catch(() => false);
41
+ if (!reachable) {
42
+ if (logger?.warn) logger.warn(`sonar audit input skipped: host ${host} not reachable`);
43
+ return { available: false, reason: `sonar host ${host} not reachable` };
44
+ }
45
+
46
+ let issuesResult;
47
+ try {
48
+ issuesResult = await getOpenIssues(config);
49
+ } catch (err) {
50
+ if (logger?.warn) logger.warn(`sonar audit input: getOpenIssues failed: ${err?.message || err}`);
51
+ return { available: false, reason: `getOpenIssues failed: ${err?.message || err}` };
52
+ }
53
+
54
+ let qualityGate = null;
55
+ try {
56
+ const qg = await getQualityGateStatus(config);
57
+ if (qg?.ok) qualityGate = { status: qg.status, conditions: qg.conditions };
58
+ } catch { /* gate is optional context */ }
59
+
60
+ return {
61
+ available: true,
62
+ issues: issuesResult.issues || [],
63
+ total: issuesResult.total || 0,
64
+ qualityGate,
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Group Sonar issues by severity, preserving Sonar's canonical ordering.
70
+ * Used by the prompt builder to render the findings section.
71
+ */
72
+ export function groupIssuesBySeverity(issues) {
73
+ const order = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"];
74
+ const groups = Object.fromEntries(order.map(s => [s, []]));
75
+ for (const issue of issues || []) {
76
+ const sev = issue.severity || "INFO";
77
+ if (groups[sev]) groups[sev].push(issue);
78
+ else groups.INFO.push(issue);
79
+ }
80
+ return groups;
81
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * WebPerf input collector for `kj audit` (KJC-TSK-0360).
3
+ *
4
+ * Two modes:
5
+ * - "static-hints": no live measurement available, but the project is
6
+ * frontend so the LLM should look for frontend-perf patterns in the
7
+ * source (bundle bloat, render-blocking, image opt, lazy loading).
8
+ * This is the common path because `kj audit` runs against a code
9
+ * tree, not a live preview server.
10
+ * - "cwv-result": a Core Web Vitals measurement was supplied. Three
11
+ * possible sources, in priority order:
12
+ * 1. `config.webperf.lastResult` — inline {metrics, thresholds, ...}
13
+ * (rare; legacy / programmatic injection)
14
+ * 2. `config.webperf.lastResultPath` — file path written by
15
+ * `kj webperf` (KJC-TSK-0149). This is the standard wiring.
16
+ * 3. `~/.karajan/webperf/<projectSlug>/last.json` — fallback
17
+ * discovery when the user ran `kj webperf` but didn't patch
18
+ * their kj.config.yml.
19
+ * evaluateCwv from src/webperf/cwv-gate.js produces a structured
20
+ * verdict the prompt renders verbatim.
21
+ *
22
+ * The "live measurement" path is intentionally NOT wired here: the
23
+ * audit's LLM sub-agent is forbidden from MCP tool use (SUBAGENT_PREAMBLE
24
+ * in src/prompts/audit.js), and spawning Chrome / launching a preview
25
+ * server during a read-only audit is wrong layer. `kj webperf` does
26
+ * the measurement; `kj audit` consumes the persisted result.
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import os from "node:os";
32
+ import { evaluateCwv, CWV_THRESHOLDS } from "../webperf/cwv-gate.js";
33
+
34
+ // Resolve per call — `os.homedir()` reads $HOME each time, so test
35
+ // suites that override $HOME for hermetic tmp dirs work without
36
+ // special re-imports.
37
+ function webperfHome() {
38
+ return path.join(os.homedir(), ".karajan", "webperf");
39
+ }
40
+
41
+ function projectSlug(projectDir) {
42
+ return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
43
+ }
44
+
45
+ /**
46
+ * Try the three discovery paths in priority order. Returns the parsed
47
+ * scan object (shape produced by src/webperf/scanner.js) or null.
48
+ */
49
+ function loadPersistedScan(webperfConfig, projectDir) {
50
+ // 1. Inline result on the config (back-compat)
51
+ if (webperfConfig.lastResult && typeof webperfConfig.lastResult === "object" && webperfConfig.lastResult.metrics) {
52
+ return webperfConfig.lastResult;
53
+ }
54
+ // 2. Explicit file path on the config
55
+ const candidates = [];
56
+ if (webperfConfig.lastResultPath) candidates.push(webperfConfig.lastResultPath);
57
+ // 3. Fallback: ~/.karajan/webperf/<slug>/last.json
58
+ if (projectDir) {
59
+ candidates.push(path.join(webperfHome(), projectSlug(projectDir), "last.json"));
60
+ }
61
+ for (const candidate of candidates) {
62
+ try {
63
+ const raw = fs.readFileSync(candidate, "utf8");
64
+ const parsed = JSON.parse(raw);
65
+ if (parsed && typeof parsed === "object" && parsed.metrics) return parsed;
66
+ } catch { /* missing file or unparseable — try next */ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Decide what (if anything) to feed the audit prompt about web perf.
73
+ *
74
+ * @param {object|null} stack - detectProjectStack output (null when unknown)
75
+ * @param {object} [config] - resolved Karajan config (config.webperf may carry a stale CWV result)
76
+ * @returns {{available: boolean, mode?: 'static-hints'|'cwv-result', cwv?: object, reason?: string}}
77
+ */
78
+ export function collectWebPerfInput(stack, config = {}) {
79
+ const webperfConfig = config?.webperf || {};
80
+ const projectDir = config?.projectDir;
81
+ const persisted = loadPersistedScan(webperfConfig, projectDir);
82
+
83
+ if (persisted) {
84
+ const verdict = persisted.cwv || evaluateCwv(persisted.metrics, persisted.thresholds || webperfConfig.thresholds);
85
+ return {
86
+ available: true,
87
+ mode: "cwv-result",
88
+ url: persisted.url || webperfConfig.url || null,
89
+ capturedAt: persisted.capturedAt || null,
90
+ cwv: verdict,
91
+ thresholds: persisted.thresholds || webperfConfig.thresholds || CWV_THRESHOLDS,
92
+ };
93
+ }
94
+
95
+ // No live measurement → static hints only when the project actually has
96
+ // a frontend layer to audit. Backend-only projects get nothing.
97
+ const isFrontendish = !stack || stack.isFrontend === true || stack.isFullstack === true;
98
+ if (!isFrontendish) {
99
+ return { available: false, reason: "project is backend-only — no frontend-perf hints to give" };
100
+ }
101
+
102
+ return { available: true, mode: "static-hints" };
103
+ }
104
+
105
+ /**
106
+ * Render a CWV verdict (from collectWebPerfInput's cwv-result mode) as
107
+ * concise prompt-friendly markdown. Kept independent from the prompt
108
+ * builder so it stays unit-testable in isolation.
109
+ */
110
+ export function formatCwvVerdict(input) {
111
+ if (!input || input.mode !== "cwv-result" || !input.cwv) return null;
112
+ const { cwv, url, capturedAt } = input;
113
+ const lines = [];
114
+ if (url) lines.push(`- Target URL: ${url}`);
115
+ if (capturedAt) lines.push(`- Captured: ${capturedAt}`);
116
+ lines.push(`- Verdict: ${cwv.pass ? "PASS" : "FAIL"}`);
117
+ if (Object.keys(cwv.scores || {}).length) {
118
+ lines.push("- Scores:");
119
+ for (const [metric, score] of Object.entries(cwv.scores)) {
120
+ lines.push(` - ${metric.toUpperCase()}: ${score.value} (${score.rating})`);
121
+ }
122
+ }
123
+ if (cwv.blocking?.length) {
124
+ lines.push("- Blocking metrics (above poor threshold):");
125
+ for (const b of cwv.blocking) {
126
+ lines.push(` - ${b.metric.toUpperCase()} ${b.value} > ${b.threshold}`);
127
+ }
128
+ }
129
+ if (cwv.advisory?.length) {
130
+ lines.push("- Advisory metrics (between good and poor):");
131
+ for (const a of cwv.advisory) {
132
+ lines.push(` - ${a.metric.toUpperCase()} ${a.value} > good=${a.threshold}`);
133
+ }
134
+ }
135
+ return lines.join("\n");
136
+ }
@@ -51,7 +51,7 @@ const Operation = v.looseObject({
51
51
  acceptance: v.pipe(v.array(AcceptanceTest), v.minLength(1)),
52
52
  });
53
53
 
54
- export const CanvasSchema = v.looseObject({
54
+ const CanvasSchema = v.looseObject({
55
55
  // Title is the project / feature name. The planner uses it as the
56
56
  // plan name unless the user overrides on prompt.
57
57
  title: NonEmptyString,
@@ -60,7 +60,7 @@ function createCoreBinaryCheck(bin) {
60
60
  /**
61
61
  * Docker check. Manual strategy (platform-dependent install).
62
62
  */
63
- export function createDockerCheck() {
63
+ function createDockerCheck() {
64
64
  return {
65
65
  name: "docker",
66
66
  label: "Docker",
@@ -80,7 +80,7 @@ export function createDockerCheck() {
80
80
  /**
81
81
  * Serena MCP (optional). Prompt strategy: we know how to install it.
82
82
  */
83
- export function createSerenaCheck() {
83
+ function createSerenaCheck() {
84
84
  return {
85
85
  name: "serena",
86
86
  label: "Serena MCP",
package/src/checks/ci.js CHANGED
@@ -37,7 +37,7 @@ function createWorkflowCheck(workflow) {
37
37
  };
38
38
  }
39
39
 
40
- export function createGhCliCheck() {
40
+ function createGhCliCheck() {
41
41
  return {
42
42
  name: "ci:gh",
43
43
  label: "CI: gh CLI",
@@ -55,7 +55,7 @@ export function createGhCliCheck() {
55
55
  };
56
56
  }
57
57
 
58
- export function createGhSecretsCheck() {
58
+ function createGhSecretsCheck() {
59
59
  return {
60
60
  name: "ci:secrets",
61
61
  label: "CI: GitHub secrets",
@@ -20,7 +20,7 @@ import { STRATEGY } from "./types.js";
20
20
  /**
21
21
  * kj.config.yml presence check.
22
22
  */
23
- export function createConfigFileCheck() {
23
+ function createConfigFileCheck() {
24
24
  return {
25
25
  name: "config",
26
26
  label: "Config file",
@@ -42,7 +42,7 @@ export function createConfigFileCheck() {
42
42
  /**
43
43
  * Review rules (.md) informational check.
44
44
  */
45
- export function createReviewRulesCheck() {
45
+ function createReviewRulesCheck() {
46
46
  return {
47
47
  name: "review-rules",
48
48
  label: "Reviewer rules (.md)",
@@ -63,7 +63,7 @@ export function createReviewRulesCheck() {
63
63
  /**
64
64
  * Coder rules (.md) informational check.
65
65
  */
66
- export function createCoderRulesCheck() {
66
+ function createCoderRulesCheck() {
67
67
  return {
68
68
  name: "coder-rules",
69
69
  label: "Coder rules (.md)",
@@ -104,7 +104,7 @@ function findDuplicateTomlKeys(content) {
104
104
  /**
105
105
  * Claude config (~/.claude.json) validity.
106
106
  */
107
- export function createClaudeConfigCheck() {
107
+ function createClaudeConfigCheck() {
108
108
  return {
109
109
  name: "agent-config:claude",
110
110
  label: "Agent config: claude (~/.claude.json)",
@@ -135,7 +135,7 @@ export function createClaudeConfigCheck() {
135
135
  /**
136
136
  * Codex config (~/.codex/config.toml) validity.
137
137
  */
138
- export function createCodexConfigCheck() {
138
+ function createCodexConfigCheck() {
139
139
  return {
140
140
  name: "agent-config:codex",
141
141
  label: "Agent config: codex (~/.codex/config.toml)",
@@ -173,7 +173,7 @@ export function createCodexConfigCheck() {
173
173
  /**
174
174
  * KJ config (~/.karajan/kj.config.yml) YAML validity (separate from existence).
175
175
  */
176
- export function createKjConfigYamlCheck() {
176
+ function createKjConfigYamlCheck() {
177
177
  return {
178
178
  name: "agent-config:karajan",
179
179
  label: "Agent config: karajan (kj.config.yml)",
@@ -15,6 +15,10 @@ const REQUIRED_DIRS = [
15
15
  "logs",
16
16
  ];
17
17
 
18
+ /**
19
+ * @internal Exported for dynamic import from tests/checks/dir-setup.test.js
20
+ * and tests/checks/auto-remediation-e2e.test.js. Knip false positive.
21
+ */
18
22
  export function createKarajanDirsCheck() {
19
23
  return {
20
24
  name: "karajan-dirs",
@@ -86,6 +86,9 @@ async function pingMcpStdio(command, args) {
86
86
  /**
87
87
  * karajan-mcp health — stdio server bundled with this package.
88
88
  * Strategy: auto (we can re-spawn it ourselves).
89
+ *
90
+ * @internal Exported for dynamic import from tests/checks/mcp-health.test.js.
91
+ * Knip false positive.
89
92
  */
90
93
  export function createKarajanMcpCheck() {
91
94
  return {
@@ -121,6 +124,9 @@ export function createKarajanMcpCheck() {
121
124
  /**
122
125
  * Serena MCP — external tool the user installs themselves. Warn only when
123
126
  * enabled in config. Strategy: manual (we don't manage its lifecycle).
127
+ *
128
+ * @internal Exported for dynamic import from tests/checks/mcp-health.test.js.
129
+ * Knip false positive.
124
130
  */
125
131
  export function createSerenaMcpCheck() {
126
132
  return {
@@ -24,6 +24,10 @@ function extractPortFromHost(host, fallback) {
24
24
  return m ? Number(m[1]) : fallback;
25
25
  }
26
26
 
27
+ /**
28
+ * @internal Exported for dynamic import from tests/checks/ports.test.js
29
+ * and tests/checks/auto-remediation-e2e.test.js. Knip false positive.
30
+ */
27
31
  export function createSonarPortCheck() {
28
32
  return {
29
33
  name: "port:sonar",
@@ -133,6 +137,10 @@ export function createSonarPortCheck() {
133
137
  };
134
138
  }
135
139
 
140
+ /**
141
+ * @internal Exported for dynamic import from tests/checks/ports.test.js
142
+ * and tests/checks/auto-remediation-e2e.test.js. Knip false positive.
143
+ */
136
144
  export function createHuBoardPortCheck() {
137
145
  return {
138
146
  name: "port:hu-board",
package/src/checks/rtk.js CHANGED
@@ -8,7 +8,7 @@ import { getInstallCommand } from "../utils/os-detect.js";
8
8
  import { withDocLink } from "../utils/doc-links.js";
9
9
  import { STRATEGY } from "./types.js";
10
10
 
11
- export function createRtkCheck() {
11
+ function createRtkCheck() {
12
12
  return {
13
13
  name: "rtk",
14
14
  label: "RTK (Rust Token Killer)",
@@ -8,10 +8,14 @@ import { isOpenSkillsAvailable } from "../skills/openskills-client.js";
8
8
  import { runCommand } from "../utils/process.js";
9
9
  import { STRATEGY } from "./types.js";
10
10
 
11
- export function skillsEnabled(config) {
11
+ function skillsEnabled(config) {
12
12
  return config?.skills?.enabled !== false;
13
13
  }
14
14
 
15
+ /**
16
+ * @internal Exported for dynamic import from tests/checks/skills.test.js
17
+ * and tests/checks/auto-remediation-e2e.test.js. Knip false positive.
18
+ */
15
19
  export function createOpenSkillsCheck() {
16
20
  return {
17
21
  name: "openskills",
@@ -26,7 +26,7 @@ function sonarHost(config) {
26
26
  * Informative check: Sonar enabled/disabled status.
27
27
  * Produces an OK result with disabled description when Sonar is off.
28
28
  */
29
- export function createSonarStatusCheck() {
29
+ function createSonarStatusCheck() {
30
30
  return {
31
31
  name: "sonarqube",
32
32
  label: "SonarQube",
@@ -14,7 +14,7 @@ function getPackageVersion() {
14
14
  return JSON.parse(readFileSync(pkgPath, "utf8")).version;
15
15
  }
16
16
 
17
- export function createKarajanVersionCheck() {
17
+ function createKarajanVersionCheck() {
18
18
  return {
19
19
  name: "karajan",
20
20
  label: "Karajan Code",
@@ -26,7 +26,7 @@ export function createKarajanVersionCheck() {
26
26
  };
27
27
  }
28
28
 
29
- export function createGitRepoCheck() {
29
+ function createGitRepoCheck() {
30
30
  return {
31
31
  name: "git",
32
32
  label: "Git repository",
@@ -146,4 +146,4 @@ export function getTokenChecks(config) {
146
146
  }
147
147
 
148
148
  // Re-export for tests that need to introspect the mapping.
149
- export { PROVIDER_TO_CLI, normalizeProvider };
149
+ export { normalizeProvider };
@@ -91,15 +91,3 @@ export const STRATEGY = Object.freeze({
91
91
  NONE: "none",
92
92
  });
93
93
 
94
- export const SEVERITY = Object.freeze({
95
- FAIL: "fail",
96
- WARN: "warn",
97
- INFO: "info",
98
- });
99
-
100
- /**
101
- * Whether the final status blocks the pipeline (for preflight).
102
- */
103
- export function isBlocking(status) {
104
- return status === STATUS.FAIL || status === STATUS.TIMEOUT;
105
- }
@@ -5,6 +5,7 @@ import { architectCommand } from "../commands/architect.js";
5
5
  import { auditCommand } from "../commands/audit.js";
6
6
  import { resumeCommand } from "../commands/resume.js";
7
7
  import { boardCommand } from "../commands/board.js";
8
+ import { webperfCommand } from "../commands/webperf.js";
8
9
  import { undoCommand } from "../commands/undo.js";
9
10
  import { syncCommand } from "../commands/sync.js";
10
11
  import { cleanCommand } from "../commands/clean.js";
@@ -90,10 +91,16 @@ export function registerMeta(program, { pkgVersion }) {
90
91
  .description("Analyze codebase health (read-only)")
91
92
  .argument("[task]", "Task description. If absent, defaults to a full-codebase analysis. Use --task-file to point at a .md.")
92
93
  .option("--task-file <path>", "Read the task from a file (e.g. .md)")
93
- .option("--dimensions <list>", "Comma-separated: security,quality,performance,architecture,testing", "all")
94
+ .option("--dimensions <list>", "Comma-separated: security,quality,performance,architecture,testing,accessibility (default: all; accessibility auto-skipped on detected backend-only projects)", "all")
94
95
  .option("--json", "Output raw JSON")
95
96
  .option("--agent-readiness", "Score the repo for AI-agent readability (llms.txt, SKILL.md coverage, page token budgets, robots allowlist, heading hierarchy). LLM-free; uses [path] or cwd as the audit target. See issue #542.")
96
97
  .option("--path <dir>", "Path to audit (used with --agent-readiness; defaults to cwd)")
98
+ .option("--no-sonar", "Skip the SonarQube findings collector (faster, less context). Sonar findings are also skipped automatically when SonarQube is unreachable.")
99
+ .option("--no-osv", "Skip the OSV-Scanner vulnerability collector. Findings are also skipped automatically when osv-scanner is not installed.")
100
+ .option("--no-semgrep", "Skip the Semgrep SAST collector. Findings are also skipped automatically when semgrep is not installed (install via 'pipx install semgrep' or 'brew install semgrep').")
101
+ .option("--report-file <path>", "Write the audit report to disk in addition to stdout. <path> may be a file (extension drives format: .md or .json) or a directory (creates audit-<ISO>.<md|json> inside). $KJ_AUDIT_REPORT_DIR env var is used as default directory if no --report-file is given.")
102
+ .option("--deterministic-only", "Skip the LLM analysis entirely. Print/persist only the deterministic findings (basalCost, sonar, stack, growth-delta, webperf). Zero tokens spent. Compatible with --report-file and --json.")
103
+ .option("-y, --yes", "Auto-confirm the 'Continue with LLM analysis?' prompt. Useful in scripts that want the full audit non-interactively. CI/non-TTY paths already auto-confirm without this flag.")
97
104
  .action(async (task, flags) => {
98
105
  await withConfig(pkgVersion, "audit", flags, async ({ config, logger }) => {
99
106
  let resolvedTask = task;
@@ -101,16 +108,45 @@ export function registerMeta(program, { pkgVersion }) {
101
108
  const { readTaskFile } = await import("../utils/task-file.js");
102
109
  resolvedTask = await readTaskFile(flags.taskFile, { projectDir: config.projectDir });
103
110
  }
111
+ // commander resolves --no-sonar to flags.sonar=false (negation flag).
112
+ // We translate to the explicit `noSonar: true` shape AuditRole expects.
104
113
  await auditCommand({
105
114
  task: resolvedTask || "Analyze the full codebase",
106
115
  config, logger,
107
116
  dimensions: flags.dimensions, json: flags.json,
108
117
  agentReadiness: Boolean(flags.agentReadiness),
109
118
  path: flags.path,
119
+ noSonar: flags.sonar === false,
120
+ noOsv: flags.osv === false,
121
+ noSemgrep: flags.semgrep === false,
122
+ reportFile: flags.reportFile || null,
123
+ deterministicOnly: Boolean(flags.deterministicOnly),
124
+ yes: Boolean(flags.yes),
110
125
  });
111
126
  });
112
127
  });
113
128
 
129
+ program
130
+ .command("webperf")
131
+ .description("Run a Lighthouse web-perf scan against a URL — Core Web Vitals + opportunities")
132
+ .argument("<url>", "Target URL (e.g. http://localhost:3000)")
133
+ .option("--mobile", "Use the Lighthouse mobile preset (default: desktop)")
134
+ .option("--json", "Output raw JSON")
135
+ .option("--no-persist", "Don't write the result into ~/.karajan/webperf/<slug>/last.json (kj audit reads it from there)")
136
+ .action(async (url, flags) => {
137
+ await withConfig(pkgVersion, "webperf", flags, async ({ config, logger }) => {
138
+ const result = await webperfCommand({
139
+ url,
140
+ config, logger,
141
+ mobile: Boolean(flags.mobile),
142
+ json: Boolean(flags.json),
143
+ persist: flags.persist !== false,
144
+ });
145
+ // Exit with non-zero when the verdict is FAIL so CI can gate.
146
+ if (result && result.ok === false) process.exitCode = 1;
147
+ });
148
+ });
149
+
114
150
  program
115
151
  .command("resume")
116
152
  .description("Resume a paused session")
@@ -153,10 +189,12 @@ export function registerMeta(program, { pkgVersion }) {
153
189
  .command("board [action]")
154
190
  .description("Manage HU Board (start|stop|status|open)")
155
191
  .option("--port <number>", "Port (default: 4000)", "4000")
192
+ .option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
156
193
  .action(async (action = "start", opts) => {
157
194
  await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
158
195
  const port = Number(opts.port) || config.hu_board?.port || 4000;
159
- await boardCommand({ action, port, logger });
196
+ const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
197
+ await boardCommand({ action, port, bind, logger });
160
198
  });
161
199
  });
162
200
 
@@ -58,6 +58,7 @@ export function registerPipeline(program, { pkgVersion }) {
58
58
  .option("--enable-tester")
59
59
  .option("--enable-security")
60
60
  .option("--enable-impeccable")
61
+ .option("--enable-perf")
61
62
  .option("--enable-triage")
62
63
  .option("--enable-discover")
63
64
  .option("--enable-architect")