karajan-code 2.17.0 → 2.18.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 CHANGED
@@ -23,7 +23,13 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.17.0 released** — Minor. `kj audit` gains two new deterministic structural collectors and the v2.16 Sonar false-positive filter is generalised to apply across every collector. **Knip dead-exports collector** (codeQuality dim): reports unused exports/types (MINOR) and unused files (MAJOR), stack-aware (JS/TS only, needs package.json), subprocess via `--reporter json`, 120s timeout. **Madge circular-import collector** (architecture dim): detects import cycles, severity by chain length (≥4 files = MAJOR), honours tsconfig.json path aliases, 60s timeout. **Generalised FP filter**: every collector (sonar, knip, madge, osv, semgrep) now uses the same `config.audit.false_positives` shape with `tool` field plus inline marker `// karajan-audit-ignore: <tool>:<ruleId>`; legacy `config.sonar.false_positives` and `// karajan-sonar-ignore:` keep working. **Built-in catalogue** ships 4 entries by default (knip:unused-files in tests/fixtures + examples, knip:unused-exports on barrel files, madge cycles in node_modules). **BREAKING(engines)**: Node `>=20.10` → `>=20.19` (knip 6.x requirement; same flavour as the v2.8 18→20.10 bump). 4 872/4 872 tests passing across 402 files. Safe upgrade from 2.16.0 if you're on Node ≥ 20.19.
26
+ > **v2.18.0 released** — Minor. Closes the **resilience audit** triggered by the public launch: 15 PRs across 5 phases hardening Karajan against the silent-failure family of bugs — *the problem is not that something fails, the problem is failing without telling the user why.* **Phase 1 — hibernation end to end** (#756–#759): a quota cap is classified as a recoverable quota class (incl. Claude Code's `"You've hit your session limit · resets 10:10pm"`); `withBrainRecovery` persists a standby JSON; the orchestrator consumes `action:"hibernate"`, seals the session `hibernated` (resumable) instead of `failed`; the last line printed is the exact resume command (`kj standby resume <id>`). **Phase 2 — don't lie** (#761–#763): `runCommand` surfaces ENOENT so a missing agent CLI gets an actionable error (not an empty one); `silenceTimeoutMs` is forwarded to every role so a hung agent is killed, not waited on forever; all 6 state-file writers go through `writeJsonAtomic` (write-temp + rename) — interrupted writes can no longer truncate plans, sessions, standby snapshots. **Phase 3 — don't lose or block** (#764–#767): a corrupt plan JSON is renamed aside with a loud warn (was silently dropped from `kj plan list`); `kj.config.yml` parse errors throw `Invalid YAML in <path>` (used to brick every kj command including `kj doctor`); `injectLoadedPlan` reconciles HUs left in `coding`/`reviewing`/`running` by a killed `kj run` (cross-checked against the run-registry); board SQLite gets `busy_timeout`, a `user_version` schema gate and corruption recovery. **Phase 4 — don't degrade silently** (#768–#769): `TriageRole` warns loudly when LLM output is unparseable (used to skip researcher/architect/security/tester with a chirpy "fallback defaults" summary); `verifyCoderOutput` distinguishes a git failure from "the coder did nothing" (`gitError` + `retryStrategy: null`) — no more iterations blaming the agent for infra. **Phase 5 — safety net** (#770): a `tests/resilience/` suite indexes every silent-failure mode and pins each one with a test, plus an end-to-end tripwire for the whole quota → hibernate → resume flow. Plus CI runs the `packages/hu-board` suite on every PR (#755). 4 959/4 959 tests passing across 416 files. Safe upgrade from 2.17.2.
27
+ >
28
+ > **v2.17.2** — Patch. Wires quota-exhaustion **hibernation end to end**. A `kj run` / `kj plan` that hits a provider session or usage cap used to abort the task with an opaque `UNKNOWN_FATAL`; now it suspends and tells you how to resume. **Session-limit classification (#756)**: `"You've hit your session limit · resets 10:10pm"` matched no rate-limit pattern → `session limit`/`weekly limit` added, and `parseCooldown` learns the 12-hour `resets 10:10pm` clock. **Hibernation persists (#757)**: `withBrainRecovery` only wrote `~/.kj/standby/<id>.json` with a `sessionState`, which no caller passed — new `buildStandbyState()` assembles it with an allowlisted env subset (never the full `process.env`). **Orchestrator consumes `action:"hibernate"` (#758)**: no code checked for it, so a hibernation sealed the HU `failed`; the coder/refactorer stages now stop cleanly and the session is sealed `hibernated` (resumable). **Resume hint (#759)**: a stopped run's last line is now the exact command — `kj standby resume <id>` for a hibernation, `kj resume <id>` otherwise. Plus CI now runs the `packages/hu-board` suite (#755). 4 931/4 931 tests passing across 410 files. Safe upgrade from 2.17.1.
29
+ >
30
+ > **v2.17.1** — Patch. Fixes **KJC-BUG-0055**: a project deleted from the HU Board (🗑️) no longer resurrects on the next `kj plan` or board restart. Four independent leaks closed: (1) **`sync.js` temporal gate** — the unconditional `removeTombstone` from KJC-BUG-0050 becomes a `plan.updatedAt > tombstone.deleted_at` comparison, so a tombstoned project revives only when the plan is genuinely newer than the delete; (2) **`ephemeral-cleaner.js`** now writes a tombstone and `rm -rf`'s `hu-stories/`, `sessions/` and `~/.kj/plans/` dirs when wiping ephemeral projects at boot — previously the orphan directories revived the project on the next scan; (3) **`fullScan` boot GC** sweeps orphan tombstoned dirs (the manual DB-wipe case); (4) **`DELETE /api/projects/:id`** honours `KJ_PLANS_DIR` instead of the hardcoded path. New `getTombstone()` helper in `db.js`. **Also fixes a silent `kj board start` failure (#753)**: the daemon's entry-point guard compared `import.meta.url` to a hand-built `file://` path, which wrongly returned false on Windows, symlinked/global installs and paths with spaces — the board exited 0 with an empty log. `server.js` now trusts a `KJ_BOARD_DAEMON` launcher flag, adds `uncaughtException`/`unhandledRejection` handlers and an actionable `better-sqlite3` load error; `board.js` detects an early daemon exit instead of reporting a phantom PID. 4 909/4 909 tests passing across 408 files. Safe upgrade from 2.17.0.
31
+ >
32
+ > **v2.17.0** — Minor. `kj audit` gains two new deterministic structural collectors and the v2.16 Sonar false-positive filter is generalised to apply across every collector. **Knip dead-exports collector** (codeQuality dim): reports unused exports/types (MINOR) and unused files (MAJOR), stack-aware (JS/TS only, needs package.json), subprocess via `--reporter json`, 120s timeout. **Madge circular-import collector** (architecture dim): detects import cycles, severity by chain length (≥4 files = MAJOR), honours tsconfig.json path aliases, 60s timeout. **Generalised FP filter**: every collector (sonar, knip, madge, osv, semgrep) now uses the same `config.audit.false_positives` shape with `tool` field plus inline marker `// karajan-audit-ignore: <tool>:<ruleId>`; legacy `config.sonar.false_positives` and `// karajan-sonar-ignore:` keep working. **Built-in catalogue** ships 4 entries by default (knip:unused-files in tests/fixtures + examples, knip:unused-exports on barrel files, madge cycles in node_modules). **BREAKING(engines)**: Node `>=20.10` → `>=20.19` (knip 6.x requirement; same flavour as the v2.8 18→20.10 bump). 4 872/4 872 tests passing across 402 files. Safe upgrade from 2.16.0 if you're on Node ≥ 20.19.
27
33
  >
28
34
  > **v2.16.0** — Minor. Quality-focused release: **deterministic Sonar false-positive filter** (KJC-TSK-0416), wire universal de Brain Recovery completado en `semantic-detector` (TSK-0413 step D), codemod `replace(/regex/g, …)` → `replaceAll(/regex/g, …)` en 41 sitios, y limpieza de hallazgos del propio `kj audit` v2.15.0. The Sonar filter combines two mechanisms: (1) **static rules** `{ rule, filePattern, reason }` from a built-in catalog (covering common false positives like `javascript:S2699` on `tests/architecture/`) plus extensible via `config.sonar.false_positives`; (2) **inline ignores** with `// karajan-sonar-ignore: <ruleId>` on the issue line. Result: the coder stops burning tokens "fixing" non-broken assertions. Brain Recovery now wraps **every** AI call in the pipeline — `semantic-detector` was the last legacy caller. 4 846/4 846 tests passing across 401 files. Safe upgrade from 2.15.0.
29
35
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.17.0",
3
+ "version": "2.18.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -33,7 +33,11 @@ const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000;
33
33
 
34
34
  const AUTH_PATTERNS = /\b401\b|\b403\b|unauthorized|invalid\s+api\s+key|authentication\s+failed|expired\s+token/i;
35
35
  const SILENCED_PATTERNS = /killed\s+after\s+\d+\s*ms|silence\s*timeout|no\s+output\s+for\s+\d+/i;
36
- const RATE_LIMIT_PATTERNS = /usage\s+limit|rate\s*limit|too\s+many\s+requests|\b429\b|throttl|exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached|monthly\s+limit|daily\s+limit/i;
36
+ // `session\s+limit` / `weekly\s+limit` cover Claude Code's usage caps
37
+ // ("You've hit your session limit · resets 10:10pm"). Without them the
38
+ // message fell through to UNKNOWN_FATAL and the run aborted instead of
39
+ // hibernating until the reset.
40
+ const RATE_LIMIT_PATTERNS = /usage\s+limit|rate\s*limit|too\s+many\s+requests|\b429\b|throttl|exceeded\s+your\s+current\s+quota|resource\s+exhausted|quota\s+exceeded|token\s+limit\s+reached|monthly\s+limit|weekly\s+limit|daily\s+limit|session\s+limit/i;
37
41
  const API_DOWN_PATTERNS = /\b50[0-4]\b|bad\s+gateway|service\s+unavailable|gateway\s+timeout|overloaded|internal\s+server\s+error/i;
38
42
  const NETWORK_PATTERNS = /ECONNREFUSED|ECONNRESET|ETIMEDOUT|socket\s+hang\s+up|fetch\s+failed|network\s+error/i;
39
43
 
@@ -15,6 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import os from "node:os";
18
+ import { writeJsonAtomicSync } from "../utils/atomic-write.js";
18
19
 
19
20
  const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
20
21
 
@@ -56,10 +57,46 @@ export function persistStandby(state) {
56
57
  createdAt: state.createdAt || new Date().toISOString(),
57
58
  persistedAt: new Date().toISOString(),
58
59
  };
59
- fs.writeFileSync(file, JSON.stringify(payload, null, 2), "utf-8");
60
+ writeJsonAtomicSync(file, payload);
60
61
  return file;
61
62
  }
62
63
 
64
+ // Only these env vars are carried into the standby snapshot. NEVER
65
+ // persist the whole `process.env` — that would leak API keys and other
66
+ // secrets into ~/.kj/standby/*.json. `kj standby resume` re-spawns with
67
+ // the live env merged on top, so the snapshot only needs what is needed
68
+ // to relaunch from the right place.
69
+ const STANDBY_ENV_ALLOWLIST = ["KJ_HOME", "HOME", "PATH"];
70
+
71
+ /**
72
+ * Builds the `sessionState` object that `withBrainRecovery` persists when
73
+ * a run hibernates. Pulls the session id, the relaunch metadata and an
74
+ * allowlisted env subset so `kj standby resume` can re-spawn the command.
75
+ * @param {object} args
76
+ * @param {object} args.session - the pipeline session ({ id, plan_id? })
77
+ * @param {object} [args.config] - resolved config ({ projectDir?, plan? })
78
+ * @param {string|null} [args.huId]
79
+ * @returns {object|null} sessionState, or null when there is no session id
80
+ */
81
+ export function buildStandbyState({ session, config = {}, huId = null } = {}) {
82
+ if (!session?.id) return null;
83
+ const env = {};
84
+ for (const key of STANDBY_ENV_ALLOWLIST) {
85
+ if (process.env[key] != null) env[key] = process.env[key];
86
+ }
87
+ for (const [key, value] of Object.entries(process.env)) {
88
+ if (key.startsWith("KJ_") && value != null) env[key] = value;
89
+ }
90
+ return {
91
+ sessionId: session.id,
92
+ planId: session.plan_id || config.plan || null,
93
+ huId: huId || null,
94
+ argv: process.argv.slice(2),
95
+ cwd: config.projectDir || process.cwd(),
96
+ env,
97
+ };
98
+ }
99
+
63
100
  /**
64
101
  * @param {string} sessionId
65
102
  * @returns {object|null}
@@ -15,6 +15,7 @@
15
15
  import { checkBinary, KNOWN_AGENTS } from "../utils/agent-detect.js";
16
16
  import { runCommand } from "../utils/process.js";
17
17
  import { withDocLink } from "../utils/doc-links.js";
18
+ import { getInstallHint, appliesToStack } from "../utils/install-hints.js";
18
19
  import { STRATEGY } from "./types.js";
19
20
 
20
21
  /**
@@ -105,6 +106,56 @@ function createSerenaCheck() {
105
106
  };
106
107
  }
107
108
 
109
+ /**
110
+ * Audit-side external tool (semgrep, osv-scanner, lighthouse) check.
111
+ *
112
+ * These are not blockers: `kj audit` and `kj webperf` degrade to
113
+ * available:false when missing. We surface them in `kj doctor` so the
114
+ * user can see at a glance which audit dimensions are active, and we
115
+ * point to `kj install-tools` for one-line remediation.
116
+ *
117
+ * `lighthouse` is stack-gated: only flagged when the project is
118
+ * frontend / fullstack. On backend-only projects the check is a no-op
119
+ * (returns ok:true with detail "n/a") to keep doctor output focused.
120
+ */
121
+ function createAuditToolCheck(tool, { gatedByStack = false, label }) {
122
+ return {
123
+ name: `audit-tool:${tool}`,
124
+ label,
125
+ strategy: STRATEGY.MANUAL,
126
+ async detect({ config } = {}) {
127
+ if (gatedByStack) {
128
+ // Stack detection is best-effort — failing here just means we
129
+ // treat the project as backend-only (skip the check). Keeps the
130
+ // doctor pipeline resilient to weird repos.
131
+ let stack = null;
132
+ try {
133
+ const { detectProjectStack } = await import("../utils/stack-detect.js");
134
+ stack = await detectProjectStack(config?.projectDir || process.cwd());
135
+ } catch { /* ignore */ }
136
+ if (!appliesToStack(tool, stack)) {
137
+ return { ok: true, severity: "info", detail: "n/a (no frontend stack detected)" };
138
+ }
139
+ }
140
+ const result = await checkBinary(tool);
141
+ if (result.ok) {
142
+ return { ok: true, severity: "info", detail: result.version || "available" };
143
+ }
144
+ const hint = await getInstallHint(tool);
145
+ const fixLine = hint.command
146
+ ? `Run \`kj install-tools --only ${tool}\` or: ${hint.command}`
147
+ : `Run \`kj install-tools --only ${tool}\` or see ${hint.manualUrl}`;
148
+ return {
149
+ ok: false,
150
+ severity: "warn",
151
+ detail: "Not found — `kj audit` dimension will degrade",
152
+ fix: fixLine,
153
+ extra: { tool, suggested: hint.command, manager: hint.manager, manualUrl: hint.manualUrl },
154
+ };
155
+ },
156
+ };
157
+ }
158
+
108
159
  /**
109
160
  * Aggregate: all binary-related checks for the current config.
110
161
  * @returns {import("./types.js").Check[]}
@@ -119,5 +170,10 @@ export function getBinaryChecks() {
119
170
  }
120
171
  checks.push(createDockerCheck());
121
172
  checks.push(createSerenaCheck());
173
+ // KJC-TSK v2.18 — surface audit-side tools in doctor so the user knows
174
+ // which dimensions will work. lighthouse is gated to frontend stacks.
175
+ checks.push(createAuditToolCheck("semgrep", { label: "Semgrep (audit/security)" }));
176
+ checks.push(createAuditToolCheck("osv-scanner", { label: "OSV-Scanner (audit/security)" }));
177
+ checks.push(createAuditToolCheck("lighthouse", { label: "Lighthouse (webperf)", gatedByStack: true }));
122
178
  return checks;
123
179
  }
@@ -196,6 +196,10 @@ export async function runChecks(checks, ctx, options = {}) {
196
196
  detail: e.detail ?? "",
197
197
  fix: e.detectResult?.fix,
198
198
  runMs: e.runMs,
199
+ // Forward optional structured metadata so the doctor printer can
200
+ // surface per-tool remediation tips (KJC-TSK v2.18). Undefined when
201
+ // the check didn't set one — kept minimal so JSON output stays tight.
202
+ extra: e.detectResult?.extra,
199
203
  }));
200
204
 
201
205
  const summary = summarize(reports);
@@ -3,6 +3,7 @@ import { configCommand } from "../commands/config.js";
3
3
  import { codeCommand } from "../commands/code.js";
4
4
  import { reviewCommand } from "../commands/review.js";
5
5
  import { scanCommand } from "../commands/scan.js";
6
+ import { installToolsCommand } from "../commands/install-tools.js";
6
7
  import { doctorCommand } from "../commands/doctor.js";
7
8
  import { reportCommand } from "../commands/report.js";
8
9
  import { runCommandHandler } from "../commands/run.js";
@@ -192,6 +193,26 @@ export function registerPipeline(program, { pkgVersion }) {
192
193
  if (Number.isInteger(exitCode)) process.exit(exitCode);
193
194
  });
194
195
 
196
+ program
197
+ .command("install-tools")
198
+ .description("Install external audit tools (semgrep, osv-scanner, lighthouse, docker, sonar) using the package manager available on your system")
199
+ .option("--only <tools>", "Comma-separated subset (e.g. \"semgrep,osv-scanner\"). Bypasses stack-gating.")
200
+ .option("-y, --yes", "Auto-accept all prompts (non-interactive)")
201
+ .option("--dry-run", "Show what would be installed without running anything")
202
+ .action(async (flags) => {
203
+ try {
204
+ const { exitCode } = await installToolsCommand({
205
+ only: flags.only,
206
+ yes: !!flags.yes,
207
+ dryRun: !!flags.dryRun,
208
+ });
209
+ if (Number.isInteger(exitCode)) process.exit(exitCode);
210
+ } catch (err) {
211
+ console.error(`kj install-tools: ${err?.message || err}`);
212
+ process.exit(1);
213
+ }
214
+ });
215
+
195
216
  program
196
217
  .command("status")
197
218
  .description("Show pipeline dashboard with HU progress")
@@ -88,6 +88,40 @@ function buildBoardUrl(port, projectSlug) {
88
88
  return projectSlug ? `${base}/p/${projectSlug}` : base;
89
89
  }
90
90
 
91
+ /**
92
+ * After spawning the board daemon, watch for an early exit. Resolves to
93
+ * an error string when the child dies within the window — a broken
94
+ * native module, a crash on boot, or the silent entry-point mismatch
95
+ * that made the daemon exit 0 without a trace — and to `null` when it
96
+ * survives. This is what stops `startBoard` from reporting `ok` for a
97
+ * daemon that died milliseconds after spawn. The window is overridable
98
+ * via KJ_BOARD_START_WINDOW_MS (tests set it low to stay fast).
99
+ * @param {import('node:child_process').ChildProcess} child
100
+ * @param {string} logPath
101
+ * @returns {Promise<string|null>}
102
+ */
103
+ export function waitForEarlyExit(child, logPath) {
104
+ const envWindow = Number(process.env.KJ_BOARD_START_WINDOW_MS);
105
+ const windowMs = Number.isFinite(envWindow) && envWindow >= 0 ? envWindow : 1500;
106
+ return new Promise((resolve) => {
107
+ let settled = false;
108
+ let timer = null;
109
+ const onExit = (code, signal) => {
110
+ const how = signal ? `signal ${signal}` : `exit code ${code}`;
111
+ finish(`HU Board daemon died on startup (${how}). See ${logPath} for the cause.`);
112
+ };
113
+ function finish(value) {
114
+ if (settled) return;
115
+ settled = true;
116
+ if (timer) clearTimeout(timer);
117
+ child.removeListener("exit", onExit);
118
+ resolve(value);
119
+ }
120
+ child.once("exit", onExit);
121
+ timer = setTimeout(() => finish(null), windowMs);
122
+ });
123
+ }
124
+
91
125
  export async function startBoard(desiredPort = 4000, opts = {}) {
92
126
  const { projectSlug = null, bind = "127.0.0.1" } = opts;
93
127
  const existingPid = readPid();
@@ -141,8 +175,11 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
141
175
 
142
176
  // Use process.execPath (absolute path to current node binary) instead of "node".
143
177
  // Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
178
+ // KJ_BOARD_DAEMON=1 tells server.js to run main() regardless of how
179
+ // its entry-point path compares — the symlink/Windows/space-in-path
180
+ // mismatch that used to make the daemon exit 0 in total silence.
144
181
  const child = spawn(process.execPath, [serverPath], {
145
- env: { ...process.env, PORT: String(port), BIND_HOST: bind },
182
+ env: { ...process.env, PORT: String(port), BIND_HOST: bind, KJ_BOARD_DAEMON: "1" },
146
183
  detached: true,
147
184
  stdio: ["ignore", logFd, logFd],
148
185
  cwd: BOARD_DIR
@@ -154,6 +191,14 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
154
191
  if (!child.pid) {
155
192
  throw new Error("Failed to spawn HU Board server");
156
193
  }
194
+ // Don't report success blindly: a crash on boot (e.g. broken
195
+ // better-sqlite3, or the silent entry-point mismatch) used to be
196
+ // reported as ok:true, leaving the user with a dead board and a log
197
+ // showing only the start line. Catch the early exit here.
198
+ const failure = await waitForEarlyExit(child, logPath);
199
+ if (failure) {
200
+ throw new Error(failure);
201
+ }
157
202
  fs.writeFileSync(PID_FILE, String(child.pid));
158
203
  // Detach for real: `detached: true` only sets up the new session;
159
204
  // without `unref()` the parent's event loop still holds a reference
@@ -289,7 +334,18 @@ export function renderBoardBanner({ url, status, projectName }) {
289
334
  export async function boardCommand({ action = "start", port = 4000, bind = "127.0.0.1", logger }) {
290
335
  switch (action) {
291
336
  case "start": {
292
- const result = await startBoard(port, { bind });
337
+ let result;
338
+ try {
339
+ result = await startBoard(port, { bind });
340
+ } catch (err) {
341
+ // The daemon died on boot (broken native module, crash) or no
342
+ // free port was found. Surface it cleanly — the whole point of
343
+ // this fix is that `kj board start` never reports a phantom
344
+ // success while leaving a dead board behind.
345
+ logger.error(`HU Board failed to start: ${err.message}`);
346
+ process.exitCode = 1;
347
+ return { ok: false, error: err.message };
348
+ }
293
349
  if (result.alreadyRunning) {
294
350
  logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
295
351
  } else {
@@ -171,4 +171,21 @@ function printHuman(report, { verbose }) {
171
171
  if (Object.keys(report.overrides).length > 0) {
172
172
  console.log(`Runtime overrides applied: ${JSON.stringify(report.overrides)}`);
173
173
  }
174
+
175
+ // KJC-TSK v2.18 — if any external audit tool (semgrep, osv-scanner,
176
+ // lighthouse) reported missing, surface the one-line remediation so
177
+ // the user doesn't have to dig the fix lines out of the wall of
178
+ // checks. The audit-tool checks emit names like "audit-tool:semgrep"
179
+ // and embed { tool, suggested } in `extra` (see binaries.js).
180
+ const installable = report.checks
181
+ .filter((c) => c.name?.startsWith("audit-tool:") && c.status === STATUS.WARN && c.extra?.tool)
182
+ .map((c) => c.extra.tool);
183
+ if (installable.length > 0) {
184
+ console.log();
185
+ const list = installable.join(", ");
186
+ const only = installable.length === 1 ? ` --only ${installable[0]}` : "";
187
+ console.log(`Tip: run \`kj install-tools${only}\` to install: ${list}.`);
188
+ }
174
189
  }
190
+
191
+ export const __test = { printHuman };
@@ -0,0 +1,211 @@
1
+ // `kj install-tools` — install the external audit tools that `kj audit`
2
+ // and `kj webperf` rely on but does not bundle.
3
+ //
4
+ // Covers semgrep, osv-scanner, lighthouse, docker, sonar. Picks the
5
+ // install command per tool from `getInstallHint` (HU1) so the command
6
+ // matches the package manager available on the user's system.
7
+ //
8
+ // Lighthouse is stack-gated: by default we only prompt about it when
9
+ // the project is frontend/fullstack. Pass `--only lighthouse` to bypass
10
+ // the gate.
11
+ //
12
+ // Docker and sonar are special cases — see TOOL_HANDLERS below.
13
+
14
+ import readline from "node:readline";
15
+ import { execFile } from "node:child_process";
16
+ import { promisify } from "node:util";
17
+ import path from "node:path";
18
+ import fs from "node:fs/promises";
19
+ import { checkBinary } from "../utils/agent-detect.js";
20
+ import { getInstallHint, detectPackageManagers, appliesToStack } from "../utils/install-hints.js";
21
+ import { detectProjectStack } from "../utils/stack-detect.js";
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ const ALL_TOOLS = ["semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
26
+
27
+ function parseOnlyList(raw) {
28
+ if (!raw) return null;
29
+ const wanted = String(raw).split(",").map((t) => t.trim()).filter(Boolean);
30
+ const invalid = wanted.filter((t) => !ALL_TOOLS.includes(t));
31
+ if (invalid.length > 0) {
32
+ throw new Error(`Unknown tool(s) in --only: ${invalid.join(", ")}. Valid: ${ALL_TOOLS.join(", ")}`);
33
+ }
34
+ return wanted;
35
+ }
36
+
37
+ function promptYesNo(question, defaultYes = true) {
38
+ return new Promise((resolve) => {
39
+ if (!process.stdin.isTTY) { resolve(defaultYes); return; } // non-TTY → assume default
40
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
41
+ const hint = defaultYes ? "Y/n" : "y/N";
42
+ rl.question(`${question} [${hint}] `, (answer) => {
43
+ rl.close();
44
+ const a = (answer || "").trim().toLowerCase();
45
+ if (!a) return resolve(defaultYes);
46
+ resolve(a === "y" || a === "yes");
47
+ });
48
+ });
49
+ }
50
+
51
+ async function isInstalled(tool) {
52
+ // sonar is a Docker container, not a binary. We check it separately
53
+ // by looking at running containers via `docker ps`.
54
+ if (tool === "sonar") return checkSonarRunning();
55
+ if (tool === "lighthouse") return (await checkBinary("lighthouse")).ok;
56
+ if (tool === "docker") return (await checkBinary("docker")).ok;
57
+ return (await checkBinary(tool)).ok;
58
+ }
59
+
60
+ async function checkSonarRunning() {
61
+ try {
62
+ const { stdout } = await execFileAsync("docker", ["ps", "--format", "{{.Names}}", "--filter", "name=sonarqube"], { timeout: 5000 });
63
+ return stdout.trim().length > 0;
64
+ } catch { return false; }
65
+ }
66
+
67
+ async function runInstall(command) {
68
+ // Split on whitespace (commands here are simple, no nested quoting).
69
+ const [bin, ...args] = command.split(/\s+/);
70
+ try {
71
+ const { stdout, stderr } = await execFileAsync(bin, args, { timeout: 300_000, maxBuffer: 32 * 1024 * 1024 });
72
+ return { ok: true, stdout, stderr };
73
+ } catch (err) {
74
+ return { ok: false, error: err?.shortMessage || err?.message || String(err), stderr: err?.stderr };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Sonar handler: special case. Cannot install Sonar via package manager;
80
+ * the install is `docker compose -f <our-template> up -d` if Docker is
81
+ * available. Falls back to a manual URL otherwise.
82
+ */
83
+ async function handleSonar({ available, dryRun }) {
84
+ if (!available.docker) {
85
+ return {
86
+ tool: "sonar",
87
+ action: "manual",
88
+ reason: "docker not available — sonar needs docker. Install docker first.",
89
+ manualUrl: "https://docs.docker.com/get-docker/",
90
+ };
91
+ }
92
+ const composePath = path.join(process.cwd(), "docker-compose.yml");
93
+ let composeExists = false;
94
+ try { await fs.access(composePath); composeExists = true; } catch { /* missing */ }
95
+ const command = composeExists
96
+ ? "docker compose up -d"
97
+ : "docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community";
98
+ return {
99
+ tool: "sonar",
100
+ action: dryRun ? "dry-run" : "ready",
101
+ command,
102
+ manualUrl: "https://docs.sonarsource.com/sonarqube-community-build/try-out-sonarqube/",
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Docker handler: never auto-install (platform-specific, invasive).
108
+ * Always point at the official docs.
109
+ */
110
+ function handleDocker(available) {
111
+ return {
112
+ tool: "docker",
113
+ action: "manual",
114
+ reason: "Docker install is platform-specific — see official docs.",
115
+ manualUrl: "https://docs.docker.com/get-docker/",
116
+ suggested: available.brew ? "brew install --cask docker" : null,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Plan + (optionally execute) the install of every requested tool.
122
+ *
123
+ * @param {Object} opts
124
+ * @param {string|null} [opts.only] - comma-separated subset
125
+ * @param {boolean} [opts.yes] - skip prompts
126
+ * @param {boolean} [opts.dryRun] - plan-only, no execution
127
+ * @param {Object} [opts.logger]
128
+ * @returns {Promise<{ results: Array<Object>, exitCode: number }>}
129
+ */
130
+ export async function installToolsCommand(opts = {}) {
131
+ const only = parseOnlyList(opts.only);
132
+ const yes = !!opts.yes;
133
+ const dryRun = !!opts.dryRun;
134
+ const logger = opts.logger || console;
135
+
136
+ const targetTools = only || ALL_TOOLS;
137
+ const available = await detectPackageManagers();
138
+ const stack = await detectProjectStack(process.cwd()).catch(() => null);
139
+
140
+ const results = [];
141
+ for (const tool of targetTools) {
142
+ // Stack gating — lighthouse on backend-only projects is skipped
143
+ // UNLESS the user passed --only lighthouse explicitly.
144
+ if (tool === "lighthouse" && !only && !appliesToStack(tool, stack)) {
145
+ results.push({ tool, action: "skipped", reason: "no frontend stack detected" });
146
+ logger.info?.(`⊘ ${tool}: skipped (no frontend stack detected; use --only lighthouse to force)`);
147
+ continue;
148
+ }
149
+
150
+ if (await isInstalled(tool)) {
151
+ results.push({ tool, action: "already-installed" });
152
+ logger.info?.(`✓ ${tool}: already installed`);
153
+ continue;
154
+ }
155
+
156
+ if (tool === "sonar") {
157
+ const result = await handleSonar({ available, dryRun });
158
+ results.push(result);
159
+ logger.info?.(`▸ sonar: ${result.command || result.manualUrl}`);
160
+ if (!dryRun && result.action === "ready") {
161
+ const proceed = yes || await promptYesNo(`Start SonarQube with: ${result.command}?`);
162
+ if (proceed) {
163
+ const r = await runInstall(result.command);
164
+ result.action = r.ok ? "installed" : "failed";
165
+ if (!r.ok) result.error = r.error;
166
+ }
167
+ }
168
+ continue;
169
+ }
170
+
171
+ if (tool === "docker") {
172
+ const result = handleDocker(available);
173
+ results.push(result);
174
+ logger.info?.(`▸ docker: ${result.suggested || result.manualUrl}`);
175
+ continue;
176
+ }
177
+
178
+ // semgrep / osv-scanner / lighthouse — package-manager driven.
179
+ const hint = await getInstallHint(tool, available);
180
+ if (!hint.command || !hint.manager) {
181
+ results.push({ tool, action: "manual", reason: "no compatible package manager found", manualUrl: hint.manualUrl, suggested: hint.command });
182
+ logger.warn?.(`✗ ${tool}: no compatible package manager. Manual: ${hint.manualUrl}`);
183
+ continue;
184
+ }
185
+ if (dryRun) {
186
+ results.push({ tool, action: "dry-run", command: hint.command, manager: hint.manager });
187
+ logger.info?.(`▸ ${tool}: would run \`${hint.command}\` (manager: ${hint.manager})`);
188
+ continue;
189
+ }
190
+ const proceed = yes || await promptYesNo(`Install ${tool} with: ${hint.command}?`);
191
+ if (!proceed) {
192
+ results.push({ tool, action: "declined", command: hint.command });
193
+ logger.info?.(`⊘ ${tool}: declined`);
194
+ continue;
195
+ }
196
+ logger.info?.(`▸ ${tool}: running \`${hint.command}\`...`);
197
+ const r = await runInstall(hint.command);
198
+ if (r.ok) {
199
+ results.push({ tool, action: "installed", command: hint.command, manager: hint.manager });
200
+ logger.info?.(`✓ ${tool}: installed via ${hint.manager}`);
201
+ } else {
202
+ results.push({ tool, action: "failed", command: hint.command, error: r.error });
203
+ logger.warn?.(`✗ ${tool}: install failed (${r.error})`);
204
+ }
205
+ }
206
+
207
+ const failures = results.filter((r) => r.action === "failed").length;
208
+ return { results, exitCode: failures > 0 ? 1 : 0 };
209
+ }
210
+
211
+ export const __test = { parseOnlyList, isInstalled, ALL_TOOLS };
@@ -11,6 +11,8 @@ import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js
11
11
  import { runStructuralPass } from "../../plan/plan-structural-pass.js";
12
12
  import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
13
13
  import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
14
+ import { buildStandbyState } from "../../brain/standby-store.js";
15
+ import { printResumeHint } from "../../utils/display/resume-hint.js";
14
16
  import { formatPlan, formatHuTable } from "./_shared.js";
15
17
 
16
18
  /**
@@ -61,6 +63,13 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
61
63
  // self-fix iter N por delante.
62
64
  const progress = createCliProgressReporter({ role: "planner:initial", quiet: Boolean(json) });
63
65
  let result;
66
+ // `kj plan` has no pipeline session, so synthesise a standby id
67
+ // (`plan_<stamp>`). If the planner hits a quota cap, withBrainRecovery
68
+ // persists it and `kj standby resume` re-spawns via the saved argv.
69
+ const plannerSessionState = buildStandbyState({
70
+ session: { id: `plan_${new Date().toISOString().replaceAll(/[:.]/g, "-")}` },
71
+ config,
72
+ });
64
73
  try {
65
74
  // KJC-TSK-0413: planner inicial pasa por Brain Recovery — clasifica
66
75
  // 401/429/5xx/SILENCED/etc y decide retry/standby/hibernate/abort.
@@ -69,6 +78,7 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
69
78
  taskArgs: { prompt, role: "planner", silenceTimeoutMs, timeoutMs, onOutput: progress.onOutput },
70
79
  role: "planner",
71
80
  logger,
81
+ sessionState: plannerSessionState,
72
82
  });
73
83
  progress.finish(result.ok ? "done" : (result.action || "failed"));
74
84
  } catch (err) {
@@ -76,6 +86,18 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
76
86
  throw err;
77
87
  }
78
88
 
89
+ if (!result.ok && result.action === "hibernate") {
90
+ // Quota cap: Brain Recovery persisted a standby snapshot. Stop
91
+ // cleanly with a resume hint instead of throwing a generic error.
92
+ const hibResult = {
93
+ ok: false, hibernated: true,
94
+ sessionId: plannerSessionState?.sessionId || null,
95
+ standbyFile: result.standbyFile || null,
96
+ };
97
+ if (!json) printResumeHint(hibResult);
98
+ process.exitCode = 1;
99
+ return hibResult;
100
+ }
79
101
  if (!result.ok) throw new Error(result.error || result.output || "Planner failed");
80
102
 
81
103
  const parsed = parseMaybeJsonString(result.output);
@@ -7,6 +7,7 @@ import { withCliRunLog } from "../utils/cli-run-log.js";
7
7
  import { registerRun, unregisterRun } from "../utils/run-registry.js";
8
8
  import { printHeader } from "../utils/display/header.js";
9
9
  import { printEvent } from "../utils/display/event-handlers.js";
10
+ import { printResumeHint } from "../utils/display/resume-hint.js";
10
11
  import { resolveRole } from "../config.js";
11
12
  import { parseCardId } from "../planning-game/adapter.js";
12
13
  import { confirmCwd } from "../utils/cwd-confirm.js";
@@ -175,6 +176,10 @@ export async function runCommandHandler({ task, config, logger, flags }) {
175
176
 
176
177
  if (jsonMode) {
177
178
  console.log(JSON.stringify(result, null, 2));
179
+ } else {
180
+ // Last line printed: if the run stopped (hibernated / paused /
181
+ // failed), tell the user the exact command to resume it.
182
+ printResumeHint(result);
178
183
  }
179
184
  return { ok: !result?.paused && result?.approved !== false };
180
185
  });