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.
- package/README.md +14 -1
- package/package.json +1 -1
- package/src/audit/agent-readiness.js +21 -3
- package/src/audit/basal-cost.js +92 -9
- package/src/audit/deterministic-summary.js +200 -0
- package/src/audit/osv-findings.js +162 -0
- package/src/audit/semgrep-findings.js +117 -0
- package/src/audit/sonar-findings.js +81 -0
- package/src/audit/webperf-input.js +136 -0
- package/src/canvas/schema.js +1 -1
- package/src/checks/binaries.js +2 -2
- package/src/checks/ci.js +2 -2
- package/src/checks/config-files.js +6 -6
- package/src/checks/dir-setup.js +4 -0
- package/src/checks/mcp-health.js +6 -0
- package/src/checks/ports.js +8 -0
- package/src/checks/rtk.js +1 -1
- package/src/checks/skills.js +5 -1
- package/src/checks/sonar.js +1 -1
- package/src/checks/system.js +2 -2
- package/src/checks/tokens.js +1 -1
- package/src/checks/types.js +0 -12
- package/src/cli/register-meta.js +40 -2
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/audit.js +239 -28
- package/src/commands/board.js +7 -4
- package/src/commands/doctor.js +1 -1
- package/src/commands/webperf.js +164 -0
- package/src/config/defaults.js +1 -0
- package/src/config/overrides.js +1 -0
- package/src/config/schema.js +1 -0
- package/src/config/test-harness.js +0 -1
- package/src/mcp/handlers/run-handler.js +2 -2
- package/src/mcp/run-kj.js +1 -0
- package/src/mcp/sovereignty-guard.js +1 -1
- package/src/mcp/tools.js +1 -0
- package/src/orchestrator/ci-integration.js +1 -1
- package/src/orchestrator/config-init.js +3 -1
- package/src/orchestrator/direct-actions.js +1 -1
- package/src/orchestrator/drivers/error-recovery.js +2 -2
- package/src/orchestrator/drivers/iteration-loop.js +3 -13
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +12 -0
- package/src/orchestrator/drivers/pre-loop.js +1 -4
- package/src/orchestrator/feedback-queue.js +0 -1
- package/src/orchestrator/integrations.js +0 -7
- package/src/orchestrator/role-output-compressor.js +0 -1
- package/src/orchestrator/session-journal.js +5 -41
- package/src/orchestrator/stages/perf-stage.js +140 -0
- package/src/orchestrator/stages/stage-classes.js +0 -2
- package/src/plan/plan-store.js +0 -14
- package/src/planning-game/client.js +0 -14
- package/src/planning-game/pipeline-adapter.js +1 -1
- package/src/prompts/audit.js +206 -6
- package/src/prompts/rtk-snippet.js +1 -1
- package/src/roles/audit-role.js +125 -13
- package/src/roles/perf-role.js +122 -0
- package/src/skills/addyosmani-role-map.js +6 -0
- package/src/sonar/api.js +1 -1
- package/src/utils/display/event-handlers.js +1 -1
- package/src/utils/display/header.js +1 -1
- package/src/utils/display/session-summary.js +3 -3
- package/src/utils/display/solomon.js +1 -1
- package/src/utils/role-assigner.js +1 -1
- package/src/webperf/scanner.js +209 -0
package/src/commands/audit.js
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import readline from "node:readline";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { promisify } from "node:util";
|
|
3
6
|
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
4
7
|
import { resolveRole } from "../config.js";
|
|
5
|
-
import {
|
|
8
|
+
import { AUDIT_DIMENSIONS } from "../prompts/audit.js";
|
|
9
|
+
import { AuditRole } from "../roles/audit-role.js";
|
|
6
10
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
7
11
|
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
8
12
|
import { runAgentReadiness, formatAgentReadinessReport } from "../audit/agent-readiness.js";
|
|
13
|
+
import { formatDeterministicSummary } from "../audit/deterministic-summary.js";
|
|
14
|
+
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Prompt the user (TTY only) to decide whether to continue with the LLM
|
|
19
|
+
* phase after deterministic findings have been printed. KJC-TSK-0364.
|
|
20
|
+
*
|
|
21
|
+
* @returns {Promise<boolean>} true to continue with LLM, false to skip
|
|
22
|
+
*/
|
|
23
|
+
function promptContinueWithLlm({ promptFn = null } = {}) {
|
|
24
|
+
if (typeof promptFn === "function") return promptFn();
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
if (!process.stdin.isTTY) { resolve(true); return; } // CI / piped input → continue
|
|
27
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
28
|
+
rl.question("Continue with LLM analysis? [y/N] ", (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
resolve(/^y(es)?$/i.test((answer || "").trim()));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
9
34
|
|
|
10
35
|
function formatFindings(findings) {
|
|
11
36
|
const lines = [];
|
|
@@ -45,10 +70,11 @@ const DIMENSION_LABELS = {
|
|
|
45
70
|
codeQuality: "Code Quality",
|
|
46
71
|
performance: "Performance",
|
|
47
72
|
architecture: "Architecture",
|
|
48
|
-
testing: "Testing"
|
|
73
|
+
testing: "Testing",
|
|
74
|
+
accessibility: "Accessibility (WCAG 2.x)"
|
|
49
75
|
};
|
|
50
76
|
|
|
51
|
-
function formatAudit(parsed) {
|
|
77
|
+
function formatAudit(parsed, usage = null) {
|
|
52
78
|
const lines = [];
|
|
53
79
|
lines.push("## Codebase Health Report");
|
|
54
80
|
lines.push(`**Overall Health:** ${parsed.summary.overallHealth}`);
|
|
@@ -66,10 +92,119 @@ function formatAudit(parsed) {
|
|
|
66
92
|
}
|
|
67
93
|
|
|
68
94
|
if (parsed.textSummary) lines.push(`---\n${parsed.textSummary}`);
|
|
95
|
+
|
|
96
|
+
const usageBlock = formatUsageSummary(usage);
|
|
97
|
+
if (usageBlock) {
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push(usageBlock);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Format the LLM token + cost summary that closes every audit report.
|
|
107
|
+
* KJC-TSK-0363: makes the LLM cost visible (was silently discarded
|
|
108
|
+
* pre-patch — auditCommand never read roleResult.usage).
|
|
109
|
+
*
|
|
110
|
+
* @param {object|null} usage - shape produced by AuditRole's extractUsage
|
|
111
|
+
* @returns {string|null} markdown block, or null when there's nothing useful to show
|
|
112
|
+
*/
|
|
113
|
+
function formatUsageSummary(usage) {
|
|
114
|
+
if (!usage) return null;
|
|
115
|
+
const lines = ["## LLM Usage"];
|
|
116
|
+
lines.push(`- Provider: ${usage.provider}${usage.model ? ` (${usage.model})` : ""}`);
|
|
117
|
+
if (typeof usage.durationMs === "number") {
|
|
118
|
+
lines.push(`- Duration: ${(usage.durationMs / 1000).toFixed(1)}s`);
|
|
119
|
+
}
|
|
120
|
+
if (!usage.available) {
|
|
121
|
+
lines.push(`- Tokens: usage data not available — ${usage.reason || "provider does not report it"}`);
|
|
122
|
+
return lines.join("\n");
|
|
123
|
+
}
|
|
124
|
+
// Force en-US locale so the comma grouping is deterministic across CI
|
|
125
|
+
// machines (Node's default Intl.NumberFormat depends on the runtime
|
|
126
|
+
// locale; without this the rendering would look like "1234" on
|
|
127
|
+
// locale-less Node and "1,234" on a US-locale workstation).
|
|
128
|
+
const fmt = (n) => n.toLocaleString("en-US");
|
|
129
|
+
lines.push(`- Tokens: ${fmt(usage.total_tokens)} total (${fmt(usage.tokens_in)} in, ${fmt(usage.tokens_out)} out)`);
|
|
130
|
+
if (typeof usage.cost_usd === "number") {
|
|
131
|
+
lines.push(`- Estimated cost: $${usage.cost_usd.toFixed(4)} USD`);
|
|
132
|
+
}
|
|
69
133
|
return lines.join("\n");
|
|
70
134
|
}
|
|
71
135
|
|
|
72
|
-
|
|
136
|
+
/**
|
|
137
|
+
* Resolve where to write the audit report on disk, given a flag value, an
|
|
138
|
+
* env override, and the desired format. Returns null if no write target
|
|
139
|
+
* was requested. Creates parent directories on demand.
|
|
140
|
+
*
|
|
141
|
+
* Resolution rules (KJC-TSK-0362):
|
|
142
|
+
* - flag undefined + no env → null (legacy behaviour, stdout only)
|
|
143
|
+
* - flag is a directory → write `audit-<ISO>.{md,json}` inside it
|
|
144
|
+
* - flag is a non-directory path → use it verbatim (extension drives format
|
|
145
|
+
* when --json/--md not explicit)
|
|
146
|
+
* - flag is undefined but env $KJ_AUDIT_REPORT_DIR is set → treat env as dir
|
|
147
|
+
*/
|
|
148
|
+
async function resolveReportFilePath(flagValue, isJson) {
|
|
149
|
+
const envDir = process.env.KJ_AUDIT_REPORT_DIR;
|
|
150
|
+
const target = flagValue || (envDir ? envDir : null);
|
|
151
|
+
if (!target) return null;
|
|
152
|
+
|
|
153
|
+
const ext = isJson ? "json" : "md";
|
|
154
|
+
let stats = null;
|
|
155
|
+
try { stats = await fs.stat(target); } catch { /* not a directory yet */ }
|
|
156
|
+
const isDir = stats?.isDirectory() || target.endsWith(path.sep) || target === envDir;
|
|
157
|
+
|
|
158
|
+
let resolved;
|
|
159
|
+
if (isDir) {
|
|
160
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
161
|
+
resolved = path.resolve(target, `audit-${stamp}.${ext}`);
|
|
162
|
+
} else {
|
|
163
|
+
resolved = path.resolve(target);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
await fs.mkdir(path.dirname(resolved), { recursive: true });
|
|
167
|
+
return resolved;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Build the markdown header that prefixes a persisted report — captures
|
|
172
|
+
* timestamp + repo state + invocation flags so the file is reproducible.
|
|
173
|
+
*/
|
|
174
|
+
async function buildReportHeader(projectDir, invocation) {
|
|
175
|
+
const lines = ["# Karajan Audit Report", ""];
|
|
176
|
+
lines.push(`- **Generated:** ${new Date().toISOString()}`);
|
|
177
|
+
lines.push(`- **Project:** ${projectDir || process.cwd()}`);
|
|
178
|
+
|
|
179
|
+
// Best-effort git info — silent if outside a repo.
|
|
180
|
+
try {
|
|
181
|
+
const branch = (await execFileAsync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: projectDir, timeout: 2000 })).stdout.trim();
|
|
182
|
+
const commit = (await execFileAsync("git", ["rev-parse", "--short", "HEAD"], { cwd: projectDir, timeout: 2000 })).stdout.trim();
|
|
183
|
+
lines.push(`- **Branch:** ${branch}`);
|
|
184
|
+
lines.push(`- **Commit:** ${commit}`);
|
|
185
|
+
} catch { /* not a git repo or git missing */ }
|
|
186
|
+
|
|
187
|
+
if (invocation) lines.push(`- **Invocation:** \`${invocation}\``);
|
|
188
|
+
lines.push("");
|
|
189
|
+
lines.push("---");
|
|
190
|
+
lines.push("");
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }) {
|
|
195
|
+
const parts = ["kj audit"];
|
|
196
|
+
if (task && task !== "Analyze the full codebase") parts.push(JSON.stringify(task));
|
|
197
|
+
if (dimensions && dimensions !== "all") parts.push(`--dimensions=${dimensions}`);
|
|
198
|
+
if (noSonar) parts.push("--no-sonar");
|
|
199
|
+
if (noOsv) parts.push("--no-osv");
|
|
200
|
+
if (noSemgrep) parts.push("--no-semgrep");
|
|
201
|
+
if (deterministicOnly) parts.push("--deterministic-only");
|
|
202
|
+
if (yes) parts.push("--yes");
|
|
203
|
+
if (json) parts.push("--json");
|
|
204
|
+
return parts.join(" ");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, reportFile = null, deterministicOnly = false, yes = false, promptFn = null }) {
|
|
73
208
|
// --agent-readiness is a STANDALONE, deterministic, LLM-free audit
|
|
74
209
|
// dimension. It scores any third-party repo for AI-agent readability
|
|
75
210
|
// (llms.txt presence, page token budgets, robots allowlist, etc.).
|
|
@@ -88,44 +223,120 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
|
|
|
88
223
|
}
|
|
89
224
|
|
|
90
225
|
return withCliRunLog("audit", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
91
|
-
const
|
|
92
|
-
await assertAgentsAvailable([
|
|
93
|
-
logger.info(`Audit (${
|
|
94
|
-
runLog.logText(`[audit] provider=${
|
|
226
|
+
const auditRoleConfig = resolveRole(config, "audit");
|
|
227
|
+
await assertAgentsAvailable([auditRoleConfig.provider]);
|
|
228
|
+
logger.info(`Audit (${auditRoleConfig.provider}) starting...`);
|
|
229
|
+
runLog.logText(`[audit] provider=${auditRoleConfig.provider} dimensions=${dimensions || "all"} deterministicOnly=${deterministicOnly}`);
|
|
95
230
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
231
|
+
// Two-phase audit (KJC-TSK-0364) — collect deterministic findings
|
|
232
|
+
// first; show them; ask the user (when TTY + not --yes + not
|
|
233
|
+
// --deterministic-only) whether to spend tokens on the LLM analysis.
|
|
234
|
+
// CI / piped input / --yes / --deterministic-only never prompts.
|
|
235
|
+
const role = new AuditRole({ config, logger });
|
|
236
|
+
const roleInput = {
|
|
237
|
+
task: task || "Analyze the full codebase",
|
|
238
|
+
dimensions: dimensions || null,
|
|
239
|
+
noSonar,
|
|
240
|
+
noOsv,
|
|
241
|
+
noSemgrep,
|
|
242
|
+
};
|
|
243
|
+
const deterministicCtx = await role.collectDeterministic(roleInput);
|
|
244
|
+
const deterministicMd = formatDeterministicSummary(deterministicCtx);
|
|
100
245
|
|
|
101
|
-
|
|
246
|
+
// --json suppresses the interactive flow regardless: it would mangle
|
|
247
|
+
// a script's stdout. JSON consumers always get the full audit unless
|
|
248
|
+
// they explicitly pass deterministicOnly.
|
|
249
|
+
const reportPath = await resolveReportFilePath(reportFile, json);
|
|
250
|
+
let runLlm = !deterministicOnly;
|
|
251
|
+
if (runLlm && !json && !yes) {
|
|
252
|
+
console.log(deterministicMd);
|
|
253
|
+
console.log("");
|
|
254
|
+
const ok = await promptContinueWithLlm({ promptFn });
|
|
255
|
+
runLlm = ok;
|
|
256
|
+
if (!ok) logger.info("LLM analysis skipped (deterministic-only mode).");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!runLlm) {
|
|
260
|
+
// Deterministic-only path. Produce the report from what we already
|
|
261
|
+
// have, persist it (md or json), and exit. Zero tokens spent.
|
|
262
|
+
const stdoutContent = json
|
|
263
|
+
? JSON.stringify({ deterministic: deterministicCtx, mode: "deterministic-only" }, null, 2)
|
|
264
|
+
: (deterministicOnly ? deterministicMd : ""); // already printed above when interactive
|
|
265
|
+
if (json || deterministicOnly) console.log(stdoutContent);
|
|
266
|
+
|
|
267
|
+
if (reportPath) {
|
|
268
|
+
let payload;
|
|
269
|
+
if (reportPath.endsWith(".json")) {
|
|
270
|
+
payload = JSON.stringify({ deterministic: deterministicCtx, mode: "deterministic-only" }, null, 2);
|
|
271
|
+
} else {
|
|
272
|
+
const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }));
|
|
273
|
+
payload = header + deterministicMd + "\n";
|
|
274
|
+
}
|
|
275
|
+
await fs.writeFile(reportPath, payload, "utf8");
|
|
276
|
+
runLog.logText(`[audit] report written (deterministic-only) → ${reportPath}`);
|
|
277
|
+
logger.info(`Audit report written: ${reportPath}`);
|
|
278
|
+
}
|
|
279
|
+
logger.info("Audit completed (deterministic-only).");
|
|
280
|
+
return { ok: true, mode: "deterministic-only", reportPath: reportPath || undefined };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Continue with the LLM phase using the already-collected context.
|
|
102
284
|
const progress = createCliProgressReporter({ role: "auditor" });
|
|
103
|
-
let
|
|
285
|
+
let roleResult;
|
|
104
286
|
try {
|
|
105
|
-
|
|
106
|
-
progress.finish(
|
|
287
|
+
roleResult = await role.executeWithDeterministic({ ...roleInput, onOutput: progress.onOutput }, deterministicCtx);
|
|
288
|
+
progress.finish(roleResult.ok ? "done" : "failed");
|
|
107
289
|
} catch (err) { progress.finish("failed"); throw err; }
|
|
108
290
|
|
|
109
|
-
if (!
|
|
110
|
-
|
|
291
|
+
if (!roleResult.ok) {
|
|
292
|
+
const err = roleResult.result?.error || "Audit failed";
|
|
293
|
+
throw new Error(err);
|
|
111
294
|
}
|
|
112
295
|
|
|
113
|
-
const parsed =
|
|
114
|
-
|
|
296
|
+
const parsed = roleResult.result;
|
|
297
|
+
const usage = roleResult.usage;
|
|
298
|
+
if (parsed?.summary?.overallHealth) {
|
|
115
299
|
runLog.logText(`[audit] findings=${parsed.summary.totalFindings} (critical=${parsed.summary.critical}, high=${parsed.summary.high})`);
|
|
116
300
|
}
|
|
301
|
+
if (usage?.available) {
|
|
302
|
+
runLog.logText(`[audit] usage tokens=${usage.total_tokens} cost=$${(usage.cost_usd ?? 0).toFixed(4)} duration=${(usage.durationMs / 1000).toFixed(1)}s`);
|
|
303
|
+
}
|
|
117
304
|
|
|
305
|
+
let stdoutContent;
|
|
118
306
|
if (json) {
|
|
119
|
-
|
|
120
|
-
|
|
307
|
+
const jsonPayload = parsed
|
|
308
|
+
? { ...parsed, usage: usage || undefined }
|
|
309
|
+
: { ...roleResult, usage: usage || undefined };
|
|
310
|
+
stdoutContent = JSON.stringify(jsonPayload, null, 2);
|
|
311
|
+
} else if (parsed?.summary?.overallHealth) {
|
|
312
|
+
stdoutContent = formatAudit(parsed, usage);
|
|
313
|
+
} else if (parsed?.raw) {
|
|
314
|
+
stdoutContent = parsed.raw + (usage ? `\n\n${formatUsageSummary(usage) || ""}` : "");
|
|
315
|
+
} else {
|
|
316
|
+
stdoutContent = roleResult.summary || "Audit complete.";
|
|
121
317
|
}
|
|
318
|
+
console.log(stdoutContent);
|
|
122
319
|
|
|
123
|
-
if (
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
320
|
+
if (reportPath) {
|
|
321
|
+
let payload;
|
|
322
|
+
if (reportPath.endsWith(".json")) {
|
|
323
|
+
const jsonPayload = parsed
|
|
324
|
+
? { ...parsed, usage: usage || undefined }
|
|
325
|
+
: { ...roleResult, usage: usage || undefined };
|
|
326
|
+
payload = JSON.stringify(jsonPayload, null, 2);
|
|
327
|
+
} else {
|
|
328
|
+
const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, json, deterministicOnly, yes }));
|
|
329
|
+
payload = header + stdoutContent + "\n";
|
|
330
|
+
}
|
|
331
|
+
await fs.writeFile(reportPath, payload, "utf8");
|
|
332
|
+
runLog.logText(`[audit] report written → ${reportPath}`);
|
|
333
|
+
logger.info(`Audit report written: ${reportPath}`);
|
|
127
334
|
}
|
|
335
|
+
|
|
128
336
|
logger.info("Audit completed.");
|
|
129
|
-
return { ok: true };
|
|
337
|
+
return { ok: true, reportPath: reportPath || undefined };
|
|
130
338
|
});
|
|
131
339
|
}
|
|
340
|
+
|
|
341
|
+
// Exposed for tests — kept module-private otherwise.
|
|
342
|
+
export { formatAudit, formatUsageSummary, AUDIT_DIMENSIONS };
|
package/src/commands/board.js
CHANGED
|
@@ -89,7 +89,7 @@ function buildBoardUrl(port, projectSlug) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
92
|
-
const { projectSlug = null } = opts;
|
|
92
|
+
const { projectSlug = null, bind = "127.0.0.1" } = opts;
|
|
93
93
|
const existingPid = readPid();
|
|
94
94
|
if (existingPid && isProcessAlive(existingPid)) {
|
|
95
95
|
// Trust the saved PID — port info comes from the PID file if present
|
|
@@ -129,7 +129,7 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
129
129
|
// Use process.execPath (absolute path to current node binary) instead of "node".
|
|
130
130
|
// Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
|
|
131
131
|
const child = spawn(process.execPath, [serverPath], {
|
|
132
|
-
env: { ...process.env, PORT: String(port) },
|
|
132
|
+
env: { ...process.env, PORT: String(port), BIND_HOST: bind },
|
|
133
133
|
detached: true,
|
|
134
134
|
stdio: "ignore",
|
|
135
135
|
cwd: BOARD_DIR
|
|
@@ -265,14 +265,17 @@ export function renderBoardBanner({ url, status, projectName }) {
|
|
|
265
265
|
return ["", rule, ...content, rule, ""].join("\n");
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
export async function boardCommand({ action = "start", port = 4000, logger }) {
|
|
268
|
+
export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
|
|
269
269
|
switch (action) {
|
|
270
270
|
case "start": {
|
|
271
|
-
const result = await startBoard(port);
|
|
271
|
+
const result = await startBoard(port, { bind });
|
|
272
272
|
if (result.alreadyRunning) {
|
|
273
273
|
logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
|
|
274
274
|
} else {
|
|
275
275
|
logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
|
|
276
|
+
if (bind !== "127.0.0.1" && bind !== "localhost") {
|
|
277
|
+
logger.warn(`HU Board bound to ${bind} — token auth enforced for non-loopback peers. Token: ~/.karajan/hu-board/token`);
|
|
278
|
+
}
|
|
276
279
|
}
|
|
277
280
|
return result;
|
|
278
281
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -77,7 +77,7 @@ function confirmViaTty(describe) {
|
|
|
77
77
|
* @param {{ config: Object, checkOnly?: boolean, yes?: boolean, onConfirm?: Function, logger?: Object }} args
|
|
78
78
|
* @returns {Promise<import("../checks/types.js").RunReport>}
|
|
79
79
|
*/
|
|
80
|
-
|
|
80
|
+
async function runDoctor({ config, checkOnly = false, yes = false, onConfirm, logger }) {
|
|
81
81
|
const checks = buildChecks(config);
|
|
82
82
|
return runCheckPipeline(checks, { config }, {
|
|
83
83
|
mode: checkOnly ? "check-only" : "fix",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj webperf <url>` — KJC-TSK-0149.
|
|
3
|
+
*
|
|
4
|
+
* Headless Lighthouse wrapper. Produces structured CWV + opportunities
|
|
5
|
+
* JSON. Writes the result into `~/.karajan/webperf/<projectSlug>/last.json`
|
|
6
|
+
* so future `kj audit` runs can surface the verdict via the existing
|
|
7
|
+
* WebPerf section (KJC-TSK-0360 cwv-result mode).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
import { runWebperfScan } from "../webperf/scanner.js";
|
|
15
|
+
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
16
|
+
|
|
17
|
+
// Resolve home each call so tests that override $HOME for hermetic
|
|
18
|
+
// tmp dirs work without re-importing the module.
|
|
19
|
+
function defaultReportDir() {
|
|
20
|
+
return path.join(os.homedir(), ".karajan", "webperf");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Slug a project directory the same way audit/basal-cost.js and
|
|
25
|
+
* plan/plan-store.js do, so the per-project webperf history aligns
|
|
26
|
+
* with audit history under ~/.karajan/.
|
|
27
|
+
*/
|
|
28
|
+
function projectSlug(projectDir) {
|
|
29
|
+
return path.basename(projectDir || process.cwd()).replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatHumanReport(result) {
|
|
33
|
+
if (!result.ok) {
|
|
34
|
+
return `WebPerf scan FAILED — ${result.reason}`;
|
|
35
|
+
}
|
|
36
|
+
const lines = ["## WebPerf Scan", ""];
|
|
37
|
+
lines.push(`- URL: ${result.url}`);
|
|
38
|
+
lines.push(`- Preset: ${result.preset}`);
|
|
39
|
+
lines.push(`- Captured: ${result.capturedAt}`);
|
|
40
|
+
if (result.lighthouseVersion) lines.push(`- Lighthouse: v${result.lighthouseVersion}`);
|
|
41
|
+
if (typeof result.performanceScore === "number") {
|
|
42
|
+
lines.push(`- Performance score: ${result.performanceScore}/100`);
|
|
43
|
+
}
|
|
44
|
+
lines.push("");
|
|
45
|
+
lines.push(`### Verdict: ${result.cwv.pass ? "PASS" : "FAIL"}`);
|
|
46
|
+
lines.push("");
|
|
47
|
+
lines.push("### Core Web Vitals");
|
|
48
|
+
for (const [metric, score] of Object.entries(result.cwv.scores || {})) {
|
|
49
|
+
const value = formatMetricValue(metric, score.value);
|
|
50
|
+
lines.push(`- ${metric.toUpperCase()}: ${value} (${score.rating})`);
|
|
51
|
+
}
|
|
52
|
+
if (result.cwv.blocking?.length) {
|
|
53
|
+
lines.push("");
|
|
54
|
+
lines.push("### Blocking metrics (above poor threshold)");
|
|
55
|
+
for (const b of result.cwv.blocking) {
|
|
56
|
+
lines.push(`- ${b.metric.toUpperCase()}: ${formatMetricValue(b.metric, b.value)} > ${formatMetricValue(b.metric, b.threshold)}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (result.issues?.length) {
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(`### Top opportunities (${result.issues.length})`);
|
|
62
|
+
for (const issue of result.issues.slice(0, 10)) {
|
|
63
|
+
const savings = issue.savingsMs ? ` (saves ~${Math.round(issue.savingsMs)}ms)` : "";
|
|
64
|
+
lines.push(`- [${issue.severity.toUpperCase()}] ${issue.id}${savings}: ${issue.title}`);
|
|
65
|
+
}
|
|
66
|
+
if (result.issues.length > 10) {
|
|
67
|
+
lines.push(`- ... and ${result.issues.length - 10} more`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return lines.join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatMetricValue(metric, value) {
|
|
74
|
+
if (typeof value !== "number") return "n/a";
|
|
75
|
+
if (metric === "cls") return value.toFixed(3);
|
|
76
|
+
return `${Math.round(value)}ms`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Persist the scan result into ~/.karajan/webperf/<slug>/last.json so
|
|
81
|
+
* `kj audit` can read it via collectWebPerfInput (KJC-TSK-0360).
|
|
82
|
+
*/
|
|
83
|
+
async function persistResult(result, projectDir) {
|
|
84
|
+
if (!result.ok) return null;
|
|
85
|
+
const dir = path.join(defaultReportDir(), projectSlug(projectDir));
|
|
86
|
+
await fs.mkdir(dir, { recursive: true });
|
|
87
|
+
const filePath = path.join(dir, "last.json");
|
|
88
|
+
await fs.writeFile(filePath, JSON.stringify(result, null, 2), "utf8");
|
|
89
|
+
return filePath;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Optionally update kj.config.yml so `kj audit` finds the result by
|
|
94
|
+
* default (mirrors how sonar config is wired). Best-effort: never
|
|
95
|
+
* fails the command; absence of config file or write error is logged.
|
|
96
|
+
*/
|
|
97
|
+
async function patchConfigLastResult(configPath, lastResultPath, logger) {
|
|
98
|
+
if (!configPath || !lastResultPath) return false;
|
|
99
|
+
let raw;
|
|
100
|
+
try { raw = await fs.readFile(configPath, "utf8"); } catch { return false; }
|
|
101
|
+
let parsed;
|
|
102
|
+
try { parsed = yaml.load(raw) || {}; } catch (err) {
|
|
103
|
+
if (logger?.warn) logger.warn(`webperf: cannot parse ${configPath}: ${err.message}`);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
parsed.webperf = parsed.webperf || {};
|
|
107
|
+
parsed.webperf.lastResultPath = lastResultPath;
|
|
108
|
+
try {
|
|
109
|
+
await fs.writeFile(configPath, yaml.dump(parsed), "utf8");
|
|
110
|
+
return true;
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (logger?.warn) logger.warn(`webperf: cannot write ${configPath}: ${err.message}`);
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function webperfCommand({ url, config, logger, json = false, mobile = false, persist = true, thresholds = null }) {
|
|
118
|
+
if (!url) {
|
|
119
|
+
throw new Error("kj webperf: <url> is required (e.g. http://localhost:3000)");
|
|
120
|
+
}
|
|
121
|
+
return withCliRunLog("webperf", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
122
|
+
logger.info(`Running webperf scan against ${url} (preset: ${mobile ? "mobile" : "desktop"})`);
|
|
123
|
+
runLog.logText(`[webperf] scan url=${url} preset=${mobile ? "mobile" : "desktop"}`);
|
|
124
|
+
|
|
125
|
+
const scanThresholds = thresholds || config?.webperf?.thresholds || null;
|
|
126
|
+
const result = await runWebperfScan(url, {
|
|
127
|
+
preset: mobile ? "mobile" : "desktop",
|
|
128
|
+
thresholds: scanThresholds,
|
|
129
|
+
logger,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (json) {
|
|
133
|
+
console.log(JSON.stringify(result, null, 2));
|
|
134
|
+
} else {
|
|
135
|
+
console.log(formatHumanReport(result));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
runLog.logText(`[webperf] failed reason=${result.reason}`);
|
|
140
|
+
return { ok: false, reason: result.reason };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
runLog.logText(`[webperf] verdict=${result.cwv.pass ? "PASS" : "FAIL"} score=${result.performanceScore}`);
|
|
144
|
+
|
|
145
|
+
let savedPath = null;
|
|
146
|
+
if (persist) {
|
|
147
|
+
try {
|
|
148
|
+
savedPath = await persistResult(result, config?.projectDir);
|
|
149
|
+
if (savedPath) {
|
|
150
|
+
logger.info(`WebPerf result saved: ${savedPath}`);
|
|
151
|
+
// Wire into kj.config.yml so `kj audit` picks it up automatically.
|
|
152
|
+
const configPath = config?.configFilePath
|
|
153
|
+
|| path.join(config?.projectDir || process.cwd(), ".karajan", "kj.config.yml");
|
|
154
|
+
await patchConfigLastResult(configPath, savedPath, logger);
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logger.warn(`webperf: persist failed (non-blocking): ${err.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Exit code: 0 for PASS, 1 for FAIL. Lets CI gate on it.
|
|
162
|
+
return { ok: result.cwv.pass, result, savedPath };
|
|
163
|
+
});
|
|
164
|
+
}
|
package/src/config/defaults.js
CHANGED
package/src/config/overrides.js
CHANGED
|
@@ -37,6 +37,7 @@ const PIPELINE_ENABLE_FLAGS = [
|
|
|
37
37
|
["enablePlanner", "planner"], ["enableRefactorer", "refactorer"],
|
|
38
38
|
["enableSolomon", "solomon"], ["enableResearcher", "researcher"],
|
|
39
39
|
["enableTester", "tester"], ["enableSecurity", "security"], ["enableImpeccable", "impeccable"],
|
|
40
|
+
["enablePerf", "perf"],
|
|
40
41
|
["enableTriage", "triage"], ["enableDiscover", "discover"],
|
|
41
42
|
["enableArchitect", "architect"],
|
|
42
43
|
["enableHuReviewer", "hu_reviewer"]
|
package/src/config/schema.js
CHANGED
|
@@ -89,6 +89,7 @@ const PipelineMap = v.optional(v.looseObject({
|
|
|
89
89
|
tester: v.optional(PipelineEntry),
|
|
90
90
|
security: v.optional(PipelineEntry),
|
|
91
91
|
impeccable: v.optional(PipelineEntry),
|
|
92
|
+
perf: v.optional(PipelineEntry),
|
|
92
93
|
triage: v.optional(PipelineEntry),
|
|
93
94
|
discover: v.optional(PipelineEntry),
|
|
94
95
|
architect: v.optional(PipelineEntry),
|
|
@@ -263,7 +263,7 @@ export async function handleResume(a, server, extra) {
|
|
|
263
263
|
}
|
|
264
264
|
a.answer = validation.sanitized;
|
|
265
265
|
}
|
|
266
|
-
applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable"]);
|
|
266
|
+
applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable", "enablePerf"]);
|
|
267
267
|
return handleResumeDirect(a, server, extra);
|
|
268
268
|
}
|
|
269
269
|
|
|
@@ -303,6 +303,6 @@ export async function handleRun(a, server, extra) {
|
|
|
303
303
|
const logger = createLogger("info", "mcp");
|
|
304
304
|
logger.info("Preflight auto-acknowledged with default agent config");
|
|
305
305
|
}
|
|
306
|
-
applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable"]);
|
|
306
|
+
applySessionOverrides(a, ["coder", "reviewer", "tester", "security", "solomon", "enableTester", "enableSecurity", "enableImpeccable", "enablePerf"]);
|
|
307
307
|
return handleRunDirect(a, server, extra);
|
|
308
308
|
}
|
package/src/mcp/run-kj.js
CHANGED
|
@@ -50,6 +50,7 @@ export async function runKjCommand({ command, commandArgs = [], options = {}, en
|
|
|
50
50
|
normalizeBoolFlag(options.enableTester, "--enable-tester", args);
|
|
51
51
|
normalizeBoolFlag(options.enableSecurity, "--enable-security", args);
|
|
52
52
|
normalizeBoolFlag(options.enableImpeccable, "--enable-impeccable", args);
|
|
53
|
+
normalizeBoolFlag(options.enablePerf, "--enable-perf", args);
|
|
53
54
|
normalizeBoolFlag(options.design, "--design", args);
|
|
54
55
|
normalizeBoolFlag(options.enableTriage, "--enable-triage", args);
|
|
55
56
|
normalizeBoolFlag(options.enableDiscover, "--enable-discover", args);
|
|
@@ -14,7 +14,7 @@ const ALLOWED_PARAMS = new Set([
|
|
|
14
14
|
"planner", "coder", "reviewer", "refactorer",
|
|
15
15
|
"plannerModel", "coderModel", "reviewerModel", "refactorerModel",
|
|
16
16
|
"enablePlanner", "enableReviewer", "enableRefactorer", "enableResearcher",
|
|
17
|
-
"enableTester", "enableSecurity", "enableImpeccable",
|
|
17
|
+
"enableTester", "enableSecurity", "enableImpeccable", "enablePerf",
|
|
18
18
|
"enableDiscover", "enableArchitect",
|
|
19
19
|
"architectModel", "huFile",
|
|
20
20
|
"enableSerena", "enableCi",
|
package/src/mcp/tools.js
CHANGED
|
@@ -76,6 +76,7 @@ export const tools = [
|
|
|
76
76
|
enableTester: { type: "boolean" },
|
|
77
77
|
enableSecurity: { type: "boolean" },
|
|
78
78
|
enableImpeccable: { type: "boolean" },
|
|
79
|
+
enablePerf: { type: "boolean" },
|
|
79
80
|
enableTriage: { type: "boolean" },
|
|
80
81
|
enableDiscover: { type: "boolean" },
|
|
81
82
|
enableArchitect: { type: "boolean" },
|
|
@@ -86,7 +86,7 @@ export async function handleCiEarlyPrOrPush({ ciEnabled, config, session, emitte
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
function formatBlockingIssues(issues) {
|
|
90
90
|
return issues?.map((x) => `- ${x.id || "ISSUE"} [${x.severity || ""}] ${x.description}`).join("\n") || "";
|
|
91
91
|
}
|
|
92
92
|
|
|
@@ -217,6 +217,7 @@ export function resolvePipelineFlags(config) {
|
|
|
217
217
|
testerEnabled: Boolean(config.pipeline?.tester?.enabled),
|
|
218
218
|
securityEnabled: Boolean(config.pipeline?.security?.enabled),
|
|
219
219
|
impeccableEnabled: Boolean(config.pipeline?.impeccable?.enabled),
|
|
220
|
+
perfEnabled: Boolean(config.pipeline?.perf?.enabled),
|
|
220
221
|
reviewerEnabled: config.pipeline?.reviewer?.enabled !== false,
|
|
221
222
|
discoverEnabled: Boolean(config.pipeline?.discover?.enabled),
|
|
222
223
|
architectEnabled: Boolean(config.pipeline?.architect?.enabled),
|
|
@@ -225,7 +226,7 @@ export function resolvePipelineFlags(config) {
|
|
|
225
226
|
}
|
|
226
227
|
|
|
227
228
|
export async function handleDryRun({ task, config, flags, emitter, pipelineFlags }) {
|
|
228
|
-
const { plannerEnabled, refactorerEnabled, researcherEnabled, testerEnabled, securityEnabled, impeccableEnabled, reviewerEnabled, discoverEnabled, architectEnabled, huReviewerEnabled } = pipelineFlags;
|
|
229
|
+
const { plannerEnabled, refactorerEnabled, researcherEnabled, testerEnabled, securityEnabled, impeccableEnabled, perfEnabled, reviewerEnabled, discoverEnabled, architectEnabled, huReviewerEnabled } = pipelineFlags;
|
|
229
230
|
const plannerRole = resolveRole(config, "planner");
|
|
230
231
|
const coderRole = resolveRole(config, "coder");
|
|
231
232
|
const reviewerRole = resolveRole(config, "reviewer");
|
|
@@ -259,6 +260,7 @@ export async function handleDryRun({ task, config, flags, emitter, pipelineFlags
|
|
|
259
260
|
tester_enabled: testerEnabled,
|
|
260
261
|
security_enabled: securityEnabled,
|
|
261
262
|
impeccable_enabled: impeccableEnabled,
|
|
263
|
+
perf_enabled: perfEnabled,
|
|
262
264
|
solomon_enabled: Boolean(config.pipeline?.solomon?.enabled),
|
|
263
265
|
hu_reviewer_enabled: huReviewerEnabled
|
|
264
266
|
},
|