karajan-code 2.8.0 → 2.9.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 (47) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/audit/basal-cost.js +92 -9
  4. package/src/audit/deterministic-summary.js +200 -0
  5. package/src/audit/osv-findings.js +162 -0
  6. package/src/audit/semgrep-findings.js +117 -0
  7. package/src/audit/sonar-findings.js +81 -0
  8. package/src/audit/webperf-input.js +90 -0
  9. package/src/canvas/schema.js +1 -1
  10. package/src/checks/binaries.js +2 -2
  11. package/src/checks/ci.js +2 -2
  12. package/src/checks/config-files.js +6 -6
  13. package/src/checks/dir-setup.js +4 -0
  14. package/src/checks/mcp-health.js +6 -0
  15. package/src/checks/ports.js +8 -0
  16. package/src/checks/rtk.js +1 -1
  17. package/src/checks/skills.js +5 -1
  18. package/src/checks/sonar.js +1 -1
  19. package/src/checks/system.js +2 -2
  20. package/src/checks/tokens.js +1 -1
  21. package/src/checks/types.js +0 -12
  22. package/src/cli/register-meta.js +15 -1
  23. package/src/commands/audit.js +239 -28
  24. package/src/commands/doctor.js +1 -1
  25. package/src/config/test-harness.js +0 -1
  26. package/src/orchestrator/ci-integration.js +1 -1
  27. package/src/orchestrator/direct-actions.js +1 -1
  28. package/src/orchestrator/drivers/error-recovery.js +2 -2
  29. package/src/orchestrator/drivers/iteration-loop.js +3 -13
  30. package/src/orchestrator/drivers/pre-loop.js +1 -4
  31. package/src/orchestrator/feedback-queue.js +0 -1
  32. package/src/orchestrator/integrations.js +0 -7
  33. package/src/orchestrator/role-output-compressor.js +0 -1
  34. package/src/orchestrator/session-journal.js +4 -41
  35. package/src/orchestrator/stages/stage-classes.js +0 -2
  36. package/src/plan/plan-store.js +0 -14
  37. package/src/planning-game/client.js +0 -14
  38. package/src/planning-game/pipeline-adapter.js +1 -1
  39. package/src/prompts/audit.js +206 -6
  40. package/src/prompts/rtk-snippet.js +1 -1
  41. package/src/roles/audit-role.js +125 -13
  42. package/src/sonar/api.js +1 -1
  43. package/src/utils/display/event-handlers.js +1 -1
  44. package/src/utils/display/header.js +1 -1
  45. package/src/utils/display/session-summary.js +3 -3
  46. package/src/utils/display/solomon.js +1 -1
  47. package/src/utils/role-assigner.js +1 -1
@@ -1,4 +1,12 @@
1
1
  import { extractFirstJson } from "../utils/json-extract.js";
2
+ import { groupIssuesBySeverity } from "../audit/sonar-findings.js";
3
+ import { groupVulnerabilitiesBySeverity } from "../audit/osv-findings.js";
4
+ import { groupFindingsBySeverity as groupSemgrepBySeverity } from "../audit/semgrep-findings.js";
5
+ import { formatCwvVerdict } from "../audit/webperf-input.js";
6
+
7
+ const MAX_SONAR_ISSUES_IN_PROMPT = 50;
8
+ const MAX_OSV_VULNS_IN_PROMPT = 50;
9
+ const MAX_SEMGREP_FINDINGS_IN_PROMPT = 50;
2
10
 
3
11
  const SUBAGENT_PREAMBLE = [
4
12
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -7,14 +15,20 @@ const SUBAGENT_PREAMBLE = [
7
15
  "DO NOT modify any files — this is a read-only analysis. Only use: Read, Grep, Glob, Bash (for analysis commands like wc, find, git log, du, npm ls)."
8
16
  ].join(" ");
9
17
 
10
- export const AUDIT_DIMENSIONS = ["security", "codeQuality", "performance", "architecture", "testing"];
18
+ export const AUDIT_DIMENSIONS = ["security", "codeQuality", "performance", "architecture", "testing", "accessibility"];
19
+
20
+ // Dimensions that only apply to frontend / fullstack projects. Auto-dropped
21
+ // from the default dimension list when stack detection knows the project
22
+ // is backend-only — preserves activation when the user explicitly opts in
23
+ // via --dimensions=accessibility (KJC-TSK-0359).
24
+ const FRONTEND_ONLY_DIMENSIONS = new Set(["accessibility"]);
11
25
 
12
26
  const VALID_HEALTH = new Set(["good", "fair", "poor", "critical"]);
13
27
  const VALID_SCORES = new Set(["A", "B", "C", "D", "F"]);
14
28
  const VALID_SEVERITIES = new Set(["critical", "high", "medium", "low"]);
15
29
  const VALID_IMPACT = new Set(["high", "medium", "low"]);
16
30
 
17
- export function buildAuditPrompt({ task, instructions, dimensions = null, context = null, basalCost = null, growthDelta = null }) {
31
+ export function buildAuditPrompt({ task, instructions, dimensions = null, context = null, basalCost = null, growthDelta = null, stack = null, sonarFindings = null, webperf = null, osvFindings = null, semgrepFindings = null }) {
18
32
  const sections = [SUBAGENT_PREAMBLE];
19
33
 
20
34
  if (instructions) {
@@ -27,9 +41,46 @@ export function buildAuditPrompt({ task, instructions, dimensions = null, contex
27
41
  "DO NOT modify any files. This is a READ-ONLY analysis."
28
42
  );
29
43
 
30
- const activeDimensions = dimensions
31
- ? dimensions.filter(d => AUDIT_DIMENSIONS.includes(d))
32
- : AUDIT_DIMENSIONS;
44
+ // Stack hint — when detected, tells the LLM what kind of project this is
45
+ // so it can filter out irrelevant heuristics (e.g. don't flag bundle size
46
+ // in an Express-only API; don't flag N+1 queries in a static Astro site).
47
+ // Detected by src/utils/stack-detect.js — KJC-TSK-0358.
48
+ if (stack && (stack.frameworks?.length > 0 || stack.language || stack.isFrontend || stack.isBackend)) {
49
+ const stackLines = ["## Project Stack"];
50
+ if (stack.language) stackLines.push(`- Language: ${stack.language}`);
51
+ if (stack.frameworks?.length) stackLines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
52
+ const tier = stack.isFullstack
53
+ ? "fullstack (frontend + backend)"
54
+ : stack.isFrontend
55
+ ? "frontend-only"
56
+ : stack.isBackend
57
+ ? "backend-only"
58
+ : "unknown";
59
+ stackLines.push(`- Tier: ${tier}`);
60
+ if (stack.isFullstack) {
61
+ stackLines.push("Apply BOTH backend heuristics (queries, sync I/O, caching) AND frontend heuristics (bundle size, lazy loading, render-blocking) where each layer applies.");
62
+ } else if (stack.isFrontend) {
63
+ stackLines.push("This project is frontend-only. SKIP findings about N+1 queries, sync file I/O in request handlers, missing pagination on list endpoints, and other backend-specific patterns. Focus on bundle size, lazy loading, render-blocking resources, image optimisation, and accessibility hints.");
64
+ } else if (stack.isBackend) {
65
+ stackLines.push("This project is backend-only. SKIP findings about bundle size, lazy loading, render-blocking resources, image optimisation, and frontend accessibility. Focus on queries, sync I/O, pagination, caching, auth/authz, and dependency hygiene.");
66
+ }
67
+ sections.push(stackLines.join("\n"));
68
+ }
69
+
70
+ // When dimensions are explicit, honour them verbatim — even frontend-only
71
+ // dimensions on a backend project (the user knows what they want).
72
+ // When dimensions are absent, default to all but drop frontend-only
73
+ // dimensions if the stack is provably backend-only. Unknown stack falls
74
+ // back to the full list (no false negatives — better an extra section
75
+ // than a missing one).
76
+ let activeDimensions;
77
+ if (dimensions) {
78
+ activeDimensions = dimensions.filter(d => AUDIT_DIMENSIONS.includes(d));
79
+ } else if (stack && stack.isBackend && !stack.isFrontend) {
80
+ activeDimensions = AUDIT_DIMENSIONS.filter(d => !FRONTEND_ONLY_DIMENSIONS.has(d));
81
+ } else {
82
+ activeDimensions = AUDIT_DIMENSIONS;
83
+ }
33
84
 
34
85
  if (activeDimensions.includes("security")) {
35
86
  sections.push(
@@ -105,9 +156,32 @@ export function buildAuditPrompt({ task, instructions, dimensions = null, contex
105
156
  );
106
157
  }
107
158
 
159
+ if (activeDimensions.includes("accessibility")) {
160
+ sections.push(
161
+ "## Accessibility Analysis (WCAG 2.x)",
162
+ [
163
+ "- Missing alt text on <img> tags (decorative images need empty alt=\"\", not absent)",
164
+ "- Form inputs without associated <label> or aria-label",
165
+ "- Heading hierarchy violations (h1 → h3 skipping h2; multiple h1 per page)",
166
+ "- Buttons / links containing only icons without aria-label or visually-hidden text",
167
+ "- Interactive elements built from <div>/<span> without role + keyboard handlers",
168
+ "- ARIA roles misused (presentation on focusable elements, redundant on native semantics)",
169
+ "- Focus management gaps (modals without focus trap, no visible :focus styles, tabindex misuse)",
170
+ "- Colour-only signalling (status indicated by red/green only, no icon or text)",
171
+ "- Static contrast — flag suspicious low-contrast colour pairs in CSS variables / token files",
172
+ "- Missing lang attribute on <html>, missing skip-to-content link, missing landmarks"
173
+ ].join("\n"),
174
+ "Skip findings about colour contrast that require runtime rendering (e.g. computed styles); flag the suspect CSS values and recommend an axe-core/Lighthouse runtime check."
175
+ );
176
+ }
177
+
178
+ // JSON schema — keep the per-dimension entries in sync with AUDIT_DIMENSIONS.
179
+ const schemaDimensions = AUDIT_DIMENSIONS
180
+ .map(d => `"${d}":{"score":"A|B|C|D|F","findings":[]}`)
181
+ .join(",");
108
182
  sections.push(
109
183
  "Return a single valid JSON object and nothing else.",
110
- 'JSON schema: {"ok":true,"result":{"summary":{"overallHealth":"good|fair|poor|critical","totalFindings":number,"critical":number,"high":number,"medium":number,"low":number},"dimensions":{"security":{"score":"A|B|C|D|F","findings":[]},"codeQuality":{"score":"A|B|C|D|F","findings":[]},"performance":{"score":"A|B|C|D|F","findings":[]},"architecture":{"score":"A|B|C|D|F","findings":[]},"testing":{"score":"A|B|C|D|F","findings":[]}},"topRecommendations":[{"priority":number,"dimension":string,"action":string,"impact":"high|medium|low","effort":"high|medium|low"}]},"summary":string}',
184
+ `JSON schema: {"ok":true,"result":{"summary":{"overallHealth":"good|fair|poor|critical","totalFindings":number,"critical":number,"high":number,"medium":number,"low":number},"dimensions":{${schemaDimensions}},"topRecommendations":[{"priority":number,"dimension":string,"action":string,"impact":"high|medium|low","effort":"high|medium|low"}]},"summary":string}`,
111
185
  'Each finding: {"severity":"critical|high|medium|low","file":string,"line":number,"rule":string,"description":string,"recommendation":string}',
112
186
  `Only include dimensions you were asked to analyze: ${activeDimensions.join(", ")}`
113
187
  );
@@ -146,6 +220,132 @@ export function buildAuditPrompt({ task, instructions, dimensions = null, contex
146
220
  sections.push(lines.join("\n"));
147
221
  }
148
222
 
223
+ // Sonar findings — KJC-TSK-0361. Deterministic input from a tool the
224
+ // codebase already runs (kj run + SonarStage). The audit reads existing
225
+ // open issues + quality-gate state instead of re-scanning. Capped at
226
+ // MAX_SONAR_ISSUES_IN_PROMPT total entries (50) across all severities to
227
+ // bound prompt size; the LLM is told the truncated count so it can
228
+ // prioritise.
229
+ if (sonarFindings?.available && (sonarFindings.total > 0 || sonarFindings.qualityGate)) {
230
+ const lines = ["## SonarQube Findings"];
231
+ if (sonarFindings.qualityGate?.status) {
232
+ lines.push(`- Quality gate: ${sonarFindings.qualityGate.status}`);
233
+ }
234
+ if (sonarFindings.total > 0) {
235
+ lines.push(`- Total open issues: ${sonarFindings.total}`);
236
+ const groups = groupIssuesBySeverity(sonarFindings.issues);
237
+ let remaining = MAX_SONAR_ISSUES_IN_PROMPT;
238
+ for (const [severity, issues] of Object.entries(groups)) {
239
+ if (issues.length === 0) continue;
240
+ lines.push("");
241
+ lines.push(`### ${severity} (${issues.length})`);
242
+ const slice = issues.slice(0, Math.max(0, remaining));
243
+ for (const issue of slice) {
244
+ const loc = issue.component ? `${issue.component}${issue.line ? `:${issue.line}` : ""}` : "";
245
+ const rule = issue.rule ? ` [${issue.rule}]` : "";
246
+ lines.push(` - ${loc}${rule} ${issue.message || ""}`.trim());
247
+ }
248
+ if (issues.length > slice.length) {
249
+ lines.push(` - ... and ${issues.length - slice.length} more in ${severity}`);
250
+ }
251
+ remaining -= slice.length;
252
+ }
253
+ }
254
+ lines.push("");
255
+ lines.push("Cross-reference these with your own findings. When your finding overlaps a Sonar rule, mention the rule ID in the `rule` field — that's a high-confidence match. Findings the LLM raises that Sonar can't see (architecture, naming, API design) are still valuable; mark them with rule names like \"AUDIT-ARCH-N\".");
256
+ sections.push(lines.join("\n"));
257
+ }
258
+
259
+ // WebPerf — KJC-TSK-0360. Two modes: a real CWV measurement when one is
260
+ // surfaced via config.webperf.lastResult (future `kj webperf` command)
261
+ // OR static frontend-perf hints for the LLM to look at the source code
262
+ // when no live measurement is available. Backend-only projects skip the
263
+ // section entirely (collectWebPerfInput returns available:false).
264
+ if (webperf?.available) {
265
+ const lines = ["## WebPerf — Frontend Performance"];
266
+ if (webperf.mode === "cwv-result") {
267
+ lines.push("Live Core Web Vitals measurement available — incorporate the verdict below into the performance dimension findings:");
268
+ lines.push("");
269
+ lines.push(formatCwvVerdict(webperf));
270
+ lines.push("");
271
+ lines.push("If a metric is `poor`, recommend a concrete code change (preconnect, lazy-load, image format, critical CSS, defer script). Cite the offending file or pattern when you can find it in the source.");
272
+ } else {
273
+ lines.push("No live CWV measurement was provided. Audit the source for these static frontend-perf patterns and include findings under the `performance` dimension:");
274
+ lines.push("");
275
+ lines.push("- Render-blocking `<link rel=\"stylesheet\">` or `<script>` without `defer`/`async` in `<head>`");
276
+ lines.push("- Large JavaScript bundles imported eagerly that could be `await import(...)` or split by route");
277
+ lines.push("- Non-modern image formats (only .jpg/.png) where webp/avif are widely supported");
278
+ lines.push("- `<img>` without `width`/`height` attributes (causes CLS)");
279
+ lines.push("- `<img>` without `loading=\"lazy\"` for off-screen images, or videos auto-playing without `preload=\"metadata\"`");
280
+ lines.push("- Web fonts loaded without `font-display: swap` or without `<link rel=\"preconnect\">` to the font origin");
281
+ lines.push("- Whole-library imports for one helper (e.g. `import _ from 'lodash'` instead of `lodash/get`)");
282
+ lines.push("- Missing critical CSS extraction or above-the-fold inlining strategy in build config");
283
+ lines.push("- Large third-party scripts (analytics, A/B, chat widgets) without defer + facade pattern");
284
+ lines.push("- Unbounded list rendering without virtualisation for very long collections");
285
+ }
286
+ sections.push(lines.join("\n"));
287
+ }
288
+
289
+ // OSV vulnerabilities — KJC-TSK-0365. Deterministic CVE/GHSA findings
290
+ // from osv-scanner across the dependency manifest. Capped at
291
+ // MAX_OSV_VULNS_IN_PROMPT entries; the LLM is told the truncated count.
292
+ // Findings fold into the `security` dimension with the upstream
293
+ // identifier (CVE-/GHSA-) as the rule.
294
+ if (osvFindings?.available && (osvFindings.total > 0)) {
295
+ const lines = ["## OSV Vulnerabilities"];
296
+ lines.push(`- Total open vulnerabilities in dependencies: ${osvFindings.total}`);
297
+ const groups = groupVulnerabilitiesBySeverity(osvFindings.vulnerabilities);
298
+ let remaining = MAX_OSV_VULNS_IN_PROMPT;
299
+ for (const [severity, vulns] of Object.entries(groups)) {
300
+ if (vulns.length === 0) continue;
301
+ lines.push("");
302
+ lines.push(`### ${severity} (${vulns.length})`);
303
+ const slice = vulns.slice(0, Math.max(0, remaining));
304
+ for (const vuln of slice) {
305
+ const where = vuln.package ? `${vuln.package}@${vuln.version || "?"}` : "(unknown package)";
306
+ const fixed = vuln.fixed ? ` → fixed in ${vuln.fixed}` : "";
307
+ lines.push(` - ${vuln.id} ${where}${fixed}: ${vuln.summary || ""}`.trim());
308
+ }
309
+ if (vulns.length > slice.length) {
310
+ lines.push(` - ... and ${vulns.length - slice.length} more in ${severity}`);
311
+ }
312
+ remaining -= slice.length;
313
+ }
314
+ lines.push("");
315
+ lines.push("These CVE/GHSA findings are GROUND TRUTH — incorporate them into the `security` dimension. Use the OSV id (CVE-XXXX-XXXX or GHSA-xxxx-xxxx-xxxx) as the `rule` field. Recommend the fixed version when available.");
316
+ sections.push(lines.join("\n"));
317
+ }
318
+
319
+ // Semgrep SAST findings — KJC-TSK-0366. Static analysis catches what
320
+ // sonar/CVE-DBs miss: SQL/Cmd injection, XSS, hardcoded secrets,
321
+ // taint flow, language-specific anti-patterns. Capped at
322
+ // MAX_SEMGREP_FINDINGS_IN_PROMPT entries; LLM is told these are
323
+ // deterministic and asked to use rule IDs as-is in findings.
324
+ if (semgrepFindings?.available && semgrepFindings.total > 0) {
325
+ const lines = ["## Semgrep SAST Findings"];
326
+ lines.push(`- Total findings: ${semgrepFindings.total}`);
327
+ const groups = groupSemgrepBySeverity(semgrepFindings.findings);
328
+ let remaining = MAX_SEMGREP_FINDINGS_IN_PROMPT;
329
+ for (const [severity, findings] of Object.entries(groups)) {
330
+ if (findings.length === 0) continue;
331
+ lines.push("");
332
+ lines.push(`### ${severity} (${findings.length})`);
333
+ const slice = findings.slice(0, Math.max(0, remaining));
334
+ for (const f of slice) {
335
+ const loc = f.file ? `${f.file}${f.line ? `:${f.line}` : ""}` : "";
336
+ const cwe = f.cwe ? ` (${Array.isArray(f.cwe) ? f.cwe.join(", ") : f.cwe})` : "";
337
+ lines.push(` - ${loc} [${f.rule}]${cwe}: ${f.message || ""}`.trim());
338
+ }
339
+ if (findings.length > slice.length) {
340
+ lines.push(` - ... and ${findings.length - slice.length} more in ${severity}`);
341
+ }
342
+ remaining -= slice.length;
343
+ }
344
+ lines.push("");
345
+ lines.push("These SAST findings are GROUND TRUTH — fold ERROR-severity ones into the `security` dimension as critical/high findings. Use the semgrep rule id (e.g. javascript.express.security.audit.xss.direct-response-write) verbatim in the `rule` field; that lets the developer look it up in the semgrep registry.");
346
+ sections.push(lines.join("\n"));
347
+ }
348
+
149
349
  if (context) {
150
350
  sections.push(`## Context\n${context}`);
151
351
  }
@@ -2,7 +2,7 @@
2
2
  * RTK (Rust Token Killer) prompt instruction snippet.
3
3
  * Injected into coder/reviewer prompts when RTK is detected.
4
4
  */
5
- export const RTK_INSTRUCTIONS = [
5
+ const RTK_INSTRUCTIONS = [
6
6
  "## Token Optimization (RTK detected)",
7
7
  "RTK is installed. Prefix ALL Bash tool calls with `rtk` to reduce token usage:",
8
8
  "- Use `rtk git status` instead of `git status`",
@@ -1,6 +1,11 @@
1
1
  import { AgentRole } from "./agent-role.js";
2
2
  import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
3
3
  import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
4
+ import { detectProjectStack } from "../utils/stack-detect.js";
5
+ import { collectSonarFindings } from "../audit/sonar-findings.js";
6
+ import { collectWebPerfInput } from "../audit/webperf-input.js";
7
+ import { collectOsvFindings } from "../audit/osv-findings.js";
8
+ import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
4
9
 
5
10
  function parseDimensions(dimensionsStr) {
6
11
  if (!dimensionsStr || dimensionsStr === "all") return null;
@@ -26,37 +31,98 @@ export class AuditRole extends AgentRole {
26
31
  super({ ...opts, name: "audit" });
27
32
  }
28
33
 
29
- async execute(input) {
30
- const task = typeof input === "string" ? input : input?.task || this.context?.task || "";
31
- const onOutput = typeof input === "string" ? null : input?.onOutput || null;
32
- const rawDimensions = typeof input === "object" ? input?.dimensions || null : null;
33
- const context = typeof input === "object" ? input?.context || null : null;
34
- const dimensions = typeof rawDimensions === "string" ? parseDimensions(rawDimensions) : rawDimensions;
35
-
34
+ /**
35
+ * Run the deterministic side of an audit — basalCost, stack detection,
36
+ * Sonar issues, WebPerf input — without invoking the LLM. Cheap (no
37
+ * tokens, no API call). KJC-TSK-0364 splits the original execute() into
38
+ * this collector + executeWithDeterministic() so the CLI can show
39
+ * deterministic findings first and ask the user before paying for the
40
+ * LLM phase.
41
+ *
42
+ * @param {object|string} input - same shape execute() accepts
43
+ * @returns {Promise<object>} deterministic context (the same data
44
+ * execute() would gather internally)
45
+ */
46
+ async collectDeterministic(input) {
47
+ const noSonar = typeof input === "object" ? Boolean(input?.noSonar) : false;
48
+ const noOsv = typeof input === "object" ? Boolean(input?.noOsv) : false;
49
+ const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
36
50
  const projectDir = this.config?.projectDir || process.cwd();
37
51
  let basalCost = null;
38
52
  let growthDelta = null;
53
+ let stack = null;
54
+ let sonarFindings = null;
55
+ let webperf = null;
56
+ let osvFindings = null;
57
+ let semgrepFindings = null;
39
58
  try {
40
59
  basalCost = await measureBasalCost(projectDir);
41
60
  const previous = await loadPreviousAudit(projectDir);
42
61
  growthDelta = computeGrowthDelta(basalCost, previous);
43
62
  } catch { /* basal cost is best-effort */ }
63
+ try {
64
+ stack = await detectProjectStack(projectDir);
65
+ } catch { /* stack detect is best-effort */ }
66
+ if (!noSonar) {
67
+ try {
68
+ sonarFindings = await collectSonarFindings(this.config, this.logger);
69
+ } catch { /* sonar fetch is best-effort */ }
70
+ }
71
+ try {
72
+ webperf = collectWebPerfInput(stack, this.config);
73
+ } catch { /* webperf input is best-effort */ }
74
+ // OSV vulnerabilities — KJC-TSK-0365. Best-effort: missing
75
+ // osv-scanner binary returns available:false and the audit
76
+ // continues without the section.
77
+ if (!noOsv) {
78
+ try {
79
+ osvFindings = await collectOsvFindings(projectDir, this.logger);
80
+ } catch { /* osv-scanner is best-effort */ }
81
+ }
82
+ // Semgrep SAST — KJC-TSK-0366. Best-effort: missing semgrep binary
83
+ // returns available:false and the audit continues without the section.
84
+ if (!noSemgrep) {
85
+ try {
86
+ semgrepFindings = await collectSemgrepFindings(projectDir, this.logger);
87
+ } catch { /* semgrep is best-effort */ }
88
+ }
89
+ return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings };
90
+ }
91
+
92
+ /**
93
+ * Run the LLM phase of an audit using a pre-collected deterministic
94
+ * context. KJC-TSK-0364: separated from execute() so the CLI can run
95
+ * collectDeterministic() first, show findings, prompt, and only then
96
+ * call this if the user wants to spend tokens.
97
+ */
98
+ async executeWithDeterministic(input, deterministicCtx) {
99
+ const task = typeof input === "string" ? input : input?.task || this.context?.task || "";
100
+ const onOutput = typeof input === "string" ? null : input?.onOutput || null;
101
+ const rawDimensions = typeof input === "object" ? input?.dimensions || null : null;
102
+ const context = typeof input === "object" ? input?.context || null : null;
103
+ const dimensions = typeof rawDimensions === "string" ? parseDimensions(rawDimensions) : rawDimensions;
104
+
105
+ const { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings } = deterministicCtx;
44
106
 
45
107
  const provider = this.resolveProvider();
46
108
  const agent = this.createAgentInstance(provider);
47
- const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta });
109
+ const prompt = buildAuditPrompt({ task, instructions: this.instructions, dimensions, context, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings });
48
110
  const runArgs = { prompt, role: "audit" };
49
111
  if (onOutput) runArgs.onOutput = onOutput;
112
+ const startedAt = Date.now();
50
113
  const result = await agent.runTask(runArgs);
114
+ const durationMs = Date.now() - startedAt;
115
+
116
+ const usage = extractUsage(result, { provider, durationMs });
51
117
 
52
118
  if (!result.ok) {
53
- return { ok: false, result: { error: result.error || result.output || "Audit failed", provider }, summary: `Audit failed: ${result.error || "unknown error"}`, usage: result.usage };
119
+ return { ok: false, result: { error: result.error || result.output || "Audit failed", provider }, summary: `Audit failed: ${result.error || "unknown error"}`, usage };
54
120
  }
55
121
 
56
122
  try {
57
123
  const parsed = parseAuditOutput(result.output);
58
124
  if (!parsed) {
59
- return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage: result.usage };
125
+ return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage };
60
126
  }
61
127
  if (basalCost) { try { await saveAuditSnapshot(projectDir, basalCost); } catch { /* best-effort */ } }
62
128
 
@@ -65,13 +131,59 @@ export class AuditRole extends AgentRole {
65
131
  result: {
66
132
  summary: parsed.summary, dimensions: parsed.dimensions,
67
133
  topRecommendations: parsed.topRecommendations,
68
- basalCost: basalCost || undefined, growthDelta: growthDelta || undefined, provider
134
+ textSummary: parsed.textSummary || undefined,
135
+ basalCost: basalCost || undefined, growthDelta: growthDelta || undefined,
136
+ stack: stack || undefined,
137
+ sonarFindings: sonarFindings?.available ? sonarFindings : undefined,
138
+ webperf: webperf?.available ? webperf : undefined,
139
+ osvFindings: osvFindings?.available ? osvFindings : undefined,
140
+ semgrepFindings: semgrepFindings?.available ? semgrepFindings : undefined,
141
+ provider
69
142
  },
70
143
  summary: buildSummary(parsed),
71
- usage: result.usage
144
+ usage
72
145
  };
73
146
  } catch {
74
- return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage: result.usage };
147
+ return { ok: true, result: { raw: result.output, provider }, summary: "Audit complete (unstructured output)", usage };
75
148
  }
76
149
  }
150
+
151
+ /**
152
+ * Convenience: run both phases in one call. Identical to the previous
153
+ * (single-phase) execute() so MCP and pipeline callers don't change.
154
+ */
155
+ async execute(input) {
156
+ const deterministicCtx = await this.collectDeterministic(input);
157
+ return this.executeWithDeterministic(input, deterministicCtx);
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Consolidate the usage telemetry the agent spreads across its top-level
163
+ * result fields. Returns a single object so consumers (CLI formatter,
164
+ * report-file writer, MCP) don't need to know about the spread shape.
165
+ *
166
+ * @param {object} result - raw agent.runTask() return
167
+ * @param {{provider: string, durationMs: number}} ctx
168
+ * @returns {{available: boolean, provider: string, model?: string, tokens_in?: number, tokens_out?: number, total_tokens?: number, cost_usd?: number, durationMs: number, reason?: string}}
169
+ */
170
+ function extractUsage(result, { provider, durationMs }) {
171
+ if (!result || typeof result !== "object") {
172
+ return { available: false, provider, durationMs, reason: "no agent result" };
173
+ }
174
+ const tokens_in = Number.isFinite(result.tokens_in) ? result.tokens_in : null;
175
+ const tokens_out = Number.isFinite(result.tokens_out) ? result.tokens_out : null;
176
+ if (tokens_in === null && tokens_out === null) {
177
+ return { available: false, provider, model: result.model, durationMs, reason: `provider "${provider}" did not report token usage` };
178
+ }
179
+ return {
180
+ available: true,
181
+ provider,
182
+ model: result.model,
183
+ tokens_in: tokens_in ?? 0,
184
+ tokens_out: tokens_out ?? 0,
185
+ total_tokens: (tokens_in ?? 0) + (tokens_out ?? 0),
186
+ cost_usd: typeof result.cost_usd === "number" ? result.cost_usd : null,
187
+ durationMs,
188
+ };
77
189
  }
package/src/sonar/api.js CHANGED
@@ -3,7 +3,7 @@ import { withRetry } from "../utils/retry.js";
3
3
  import { resolveSonarProjectKey } from "./project-key.js";
4
4
  import { resolveSonarToken } from "./config-resolver.js";
5
5
 
6
- export class SonarApiError extends Error {
6
+ class SonarApiError extends Error {
7
7
  constructor(message, { url, httpStatus, hint } = {}) {
8
8
  super(message);
9
9
  this.name = "SonarApiError";
@@ -342,7 +342,7 @@ export const EVENT_HANDLERS = {
342
342
  };
343
343
 
344
344
  /** Event types suppressed in quiet mode (internal/noise events). */
345
- export const QUIET_SUPPRESSED = new Set([
345
+ const QUIET_SUPPRESSED = new Set([
346
346
  "agent:output",
347
347
  "agent:stall",
348
348
  "pipeline:simplify",
@@ -8,7 +8,7 @@ import { ANSI } from "./formatters.js";
8
8
  const DISPLAY_PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../package.json");
9
9
  const DISPLAY_VERSION = JSON.parse(readFileSync(DISPLAY_PKG_PATH, "utf8")).version;
10
10
 
11
- export const BAR = `${ANSI.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${ANSI.reset}`;
11
+ const BAR = `${ANSI.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${ANSI.reset}`;
12
12
 
13
13
  /**
14
14
  * Print task, configuration and pipeline header
@@ -15,7 +15,7 @@ export function printSessionStages(stages) {
15
15
  printSessionSonar(stages.sonar);
16
16
  }
17
17
 
18
- export function printSessionPlanner(planner) {
18
+ function printSessionPlanner(planner) {
19
19
  if (!planner?.title && !planner?.approach && !planner?.completedSteps?.length) return;
20
20
  const planParts = [];
21
21
  if (planner.title) planParts.push(planner.title);
@@ -26,7 +26,7 @@ export function printSessionPlanner(planner) {
26
26
  }
27
27
  }
28
28
 
29
- export function printSessionSonar(sonar) {
29
+ function printSessionSonar(sonar) {
30
30
  if (!sonar) return;
31
31
  const gateLabel = sonar.gateStatus === "OK" ? ANSI.green : ANSI.red;
32
32
  console.log(` ${ANSI.dim}\ud83d\udd0d Sonar: ${gateLabel}${sonar.gateStatus}${ANSI.reset}${ANSI.dim} (${sonar.openIssues ?? 0} issues)${ANSI.reset}`);
@@ -55,7 +55,7 @@ export function printSessionGit(git) {
55
55
  }
56
56
  }
57
57
 
58
- export function isBudgetUnavailable(budget) {
58
+ function isBudgetUnavailable(budget) {
59
59
  return budget.usage_available === false ||
60
60
  (budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0);
61
61
  }
@@ -1,6 +1,6 @@
1
1
  import { ANSI } from "./formatters.js";
2
2
 
3
- export const SOLOMON_RULING_HANDLERS = {
3
+ const SOLOMON_RULING_HANDLERS = {
4
4
  approve(detail, elapsed) {
5
5
  const dismissedCount = detail?.dismissed?.length || 0;
6
6
  const dismissedSuffix = dismissedCount > 0 ? ` (${dismissedCount} dismissed)` : "";
@@ -128,4 +128,4 @@ export function applyRoleAssignments(config, assignments) {
128
128
  return config;
129
129
  }
130
130
 
131
- export { CAPABILITY_TIERS, ROLE_PREFERENCES };
131
+ export { CAPABILITY_TIERS };