karajan-code 2.18.1 → 2.19.1

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 (57) hide show
  1. package/README.md +53 -34
  2. package/docs/README.es.md +13 -2
  3. package/package.json +9 -1
  4. package/packages/hu-board/README.md +69 -0
  5. package/packages/hu-board/package.json +23 -0
  6. package/packages/hu-board/public/app.js +4038 -0
  7. package/packages/hu-board/public/index.html +55 -0
  8. package/packages/hu-board/public/pipeline.css +132 -0
  9. package/packages/hu-board/public/pipeline.html +48 -0
  10. package/packages/hu-board/public/pipeline.js +200 -0
  11. package/packages/hu-board/public/styles.css +918 -0
  12. package/packages/hu-board/src/auth.js +92 -0
  13. package/packages/hu-board/src/boot-info.js +16 -0
  14. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  15. package/packages/hu-board/src/command-runner.js +134 -0
  16. package/packages/hu-board/src/config-yaml.js +285 -0
  17. package/packages/hu-board/src/db.js +739 -0
  18. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  19. package/packages/hu-board/src/event-bus.js +58 -0
  20. package/packages/hu-board/src/format.js +83 -0
  21. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  22. package/packages/hu-board/src/journal-parser.js +180 -0
  23. package/packages/hu-board/src/plan-mutations.js +422 -0
  24. package/packages/hu-board/src/preflight.js +285 -0
  25. package/packages/hu-board/src/routes/api.js +1271 -0
  26. package/packages/hu-board/src/routes/pipeline.js +45 -0
  27. package/packages/hu-board/src/run-tracker.js +121 -0
  28. package/packages/hu-board/src/server.js +435 -0
  29. package/packages/hu-board/src/sync.js +626 -0
  30. package/packages/hu-board/src/token-store.js +57 -0
  31. package/packages/hu-board/src/zombie-reaper.js +207 -0
  32. package/src/audit/webperf-input.js +4 -7
  33. package/src/brain/standby-store.js +6 -4
  34. package/src/checks/dir-setup.js +39 -3
  35. package/src/cli/register-pipeline.js +1 -0
  36. package/src/cli/register-plan.js +1 -0
  37. package/src/cli.js +13 -0
  38. package/src/commands/plan/generate.js +15 -0
  39. package/src/commands/run.js +19 -1
  40. package/src/commands/webperf.js +4 -6
  41. package/src/config/role-resolver.js +3 -1
  42. package/src/mcp/handlers/direct-handlers.js +3 -0
  43. package/src/mcp/handlers/run-handler.js +5 -0
  44. package/src/mcp/tools.js +4 -2
  45. package/src/plan/plan-store.js +7 -27
  46. package/src/prompts/spec-reviewer.js +66 -0
  47. package/src/roles/perf-role.js +4 -4
  48. package/src/roles/spec-reviewer-role.js +106 -0
  49. package/src/spec-review/refine-loop.js +58 -0
  50. package/src/spec-review/run-spec-review.js +70 -0
  51. package/src/utils/board-prompt-bridge.js +4 -5
  52. package/src/utils/display/spec-findings.js +44 -0
  53. package/src/utils/garbage-collector.js +8 -6
  54. package/src/utils/home-migration.js +87 -0
  55. package/src/utils/paths.js +85 -17
  56. package/src/utils/run-registry.js +4 -4
  57. package/templates/roles/spec-reviewer.md +38 -0
@@ -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
+ }
@@ -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
+ }
@@ -30,7 +30,7 @@
30
30
 
31
31
  import fs from "node:fs/promises";
32
32
  import path from "node:path";
33
- import os from "node:os";
33
+ import { resolveHome } from "./paths.js";
34
34
 
35
35
  const FINAL_PLAN_STATUSES = new Set([
36
36
  // Terminal plan states the user explicitly opted into — retention applies.
@@ -39,13 +39,15 @@ const FINAL_PLAN_STATUSES = new Set([
39
39
  const KNOWN_PLAN_STATUSES = new Set([...FINAL_PLAN_STATUSES, "draft", "running"]);
40
40
  const FINAL_SESSION_STATUSES = new Set(["approved", "failed", "stopped", "rejected", "completed"]);
41
41
 
42
- function getKjHome() {
43
- return process.env.KJ_HOME || path.join(os.homedir(), ".kj");
44
- }
45
-
42
+ // Post-PR-3 of KJC-PCS-0047 both helpers point at the canonical
43
+ // `~/.karajan/` root. `getKjHome` is kept as an alias of
44
+ // `getKarajanHome` so legacy callers inside the file continue to
45
+ // compile during the consolidation; both names resolve to the same
46
+ // directory now that the layout is unified.
46
47
  function getKarajanHome() {
47
- return process.env.KARAJAN_HOME || path.join(os.homedir(), ".karajan");
48
+ return resolveHome({ defaultSegment: ".karajan" });
48
49
  }
50
+ const getKjHome = getKarajanHome;
49
51
 
50
52
  const DAY_MS = 24 * 60 * 60 * 1000;
51
53
 
@@ -0,0 +1,87 @@
1
+ // One-shot ~/.kj/ → ~/.karajan/ home consolidation (PR 2 of KJC-PCS-0047).
2
+ // Idempotent via `<target>/.kj-migrated.json`. Tarball backup under
3
+ // `<target>/backup/`. `plans/`, `standby/`, `worktrees/` are moved
4
+ // wholesale; `runs/` is merged with `.karajan/` winning on conflict.
5
+ // Cross-device safe. Non-fatal: CLI bootstrap wraps in try/catch.
6
+
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+ import { execa } from "execa";
11
+
12
+ const MARKER = ".kj-migrated.json";
13
+ const MOVE_DIRS = ["plans", "standby", "worktrees"];
14
+
15
+ async function readdirOrNull(dir) {
16
+ try { return await fs.readdir(dir); } catch (err) {
17
+ if (err.code === "ENOENT") return null;
18
+ throw err;
19
+ }
20
+ }
21
+
22
+ async function moveChildren(src, dst, { mergeMode = false } = {}) {
23
+ const children = await readdirOrNull(src);
24
+ if (!children?.length) return 0;
25
+ await fs.mkdir(dst, { recursive: true });
26
+ let n = 0;
27
+ for (const c of children) {
28
+ const dstChild = path.join(dst, c);
29
+ if (mergeMode) {
30
+ try { await fs.access(dstChild); continue; } catch { /* no conflict */ }
31
+ }
32
+ const srcChild = path.join(src, c);
33
+ try { await fs.rename(srcChild, dstChild); } catch (err) {
34
+ if (err.code !== "EXDEV") throw err;
35
+ await fs.cp(srcChild, dstChild, { recursive: true });
36
+ await fs.rm(srcChild, { recursive: true, force: true });
37
+ }
38
+ n += 1;
39
+ }
40
+ await fs.rm(src, { recursive: true, force: true }).catch(() => {});
41
+ return n;
42
+ }
43
+
44
+ export async function migrateKjToKarajan({ dryRun = false, force = false } = {}) {
45
+ // VITEST guard — tests setting KJ_HOME would otherwise migrate against
46
+ // the developer's real HOME. Migrator's own tests pass `force: true`.
47
+ if (process.env.VITEST && !force) return { migrated: false, reason: "vitest-skip" };
48
+
49
+ const sourceDir = process.env.KJ_HOME || path.join(os.homedir(), ".kj");
50
+ const targetDir = process.env.KARAJAN_HOME || path.join(os.homedir(), ".karajan");
51
+
52
+ if (path.resolve(sourceDir) === path.resolve(targetDir)) {
53
+ return { migrated: false, reason: "same-path" };
54
+ }
55
+ const entries = await readdirOrNull(sourceDir);
56
+ if (!entries) return { migrated: false, reason: "source-missing" };
57
+ if (entries.length === 0) return { migrated: false, reason: "source-empty" };
58
+
59
+ await fs.mkdir(targetDir, { recursive: true });
60
+ const markerPath = path.join(targetDir, MARKER);
61
+ try { await fs.access(markerPath); return { migrated: false, reason: "already-migrated" }; }
62
+ catch { /* not present — proceed */ }
63
+
64
+ if (dryRun) return { migrated: false, reason: "dry-run", sourceDir, targetDir };
65
+
66
+ const backupDir = path.join(targetDir, "backup");
67
+ await fs.mkdir(backupDir, { recursive: true });
68
+ const ts = new Date().toISOString().replaceAll(/[:.]/g, "-");
69
+ const backupPath = path.join(backupDir, `kj-pre-migration-${ts}.tar.gz`);
70
+ await execa("tar", ["-czf", backupPath, "-C", path.dirname(sourceDir), path.basename(sourceDir)]);
71
+
72
+ const counts = {};
73
+ for (const sub of MOVE_DIRS) {
74
+ counts[sub] = await moveChildren(path.join(sourceDir, sub), path.join(targetDir, sub));
75
+ }
76
+ counts.runs = await moveChildren(path.join(sourceDir, "runs"), path.join(targetDir, "runs"), { mergeMode: true });
77
+
78
+ const marker = { version: 1, migrated_at: new Date().toISOString(), items_moved: counts, backup_path: backupPath, source_dir: sourceDir, target_dir: targetDir };
79
+ await fs.writeFile(markerPath, JSON.stringify(marker, null, 2));
80
+
81
+ const summary = Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k}`).join(" + ");
82
+ if (summary) {
83
+ // eslint-disable-next-line no-console
84
+ console.warn(`\x1b[33m[warn]\x1b[0m Migrated ${summary} from ${sourceDir} to ${targetDir} (backup: ${backupPath})`);
85
+ }
86
+ return { migrated: true, counts, backupPath, marker };
87
+ }
@@ -1,32 +1,85 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
- // Per-process tmp dir for VITEST runs that forget to set KJ_HOME. Same
5
- // rationale as src/plan/plan-store.js — without this, tests that use
6
- // real session/board paths leak files into the developer's real
7
- // `~/.karajan/` and pollute the HU Board's SQLite DB. Memoised so
8
- // every helper in the same process agrees on the path. The trailing
9
- // segment is `.karajan` to keep semantic parity with non-VITEST
10
- // defaults (existing tests assert paths contain ".karajan/...").
11
- let _vitestKarajanHome = null;
12
- function vitestTmpKarajanHome() {
13
- if (_vitestKarajanHome) return _vitestKarajanHome;
14
- _vitestKarajanHome = path.join(
4
+ // Per-process vitest root. We memoise the *root* (not the suffixed
5
+ // `.karajan` / `.kj` path) so callers with different legacy defaults
6
+ // — plan-store wants `.kj`, db.js wants `.karajan` — can share one
7
+ // random tmp prefix per test run without colliding.
8
+ let _vitestRoot = null;
9
+ function vitestRoot() {
10
+ if (_vitestRoot) return _vitestRoot;
11
+ _vitestRoot = path.join(
15
12
  os.tmpdir(),
16
- `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
17
- ".karajan"
13
+ `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
18
14
  );
19
- return _vitestKarajanHome;
15
+ return _vitestRoot;
20
16
  }
21
17
 
22
- export function getKarajanHome() {
18
+ function vitestTmpHome(defaultSegment) {
19
+ return path.join(vitestRoot(), defaultSegment);
20
+ }
21
+
22
+ // One-shot warning so users with KJ_HOME set in their shell rcfile do
23
+ // not get spammed once per `kj` invocation. Reset across processes,
24
+ // which is the granularity that matters for a humans-reading-stderr
25
+ // audience.
26
+ let _kjHomeWarned = false;
27
+ function emitKjHomeDeprecationWarning() {
28
+ if (_kjHomeWarned) return;
29
+ _kjHomeWarned = true;
30
+ // eslint-disable-next-line no-console
31
+ console.warn(
32
+ "\x1b[33m[warn]\x1b[0m KJ_HOME is deprecated, rename to KARAJAN_HOME (KJ_HOME will be removed in a future release)"
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Unified resolver for Karajan's HOME-level storage root. Used by every
38
+ * helper that previously rolled its own `getKjHome()` with subtly
39
+ * different defaults and VITEST handling.
40
+ *
41
+ * Precedence (highest first):
42
+ * 1. KARAJAN_HOME env var — explicit, no warning
43
+ * 2. KJ_HOME env var — explicit, prints deprecation warning once
44
+ * 3. VITEST tmp dir — auto-isolation under `os.tmpdir()/karajan-vitest-<pid>-<rand>/<defaultSegment>`
45
+ * 4. `~/<defaultSegment>` — production default
46
+ *
47
+ * Callers pass `defaultSegment` so we can preserve `.kj` for legacy
48
+ * paths (plan-store, standby-store) and `.karajan` for the canonical
49
+ * root, until PR 3 unifies the defaults too.
50
+ *
51
+ * @param {object} [options]
52
+ * @param {string} [options.defaultSegment=".karajan"] HOME subdir name
53
+ * @returns {string} absolute path
54
+ */
55
+ export function resolveHome({ defaultSegment = ".karajan" } = {}) {
56
+ if (process.env.KARAJAN_HOME) {
57
+ return path.resolve(process.env.KARAJAN_HOME);
58
+ }
23
59
  if (process.env.KJ_HOME) {
60
+ emitKjHomeDeprecationWarning();
24
61
  return path.resolve(process.env.KJ_HOME);
25
62
  }
26
63
  if (process.env.VITEST) {
27
- return vitestTmpKarajanHome();
64
+ return vitestTmpHome(defaultSegment);
28
65
  }
29
- return path.join(os.homedir(), ".karajan");
66
+ return path.join(os.homedir(), defaultSegment);
67
+ }
68
+
69
+ /**
70
+ * Canonical Karajan home (`~/.karajan` by default). Every helper that
71
+ * used to roll its own `getKjHome()` now goes through this one — see
72
+ * KJC-PCS-0047 PR 3 for the consolidation.
73
+ */
74
+ export function getKarajanHome() {
75
+ return resolveHome({ defaultSegment: ".karajan" });
76
+ }
77
+
78
+ // Test-only export. Tests that depend on observing the deprecation
79
+ // warning being emitted exactly once need to reset the latch between
80
+ // cases.
81
+ export function __resetKjHomeWarningForTests() {
82
+ _kjHomeWarned = false;
30
83
  }
31
84
 
32
85
  export function getSessionRoot() {
@@ -36,3 +89,18 @@ export function getSessionRoot() {
36
89
  export function getSonarComposePath() {
37
90
  return path.join(getKarajanHome(), "docker-compose.sonar.yml");
38
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.