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,67 @@
1
+ // Sonar 401 auto-recovery — KJC-BUG-0057 / v2.19.2.
2
+ //
3
+ // When the Sonar API returns 401 (token missing / invalid / expired / revoked)
4
+ // and we still have admin/admin (or a user/pass we saved at `kj init` time),
5
+ // regenerate the analysis token VIA THE REST API and persist it. The caller
6
+ // retries the original request once with the new token.
7
+ //
8
+ // Programmatic, no LLM involvement. The whole point of the user feedback was
9
+ // that this is plumbing, not a decision: Karajan has the credentials to
10
+ // re-bootstrap and should do it without asking.
11
+
12
+ import { bootstrapSonarToken } from "./token-bootstrap.js";
13
+ import { saveSonarToken } from "./credentials.js";
14
+
15
+ // Per-process latch: a single Sonar run that 401s on every endpoint must
16
+ // only trigger ONE bootstrap attempt — not one per endpoint. The latch
17
+ // resets when the process exits.
18
+ let _recoveryAttempted = false;
19
+
20
+ /**
21
+ * @internal Reset the latch between tests (mirrors __resetKjHomeWarningForTests).
22
+ */
23
+ export function __resetSonarRecoveryLatchForTests() {
24
+ _recoveryAttempted = false;
25
+ }
26
+
27
+ /**
28
+ * Attempt to re-bootstrap the Sonar token after a 401. Mutates `config`
29
+ * in place when successful so the immediate retry picks up the new token.
30
+ *
31
+ * @param {object} config Karajan config (will be mutated on success).
32
+ * @param {object} [logger]
33
+ * @returns {Promise<{ ok: boolean, reason?: string, newToken?: string, savedTo?: string, passwordRotated?: boolean }>}
34
+ */
35
+ export async function recoverSonarToken(config, logger = null) {
36
+ if (_recoveryAttempted) {
37
+ return { ok: false, reason: "recovery already attempted in this process" };
38
+ }
39
+ _recoveryAttempted = true;
40
+
41
+ const host = config?.sonarqube?.host || "http://localhost:9000";
42
+ logger?.warn?.(`[sonar-recovery] 401 from ${host} — attempting token re-bootstrap`);
43
+
44
+ const result = await bootstrapSonarToken({ host });
45
+
46
+ if (!result.ok) {
47
+ logger?.warn?.(`[sonar-recovery] bootstrap failed: ${result.reason}`);
48
+ return { ok: false, reason: result.reason };
49
+ }
50
+
51
+ // Mutate the in-memory config so the caller's retry sees the new token.
52
+ config.sonarqube = config.sonarqube || {};
53
+ config.sonarqube.token = result.token;
54
+
55
+ // Also persist to ~/.karajan/sonar-credentials.json so future processes
56
+ // pick it up via the normal config-resolver chain instead of triggering
57
+ // recovery again. token-bootstrap.js already wrote sonar.token; this
58
+ // mirrors it into the structured credentials file the resolver reads.
59
+ try {
60
+ await saveSonarToken(result.token);
61
+ } catch (err) {
62
+ logger?.warn?.(`[sonar-recovery] persist to sonar-credentials.json failed (non-blocking): ${err.message}`);
63
+ }
64
+
65
+ logger?.info?.(`[sonar-recovery] new token persisted to ${result.savedTo}${result.passwordRotated ? " (admin password rotated)" : ""} — retrying original request`);
66
+ return { ok: true, newToken: result.token, savedTo: result.savedTo, passwordRotated: result.passwordRotated };
67
+ }
@@ -0,0 +1,58 @@
1
+ // Refine loop for the spec-reviewer (KJC-PCS-0048 PR 3). Called from
2
+ // run-spec-review.js when the user picks [r]efine. Asks the role for a
3
+ // rewritten v2, persists v1.md / v2.md, opens $EDITOR for the user to
4
+ // tweak, uses a SHA-256 hash diff to flag whether the v2 needs a fresh
5
+ // review pass (user modified it) or can proceed as-is.
6
+
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import crypto from "node:crypto";
10
+ import { spawnSync } from "node:child_process";
11
+
12
+ const sha256 = (s) => crypto.createHash("sha256").update(String(s || ""), "utf8").digest("hex");
13
+
14
+ function openEditor(filePath, { logger, openEditorFn = null } = {}) {
15
+ if (openEditorFn) return openEditorFn(filePath);
16
+ const raw = process.env.VISUAL || process.env.EDITOR || "vi"; // same precedence as src/commands/config.js
17
+ const parts = raw.split(/\s+/);
18
+ logger?.info?.(`Opening ${parts[0]} on ${filePath}`);
19
+ return spawnSync(parts[0], [...parts.slice(1), filePath], { stdio: "inherit" }).status === 0;
20
+ }
21
+
22
+ /**
23
+ * Single refine iteration. The outer loop in run-spec-review.js decides
24
+ * whether to re-invoke us based on the returned `hashChanged` flag
25
+ * (re-review the v2 because the user modified it) or accept the v2 as
26
+ * the final spec (`hashChanged === false`).
27
+ *
28
+ * @returns {Promise<{ok, refinedSpec?, hashChanged, error?, v2Path?}>}
29
+ */
30
+ export async function runRefinePass({ spec, findings, role, projectDir, taskFile, logger, fs: fsOverride, openEditorFn }) {
31
+ const _fs = fsOverride || fs;
32
+ const refineRes = await role.refineSpec({ spec, findings });
33
+ if (!refineRes.ok) return { ok: false, error: refineRes.error || "refine failed", hashChanged: false };
34
+
35
+ let v2Path;
36
+ try {
37
+ const root = projectDir || process.cwd();
38
+ const ts = new Date().toISOString().replaceAll(/[:.]/g, "-");
39
+ const dir = path.join(root, ".reviews", `spec-review-${ts}`);
40
+ await _fs.mkdir(dir, { recursive: true });
41
+ await _fs.writeFile(path.join(dir, "spec-v1.md"), spec, "utf8");
42
+ v2Path = path.join(dir, "spec-v2.md");
43
+ await _fs.writeFile(v2Path, refineRes.refinedSpec, "utf8");
44
+ if (taskFile) {
45
+ // Mirror the v2 next to the user's --task-file so they find it
46
+ // without diving into .reviews/.
47
+ const p = path.parse(taskFile);
48
+ await _fs.writeFile(path.join(p.dir, `${p.name}.v2${p.ext || ".md"}`), refineRes.refinedSpec, "utf8").catch(() => {});
49
+ }
50
+ } catch (err) { return { ok: false, error: `persist failed: ${err.message}`, hashChanged: false }; }
51
+
52
+ const before = sha256(refineRes.refinedSpec);
53
+ openEditor(v2Path, { logger, openEditorFn });
54
+ let after;
55
+ try { after = await _fs.readFile(v2Path, "utf8"); }
56
+ catch (err) { return { ok: false, error: `read v2 failed: ${err.message}`, hashChanged: false }; }
57
+ return { ok: true, refinedSpec: after, hashChanged: before !== sha256(after), v2Path };
58
+ }
@@ -0,0 +1,70 @@
1
+ // Pre-pipeline orchestrator for the spec-reviewer (KJC-PCS-0048 PR 2).
2
+ // Called from src/commands/run.js and src/commands/plan/generate.js
3
+ // BEFORE the actual pipeline. Decides whether to proceed based on the
4
+ // role's findings + the user's answer to the interactive prompt.
5
+ // PR 2 only supports [c]ontinue / [x]cancel; refine loop lands in PR 3.
6
+
7
+ import { SpecReviewerRole } from "../roles/spec-reviewer-role.js";
8
+ import { printFindings } from "../utils/display/spec-findings.js";
9
+ import { runRefinePass } from "./refine-loop.js";
10
+
11
+ const MAX_REFINE_ITERATIONS = 5;
12
+
13
+ export async function runSpecReview({ spec, config, logger, askQuestion, flags = {} }) {
14
+ // VITEST guard — pre-existing test suites (e.g. tests/commands/command-run.test.js)
15
+ // mock `runFlow` + `assertAgentsAvailable` but predate this role and have
16
+ // no need to exercise it. Auto-skip in vitest unless the test passes
17
+ // `flags.forceSpecReview: true` (the spec-review's own tests bypass via
18
+ // their vi.doMock of SpecReviewerRole and never reach this guard).
19
+ if (process.env.VITEST && !flags.forceSpecReview) return { proceed: true, skipped: true };
20
+ if (flags.skipSpecReview || config?.spec_reviewer?.enabled === false) {
21
+ return { proceed: true, skipped: true };
22
+ }
23
+ if (!spec || !String(spec).trim()) return { proceed: true, skipped: true };
24
+
25
+ const role = new SpecReviewerRole({ config, logger });
26
+ await role.init({ task: spec });
27
+
28
+ // Outer loop: review → prompt → optional refine → re-review (the
29
+ // refine path replaces `currentSpec` and we go around again).
30
+ let currentSpec = spec;
31
+ for (let iter = 0; iter < MAX_REFINE_ITERATIONS; iter += 1) {
32
+ const res = await role.execute({ spec: currentSpec });
33
+ if (!res.ok) {
34
+ logger?.warn?.(`[spec] reviewer failed: ${res.summary} — continuing without review`);
35
+ return { proceed: true, error: res.result?.error, finalSpec: currentSpec };
36
+ }
37
+ const { severity, findings } = res.result || {};
38
+ if (!findings || findings.length === 0 || severity === "ok") {
39
+ logger?.info?.(iter === 0 ? "✓ spec OK" : "✓ refined spec OK");
40
+ return { proceed: true, severity: "ok", findings: [], finalSpec: currentSpec, refined: iter > 0 };
41
+ }
42
+ printFindings(findings, severity);
43
+ if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
44
+
45
+ const answer = await askQuestion(
46
+ `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
47
+ { detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 } },
48
+ );
49
+ if (answer === null) return { proceed: false, cancelled: true, severity, findings };
50
+ const n = String(answer).trim().toLowerCase();
51
+ if (n.startsWith("x") || n === "cancel") return { proceed: false, cancelled: true, severity, findings };
52
+ if (n.startsWith("r") || n === "refine") {
53
+ const refine = await runRefinePass({
54
+ spec: currentSpec, findings, role,
55
+ projectDir: config?.projectDir, taskFile: flags.taskFile, logger,
56
+ });
57
+ if (!refine.ok) {
58
+ logger?.warn?.(`[spec] refine failed: ${refine.error} — proceeding with original spec`);
59
+ return { proceed: true, severity, findings, finalSpec: currentSpec };
60
+ }
61
+ currentSpec = refine.refinedSpec;
62
+ if (refine.hashChanged) continue; // user tweaked v2 → re-review
63
+ logger?.info?.("✓ refined spec accepted by user (no edits)");
64
+ return { proceed: true, severity: "ok", findings: [], finalSpec: currentSpec, refined: true };
65
+ }
66
+ return { proceed: true, severity, findings, finalSpec: currentSpec }; // continue
67
+ }
68
+ logger?.warn?.(`[spec] max refine iterations (${MAX_REFINE_ITERATIONS}) reached — proceeding with last spec`);
69
+ return { proceed: true, finalSpec: currentSpec, refined: true };
70
+ }
@@ -28,16 +28,15 @@
28
28
 
29
29
  import { mkdir, writeFile, readFile, unlink, stat } from "node:fs/promises";
30
30
  import { join } from "node:path";
31
- import { homedir } from "node:os";
32
31
  import { randomUUID } from "node:crypto";
33
32
 
34
33
  const POLL_INTERVAL_MS = 500;
35
34
  const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
36
35
 
37
- function getPromptsDir() {
38
- const home = process.env.KJ_HOME || join(homedir(), ".karajan");
39
- return join(home, "prompts");
40
- }
36
+ // Delegated to src/utils/paths.js so KARAJAN_HOME, KJ_HOME (with
37
+ // deprecation warning), VITEST tmp and the default all share one
38
+ // precedence chain — KJC-TSK-0420.
39
+ import { getPromptsDir } from "./paths.js";
41
40
 
42
41
  function exists(path) {
43
42
  return stat(path).then(() => true, () => false);
@@ -0,0 +1,44 @@
1
+ // Pretty-printer for SpecReviewerRole findings (KJC-PCS-0048 PR 2).
2
+ // Grouped by category, colours by severity, every finding gets its
3
+ // `suggestion` printed below as the actionable fix-it line.
4
+
5
+ const COLOURS = {
6
+ reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
7
+ cyan: "\x1b[36m", yellow: "\x1b[33m", red: "\x1b[31m", gray: "\x1b[90m",
8
+ };
9
+
10
+ const SEV_COLOUR = { info: COLOURS.cyan, warn: COLOURS.yellow, fail: COLOURS.red };
11
+ const SEV_LABEL = { info: "INFO", warn: "WARN", fail: "FAIL" };
12
+
13
+ function colour(severity, text) {
14
+ const c = SEV_COLOUR[severity] || COLOURS.gray;
15
+ return `${c}${text}${COLOURS.reset}`;
16
+ }
17
+
18
+ /**
19
+ * @param {Array<{id,severity,category,message,suggestion?}>} findings
20
+ * @param {"ok"|"info"|"warn"|"fail"} severity
21
+ * @param {object} [opts]
22
+ * @param {(line:string)=>void} [opts.write] Defaults to `console.warn` (stderr).
23
+ */
24
+ export function printFindings(findings, severity, opts = {}) {
25
+ const write = opts.write || ((line) => process.stderr.write(`${line}\n`));
26
+ if (!Array.isArray(findings) || findings.length === 0) return;
27
+
28
+ write("");
29
+ write(`${COLOURS.bold}Spec review — ${colour(severity, SEV_LABEL[severity] || severity.toUpperCase())}${COLOURS.reset} (${findings.length} finding${findings.length === 1 ? "" : "s"})`);
30
+
31
+ const byCat = new Map();
32
+ for (const f of findings) {
33
+ if (!byCat.has(f.category)) byCat.set(f.category, []);
34
+ byCat.get(f.category).push(f);
35
+ }
36
+ for (const [cat, items] of byCat) {
37
+ write(`${COLOURS.dim}── ${cat}${COLOURS.reset}`);
38
+ for (const f of items) {
39
+ write(` ${colour(f.severity, "●")} ${COLOURS.bold}${f.id}${COLOURS.reset} ${f.message}`);
40
+ if (f.suggestion) write(` ${COLOURS.dim}→${COLOURS.reset} ${f.suggestion}`);
41
+ }
42
+ }
43
+ write("");
44
+ }
@@ -89,3 +89,18 @@ export function getSessionRoot() {
89
89
  export function getSonarComposePath() {
90
90
  return path.join(getKarajanHome(), "docker-compose.sonar.yml");
91
91
  }
92
+
93
+ /** Webperf cache: `<karajan-home>/webperf/` — KJC-TSK-0420. */
94
+ export function getWebperfDir() {
95
+ return path.join(getKarajanHome(), "webperf");
96
+ }
97
+
98
+ /** Run-registry: `<karajan-home>/runs/` — KJC-TSK-0420. */
99
+ export function getRunsDir() {
100
+ return path.join(getKarajanHome(), "runs");
101
+ }
102
+
103
+ /** Board prompt bridge: `<karajan-home>/prompts/` — KJC-TSK-0420. */
104
+ export function getPromptsDir() {
105
+ return path.join(getKarajanHome(), "prompts");
106
+ }
@@ -19,16 +19,16 @@
19
19
 
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
- import os from "node:os";
23
22
  import { writeJsonAtomicSync } from "./atomic-write.js";
23
+ import { getRunsDir } from "./paths.js";
24
24
 
25
25
  /**
26
26
  * Directorio raíz donde viven los registros de run.
27
- * Honra `KJ_HOME` para tests y setups custom.
27
+ * Delega en `getRunsDir()` (KJC-TSK-0420) — respeta KARAJAN_HOME,
28
+ * KJ_HOME (con warning de deprecación), VITEST tmp y default.
28
29
  */
29
30
  export function runsDir() {
30
- const home = process.env.KJ_HOME || path.join(os.homedir(), ".karajan");
31
- return path.join(home, "runs");
31
+ return getRunsDir();
32
32
  }
33
33
 
34
34
  /**
@@ -0,0 +1,38 @@
1
+ You are the **Spec Reviewer**. You run BEFORE every other role. You audit the
2
+ user's spec (prompt or `.md`) for deficiencies. You do NOT execute or plan.
3
+
4
+ ## Categories (every finding gets exactly one)
5
+
6
+ - **ambiguity** — words without a concrete metric ("better", "clean", "fast").
7
+ - **missing_scope** — no boundary of what should and should not be touched.
8
+ - **missing_ac** — no acceptance criteria; nothing to verify against.
9
+ - **contradiction** — two requirements that cannot both be satisfied.
10
+ - **stack** — no tech stack hint when one is required.
11
+ - **assumptions** — depends on unstated context.
12
+ - **out_of_scope** — asks for things outside what Karajan can do.
13
+
14
+ ## Severity
15
+
16
+ `info` (nuance), `warn` (real gap), `fail` (unworkable). Top-level `severity`
17
+ is the WORST of any finding, or `ok` if findings is empty.
18
+
19
+ ## Output (JSON, nothing else)
20
+
21
+ ```json
22
+ {
23
+ "severity": "ok|info|warn|fail",
24
+ "findings": [
25
+ {
26
+ "id": "F-001",
27
+ "severity": "info|warn|fail",
28
+ "category": "ambiguity|missing_scope|missing_ac|contradiction|stack|assumptions|out_of_scope",
29
+ "message": "quote the offending phrase verbatim when possible",
30
+ "suggestion": "a sentence the user can paste into the spec"
31
+ }
32
+ ],
33
+ "reasoning": "one or two sentences summarising the verdict"
34
+ }
35
+ ```
36
+
37
+ Direct, accionable. No moralising. `suggestion` must be concrete (a paste-able
38
+ sentence), not vague advice.