karajan-code 2.7.4 → 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 (192) hide show
  1. package/README.md +1 -1
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/audit/basal-cost.js +92 -9
  10. package/src/audit/deterministic-summary.js +200 -0
  11. package/src/audit/osv-findings.js +162 -0
  12. package/src/audit/semgrep-findings.js +117 -0
  13. package/src/audit/sonar-findings.js +81 -0
  14. package/src/audit/webperf-input.js +90 -0
  15. package/src/bootstrap.js +26 -1
  16. package/src/brain/decision-tracker.js +3 -1
  17. package/src/brain/decisor.js +1 -1
  18. package/src/canvas/parse-md.js +238 -0
  19. package/src/canvas/schema.js +101 -0
  20. package/src/canvas/to-flat.js +85 -0
  21. package/src/canvas/validate.js +163 -0
  22. package/src/checks/binaries.js +3 -3
  23. package/src/checks/ci.js +2 -2
  24. package/src/checks/config-files.js +6 -6
  25. package/src/checks/dir-setup.js +4 -0
  26. package/src/checks/mcp-health.js +6 -0
  27. package/src/checks/ports.js +91 -29
  28. package/src/checks/rtk.js +1 -1
  29. package/src/checks/skills.js +5 -1
  30. package/src/checks/sonar.js +2 -2
  31. package/src/checks/system.js +3 -3
  32. package/src/checks/tokens.js +1 -1
  33. package/src/checks/types.js +0 -12
  34. package/src/cli/_shared.js +30 -0
  35. package/src/cli/register-meta.js +218 -0
  36. package/src/cli/register-pipeline.js +212 -0
  37. package/src/cli/register-plan.js +152 -0
  38. package/src/cli/register-roles-skills.js +38 -0
  39. package/src/cli/register-sonar.js +28 -0
  40. package/src/cli.js +83 -468
  41. package/src/commands/agents.js +18 -6
  42. package/src/commands/architect.js +29 -2
  43. package/src/commands/audit.js +265 -30
  44. package/src/commands/board.js +160 -30
  45. package/src/commands/clean.js +83 -0
  46. package/src/commands/code.js +7 -2
  47. package/src/commands/discover.js +7 -2
  48. package/src/commands/doctor.js +1 -1
  49. package/src/commands/init.js +2 -2
  50. package/src/commands/plan/_shared.js +40 -0
  51. package/src/commands/plan/add-hu.js +21 -0
  52. package/src/commands/plan/delete.js +9 -0
  53. package/src/commands/plan/generate.js +284 -0
  54. package/src/commands/plan/index.js +14 -0
  55. package/src/commands/plan/list.js +21 -0
  56. package/src/commands/plan/ready.js +46 -0
  57. package/src/commands/plan/remove-hu.js +17 -0
  58. package/src/commands/plan/show.js +81 -0
  59. package/src/commands/plan/validate.js +20 -0
  60. package/src/commands/plan.js +14 -291
  61. package/src/commands/researcher.js +7 -2
  62. package/src/commands/resume.js +24 -1
  63. package/src/commands/review.js +7 -2
  64. package/src/commands/run.js +52 -1
  65. package/src/commands/status.js +1 -1
  66. package/src/commands/sync.js +75 -0
  67. package/src/commands/triage.js +7 -2
  68. package/src/commands/undo.js +1 -1
  69. package/src/config/defaults.js +213 -0
  70. package/src/config/loader.js +137 -0
  71. package/src/config/overrides.js +199 -0
  72. package/src/config/role-resolver.js +159 -0
  73. package/src/config/test-harness.js +78 -0
  74. package/src/config.js +30 -632
  75. package/src/git/automation.js +2 -2
  76. package/src/guards/output-guard.js +0 -2
  77. package/src/hu/acceptance-runner.js +52 -10
  78. package/src/hu/store.js +11 -0
  79. package/src/mcp/handlers/direct-handlers.js +2 -5
  80. package/src/mcp/handlers/management-handlers.js +48 -4
  81. package/src/mcp/handlers/run-handler.js +9 -8
  82. package/src/mcp/preflight.js +32 -13
  83. package/src/mcp/server-handlers.js +4 -3
  84. package/src/mcp/suggest-handler.js +3 -2
  85. package/src/mcp/tools.js +15 -0
  86. package/src/orchestrator/agent-fallback.js +1 -1
  87. package/src/orchestrator/ci-integration.js +11 -11
  88. package/src/orchestrator/config-init.js +25 -7
  89. package/src/orchestrator/direct-actions.js +7 -3
  90. package/src/orchestrator/drivers/error-recovery.js +287 -0
  91. package/src/orchestrator/drivers/init-context.js +222 -0
  92. package/src/orchestrator/drivers/iteration-loop.js +301 -0
  93. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  94. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  95. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  96. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  97. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  98. package/src/orchestrator/drivers/post-loop.js +370 -0
  99. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  100. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  101. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  102. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  103. package/src/orchestrator/drivers/pre-loop.js +431 -0
  104. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  105. package/src/orchestrator/feedback-queue.js +0 -1
  106. package/src/orchestrator/flow-control.js +4 -3
  107. package/src/orchestrator/flow-runner.js +121 -2077
  108. package/src/orchestrator/fs-leak-detector.js +156 -0
  109. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  110. package/src/orchestrator/integrations.js +81 -0
  111. package/src/orchestrator/post-loop-stages.js +35 -8
  112. package/src/orchestrator/preflight-checks.js +20 -7
  113. package/src/orchestrator/repair/repair-detector.js +117 -0
  114. package/src/orchestrator/repair/repair-runner.js +142 -0
  115. package/src/orchestrator/reviewer-fallback.js +1 -1
  116. package/src/orchestrator/role-output-compressor.js +0 -1
  117. package/src/orchestrator/session-journal.js +4 -41
  118. package/src/orchestrator/solomon-escalation.js +3 -2
  119. package/src/orchestrator/solomon-rules.js +7 -3
  120. package/src/orchestrator/stages/architect-stage.js +12 -11
  121. package/src/orchestrator/stages/coder-stage.js +56 -7
  122. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  123. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  124. package/src/orchestrator/stages/planner-stage.js +1 -1
  125. package/src/orchestrator/stages/research-stage.js +1 -1
  126. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  127. package/src/orchestrator/stages/sonar-stage.js +94 -10
  128. package/src/orchestrator/stages/stage-classes.js +84 -0
  129. package/src/orchestrator/stages/stage-executor.js +1 -1
  130. package/src/orchestrator/stages/triage-stage.js +1 -1
  131. package/src/orchestrator/standby.js +3 -2
  132. package/src/orchestrator/verification-gate.js +20 -12
  133. package/src/plan/adr-generator.js +170 -0
  134. package/src/plan/adr-loader.js +60 -0
  135. package/src/plan/plan-executor.js +19 -1
  136. package/src/plan/plan-hu-ops.js +170 -1
  137. package/src/plan/plan-reviewer.js +199 -0
  138. package/src/plan/plan-schema.js +69 -1
  139. package/src/plan/plan-store.js +40 -17
  140. package/src/plan/spec-detector.js +61 -0
  141. package/src/plan/tests-synthesizer.js +116 -0
  142. package/src/planning-game/client.js +3 -28
  143. package/src/planning-game/decomposition.js +1 -1
  144. package/src/planning-game/pipeline-adapter.js +37 -6
  145. package/src/prompts/audit.js +206 -6
  146. package/src/prompts/coder.js +172 -1
  147. package/src/prompts/hu-reviewer.js +0 -1
  148. package/src/prompts/planner.js +81 -4
  149. package/src/prompts/rtk-snippet.js +1 -1
  150. package/src/review/diff-generator.js +35 -22
  151. package/src/review/snapshot-diff.js +1 -1
  152. package/src/roles/audit-role.js +125 -13
  153. package/src/roles/coder-role.js +25 -3
  154. package/src/roles/planner-role.js +42 -1
  155. package/src/roles/repairer-role.js +181 -0
  156. package/src/roles/tester-role.js +70 -4
  157. package/src/session/mutators.js +304 -0
  158. package/src/session/runtime-overrides.js +59 -0
  159. package/src/{session-store.js → session/store.js} +2 -2
  160. package/src/skills/semantic-detector.js +10 -5
  161. package/src/skills/skill-detector.js +1 -1
  162. package/src/sonar/api.js +1 -1
  163. package/src/sonar/cloud-scanner.js +0 -1
  164. package/src/sonar/credentials.js +14 -6
  165. package/src/sonar/discovery.js +123 -0
  166. package/src/sonar/manager.js +18 -0
  167. package/src/sync/detect-drift.js +113 -0
  168. package/src/tracker-bootstrap.js +44 -0
  169. package/src/utils/agent-detect.js +15 -11
  170. package/src/utils/board-prompt-bridge.js +109 -0
  171. package/src/utils/cli-progress.js +161 -0
  172. package/src/utils/cwd-confirm.js +39 -0
  173. package/src/utils/derive-project-name-from-cwd.js +119 -0
  174. package/src/utils/display/event-handlers.js +1 -1
  175. package/src/utils/display/header.js +1 -1
  176. package/src/utils/display/session-summary.js +3 -3
  177. package/src/utils/display/solomon.js +1 -1
  178. package/src/utils/garbage-collector.js +396 -0
  179. package/src/utils/git.js +16 -4
  180. package/src/utils/locale.js +1 -1
  181. package/src/utils/paths.js +21 -1
  182. package/src/utils/process.js +1 -1
  183. package/src/utils/prompt-project-name.js +35 -0
  184. package/src/utils/role-assigner.js +1 -1
  185. package/src/utils/run-context.js +75 -0
  186. package/src/utils/run-log.js +12 -0
  187. package/src/utils/task-file.js +1 -1
  188. package/src/utils/telemetry.js +13 -1
  189. package/src/webperf/devtools-detect.js +1 -1
  190. package/templates/coder-rules.md +13 -0
  191. package/templates/roles/coder.md +34 -0
  192. package/templates/roles/repairer.md +94 -0
@@ -2,6 +2,7 @@ import { createAgent } from "../agents/index.js";
2
2
  import { assertAgentsAvailable } from "../agents/availability.js";
3
3
  import { resolveRole } from "../config.js";
4
4
  import { buildArchitectPrompt, parseArchitectOutput } from "../prompts/architect.js";
5
+ import { createCliProgressReporter } from "../utils/cli-progress.js";
5
6
 
6
7
  function formatLayers(layers, lines) {
7
8
  lines.push("### Layers");
@@ -67,8 +68,12 @@ export async function architectCommand({ task, config, logger, context, json })
67
68
 
68
69
  const agent = createAgent(architectRole.provider, config, logger);
69
70
  const prompt = await buildArchitectPrompt({ task, researchContext: context });
70
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
71
- const result = await agent.runTask({ prompt, onOutput, role: "architect" });
71
+ const progress = createCliProgressReporter({ role: "architect" });
72
+ let result;
73
+ try {
74
+ result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "architect" });
75
+ progress.finish(result.ok ? "done" : "failed");
76
+ } catch (err) { progress.finish("failed"); throw err; }
72
77
 
73
78
  if (!result.ok) {
74
79
  throw new Error(result.error || result.output || "Architect failed");
@@ -86,6 +91,28 @@ export async function architectCommand({ task, config, logger, context, json })
86
91
  } else {
87
92
  console.log(result.output);
88
93
  }
94
+
95
+ // Persist ADRs from the architect's tradeoffs[] (v2.7.5). One ADR
96
+ // per tradeoff in Nygard format under `~/.kj/plans/_loose/adrs/`.
97
+ // Coder/reviewer in subsequent runs can pull the active ADRs into
98
+ // their prompt for context — done in a follow-up PR.
99
+ if (parsed?.architecture && config?.projectDir) {
100
+ try {
101
+ const { persistAdrsFromArchitecture } = await import("../plan/adr-generator.js");
102
+ const { adrs, paths } = await persistAdrsFromArchitecture({
103
+ projectDir: config.projectDir,
104
+ planId: null, // standalone architect → "_loose" bucket
105
+ architecture: parsed.architecture,
106
+ });
107
+ if (paths.length > 0) {
108
+ console.log(`\n${adrs.length} ADR${adrs.length === 1 ? "" : "s"} written:`);
109
+ for (const p of paths) console.log(` - ${p}`);
110
+ }
111
+ } catch (err) {
112
+ logger.warn(`ADR write skipped: ${err.message}`);
113
+ }
114
+ }
115
+
89
116
  logger.info("Architect completed.");
90
117
  return { ok: true };
91
118
  });
@@ -1,8 +1,36 @@
1
- import { createAgent } from "../agents/index.js";
1
+ import path from "node:path";
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";
2
6
  import { assertAgentsAvailable } from "../agents/availability.js";
3
7
  import { resolveRole } from "../config.js";
4
- import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
8
+ import { AUDIT_DIMENSIONS } from "../prompts/audit.js";
9
+ import { AuditRole } from "../roles/audit-role.js";
5
10
  import { withCliRunLog } from "../utils/cli-run-log.js";
11
+ import { createCliProgressReporter } from "../utils/cli-progress.js";
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
+ }
6
34
 
7
35
  function formatFindings(findings) {
8
36
  const lines = [];
@@ -42,10 +70,11 @@ const DIMENSION_LABELS = {
42
70
  codeQuality: "Code Quality",
43
71
  performance: "Performance",
44
72
  architecture: "Architecture",
45
- testing: "Testing"
73
+ testing: "Testing",
74
+ accessibility: "Accessibility (WCAG 2.x)"
46
75
  };
47
76
 
48
- function formatAudit(parsed) {
77
+ function formatAudit(parsed, usage = null) {
49
78
  const lines = [];
50
79
  lines.push("## Codebase Health Report");
51
80
  lines.push(`**Overall Health:** ${parsed.summary.overallHealth}`);
@@ -63,45 +92,251 @@ function formatAudit(parsed) {
63
92
  }
64
93
 
65
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
+ }
66
133
  return lines.join("\n");
67
134
  }
68
135
 
69
- export async function auditCommand({ task, config, logger, dimensions, json }) {
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 }) {
208
+ // --agent-readiness is a STANDALONE, deterministic, LLM-free audit
209
+ // dimension. It scores any third-party repo for AI-agent readability
210
+ // (llms.txt presence, page token budgets, robots allowlist, etc.).
211
+ // Per addyosmani/agentic-seo. Issue #542.
212
+ if (agentReadiness) {
213
+ const rootDir = path.resolve(pathArg || config?.projectDir || process.cwd());
214
+ logger.info(`Auditing agent-readiness of ${rootDir}`);
215
+ const report = runAgentReadiness(rootDir);
216
+ if (json) {
217
+ console.log(JSON.stringify(report, null, 2));
218
+ } else {
219
+ console.log(formatAgentReadinessReport(report, rootDir));
220
+ }
221
+ // Exit code: 0 always (audit succeeded). The score is the signal.
222
+ return;
223
+ }
224
+
70
225
  return withCliRunLog("audit", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
71
- const auditRole = resolveRole(config, "audit");
72
- await assertAgentsAvailable([auditRole.provider]);
73
- logger.info(`Audit (${auditRole.provider}) starting...`);
74
- runLog.logText(`[audit] provider=${auditRole.provider} dimensions=${dimensions || "all"}`);
75
-
76
- const agent = createAgent(auditRole.provider, config, logger);
77
- const dimList = dimensions && dimensions !== "all"
78
- ? dimensions.split(",").map(d => d.trim().toLowerCase()).map(d => d === "quality" ? "codeQuality" : d).filter(d => AUDIT_DIMENSIONS.includes(d))
79
- : null;
80
-
81
- const prompt = buildAuditPrompt({ task: task || "Analyze the full codebase", dimensions: dimList });
82
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
83
- const result = await agent.runTask({ prompt, onOutput, role: "audit" });
84
-
85
- if (!result.ok) {
86
- throw new Error(result.error || result.output || "Audit failed");
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}`);
230
+
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);
245
+
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).");
87
257
  }
88
258
 
89
- const parsed = parseAuditOutput(result.output);
90
- if (parsed?.summary) {
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.
284
+ const progress = createCliProgressReporter({ role: "auditor" });
285
+ let roleResult;
286
+ try {
287
+ roleResult = await role.executeWithDeterministic({ ...roleInput, onOutput: progress.onOutput }, deterministicCtx);
288
+ progress.finish(roleResult.ok ? "done" : "failed");
289
+ } catch (err) { progress.finish("failed"); throw err; }
290
+
291
+ if (!roleResult.ok) {
292
+ const err = roleResult.result?.error || "Audit failed";
293
+ throw new Error(err);
294
+ }
295
+
296
+ const parsed = roleResult.result;
297
+ const usage = roleResult.usage;
298
+ if (parsed?.summary?.overallHealth) {
91
299
  runLog.logText(`[audit] findings=${parsed.summary.totalFindings} (critical=${parsed.summary.critical}, high=${parsed.summary.high})`);
92
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
+ }
93
304
 
305
+ let stdoutContent;
94
306
  if (json) {
95
- console.log(JSON.stringify(parsed || result.output, null, 2));
96
- return { ok: true };
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.";
97
317
  }
318
+ console.log(stdoutContent);
98
319
 
99
- if (parsed?.summary) {
100
- console.log(formatAudit(parsed));
101
- } else {
102
- console.log(result.output);
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}`);
103
334
  }
335
+
104
336
  logger.info("Audit completed.");
105
- return { ok: true };
337
+ return { ok: true, reportPath: reportPath || undefined };
106
338
  });
107
339
  }
340
+
341
+ // Exposed for tests — kept module-private otherwise.
342
+ export { formatAudit, formatUsageSummary, AUDIT_DIMENSIONS };
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
+ import http from "node:http";
2
3
  import path from "node:path";
3
4
  import net from "node:net";
4
5
  import { spawn } from "node:child_process";
5
6
  import { getKarajanHome } from "../utils/paths.js";
7
+ import { getPortOccupant } from "../utils/port-occupant.js";
6
8
 
7
9
  const BOARD_DIR = path.resolve(import.meta.dirname, "../../packages/hu-board");
8
10
  const PID_FILE = path.join(getKarajanHome(), "hu-board.pid");
@@ -25,6 +27,27 @@ function isProcessAlive(pid) {
25
27
  }
26
28
  }
27
29
 
30
+ /**
31
+ * HTTP-probe the board's /api/dashboard. Returns true when it
32
+ * responds with 2xx within ~750ms. Used as a fallback when the PID
33
+ * file is missing/stale to detect "board is up, just untracked".
34
+ */
35
+ async function isBoardReachable(port, timeoutMs = 750) {
36
+ return new Promise((resolve) => {
37
+ const req = http.request(
38
+ { host: '127.0.0.1', port, path: '/api/dashboard', method: 'GET', timeout: timeoutMs },
39
+ (res) => {
40
+ const ok = res.statusCode >= 200 && res.statusCode < 500;
41
+ res.resume(); // drain
42
+ resolve(ok);
43
+ }
44
+ );
45
+ req.on('error', () => resolve(false));
46
+ req.on('timeout', () => { req.destroy(); resolve(false); });
47
+ req.end();
48
+ });
49
+ }
50
+
28
51
  /**
29
52
  * Check if a TCP port is available on localhost.
30
53
  * Returns true when we can successfully bind (port is free).
@@ -52,11 +75,45 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
52
75
  return null;
53
76
  }
54
77
 
55
- export async function startBoard(desiredPort = 4000) {
78
+ /**
79
+ * Build the board URL. When `projectSlug` is provided, the URL points at
80
+ * the per-project view (`/p/<slug>`) so the caller's project shows up
81
+ * pre-filtered instead of "All projects".
82
+ * @param {number} port
83
+ * @param {string|null} [projectSlug]
84
+ * @returns {string}
85
+ */
86
+ function buildBoardUrl(port, projectSlug) {
87
+ const base = `http://localhost:${port}`;
88
+ return projectSlug ? `${base}/p/${projectSlug}` : base;
89
+ }
90
+
91
+ export async function startBoard(desiredPort = 4000, opts = {}) {
92
+ const { projectSlug = null } = opts;
56
93
  const existingPid = readPid();
57
94
  if (existingPid && isProcessAlive(existingPid)) {
58
95
  // Trust the saved PID — port info comes from the PID file if present
59
- return { ok: true, alreadyRunning: true, pid: existingPid, url: `http://localhost:${desiredPort}` };
96
+ return {
97
+ ok: true,
98
+ alreadyRunning: true,
99
+ pid: existingPid,
100
+ url: buildBoardUrl(desiredPort, projectSlug),
101
+ };
102
+ }
103
+
104
+ // Belt-and-suspenders (v2.7.5 fix): even if the PID file is missing
105
+ // or stale, HTTP-probe the configured port before spawning. If
106
+ // /api/dashboard responds, a board is already there — reuse it. The
107
+ // pre-fix scenario: 🔁 self-restart didn't update the PID file
108
+ // (now fixed), so a `kj plan` afterwards saw a stale PID, spawned
109
+ // a duplicate board on port 4001, and confused the user.
110
+ if (await isBoardReachable(desiredPort)) {
111
+ return {
112
+ ok: true,
113
+ alreadyRunning: true,
114
+ pid: existingPid || null,
115
+ url: buildBoardUrl(desiredPort, projectSlug),
116
+ };
60
117
  }
61
118
 
62
119
  // Find a free port starting from desiredPort
@@ -79,34 +136,85 @@ export async function startBoard(desiredPort = 4000) {
79
136
  });
80
137
  // Swallow spawn errors so an HU Board failure never crashes the pipeline.
81
138
  child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
82
- child.unref();
83
-
84
139
  if (!child.pid) {
85
140
  throw new Error("Failed to spawn HU Board server");
86
141
  }
87
142
  fs.writeFileSync(PID_FILE, String(child.pid));
88
- return { ok: true, alreadyRunning: false, pid: child.pid, url: `http://localhost:${port}`, port };
143
+ // Detach for real: `detached: true` only sets up the new session;
144
+ // without `unref()` the parent's event loop still holds a reference
145
+ // to the child handle and `kj board start` hangs at the prompt
146
+ // instead of returning. Pair this with stdio:"ignore" (already set)
147
+ // to ensure the parent isn't waiting on any of the child's pipes.
148
+ child.unref();
149
+ return { ok: true, alreadyRunning: false, pid: child.pid, url: buildBoardUrl(port, projectSlug), port };
89
150
  }
90
151
 
91
- export async function stopBoard() {
152
+ export async function stopBoard(opts = {}) {
153
+ const { port = 4000 } = opts;
154
+ // Path 1: live PID in the file → straight SIGTERM. Pre-v2.7.5
155
+ // this was the only path; if the file was missing or stale (e.g.
156
+ // user ran a board built before the server-writes-own-PID fix in
157
+ // PR #508) we'd return "wasn't running" while the ghost server
158
+ // happily kept serving requests. The fallbacks below close that gap.
92
159
  const pid = readPid();
93
- if (!pid || !isProcessAlive(pid)) {
94
- try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
95
- return { ok: true, wasRunning: false };
160
+ if (pid && isProcessAlive(pid)) {
161
+ try { process.kill(pid, "SIGTERM"); } catch { /* race with dying process */ }
162
+ fs.rmSync(PID_FILE, { force: true });
163
+ return { ok: true, wasRunning: true, pid, via: "pidfile" };
96
164
  }
165
+ fs.rmSync(PID_FILE, { force: true });
97
166
 
98
- try {
99
- process.kill(pid, "SIGTERM");
100
- } catch { /* process already dead */
101
- // already dead
167
+ // Path 2: HTTP-ask the server to exit cleanly. Works for any
168
+ // running server that has the /api/board/shutdown endpoint —
169
+ // even when there's no PID file, no process tree to walk, etc.
170
+ const shutdownUrl = `/api/board/shutdown`;
171
+ const shutdownOk = await postBoardShutdown(port, shutdownUrl);
172
+ if (shutdownOk) {
173
+ return { ok: true, wasRunning: true, pid: null, via: "api" };
102
174
  }
103
- try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
104
- return { ok: true, wasRunning: true, pid };
175
+
176
+ // Path 3: there's something on the port and the API didn't take it
177
+ // down. Find its PID via lsof / ss / netstat and SIGTERM it.
178
+ // Works against truly old servers that don't have the shutdown
179
+ // endpoint, with the cost of one extra subprocess call.
180
+ try {
181
+ const occupant = await getPortOccupant(port);
182
+ if (occupant?.pid) {
183
+ try { process.kill(occupant.pid, "SIGTERM"); } catch { /* gone */ }
184
+ return { ok: true, wasRunning: true, pid: occupant.pid, via: "port-occupant" };
185
+ }
186
+ } catch { /* lsof not installed etc — fall through */ }
187
+
188
+ return { ok: true, wasRunning: false };
189
+ }
190
+
191
+ /**
192
+ * Best-effort POST to the board's shutdown endpoint. Returns true
193
+ * when the server acks (and is presumably exiting); false on any
194
+ * error so the caller falls through to the next strategy.
195
+ */
196
+ function postBoardShutdown(port, urlPath, timeoutMs = 750) {
197
+ return new Promise((resolve) => {
198
+ const req = http.request(
199
+ { host: '127.0.0.1', port, path: urlPath, method: 'POST', timeout: timeoutMs,
200
+ headers: { 'Content-Type': 'application/json', 'Content-Length': '2' } },
201
+ (res) => {
202
+ const ok = res.statusCode >= 200 && res.statusCode < 400;
203
+ res.resume();
204
+ resolve(ok);
205
+ }
206
+ );
207
+ req.on('error', () => resolve(false));
208
+ req.on('timeout', () => { req.destroy(); resolve(false); });
209
+ req.write('{}');
210
+ req.end();
211
+ });
105
212
  }
106
213
 
107
214
  export async function boardStatus(port = 4000) {
108
215
  const pid = readPid();
109
216
  const running = pid !== null && isProcessAlive(pid);
217
+
110
218
  return {
111
219
  ok: true,
112
220
  running,
@@ -115,27 +223,46 @@ export async function boardStatus(port = 4000) {
115
223
  };
116
224
  }
117
225
 
226
+ // Strip ANSI escapes so we can measure the actual on-screen width of a
227
+ // styled string. Used to size the rules around the banner content.
228
+ const ANSI_RE = /\[[0-9;]*m/g;
229
+ const visibleWidth = (s) => s.replace(ANSI_RE, "").length;
230
+
118
231
  /**
119
- * Render a highlighted cyan box with the HU Board URL + optional project name.
120
- * Centralizes the banner used by both the init-time auto-start (flow-runner.js)
121
- * and the post-planner auto-HU generation safety net so they stay in sync.
232
+ * Render a HU Board announcement framed by two horizontal rules of EQUAL
233
+ * length. Pre-v2.7.5 we used a fixed 50-char rounded box drawn with
234
+ * corners + verticals — fine when the URL fit, but a project slug
235
+ * pushed past the right wall and the box looked broken. The user's
236
+ * verdict: "looks worse than no box at all when it overflows".
237
+ *
238
+ * New rule: just an over-and-under rule sized to the longest visible
239
+ * content line. No left/right verticals, no fixed width. Always tidy.
122
240
  *
123
241
  * @param {Object} params
124
242
  * @param {string} params.url - "http://localhost:4000"
125
243
  * @param {string} params.status - "started" | "already running"
126
244
  * @param {string} [params.projectName] - optional project label
127
- * @returns {string} ANSI-escaped box ready for console.log
245
+ * @returns {string} ANSI-coloured banner ready for console.log
128
246
  */
129
247
  export function renderBoardBanner({ url, status, projectName }) {
130
- const title = projectName ? ` Project: \u001b[1m${projectName}\u001b[0m` : null;
131
- const lines = [
132
- "",
133
- "\u001b[36m\u256d\u2500 HU Board \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e",
134
- `\u001b[36m\u2502\u001b[0m \u001b[1m${status}\u001b[0m \u001b[4m${url}\u001b[0m`,
135
- ];
136
- if (title) lines.push(`\u001b[36m\u2502\u001b[0m${title}`);
137
- lines.push("\u001b[36m\u2570\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m", "");
138
- return lines.join("\n");
248
+ const cyan = (s) => `${s}`;
249
+ const bold = (s) => `${s}`;
250
+ const underline = (s) => `${s}`;
251
+
252
+ const heading = ` ${bold("HU Board")} · ${status} → ${underline(url)}`;
253
+ const content = [heading];
254
+ if (projectName) content.push(` Project: ${bold(projectName)}`);
255
+
256
+ // Width = widest visible content line + 2-cell padding. Cap at the
257
+ // terminal width so a silly-long URL doesn't draw a 200-char rule.
258
+ const termWidth = (process.stdout.columns && process.stdout.columns > 20)
259
+ ? process.stdout.columns
260
+ : 100;
261
+ const longest = Math.max(...content.map(visibleWidth));
262
+ const ruleWidth = Math.min(longest + 2, termWidth);
263
+ const rule = cyan("─".repeat(ruleWidth));
264
+
265
+ return ["", rule, ...content, rule, ""].join("\n");
139
266
  }
140
267
 
141
268
  export async function boardCommand({ action = "start", port = 4000, logger }) {
@@ -150,9 +277,11 @@ export async function boardCommand({ action = "start", port = 4000, logger }) {
150
277
  return result;
151
278
  }
152
279
  case "stop": {
153
- const result = await stopBoard();
280
+ const result = await stopBoard({ port });
154
281
  if (result.wasRunning) {
155
- logger.info(`HU Board stopped (PID ${result.pid})`);
282
+ const pidPart = result.pid ? ` (PID ${result.pid})` : "";
283
+ const viaPart = result.via && result.via !== "pidfile" ? ` [via ${result.via}]` : "";
284
+ logger.info(`HU Board stopped${pidPart}${viaPart}`);
156
285
  } else {
157
286
  logger.info("HU Board was not running");
158
287
  }
@@ -174,6 +303,7 @@ export async function boardCommand({ action = "start", port = 4000, logger }) {
174
303
  await startBoard(port);
175
304
  }
176
305
  const url = `http://localhost:${port}`;
306
+ // eslint-disable-next-line import-x/no-unresolved -- optional peer; the .catch handles its absence
177
307
  const { default: open } = await import("open").catch(() => ({ default: null }));
178
308
  if (open) {
179
309
  await open(url);