karajan-code 2.19.0 → 2.19.2

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 (53) hide show
  1. package/README.md +4 -0
  2. package/package.json +9 -1
  3. package/packages/hu-board/README.md +69 -0
  4. package/packages/hu-board/package.json +23 -0
  5. package/packages/hu-board/public/app.js +4038 -0
  6. package/packages/hu-board/public/index.html +55 -0
  7. package/packages/hu-board/public/pipeline.css +132 -0
  8. package/packages/hu-board/public/pipeline.html +48 -0
  9. package/packages/hu-board/public/pipeline.js +200 -0
  10. package/packages/hu-board/public/styles.css +918 -0
  11. package/packages/hu-board/src/auth.js +92 -0
  12. package/packages/hu-board/src/boot-info.js +16 -0
  13. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  14. package/packages/hu-board/src/command-runner.js +134 -0
  15. package/packages/hu-board/src/config-yaml.js +285 -0
  16. package/packages/hu-board/src/db.js +739 -0
  17. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  18. package/packages/hu-board/src/event-bus.js +58 -0
  19. package/packages/hu-board/src/format.js +83 -0
  20. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  21. package/packages/hu-board/src/journal-parser.js +180 -0
  22. package/packages/hu-board/src/plan-mutations.js +422 -0
  23. package/packages/hu-board/src/preflight.js +285 -0
  24. package/packages/hu-board/src/routes/api.js +1271 -0
  25. package/packages/hu-board/src/routes/pipeline.js +45 -0
  26. package/packages/hu-board/src/run-tracker.js +121 -0
  27. package/packages/hu-board/src/server.js +435 -0
  28. package/packages/hu-board/src/sync.js +626 -0
  29. package/packages/hu-board/src/token-store.js +57 -0
  30. package/packages/hu-board/src/zombie-reaper.js +207 -0
  31. package/src/audit/webperf-input.js +4 -7
  32. package/src/checks/dir-setup.js +5 -3
  33. package/src/cli/register-pipeline.js +1 -0
  34. package/src/cli/register-plan.js +1 -0
  35. package/src/commands/plan/generate.js +15 -0
  36. package/src/commands/run.js +19 -1
  37. package/src/commands/webperf.js +4 -6
  38. package/src/config/role-resolver.js +3 -1
  39. package/src/mcp/handlers/direct-handlers.js +3 -0
  40. package/src/mcp/handlers/run-handler.js +5 -0
  41. package/src/mcp/tools.js +4 -2
  42. package/src/prompts/spec-reviewer.js +66 -0
  43. package/src/roles/perf-role.js +4 -4
  44. package/src/roles/spec-reviewer-role.js +106 -0
  45. package/src/sonar/api.js +17 -3
  46. package/src/sonar/token-recovery.js +67 -0
  47. package/src/spec-review/refine-loop.js +58 -0
  48. package/src/spec-review/run-spec-review.js +70 -0
  49. package/src/utils/board-prompt-bridge.js +4 -5
  50. package/src/utils/display/spec-findings.js +44 -0
  51. package/src/utils/paths.js +15 -0
  52. package/src/utils/run-registry.js +4 -4
  53. package/templates/roles/spec-reviewer.md +38 -0
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Zombie session reaper for the HU Board (board-polish #2,
3
+ * KJC dogfooding 2026-05-07).
4
+ *
5
+ * Why this exists
6
+ * ===============
7
+ *
8
+ * Sessions in the SQLite db can stay "in flight" forever when the
9
+ * orchestrator process dies between iterations: their `status` stays
10
+ * `coding` / `stalled` / `paused`, the UI keeps surfacing them as
11
+ * actionable, and `kj board start` weeks later still pops a
12
+ * "Karajan needs an answer" modal for a checkpoint nobody owns
13
+ * anymore.
14
+ *
15
+ * Concretely, on 2026-05-07 the user opened the board on a fresh
16
+ * `kj board start` and saw the popup of an HU from 2026-04-27 — 9
17
+ * days dead, status still `paused`. The fix per his explicit
18
+ * mandate:
19
+ *
20
+ * "No puede quedarse nada zombie. Debe haber algun sistema que lo
21
+ * compruebe al arrancar y mate esos zombies."
22
+ *
23
+ * Thresholds (per his decision in the same session):
24
+ * - status in (coding | stalled) and >= 6h since updated_at → reap
25
+ * - status === paused and >= 24h since updated_at → reap
26
+ * - everything else → skip
27
+ *
28
+ * What "reap" means: write `status: "failed"` BOTH to the DB row
29
+ * AND to the underlying `~/.karajan/sessions/<id>/session.json`,
30
+ * append a structured checkpoint of the form
31
+ * { stage: 'zombie-reap', reason: <reason>, at: <iso> }
32
+ * so the audit trail survives. The session row stays in place
33
+ * (we don't DELETE — the user can still inspect what happened).
34
+ *
35
+ * Why update the JSON on disk too
36
+ * -------------------------------
37
+ *
38
+ * The board's `fullScan()` reads every session.json on startup and
39
+ * upserts the row, OVERWRITING any DB-only mutation. If the reaper
40
+ * only updated the DB, the next reboot would reset the status back
41
+ * to `paused`/`stalled` because the JSON still says so. Persisting
42
+ * to disk makes the reap permanent on the first reboot — the user
43
+ * sees the cleanup once, and never again.
44
+ */
45
+
46
+ import fs from "node:fs";
47
+ import path from "node:path";
48
+ import os from "node:os";
49
+
50
+ const DEFAULT_CODING_HOURS = 6;
51
+ const DEFAULT_PAUSED_HOURS = 24;
52
+ const CODING_LIKE_STATUSES = new Set(["coding", "stalled", "running", "in_progress"]);
53
+ const PAUSED_STATUSES = new Set(["paused", "checkpoint", "awaiting_answer"]);
54
+
55
+ /**
56
+ * Decide whether a single session row qualifies as zombie at `now`.
57
+ * Pure function — no I/O. Used by both the reaper itself and the
58
+ * unit tests.
59
+ *
60
+ * @param {{ id: string, status: string, updated_at?: string|null,
61
+ * created_at?: string|null }} session
62
+ * @param {{ now: number, codingHours: number, pausedHours: number }} ctx
63
+ * @returns {{ zombie: boolean, reason?: string }}
64
+ */
65
+ export function classifyZombie(session, ctx) {
66
+ if (!session || !session.status) return { zombie: false };
67
+ const status = String(session.status).toLowerCase();
68
+ // Reference timestamp: prefer updated_at (the daemon writes that on
69
+ // every checkpoint sync), fall back to created_at (in case the row
70
+ // was inserted but never touched again).
71
+ const refIso = session.updated_at || session.created_at;
72
+ if (!refIso) return { zombie: false };
73
+ const refTs = new Date(refIso).getTime();
74
+ if (Number.isNaN(refTs)) return { zombie: false };
75
+ const ageHours = (ctx.now - refTs) / 3_600_000;
76
+ if (ageHours <= 0) return { zombie: false };
77
+ if (CODING_LIKE_STATUSES.has(status) && ageHours >= ctx.codingHours) {
78
+ return { zombie: true, reason: `${status} ${ageHours.toFixed(1)}h inactive (>= ${ctx.codingHours}h)` };
79
+ }
80
+ if (PAUSED_STATUSES.has(status) && ageHours >= ctx.pausedHours) {
81
+ return { zombie: true, reason: `${status} ${ageHours.toFixed(1)}h inactive (>= ${ctx.pausedHours}h)` };
82
+ }
83
+ return { zombie: false };
84
+ }
85
+
86
+ /**
87
+ * Filter a list of session rows down to the zombie ones. Pure.
88
+ *
89
+ * @param {Array<object>} sessions
90
+ * @param {{ now?: number, codingHours?: number, pausedHours?: number }} [opts]
91
+ * @returns {Array<{ session: object, reason: string }>}
92
+ */
93
+ export function findZombieSessions(sessions, opts = {}) {
94
+ const ctx = {
95
+ now: opts.now ?? Date.now(),
96
+ codingHours: opts.codingHours ?? DEFAULT_CODING_HOURS,
97
+ pausedHours: opts.pausedHours ?? DEFAULT_PAUSED_HOURS,
98
+ };
99
+ const out = [];
100
+ for (const session of sessions || []) {
101
+ const verdict = classifyZombie(session, ctx);
102
+ if (verdict.zombie) out.push({ session, reason: verdict.reason });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Append a `zombie-reap` checkpoint to a JSON-encoded checkpoints
109
+ * blob (the on-disk format). Returns the new JSON string.
110
+ *
111
+ * Defensive about malformed input: if the existing blob isn't a
112
+ * JSON array, replaces it with a fresh array containing the new
113
+ * entry — never throws.
114
+ */
115
+ export function appendZombieCheckpoint(existingJson, reason, isoTimestamp) {
116
+ let arr = [];
117
+ if (existingJson) {
118
+ try {
119
+ const parsed = JSON.parse(existingJson);
120
+ if (Array.isArray(parsed)) arr = parsed;
121
+ } catch { /* fall through with empty arr */ }
122
+ }
123
+ arr.push({ stage: "zombie-reap", reason, at: isoTimestamp });
124
+ return JSON.stringify(arr);
125
+ }
126
+
127
+ /**
128
+ * Default location of the on-disk session JSON files. Delegates to
129
+ * db.js::getKjHome (KJC-TSK-0420): KARAJAN_HOME > KJ_HOME > VITEST > default.
130
+ * @returns {string}
131
+ */
132
+ import { getKjHome } from "./db.js";
133
+ export function getSessionsDir() {
134
+ return path.join(getKjHome(), "sessions");
135
+ }
136
+
137
+ /**
138
+ * Persist `status: "failed"` and the new zombie-reap checkpoint to
139
+ * the on-disk session.json. This stops the next `fullScan()` from
140
+ * resurrecting the zombi.
141
+ *
142
+ * Safe-by-default: if the file is missing, malformed, or the write
143
+ * fails, this returns `false` and the caller logs a warning. We
144
+ * never throw and we never block the daemon's startup.
145
+ *
146
+ * @param {string} sessionId
147
+ * @param {string} reason
148
+ * @param {string} reapedAtIso
149
+ * @param {{ sessionsDir?: string }} [opts]
150
+ * @returns {boolean} true when the file was rewritten successfully
151
+ */
152
+ export function markSessionFailedOnDisk(sessionId, reason, reapedAtIso, opts = {}) {
153
+ const dir = opts.sessionsDir || getSessionsDir();
154
+ const file = path.join(dir, sessionId, "session.json");
155
+ let raw;
156
+ try { raw = fs.readFileSync(file, "utf8"); } catch { return false; }
157
+ let parsed;
158
+ try { parsed = JSON.parse(raw); } catch { return false; }
159
+ if (!parsed || typeof parsed !== "object") return false;
160
+ parsed.status = "failed";
161
+ parsed.updated_at = reapedAtIso;
162
+ parsed.checkpoints = Array.isArray(parsed.checkpoints) ? parsed.checkpoints : [];
163
+ parsed.checkpoints.push({ stage: "zombie-reap", reason, at: reapedAtIso });
164
+ try {
165
+ fs.writeFileSync(file, JSON.stringify(parsed, null, 2));
166
+ return true;
167
+ } catch { return false; }
168
+ }
169
+
170
+ /**
171
+ * Reap every zombie session in the DB AND in the underlying
172
+ * session.json files. Side-effecting. Returns the list of reaped
173
+ * sessions so the server can log them.
174
+ *
175
+ * @param {object} deps
176
+ * @param {object} deps.db better-sqlite3 Database handle
177
+ * @param {object} [deps.opts] threshold + sessionsDir overrides
178
+ * @param {Function} [deps.now] () => number, for tests
179
+ * @returns {Array<{ id: string, status_before: string, reason: string, disk_persisted: boolean }>}
180
+ */
181
+ export function reapZombieSessions({ db, opts = {}, now = () => Date.now() }) {
182
+ if (!db) return [];
183
+ const allSessions = db.prepare(
184
+ "SELECT id, status, updated_at, created_at, checkpoints FROM sessions WHERE status NOT IN ('failed', 'approved', 'cancelled', 'done')",
185
+ ).all();
186
+ const zombies = findZombieSessions(allSessions, { ...opts, now: now() });
187
+ if (zombies.length === 0) return [];
188
+ const update = db.prepare(
189
+ "UPDATE sessions SET status = 'failed', checkpoints = ?, updated_at = ? WHERE id = ?",
190
+ );
191
+ const reapedAt = new Date(now()).toISOString();
192
+ const reaped = [];
193
+ for (const { session, reason } of zombies) {
194
+ const newCheckpoints = appendZombieCheckpoint(session.checkpoints, reason, reapedAt);
195
+ update.run(newCheckpoints, reapedAt, session.id);
196
+ const diskPersisted = markSessionFailedOnDisk(session.id, reason, reapedAt, {
197
+ sessionsDir: opts.sessionsDir,
198
+ });
199
+ reaped.push({
200
+ id: session.id,
201
+ status_before: session.status,
202
+ reason,
203
+ disk_persisted: diskPersisted,
204
+ });
205
+ }
206
+ return reaped;
207
+ }
@@ -28,15 +28,12 @@
28
28
 
29
29
  import fs from "node:fs";
30
30
  import path from "node:path";
31
- import os from "node:os";
32
31
  import { evaluateCwv, CWV_THRESHOLDS } from "../webperf/cwv-gate.js";
32
+ import { getWebperfDir } from "../utils/paths.js";
33
33
 
34
- // Resolve per call — `os.homedir()` reads $HOME each time, so test
35
- // suites that override $HOME for hermetic tmp dirs work without
36
- // special re-imports.
37
- function webperfHome() {
38
- return path.join(os.homedir(), ".karajan", "webperf");
39
- }
34
+ // Delegated to src/utils/paths.js (KJC-TSK-0420) — respects
35
+ // KARAJAN_HOME / KJ_HOME (with deprecation warning) / VITEST tmp.
36
+ function webperfHome() { return getWebperfDir(); }
40
37
 
41
38
  function projectSlug(projectDir) {
42
39
  return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
7
7
  import os from "node:os";
8
8
  import path from "node:path";
9
9
  import { STRATEGY } from "./types.js";
10
+ import { getKarajanHome } from "../utils/paths.js";
10
11
 
11
12
  const REQUIRED_DIRS = [
12
13
  "", // ~/.karajan/
@@ -26,8 +27,9 @@ export function createKarajanDirsCheck() {
26
27
  strategy: STRATEGY.AUTO,
27
28
  describe: "Create missing ~/.karajan subdirectories",
28
29
  async detect() {
29
- const home = os.homedir();
30
- const root = path.join(home, ".karajan");
30
+ // KJC-TSK-0420: KARAJAN_HOME / KJ_HOME aware via getKarajanHome().
31
+ const root = getKarajanHome();
32
+ const home = os.homedir(); // still needed for path.relative() below
31
33
  const missing = [];
32
34
  for (const sub of REQUIRED_DIRS) {
33
35
  const full = sub ? path.join(root, sub) : root;
@@ -82,7 +84,7 @@ export function createLegacyKjHomeCheck() {
82
84
  try {
83
85
  const entries = await fs.readdir(legacy);
84
86
  if (entries.length === 0) return { ok: true, severity: "info", detail: "Empty legacy ~/.kj/" };
85
- const marker = path.join(os.homedir(), ".karajan", ".kj-migrated.json");
87
+ const marker = path.join(getKarajanHome(), ".kj-migrated.json"); // KJC-TSK-0420
86
88
  try { await fs.access(marker); return { ok: true, severity: "info", detail: "Already migrated" }; }
87
89
  catch { /* no marker, still pending */ }
88
90
  return {
@@ -105,6 +105,7 @@ export function registerPipeline(program, { pkgVersion }) {
105
105
  .option("--brain <mode>", "Brain decisor: on|off (default: on). When off, only legacy flags drive routing.")
106
106
  .option("--skip-role <role...>", "Force one or more roles OFF regardless of triage (e.g. --skip-role tester security)")
107
107
  .option("--force-role <role...>", "Force one or more roles ON regardless of triage")
108
+ .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
108
109
  .option("--dry-run", "Show what would be executed without running anything")
109
110
  .option("--json", "Output JSON only (no styled display)")
110
111
  .option("-q, --quiet", "Show only stage status lines, suppress raw agent output (default)")
@@ -40,6 +40,7 @@ export function registerPlan(program, { pkgVersion }) {
40
40
  // flags only if you explicitly want a fast / sketch plan.
41
41
  .option("--no-tests-synth", "Skip the tests-synthesizer pass that fills in missing acceptance_tests")
42
42
  .option("--no-plan-review", "Skip the high-level plan reviewer pass (gaps / deps / overlap / order)")
43
+ .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
43
44
  .option("--quick", "Sketch mode — skip every quality pass after the initial planner call")
44
45
  .option("-y, --yes", "Skip the project-name prompt (use the auto-derived default).")
45
46
  .option("--no-interactive", "Force non-interactive mode (no prompts, use defaults).")
@@ -13,6 +13,7 @@ import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-rou
13
13
  import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
14
14
  import { buildStandbyState } from "../../brain/standby-store.js";
15
15
  import { printResumeHint } from "../../utils/display/resume-hint.js";
16
+ import { runSpecReview } from "../../spec-review/run-spec-review.js";
16
17
  import { formatPlan, formatHuTable } from "./_shared.js";
17
18
 
18
19
  /**
@@ -40,6 +41,20 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
40
41
  }
41
42
  }
42
43
 
44
+ // Spec-reviewer pre-planner audit (KJC-PCS-0048). Static import —
45
+ // role runs every invocation (modulo bypass); no lazy-load benefit.
46
+ const reviewResult = await runSpecReview({
47
+ spec: task, config, logger, flags,
48
+ askQuestion: json ? null : (typeof flags?.askQuestion === "function" ? flags.askQuestion : null),
49
+ });
50
+ if (!reviewResult.proceed) {
51
+ logger?.info?.("Aborted by user after spec review.");
52
+ return { ok: false, aborted: true, reason: "spec-review-cancelled" };
53
+ }
54
+ if (reviewResult.refined && reviewResult.finalSpec) {
55
+ task = reviewResult.finalSpec; // downstream runs the refined spec
56
+ }
57
+
43
58
  const plannerRole = resolveRole(config, "planner");
44
59
  await assertAgentsAvailable([plannerRole.provider]);
45
60
  runLog.logText(`[planner] provider=${plannerRole.provider}`);
@@ -11,6 +11,7 @@ import { printResumeHint } from "../utils/display/resume-hint.js";
11
11
  import { resolveRole } from "../config.js";
12
12
  import { parseCardId } from "../planning-game/adapter.js";
13
13
  import { confirmCwd } from "../utils/cwd-confirm.js";
14
+ import { runSpecReview } from "../spec-review/run-spec-review.js";
14
15
 
15
16
  function createCliAskQuestion(opts = {}) {
16
17
  const { sessionId = null } = opts;
@@ -163,9 +164,26 @@ export async function runCommandHandler({ task, config, logger, flags }) {
163
164
  }
164
165
 
165
166
  const askQuestion = createCliAskQuestion();
167
+
168
+ // Spec-reviewer pre-pipeline audit (KJC-PCS-0048). Runs BEFORE
169
+ // anything else — surfaces ambiguity / missing scope / missing AC
170
+ // and lets the user bail out cheap, before any tokens burn.
171
+ // Bypass with --skip-spec-review. Static import — the role runs
172
+ // on every kj invocation (modulo bypass), no lazy-load benefit.
173
+ const reviewResult = await runSpecReview({ spec: task, config, logger, askQuestion, flags });
174
+ if (!reviewResult.proceed) {
175
+ logger.info("Aborted by user after spec review.");
176
+ cleanupRegistry();
177
+ return { ok: false, aborted: true, reason: "spec-review-cancelled" };
178
+ }
179
+ // If the user refined the spec via [r]efine, downstream runs the
180
+ // REFINED text (not the original) so the pipeline acts on what the
181
+ // user actually approved.
182
+ const effectiveTask = reviewResult.refined && reviewResult.finalSpec ? reviewResult.finalSpec : task;
183
+
166
184
  let result;
167
185
  try {
168
- result = await runFlow({ task: task, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
186
+ result = await runFlow({ task: effectiveTask, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
169
187
  } finally {
170
188
  // KJC-TSK-0396: limpia el registro pase lo que pase (éxito,
171
189
  // throw, o paused). Los handlers de SIGINT/SIGTERM/exit son
@@ -9,16 +9,14 @@
9
9
 
10
10
  import fs from "node:fs/promises";
11
11
  import path from "node:path";
12
- import os from "node:os";
13
12
  import yaml from "js-yaml";
14
13
  import { runWebperfScan } from "../webperf/scanner.js";
15
14
  import { withCliRunLog } from "../utils/cli-run-log.js";
16
15
 
17
- // Resolve home each call so tests that override $HOME for hermetic
18
- // tmp dirs work without re-importing the module.
19
- function defaultReportDir() {
20
- return path.join(os.homedir(), ".karajan", "webperf");
21
- }
16
+ // Delegated to src/utils/paths.js (KJC-TSK-0420) — respects
17
+ // KARAJAN_HOME / KJ_HOME / VITEST tmp / default.
18
+ import { getWebperfDir } from "../utils/paths.js";
19
+ function defaultReportDir() { return getWebperfDir(); }
22
20
 
23
21
  /**
24
22
  * Slug a project directory the same way audit/basal-cost.js and
@@ -49,7 +49,9 @@ function isModelCompatible(agent, model) {
49
49
  // Roles that inherit provider/model from the coder when not explicitly configured
50
50
  const CODER_INHERITED_ROLES = new Set([
51
51
  "planner", "refactorer", "solomon", "researcher", "tester", "security",
52
- "impeccable", "triage", "discover", "architect", "audit", "hu_reviewer", "hu-reviewer"
52
+ "impeccable", "triage", "discover", "architect", "audit",
53
+ "hu_reviewer", "hu-reviewer",
54
+ "spec_reviewer", "spec-reviewer",
53
55
  ]);
54
56
 
55
57
  function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
@@ -47,6 +47,9 @@ function applySessionOverrides(a, roleKeys) {
47
47
  }
48
48
 
49
49
  export async function handlePlanDirect(a, server, extra) {
50
+ // KJC-PCS-0048 PR 3b: same `specReviewMode: skip` → skipSpecReview
51
+ // mapping as kj_run. Default "auto" keeps the reviewer enabled.
52
+ if (a.specReviewMode === "skip") a.skipSpecReview = true;
50
53
  const { normalizePlanArgs } = await import("../tool-arg-normalizers.js");
51
54
  const options = normalizePlanArgs(a);
52
55
  const config = await buildConfig(options, "plan");
@@ -113,6 +113,11 @@ function collectRequiredProviders(config) {
113
113
  }
114
114
 
115
115
  export async function handleRunDirect(a, server, extra) {
116
+ // KJC-PCS-0048 PR 3b: respect specReviewMode without changing the
117
+ // existing handler contract. "skip" maps to the same flag the CLI
118
+ // honours; "auto" (default) leaves the reviewer enabled but the
119
+ // non-TTY runSpecReview path never blocks the pipeline.
120
+ if (a.specReviewMode === "skip") a.skipSpecReview = true;
116
121
  const config = await buildConfig(a);
117
122
  await assertNotOnBaseBranch(config, { allowForRun: true });
118
123
  const logger = createLogger(config.output.log_level, "mcp");
package/src/mcp/tools.js CHANGED
@@ -113,7 +113,8 @@ export const tools = [
113
113
  domain: { type: "string", description: "Domain knowledge: inline text describing the project domain, or absolute path to a .md file. Auto-saved to .karajan/domains/ for the curator." },
114
114
  brain: { type: "string", enum: ["on", "off"], description: "Brain decisor: on|off. Controls whether intent-driven routing overrides pipeline flags." },
115
115
  forceRole: { type: "array", items: { type: "string" }, description: "Force roles ON regardless of triage (e.g. [\"security\"])" },
116
- skipRole: { type: "array", items: { type: "string" }, description: "Force roles OFF regardless of triage (e.g. [\"tester\"])" }
116
+ skipRole: { type: "array", items: { type: "string" }, description: "Force roles OFF regardless of triage (e.g. [\"tester\"])" },
117
+ specReviewMode: { type: "string", enum: ["auto", "skip"], description: "Pre-pipeline spec-reviewer behaviour (KJC-PCS-0048). `auto` (default): the role runs, findings stream to the client via progress events; the pipeline continues regardless. `skip`: bypass the reviewer entirely (mirrors the --skip-spec-review CLI flag)." }
117
118
  }
118
119
  }
119
120
  },
@@ -245,7 +246,8 @@ export const tools = [
245
246
  coder: { type: "string", description: "Legacy alias for planner" },
246
247
  coderModel: { type: "string", description: "Legacy alias for plannerModel" },
247
248
  projectDir: { type: "string", description: "Absolute path to the project directory" },
248
- kjHome: { type: "string" }
249
+ kjHome: { type: "string" },
250
+ specReviewMode: { type: "string", enum: ["auto", "skip"], description: "Pre-planner spec-reviewer behaviour (KJC-PCS-0048). `auto` (default): runs the reviewer, findings stream as progress, planner continues. `skip`: bypass entirely." }
249
251
  }
250
252
  }
251
253
  },
@@ -0,0 +1,66 @@
1
+ // Prompt builder for the `spec-reviewer` role (KJC-PCS-0048 PR 1).
2
+ // Mirrors src/prompts/triage.js — project-level override at
3
+ // `<projectDir>/.karajan/roles/spec-reviewer.md` is injected via `instructions`.
4
+
5
+ const SUBAGENT_PREAMBLE = [
6
+ "IMPORTANT: You are running as a Karajan sub-agent.",
7
+ "Do NOT execute or plan the task. Do NOT mention Karajan.",
8
+ "Do NOT use any MCP tools. Your only job is to audit the user's spec.",
9
+ ].join(" ");
10
+
11
+ const VALID_CATEGORIES = [
12
+ "ambiguity", "missing_scope", "missing_ac", "contradiction",
13
+ "stack", "assumptions", "out_of_scope",
14
+ ];
15
+
16
+ const SCHEMA_HINT = JSON.stringify({
17
+ severity: "ok|info|warn|fail",
18
+ findings: [{
19
+ id: "F-001",
20
+ severity: "info|warn|fail",
21
+ category: VALID_CATEGORIES.join("|"),
22
+ message: "concrete description",
23
+ suggestion: "concrete sentence the user can paste",
24
+ }],
25
+ reasoning: "one or two sentences",
26
+ }, null, 2);
27
+
28
+ export function buildSpecReviewerPrompt({ spec, instructions } = {}) {
29
+ const sections = [SUBAGENT_PREAMBLE];
30
+ if (instructions) sections.push(instructions);
31
+ sections.push(
32
+ "You are the **Spec Reviewer** for Karajan Code.",
33
+ `Categories (pick one per finding): ${VALID_CATEGORIES.join(", ")}.`,
34
+ "Severity: `info` (nuance), `warn` (real gap), `fail` (unworkable). Top-level severity is the WORST of any finding (or `ok` if none).",
35
+ "Return JSON only:",
36
+ "```json", SCHEMA_HINT, "```",
37
+ "## Spec to audit", "```", String(spec || "").trim(), "```",
38
+ );
39
+ return sections.join("\n");
40
+ }
41
+
42
+ /**
43
+ * Build the refine prompt — used when the user picks `[r]efine`. The
44
+ * agent must rewrite the spec to fix the listed findings, returning
45
+ * ONLY the rewritten spec (no JSON, no preamble, no fence). The refine
46
+ * loop will persist this verbatim as `spec-v2.md`.
47
+ */
48
+ export function buildSpecRefinerPrompt({ originalSpec, findings, instructions } = {}) {
49
+ const sections = [SUBAGENT_PREAMBLE];
50
+ if (instructions) sections.push(instructions);
51
+ const findingsBlock = (findings || []).map((f, i) => {
52
+ const idx = i + 1;
53
+ return `${idx}. [${f.severity}/${f.category}] ${f.message}${f.suggestion ? `\n Fix: ${f.suggestion}` : ""}`;
54
+ }).join("\n");
55
+ sections.push(
56
+ "Your task: produce a rewritten version of the user's spec that addresses every finding listed below.",
57
+ "Output the rewritten spec verbatim — no JSON, no preamble, no markdown fences, no commentary.",
58
+ "Preserve the user's intent; do not invent new requirements or scope beyond the existing spec.",
59
+ "## Original spec", "```", String(originalSpec || "").trim(), "```",
60
+ "## Findings to address", findingsBlock || "(none — pass-through)",
61
+ "## Rewritten spec",
62
+ );
63
+ return sections.join("\n");
64
+ }
65
+
66
+ export { VALID_CATEGORIES };
@@ -20,7 +20,6 @@
20
20
 
21
21
  import fs from "node:fs/promises";
22
22
  import path from "node:path";
23
- import os from "node:os";
24
23
  import { BaseRole } from "./base-role.js";
25
24
  import { runWebperfScan } from "../webperf/scanner.js";
26
25
 
@@ -28,9 +27,10 @@ function projectSlug(projectDir) {
28
27
  return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
29
28
  }
30
29
 
31
- function defaultPersistDir() {
32
- return path.join(os.homedir(), ".karajan", "webperf");
33
- }
30
+ // Delegated to src/utils/paths.js (KJC-TSK-0420) — respects
31
+ // KARAJAN_HOME / KJ_HOME / VITEST tmp / default.
32
+ import { getWebperfDir } from "../utils/paths.js";
33
+ function defaultPersistDir() { return getWebperfDir(); }
34
34
 
35
35
  async function persistResult(result, projectDir) {
36
36
  if (!result?.ok) return null;
@@ -0,0 +1,106 @@
1
+ // Spec Reviewer role (KJC-PCS-0048 PR 1). Runs BEFORE intent/triage/planner;
2
+ // audits the user's spec and returns structured findings. Does NOT execute.
3
+
4
+ import { AgentRole } from "./agent-role.js";
5
+ import { buildSpecReviewerPrompt, buildSpecRefinerPrompt, VALID_CATEGORIES } from "../prompts/spec-reviewer.js";
6
+ import { extractFirstJson } from "../utils/json-extract.js";
7
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
8
+
9
+ const F_SEVS = new Set(["info", "warn", "fail"]);
10
+ const T_SEVS = new Set(["ok", "info", "warn", "fail"]);
11
+ const CATS = new Set(VALID_CATEGORIES);
12
+ const ORDER = { ok: 0, info: 1, warn: 2, fail: 3 };
13
+
14
+ function normalise(findings) {
15
+ if (!Array.isArray(findings)) return [];
16
+ return findings.map((f, i) => ({
17
+ id: typeof f?.id === "string" && f.id.trim() ? f.id.trim() : `F-${String(i + 1).padStart(3, "0")}`,
18
+ severity: F_SEVS.has(f?.severity) ? f.severity : "warn",
19
+ category: CATS.has(f?.category) ? f.category : "ambiguity",
20
+ message: String(f?.message || "").trim() || "(no description)",
21
+ suggestion: typeof f?.suggestion === "string" && f.suggestion.trim() ? f.suggestion.trim() : null,
22
+ }));
23
+ }
24
+
25
+ const worstOf = (findings) => findings.reduce((m, f) => (ORDER[f.severity] > ORDER[m] ? f.severity : m), "ok");
26
+
27
+ export class SpecReviewerRole extends AgentRole {
28
+ constructor(opts) { super({ ...opts, name: "spec-reviewer" }); }
29
+
30
+ extractInput(input) {
31
+ if (typeof input === "string") return { spec: input, onOutput: null };
32
+ return {
33
+ spec: input?.spec || input?.task || this.context?.task || "",
34
+ onOutput: input?.onOutput || null,
35
+ sessionState: input?.sessionState || null,
36
+ };
37
+ }
38
+
39
+ async buildPrompt({ spec }) {
40
+ return { prompt: buildSpecReviewerPrompt({ spec, instructions: this.instructions }) };
41
+ }
42
+
43
+ parseOutput(raw) { return extractFirstJson(raw); }
44
+
45
+ buildSuccessResult(parsed, provider) {
46
+ const findings = normalise(parsed?.findings);
47
+ const declared = T_SEVS.has(parsed?.severity) ? parsed.severity : null;
48
+ const derived = worstOf(findings);
49
+ // Trust the worse of declared vs derived — agents sometimes report
50
+ // `severity: "ok"` while still listing fail-level findings.
51
+ const severity = !declared || ORDER[derived] > ORDER[declared] ? derived : declared;
52
+ return { severity, findings, reasoning: String(parsed?.reasoning || "").trim() || null, provider };
53
+ }
54
+
55
+ buildSummary(parsed) {
56
+ const findings = normalise(parsed?.findings);
57
+ if (findings.length === 0) return "Spec OK (no findings)";
58
+ return `Spec ${worstOf(findings)}: ${findings.length} finding${findings.length === 1 ? "" : "s"}`;
59
+ }
60
+
61
+ // Parse failures degrade to a soft warning so the pipeline never blocks.
62
+ handleParseNull(r, p) { return this.#degraded(r, p, "parse-null", "LLM output was unstructured"); }
63
+ handleParseError(e, r, p) { return this.#degraded(r, p, "parse-error", e?.message || "unknown"); }
64
+
65
+ /**
66
+ * Refine mode (KJC-PCS-0048 PR 3). Bypasses the JSON-only `execute()`
67
+ * flow: returns the LLM output verbatim as the rewritten spec, so the
68
+ * refine loop can persist it as `spec-v2.md`.
69
+ * @returns {Promise<{ ok:boolean, refinedSpec?:string, error?:string, provider:string, usage?:object }>}
70
+ */
71
+ async refineSpec({ spec, findings }) {
72
+ if (!this._initialized) await this.init({ task: spec });
73
+ const provider = this.resolveProvider();
74
+ const agent = this.createAgentInstance(provider);
75
+ const prompt = buildSpecRefinerPrompt({ originalSpec: spec, findings, instructions: this.instructions });
76
+ const result = await withBrainRecovery({
77
+ agent: { runTask: (a) => agent.runTask(a), provider },
78
+ taskArgs: { prompt, role: this.name },
79
+ role: this.name, provider, emitter: this.emitter, logger: this.logger,
80
+ });
81
+ if (!result.ok) {
82
+ return { ok: false, error: result.error || "refine failed", provider, usage: result.usage };
83
+ }
84
+ const refinedSpec = String(result.output || "").replace(/^```[a-z]*\n?|```$/g, "").trim();
85
+ if (!refinedSpec) return { ok: false, error: "empty refined spec", provider, usage: result.usage };
86
+ return { ok: true, refinedSpec, provider, usage: result.usage };
87
+ }
88
+
89
+ #degraded(agentResult, provider, reason, detail) {
90
+ return {
91
+ ok: true,
92
+ result: {
93
+ severity: "warn",
94
+ findings: [{
95
+ id: "F-DEG", severity: "warn", category: "assumptions",
96
+ message: `[spec-reviewer] degraded (${reason}): ${detail}`,
97
+ suggestion: "Re-run with --skip-spec-review if this keeps failing.",
98
+ }],
99
+ reasoning: "Spec reviewer could not parse the LLM output; treating as a soft warning.",
100
+ provider, degraded: true,
101
+ },
102
+ summary: `Spec reviewer degraded: ${reason}`,
103
+ usage: agentResult?.usage,
104
+ };
105
+ }
106
+ }
package/src/sonar/api.js CHANGED
@@ -3,6 +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
  import { filterFalsePositives } from "./issue-filter.js";
6
+ import { recoverSonarToken } from "./token-recovery.js";
6
7
 
7
8
  class SonarApiError extends Error {
8
9
  constructor(message, { url, httpStatus, hint } = {}) {
@@ -25,7 +26,7 @@ function parseHttpResponse(stdout) {
25
26
  return { httpCode, body };
26
27
  }
27
28
 
28
- async function sonarFetchOnce(config, urlPath) {
29
+ async function sonarFetchOnce(config, urlPath, { _retriedAfterRecovery = false, logger = null } = {}) {
29
30
  const token = tokenFromConfig(config);
30
31
  const url = `${config.sonarqube.host}${urlPath}`;
31
32
  const res = await runCommand("curl", ["-s", "-w", "\n%{http_code}", "-u", `${token}:`, url]);
@@ -42,9 +43,22 @@ async function sonarFetchOnce(config, urlPath) {
42
43
  const { httpCode, body } = parseHttpResponse(res.stdout);
43
44
 
44
45
  if (httpCode === 401) {
46
+ // KJC-BUG-0057 (v2.19.2): try to bootstrap a fresh token via the Sonar
47
+ // REST API and retry ONCE. If recovery still fails — or this was already
48
+ // the retry — surface the actionable error like before.
49
+ if (!_retriedAfterRecovery) {
50
+ const recovery = await recoverSonarToken(config, logger);
51
+ if (recovery.ok) {
52
+ return sonarFetchOnce(config, urlPath, { _retriedAfterRecovery: true, logger });
53
+ }
54
+ throw new SonarApiError(
55
+ `SonarQube authentication failed (HTTP 401) and auto-recovery failed: ${recovery.reason}. Regenerate the token manually with 'kj init' or save admin user/password in ~/.karajan/sonar-credentials.json.`,
56
+ { url, httpStatus: 401, hint: "Run 'kj init' to regenerate the SonarQube token, or save admin credentials in ~/.karajan/sonar-credentials.json so Karajan can re-bootstrap automatically." }
57
+ );
58
+ }
45
59
  throw new SonarApiError(
46
- `SonarQube authentication failed (HTTP 401). Token may be invalid or expired. Regenerate with 'kj init'.`,
47
- { url, httpStatus: 401, hint: "Run 'kj init' to regenerate the SonarQube token." }
60
+ `SonarQube authentication failed (HTTP 401) even after auto-recovery. The newly generated token is also being rejected — the Sonar instance may be in an inconsistent state.`,
61
+ { url, httpStatus: 401, hint: "Inspect the Sonar UI directly; consider 'kj sonar restart'." }
48
62
  );
49
63
  }
50
64