karajan-code 4.4.0 → 4.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.4.0",
3
+ "version": "4.5.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",
@@ -463,6 +463,14 @@ export function registerMeta(program, { pkgVersion }) {
463
463
  .option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
464
464
  .action(async (action = "start", opts) => {
465
465
  await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
466
+ // KJC-TSK-0684 (issue #1287): an external board is the source of
467
+ // truth — don't start (or advertise) a parallel HU Board. "stop"
468
+ // stays allowed so a leftover daemon can be cleaned up.
469
+ if (config?.state_backend === "external" && action !== "stop") {
470
+ const name = config?.board?.name || "an external board";
471
+ console.log(`⚠ this project's board lives in ${name} (state_backend: external) — kj does not run a parallel HU Board here.`);
472
+ return;
473
+ }
466
474
  const port = Number(opts.port) || config.hu_board?.port || 4000;
467
475
  const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
468
476
  await boardCommand({ action, port, bind, logger });
@@ -6,12 +6,18 @@
6
6
  */
7
7
  import { installPlaybook } from "../environment/playbook.js";
8
8
  import { renderBrief, listBriefs } from "../environment/briefs.js";
9
- import { openVecStore, projectSlug, getLastIndexedCommit } from "../rag/vec-store.js";
9
+ import { existsSync } from "node:fs";
10
+ import { openVecStore, projectSlug, getLastIndexedCommit, dbPath } from "../rag/vec-store.js";
10
11
  import { ragIndexCommand } from "./rag.js";
11
12
  import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
12
- import { onnxConfig, persistOnnxChoice } from "../rag/onnx-fallback.js";
13
+ import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
14
+ import { verifyBoardAccess } from "../environment/board-access.js";
13
15
 
14
16
  function hasRagIndex(config, projectDir) {
17
+ // KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
18
+ // the DDL, and a table born at the default dim (768) breaks the ONNX
19
+ // fallback (384) later. No file → no index, without side effects.
20
+ if (!existsSync(dbPath())) return false;
15
21
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
16
22
  try { return Boolean(getLastIndexedCommit(db, projectSlug(projectDir))); }
17
23
  finally { db.close(); }
@@ -40,9 +46,26 @@ export async function envInstallCommand({ config = null, logger = null, flags =
40
46
  const result = await installPlaybook({
41
47
  projectDir, target: flags.target || "all",
42
48
  stateBackend: config?.state_backend || "hu-board",
49
+ boardName: config?.board?.name || null,
43
50
  });
44
51
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
45
52
 
53
+ // KJC-TSK-0685 (user rule): Karajan does not run without a board — it is
54
+ // what guarantees ordered, card-first work. Verify an OPERATIONAL access
55
+ // path to the declared backend BEFORE anything else; no path → block
56
+ // (exit 3) with the exact steps. The playbook stays installed.
57
+ const access = verifyBoardAccess({ config, projectDir });
58
+ if (!access.ok) {
59
+ result.boardError = access.needed.join(" | ");
60
+ result.exitCode = PENDING_EXIT_CODE;
61
+ console.log(renderPendingBlock(
62
+ [{ tool: `${access.name || access.backend} board`, action: "needs-user", reason: `Karajan does not run without a board. To use ${access.name || access.backend}: ${access.needed.join(" ")}` }],
63
+ { retry: "kj env install" },
64
+ ));
65
+ return result;
66
+ }
67
+ if (access.backend !== "hu-board") console.log(`✓ board access verified: ${access.via}`);
68
+
46
69
  // ENV-E1: RAG-first — the playbook orders "query the RAG before coding",
47
70
  // so installing the environment guarantees the index exists. KJC-TSK-0659
48
71
  // stop-on-sudo: an index that cannot be built is a BLOCKING condition —
@@ -69,11 +92,19 @@ export async function envInstallCommand({ config = null, logger = null, flags =
69
92
  if (explicitProvider) return false;
70
93
  console.log(`⚠ default embedder unavailable (${why}) — trying the built-in ONNX embedder (no install needed)…`);
71
94
  try {
95
+ // KJC-BUG-0128: an empty store may carry a vec table at the wrong
96
+ // dim (the old probe created it at 768) — reset it so ONNX (384)
97
+ // can index. A store with data is never touched.
98
+ if (!resetEmptyStore()) {
99
+ console.log(" the vector store already has data at another dimension — not switching automatically");
100
+ return false;
101
+ }
72
102
  const totals = await ragIndexCommand({ config: onnxConfig(config), logger, flags: { withSources: true } });
73
103
  if ((totals?.indexed ?? 0) > 0) {
74
104
  const p = await persistOnnxChoice(projectDir);
75
105
  console.log(`✓ RAG indexed with the built-in ONNX embedder — persisted in ${p}`);
76
- console.log(" For higher-quality embeddings later: install Ollama and run `kj rag index --rebuild`.");
106
+ console.log(" To move to Ollama later: install it, remove the rag.embedder block from .karajan/kj.config.yml,");
107
+ console.log(" delete the store (~/.karajan/rag.db) and run: kj rag index --with-sources");
77
108
  return true;
78
109
  }
79
110
  } catch (fallbackErr) {
@@ -60,6 +60,16 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
60
60
  const projectDir = config?.projectDir || process.cwd();
61
61
  const emit = (obj, human) => { console.log(flags.json ? JSON.stringify(obj) : human); return obj; };
62
62
 
63
+ // KJC-TSK-0684 (issue #1287): with an external board declared, kj never
64
+ // maintains a parallel HU Board — writes are refused with the pointer.
65
+ if (config?.state_backend === "external" && (action === "add" || action === "move")) {
66
+ const name = config?.board?.name || "an external board";
67
+ throw new Error(
68
+ `this project's board lives in ${name} (state_backend: external) — create and move cards there, `
69
+ + `through your agent's MCP/tools. kj does not mirror external boards.`
70
+ );
71
+ }
72
+
63
73
  if (action === "list") {
64
74
  const plans = await listPlans(projectDir);
65
75
  const rows = [];
@@ -60,8 +60,13 @@ const DEFAULTS = {
60
60
  // null in the user's config opts out (no cap).
61
61
  max_budget_usd: 5,
62
62
  // ENV-D1 (KJC-TSK-0642): where work items live. The HU Board ships with
63
- // kj; "planning-game" routes the v4 playbook to the user's PG MCP.
63
+ // kj; "planning-game" routes the v4 playbook to the user's PG MCP;
64
+ // "external" (KJC-TSK-0684) points card-first at the project's own board
65
+ // (Linear, Trello, Jira, GitHub Issues…) named in board.name — worked
66
+ // through the host agent's MCP/tools, never mirrored by kj. There is NO
67
+ // "none": Karajan does not run without a board.
64
68
  state_backend: "hu-board",
69
+ board: { name: null },
65
70
  review_rules: "./.karajan/review-rules.md",
66
71
  coder_rules: "./.karajan/coder-rules.md",
67
72
  base_branch: "main",
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Board access verification (KJC-TSK-0685). User rule: Karajan does not
3
+ * run without a board — it is what guarantees ordered, card-first work and
4
+ * meaningful lanes. There is NO "none". init/env-install verify an
5
+ * OPERATIONAL access path to the declared backend:
6
+ * - hu-board → always ok (ships inside the kj tarball).
7
+ * - planning-game → its MCP appears in a host agent config.
8
+ * - external → the named board's MCP appears in a host agent
9
+ * config, OR an API token is exported (conventional
10
+ * env var, or the one declared in board.token_env).
11
+ * Detection is presence-based and format-agnostic (the MCP config files
12
+ * are scanned as text) — v1 verifies a path exists, not connectivity.
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+
18
+ const TOKEN_CONVENTIONS = {
19
+ linear: ["LINEAR_API_KEY"],
20
+ trello: ["TRELLO_TOKEN", "TRELLO_API_KEY"],
21
+ jira: ["JIRA_API_TOKEN"],
22
+ github: ["GITHUB_TOKEN", "GH_TOKEN"],
23
+ asana: ["ASANA_ACCESS_TOKEN"],
24
+ };
25
+
26
+ function mcpConfigPaths(projectDir, home) {
27
+ return [
28
+ path.join(projectDir, ".mcp.json"),
29
+ path.join(home, ".claude.json"),
30
+ path.join(home, ".codex", "config.toml"),
31
+ path.join(home, ".gemini", "settings.json"),
32
+ ];
33
+ }
34
+
35
+ function mcpConfigsMention(slug, projectDir, home) {
36
+ const needle = slug.toLowerCase();
37
+ for (const p of mcpConfigPaths(projectDir, home)) {
38
+ try {
39
+ if (existsSync(p) && readFileSync(p, "utf8").toLowerCase().includes(needle)) return p;
40
+ } catch { /* unreadable config — keep looking */ }
41
+ }
42
+ return null;
43
+ }
44
+
45
+ export function verifyBoardAccess({ config = {}, projectDir = process.cwd(), env = process.env, home = os.homedir() } = {}) {
46
+ const backend = config.state_backend || "hu-board";
47
+
48
+ if (backend === "planning-game") {
49
+ const hit = mcpConfigsMention("planning-game", projectDir, home);
50
+ if (hit) return { ok: true, backend, via: `planning-game MCP (${hit})` };
51
+ return {
52
+ ok: false, backend,
53
+ needed: ["configure the planning-game MCP in your host agent (project .mcp.json or the agent's global config)"],
54
+ };
55
+ }
56
+
57
+ if (backend === "external") {
58
+ const name = String(config.board?.name || "").trim();
59
+ if (!name) return { ok: false, backend, needed: ["declare board.name in kj.config.yml (e.g. Linear, Trello, Jira, GitHub Issues)"] };
60
+ const slug = name.toLowerCase().split(/\s+/)[0];
61
+ const hit = mcpConfigsMention(slug, projectDir, home);
62
+ if (hit) return { ok: true, backend, via: `${name} MCP detected (${hit})` };
63
+ const tokenEnvs = config.board?.token_env ? [config.board.token_env] : (TOKEN_CONVENTIONS[slug] || []);
64
+ const found = tokenEnvs.find((k) => env[k]);
65
+ if (found) return { ok: true, backend, via: `API token ${found}` };
66
+ return {
67
+ ok: false, backend, name,
68
+ needed: [
69
+ `configure the ${name} MCP in your host agent (project .mcp.json or the agent's global config), or`,
70
+ tokenEnvs.length > 0
71
+ ? `export an API token: ${tokenEnvs.join(" or ")}`
72
+ : "declare board.token_env in kj.config.yml and export that variable",
73
+ ],
74
+ };
75
+ }
76
+
77
+ return { ok: true, backend: "hu-board", via: "bundled HU Board" };
78
+ }
@@ -28,15 +28,27 @@ const TARGET_FILES = {
28
28
 
29
29
  // ENV-D1 (KJC-TSK-0642): the tracking invariant names the CHOSEN state
30
30
  // backend — a playbook that says "board or PG" makes the host guess.
31
+ // KJC-TSK-0684 (issue #1287): external boards (Linear, Trello, Jira…) are
32
+ // first-class — card-first points at THE project's board, worked through
33
+ // the host agent's own MCP/tools. kj never mirrors it: a half-empty
34
+ // parallel board is worse than none.
31
35
  const BACKEND_TRACKING = {
32
36
  "hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts — and `kj hu list` BEFORE `kj hu add`: reuse the existing card if one covers the work. Cards are permanent — never delete or recreate one; discard with `kj hu move <id> skipped`. `kj brief board` explains the board.",
33
37
  "planning-game": "Every piece of work has a tracked card in the Planning Game MCP before it starts (In Progress while you work it).",
34
38
  };
35
39
 
40
+ const externalTracking = (boardName) =>
41
+ `Every piece of work has a tracked card in ${boardName} — the project's board — before it starts (use its MCP/tools; move the card as you work it). kj does not mirror it: never create a parallel HU Board.`;
42
+
43
+ function trackingLine(stateBackend, boardName) {
44
+ if (stateBackend === "external") return externalTracking(boardName || "the project's external board");
45
+ return BACKEND_TRACKING[stateBackend] || BACKEND_TRACKING["hu-board"];
46
+ }
47
+
36
48
  // AB-C2 (KJC-TSK-0653): outcome-first — invariants over step scripts.
37
49
  // Frontier models choose their own path best; what they need explicit are
38
50
  // the limits. The git gates enforce these regardless of what any brain does.
39
- const playbookBody = (stateBackend) => `# Karajan method (v4)
51
+ const playbookBody = (stateBackend, boardName) => `# Karajan method (v4)
40
52
 
41
53
  You are the orchestrator; Karajan governs. A task is DONE when its
42
54
  done-statement is literally true, the full suite is green, and every commit
@@ -46,7 +58,7 @@ Invariants (the git gates enforce these — they are not suggestions):
46
58
 
47
59
  - The project RAG answers before you assume: \`kj rag query\` — never guess
48
60
  what the codebase does.
49
- - ${BACKEND_TRACKING[stateBackend] || BACKEND_TRACKING["hu-board"]}
61
+ - ${trackingLine(stateBackend, boardName)}
50
62
  - Tests prove behavior: the failing test exists first (TDD), and the suite
51
63
  is never left red.
52
64
  - Every diff is reviewed by a DIFFERENT AI before it is committed
@@ -71,15 +83,15 @@ Hit a kj bug or friction? Diagnose it and file it upstream with
71
83
  \`kj report-issue\` (sanitized; ask your user before \`--publish\`).
72
84
  `;
73
85
 
74
- export function renderPlaybook({ stateBackend = "hu-board" } = {}) {
75
- return playbookBody(stateBackend);
86
+ export function renderPlaybook({ stateBackend = "hu-board", boardName = null } = {}) {
87
+ return playbookBody(stateBackend, boardName);
76
88
  }
77
89
 
78
90
  /**
79
91
  * Install/refresh the playbook block in the target agent files.
80
92
  * User content outside the managed block is never touched.
81
93
  */
82
- export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board" }) {
94
+ export async function installPlaybook({ projectDir, target = "all", version = "1", stateBackend = "hu-board", boardName = null }) {
83
95
  if (!PLAYBOOK_TARGETS.includes(target)) {
84
96
  throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
85
97
  }
@@ -89,7 +101,7 @@ export async function installPlaybook({ projectDir, target = "all", version = "1
89
101
  let source = "";
90
102
  try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
91
103
  const { content, action } = upsertManagedBlock({
92
- source, blockId: "playbook", version, body: renderPlaybook({ stateBackend }), style: "html",
104
+ source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName }), style: "html",
93
105
  note: "do not edit: regenerated by kj env install",
94
106
  });
95
107
  if (action !== "unchanged") await fs.writeFile(fullPath, content);
@@ -7,12 +7,39 @@
7
7
  * an index can never be written by one and queried by the other.
8
8
  */
9
9
  import fs from "node:fs/promises";
10
+ import { existsSync, rmSync } from "node:fs";
10
11
  import path from "node:path";
12
+ import Database from "better-sqlite3";
11
13
  import { parseDocument } from "yaml";
12
14
  import { getProjectConfigPath } from "../config/loader.js";
15
+ import { dbPath } from "./vec-store.js";
13
16
 
14
17
  const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
15
18
 
19
+ /**
20
+ * KJC-BUG-0128: the hasRagIndex probe could leave behind an EMPTY store
21
+ * whose vec table was created at the default dim (768) — ONNX inserts (384)
22
+ * then failed on every chunk. Before the fallback indexes, delete an empty
23
+ * store (plus WAL siblings) so it is recreated at the right dim. A store
24
+ * that already HAS chunks is never touched — switching dims there would
25
+ * destroy other projects' indexes.
26
+ */
27
+ export function resetEmptyStore() {
28
+ const storePath = dbPath();
29
+ if (!existsSync(storePath)) return true;
30
+ // Read-only, schema-agnostic inspection: no DDL, no vec extension, no
31
+ // dimension assumptions — the store is only ever OPENED to be counted.
32
+ const db = new Database(storePath, { readonly: true });
33
+ try {
34
+ const hasChunksTable = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='chunks'").get();
35
+ if (hasChunksTable && db.prepare("SELECT COUNT(*) AS n FROM chunks").get().n > 0) return false;
36
+ } finally {
37
+ db.close();
38
+ }
39
+ for (const suffix of ["", "-wal", "-shm"]) rmSync(storePath + suffix, { force: true });
40
+ return true;
41
+ }
42
+
16
43
  /** The same config, with the embedder swapped to the built-in ONNX. */
17
44
  export function onnxConfig(config) {
18
45
  return {