karajan-code 4.4.1 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,12 +24,12 @@
24
24
 
25
25
  Your AI agent (Claude Code, Codex, Gemini CLI, Cursor…) writes the code. **Karajan governs how it happens**: it installs a method your agent follows on every task, and enforces it with git gates that make a false green structurally impossible.
26
26
 
27
- - **RAG before assuming** — `kj rag query` answers what your codebase does; no agent guesses.
28
- - **Card first** — every piece of work is tracked (`kj hu add|move|list`) before it starts; architecture decisions live as git-tracked ADRs (`kj adr add|list`).
27
+ - **RAG before assuming** — `kj rag query` answers what your codebase does; no agent guesses. Works out of the box: local Ollama, or the built-in ONNX embedder when nothing can be installed; cloud embedders require an explicit sensitivity declaration and PII-redact every chunk.
28
+ - **Card first, on YOUR board** — every piece of work is tracked before it starts: kj's HU Board (`kj hu add|move|list`), the Planning Game, or the board the project already uses (Linear, Trello, Jira, GitHub Issues) via your agent's own MCP/tools. Declared, verified at install, never optional — Karajan does not run without a board. ADRs live in git (`kj adr add|list`).
29
29
  - **Tests prove behavior** — the failing test exists first; the suite is never left red.
30
- - **Cross-AI review on every commit** — `kj review --staged` binds a verdict from a *different* AI to the sha256 of the exact diff. Change the code and it must be reviewed again. Without an approved verdict, **the commit does not enter** (pre-commit gate).
30
+ - **Deterministic first, then cross-AI review** — `kj review --staged` runs SonarQube on the changed files before any AI opinion (BLOCKER/CRITICAL reject on the spot), then binds a verdict from a *different* AI to the sha256 of the exact diff — stamped with the workspace it ran from. Without an approved verdict, **the commit does not enter** (pre-commit gate).
31
31
  - **A third AI arbitrates disputes** — `kj solomon` rules when brain and reviewer disagree. Security findings are never overridable — not even by arbitration.
32
- - **Branch first** — the base branch only moves through atomic PRs.
32
+ - **Branch first, lanes for parallel work** — the base branch only moves through atomic PRs; `kj worktree start|list|done` gives each concurrent task its own isolated lane.
33
33
 
34
34
  This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
35
35
 
@@ -66,7 +66,7 @@ Full method: [Work with your agent](https://karajancode.com/docs/v4/working-with
66
66
 
67
67
  ## Headless mode
68
68
 
69
- The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
69
+ The classic multiagent pipeline lives on for CI and automation: `kj run "<task>"` orchestrates coder/reviewer/tester subprocess roles unattended, with the same gates. Agents and CI pass `--non-interactive` (or `KJ_NON_INTERACTIVE=1`): safe gates auto-answer, FAIL findings stop the run with a real exit code. `kj advanced` lists the full surface. [Headless mode docs](https://karajancode.com/docs/v4/headless/).
70
70
 
71
71
  ## v3 (historical)
72
72
 
package/docs/README.es.md CHANGED
@@ -16,12 +16,12 @@
16
16
 
17
17
  Tu agente de IA (Claude Code, Codex, Gemini CLI, Cursor…) escribe el código. **Karajan gobierna cómo ocurre**: instala un método que tu agente sigue en cada tarea y lo hace cumplir con gates de git que hacen el falso verde estructuralmente imposible.
18
18
 
19
- - **RAG antes de suponer** — `kj rag query` responde qué hace tu código; ningún agente adivina.
20
- - **Card primero** — todo trabajo se registra (`kj hu add|move|list`) antes de empezar; las decisiones de arquitectura viven como ADRs en git (`kj adr add|list`).
19
+ - **RAG antes de suponer** — `kj rag query` responde qué hace tu código; ningún agente adivina. Funciona de serie: Ollama local, o el embedder ONNX integrado cuando no se puede instalar nada; los embedders cloud exigen declarar la sensibilidad y redactan PII de cada chunk.
20
+ - **Card primero, en TU board** — todo trabajo se registra antes de empezar: el HU Board de kj (`kj hu add|move|list`), el Planning Game, o el board que el proyecto ya use (Linear, Trello, Jira, GitHub Issues) vía los MCP/tools de tu agente. Declarado, verificado en la instalación, jamás opcional — Karajan no funciona sin board. Los ADRs viven en git (`kj adr add|list`).
21
21
  - **Los tests prueban el comportamiento** — el test que falla existe primero; la suite nunca se queda en rojo.
22
- - **Revisión IA-cruzada en cada commit** — `kj review --staged` liga el veredicto de una IA *distinta* al sha256 del diff exacto. Cambia el código y hay que revisarlo de nuevo. Sin veredicto aprobado, **el commit no entra** (gate pre-commit).
22
+ - **Determinista primero, luego revisión IA-cruzada** — `kj review --staged` pasa SonarQube sobre los ficheros cambiados antes de cualquier opinión de IA (BLOCKER/CRITICAL rechazan en el acto), y después liga el veredicto de una IA *distinta* al sha256 del diff exacto — estampado con el workspace desde el que corrió. Sin veredicto aprobado, **el commit no entra** (gate pre-commit).
23
23
  - **Una tercera IA arbitra las disputas** — `kj solomon` decide cuando brain y reviewer discrepan. Los hallazgos de seguridad no los anula nadie — ni siquiera el arbitraje.
24
- - **Rama primero** — la rama base solo se mueve por PRs atómicas.
24
+ - **Rama primero, carriles para el paralelo** — la rama base solo se mueve por PRs atómicas; `kj worktree start|list|done` da a cada tarea concurrente su carril aislado.
25
25
 
26
26
  Este repo corre bajo su propio entorno: cada commit de karajan-code lleva un veredicto de IA cruzada.
27
27
 
@@ -58,7 +58,7 @@ Método completo: [Trabaja con tu agente](https://karajancode.com/docs/es/v4/wor
58
58
 
59
59
  ## Modo headless
60
60
 
61
- El pipeline multiagente clásico sigue vivo para CI y automatización: `kj run "<tarea>"` orquesta roles coder/reviewer/tester en subprocesos sin humano delante, con los mismos gates. `kj advanced` lista la superficie completa. [Doc del modo headless](https://karajancode.com/docs/es/v4/headless/).
61
+ El pipeline multiagente clásico sigue vivo para CI y automatización: `kj run "<tarea>"` orquesta roles coder/reviewer/tester en subprocesos sin humano delante, con los mismos gates. Agentes y CI pasan `--non-interactive` (o `KJ_NON_INTERACTIVE=1`): los gates seguros se auto-responden y los findings FAIL paran el run con exit code de verdad. `kj advanced` lista la superficie completa. [Doc del modo headless](https://karajancode.com/docs/es/v4/headless/).
62
62
 
63
63
  ## v3 (histórico)
64
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.4.1",
3
+ "version": "4.6.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",
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Method report (KJC-TSK-0689, MG-D of épica KJC-PCS-0068). Individual
3
+ * deviations can each be legitimate — the AGGREGATE trail is the drift
4
+ * detector. Three measurable signals over the recent history: commit
5
+ * subjects without a card reference, review verdicts by workspace
6
+ * ([root] vs lanes), and source commits without a single test change.
7
+ * Informative by design; only a card-ref drought turns it into a warn.
8
+ */
9
+ import { readdirSync, readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+ import { runCommand } from "../utils/process.js";
12
+ import { checkTestsWithCode } from "../review/tests-with-code.js";
13
+ import { CARD_REF_RE } from "../review/card-first.js";
14
+ import { STRATEGY } from "./types.js";
15
+
16
+ function recentVerdicts(projectDir, sample) {
17
+ try {
18
+ const dir = path.join(projectDir, ".karajan", "reviews");
19
+ return readdirSync(dir).filter((f) => f.endsWith(".json"))
20
+ .map((f) => JSON.parse(readFileSync(path.join(dir, f), "utf8")))
21
+ .sort((a, b) => String(b.timestamp).localeCompare(String(a.timestamp)))
22
+ .slice(0, sample);
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ export async function collectMethodStats({ projectDir, run = runCommand, sample = 20 } = {}) {
29
+ const subjectsRes = await run("git", ["log", `-${sample}`, "--no-merges", "--format=%s"], { cwd: projectDir });
30
+ const subjects = subjectsRes.exitCode === 0 ? subjectsRes.stdout.split("\n").filter(Boolean) : [];
31
+
32
+ // "@%h" carries a % so git treats it as a literal format string — a bare
33
+ // "@" is rejected as an unknown pretty alias (caught in a live smoke).
34
+ const blocksRes = await run("git", ["log", `-${Math.min(sample, 10)}`, "--no-merges", "--name-only", "--format=@%h"], { cwd: projectDir });
35
+ const blocks = blocksRes.exitCode === 0
36
+ ? blocksRes.stdout.split(/^@.*$/m).map((b) => b.split("\n").map((l) => l.trim()).filter(Boolean)).filter((b) => b.length > 0)
37
+ : [];
38
+ const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files, env: {} }).mode !== "pass").length;
39
+
40
+ const verdicts = recentVerdicts(projectDir, sample);
41
+ const stamped = verdicts.filter((v) => v.workspace);
42
+
43
+ return {
44
+ commits: { total: subjects.length, withCard: subjects.filter((s) => CARD_REF_RE.test(s)).length },
45
+ verdicts: { total: verdicts.length, stamped: stamped.length, root: stamped.filter((v) => v.workspace === "root").length },
46
+ testless: { sampled: blocks.length, offenders },
47
+ };
48
+ }
49
+
50
+ export function formatMethodStats(s) {
51
+ return `commits with card ref ${s.commits.withCard}/${s.commits.total} · verdict workspaces root ${s.verdicts.root}/${s.verdicts.stamped || 0} stamped (${s.verdicts.total} total) · source commits without tests ${s.testless.offenders}/${s.testless.sampled}`;
52
+ }
53
+
54
+ function createMethodCheck() {
55
+ return {
56
+ name: "method",
57
+ label: "Method adherence (recent history)",
58
+ strategy: STRATEGY.NONE,
59
+ async detect({ config = {}, projectDir = process.cwd(), run = runCommand } = {}) {
60
+ const stats = await collectMethodStats({ projectDir, run, sample: config.method_gates?.report_sample || 20 });
61
+ const detail = formatMethodStats(stats);
62
+ const drought = stats.commits.total >= 5 && stats.commits.withCard / stats.commits.total < 0.5;
63
+ if (drought) {
64
+ return { ok: false, severity: "warn", detail: `most recent commits carry no card reference — ${detail}` };
65
+ }
66
+ return { ok: true, severity: "info", detail };
67
+ },
68
+ };
69
+ }
70
+
71
+ export function getMethodChecks() {
72
+ return [createMethodCheck()];
73
+ }
@@ -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 });
@@ -5,17 +5,22 @@
5
5
  */
6
6
 
7
7
  import { checkHarden } from "../harden/check.js";
8
+ import { collectMethodStats, formatMethodStats } from "../checks/method.js";
8
9
 
9
10
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
10
11
  const result = await checkHarden({ projectDir, profile });
12
+ // KJC-TSK-0689 (MG-D): method adherence is VISIBILITY, not a gate — it
13
+ // rides along in check output but never affects the exit code.
14
+ const method = await collectMethodStats({ projectDir }).catch(() => null);
11
15
 
12
16
  if (json) {
13
- logger.info?.(JSON.stringify(result));
17
+ logger.info?.(JSON.stringify({ ...result, method }));
14
18
  return result.ok ? 0 : 1;
15
19
  }
16
20
 
17
21
  logger.info?.(`kj check (${profile})`);
18
22
  for (const c of result.checks) logger.info?.(` ${c.ok ? "✓" : "✗"} ${c.id}: ${c.detail}`);
23
+ if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
19
24
  if (!result.ok) logger.info?.("Harness drift detected — run `kj harden` to repair.");
20
25
  else logger.info?.("Harness OK.");
21
26
  return result.ok ? 0 : 1;
@@ -36,6 +36,7 @@ import { getDirSetupChecks } from "../checks/dir-setup.js";
36
36
  import { getProjectChecks } from "../checks/project-checks.js";
37
37
  import { getHardwareChecks } from "../checks/hardware.js";
38
38
  import { getOrphanChecks } from "../checks/orphans.js";
39
+ import { getMethodChecks } from "../checks/method.js";
39
40
 
40
41
  /**
41
42
  * Build the list of Check objects applicable to the current config.
@@ -74,6 +75,7 @@ function buildChecks(config, { projectOnly = false } = {}) {
74
75
  ...getSqueezrChecks(),
75
76
  ...getAiTrashChecks(),
76
77
  ...getOrphanChecks(),
78
+ ...getMethodChecks(),
77
79
  ...getQmdChecks(),
78
80
  ...getRagHooksChecks({ projectDir }),
79
81
  ...getProjectChecks({ projectDir }),
@@ -11,6 +11,7 @@ import { openVecStore, projectSlug, getLastIndexedCommit, dbPath } from "../rag/
11
11
  import { ragIndexCommand } from "./rag.js";
12
12
  import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
13
13
  import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
14
+ import { verifyBoardAccess } from "../environment/board-access.js";
14
15
 
15
16
  function hasRagIndex(config, projectDir) {
16
17
  // KJC-BUG-0128: probing must never CREATE the store — openVecStore runs
@@ -45,9 +46,26 @@ export async function envInstallCommand({ config = null, logger = null, flags =
45
46
  const result = await installPlaybook({
46
47
  projectDir, target: flags.target || "all",
47
48
  stateBackend: config?.state_backend || "hu-board",
49
+ boardName: config?.board?.name || null,
48
50
  });
49
51
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
50
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
+
51
69
  // ENV-E1: RAG-first — the playbook orders "query the RAG before coding",
52
70
  // so installing the environment guarantees the index exists. KJC-TSK-0659
53
71
  // stop-on-sudo: an index that cannot be built is a BLOCKING condition —
@@ -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 = [];
@@ -165,11 +175,13 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
165
175
  // silently moving the first hit could move the wrong card.
166
176
  const byId = [];
167
177
  const byShort = [];
178
+ const running = [];
168
179
  for (const meta of await listPlans(projectDir)) {
169
180
  const plan = await loadPlan(projectDir, meta.planId);
170
181
  for (const hu of plan.hus || []) {
171
182
  if (hu.id === ref) byId.push({ plan, hu });
172
183
  else if (hu.short_id === ref) byShort.push({ plan, hu });
184
+ if (hu.status === "running") running.push(hu);
173
185
  }
174
186
  }
175
187
  const matches = byId.length > 0 ? byId : byShort;
@@ -179,6 +191,16 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
179
191
  throw new Error(`"${ref}" is ambiguous (${matches.length} matches: ${ids}) — use the full id`);
180
192
  }
181
193
  const { plan, hu } = matches[0];
194
+ // KJC-TSK-0688 (MG-C): the nudge fires AT the deviation — a second
195
+ // concurrent task belongs in its own lane, not in the shared tree.
196
+ const others = running.filter((r) => r.id !== hu.id);
197
+ if (status === "running" && hu.status !== "running" && others.length > 0) {
198
+ const slug = String(hu.short_id || hu.id).toLowerCase();
199
+ console.warn(
200
+ `⚠ ${others.map((r) => r.short_id || r.id).join(", ")} ${others.length === 1 ? "is" : "are"} already running — `
201
+ + `a second concurrent task should live in its own lane: kj worktree start ${slug}`
202
+ );
203
+ }
182
204
  updateHuStatus(plan, hu.id, status);
183
205
  await savePlan(projectDir, plan);
184
206
  return emit({ id: hu.id, status }, `✓ ${hu.short_id || hu.id} → ${status}`);
@@ -11,6 +11,25 @@ import { runOneShotReview } from "../review/one-shot-review.js";
11
11
  import { runSolomonArbitration } from "../review/solomon-arbitration.js";
12
12
  import { ensureGateTrackable } from "../review/gate-gitignore.js";
13
13
  import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
14
+ import { checkCardFirst } from "../review/card-first.js";
15
+ import { checkTestsWithCode } from "../review/tests-with-code.js";
16
+
17
+ // KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
18
+ // (before spending sonar/reviewer effort) AND on --check — the pre-commit
19
+ // hook already calls --check, so existing projects gain the gate with a
20
+ // simple package update, no hook regeneration.
21
+ async function enforceCardFirst({ config, projectDir }) {
22
+ const branchRes = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
23
+ const branch = branchRes.stdout?.trim() || "HEAD";
24
+ const card = await checkCardFirst({ config, projectDir, branch });
25
+ if (card.mode === "warn") console.log(`⚠ card-first: ${card.reason}`);
26
+ if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
27
+ if (!card.ok) {
28
+ console.log(`✗ card-first gate: ${card.reason}`);
29
+ process.exitCode = 1;
30
+ }
31
+ return card;
32
+ }
14
33
 
15
34
  // Raw git always — never a wrapped/compressing runner (KJC-BUG-0115).
16
35
  async function rawDiff(range, extraArgs = []) {
@@ -87,6 +106,32 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
87
106
 
88
107
  const diff = await rawDiff(flags.range);
89
108
 
109
+ const card = await enforceCardFirst({ config, projectDir });
110
+ if (!card.ok) return { verdict: "rejected", reviewer: "card-first", issues: [{ severity: "high", description: card.reason }] };
111
+
112
+ // KJC-TSK-0687 (MG-B): the verifiable half of TDD — sources without a
113
+ // single test change warn (block via method_gates.tests_with_code).
114
+ const changedFiles = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
115
+
116
+ // KJC-TSK-0688 (MG-C): oversized-diff nudge — informative only; the
117
+ // project's CI owns any hard budget. Sums additions from --numstat.
118
+ const sizeWarn = config?.method_gates?.pr_size_warn ?? 150;
119
+ if (sizeWarn > 0) {
120
+ const numstat = await rawDiff(flags.range, ["--numstat"]);
121
+ const added = numstat.split("\n").reduce((acc, l) => acc + (Number(l.split("\t")[0]) || 0), 0);
122
+ if (added > sizeWarn) {
123
+ console.log(`⚠ pr-size: ${added} lines added (guideline ~${sizeWarn}) — atomic PRs review better; consider splitting (method_gates.pr_size_warn to tune)`);
124
+ }
125
+ }
126
+ const tests = checkTestsWithCode({ config, stagedFiles: changedFiles });
127
+ if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
128
+ if (tests.mode === "exempt") console.log(`⚠ tests-with-code exempt: ${tests.reason}`);
129
+ if (!tests.ok) {
130
+ console.log(`✗ tests-with-code gate: ${tests.reason}`);
131
+ process.exitCode = 1;
132
+ return { verdict: "rejected", reviewer: "tests-with-code", issues: [{ severity: "high", description: tests.reason }] };
133
+ }
134
+
90
135
  if (flags.check) {
91
136
  const res = await checkVerdict(projectDir, diff);
92
137
  console.log(res.ok
@@ -102,8 +147,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
102
147
  // the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
103
148
  let task = flags.task;
104
149
  if (flags.sonar !== false) {
105
- const files = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
106
- const pre = await runSonarPregate({ config, stagedFiles: files, logger });
150
+ const pre = await runSonarPregate({ config, stagedFiles: changedFiles, logger });
107
151
  if (!pre.available) {
108
152
  console.log(`⚠ sonar pre-gate skipped: ${pre.reason}`);
109
153
  } else {
@@ -60,8 +60,26 @@ 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 },
70
+ // Method gates (KJC-PCS-0068): rules climb from playbook text to
71
+ // deterministic enforcement. card_first: "auto" = block with hu-board
72
+ // (fully verifiable), warn with planning-game/external (presence only);
73
+ // explicit "warn" | "block" override. Release branches are exempt.
74
+ method_gates: {
75
+ card_first: "auto",
76
+ card_first_exempt_branches: ["chore/release-"],
77
+ // MG-B: source changes without a test change — "warn" | "block".
78
+ tests_with_code: "warn",
79
+ // MG-C: informative nudge when the staged diff exceeds this many
80
+ // added lines (0 disables). The project's CI owns any hard budget.
81
+ pr_size_warn: 150
82
+ },
65
83
  review_rules: "./.karajan/review-rules.md",
66
84
  coder_rules: "./.karajan/coder-rules.md",
67
85
  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);
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Card-first gate (KJC-TSK-0686, MG-A of épica KJC-PCS-0068): the rule
3
+ * that work starts from a card moves from playbook text into the gate.
4
+ * hu-board → the branch must reference a LIVE card (block by default);
5
+ * planning-game/external → card-shaped reference required (warn default,
6
+ * block via method_gates.card_first). Exemptions explicit and visible:
7
+ * base branch, configured prefixes (releases), KJ_ALLOW_NO_CARD=1.
8
+ */
9
+ import { listPlans, loadPlan } from "../plan/plan-store.js";
10
+ import { escapeRegExp } from "../utils/escape-regexp.js";
11
+
12
+ const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
13
+ // Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
14
+ // KJC-TSK-0684 (optional middle segments) — tight enough to skip slugs.
15
+ // Shared with the method report (MG-D): one pattern, one truth.
16
+ export const CARD_REF_RE = /\b[a-z][a-z0-9]{1,9}(?:-[a-z][a-z0-9]{1,9})*-\d{1,6}\b/i;
17
+ const DEFAULT_EXEMPT_PREFIXES = ["chore/release-"];
18
+
19
+ // Token-boundary match: BB-002 must not satisfy a branch that actually
20
+ // references BB-0022 (reviewer catch — substring matching allowed work
21
+ // onto the wrong card).
22
+ function refInBranch(ref, branchLower) {
23
+ return new RegExp(`(^|[^a-z0-9])${escapeRegExp(String(ref).toLowerCase())}([^a-z0-9]|$)`).test(branchLower);
24
+ }
25
+
26
+ async function findLiveCardInBranch(projectDir, branch) {
27
+ const needle = branch.toLowerCase();
28
+ // Scan EVERY match before deciding: any live reference satisfies the
29
+ // gate; a closed one is only reported when no live match exists
30
+ // (reviewer catch — first-match could reject a branch that also
31
+ // references a live card, depending on plan iteration order).
32
+ let closedHit = null;
33
+ for (const meta of await listPlans(projectDir)) {
34
+ const plan = await loadPlan(projectDir, meta.planId);
35
+ for (const h of plan.hus || []) {
36
+ for (const ref of [h.short_id, h.id]) {
37
+ if (ref && refInBranch(ref, needle)) {
38
+ if (LIVE_STATUSES.has(h.status)) return { ref, live: true, status: h.status };
39
+ closedHit = { ref, live: false, status: h.status };
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return closedHit;
45
+ }
46
+
47
+ export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env }) {
48
+ if (env.KJ_ALLOW_NO_CARD === "1") {
49
+ return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_CARD=1 (explicit escape hatch)" };
50
+ }
51
+ const exempt = config.method_gates?.card_first_exempt_branches || DEFAULT_EXEMPT_PREFIXES;
52
+ if (exempt.some((p) => branch.startsWith(p))) {
53
+ return { ok: true, mode: "exempt", reason: `branch prefix exempt (${branch.split("/")[0]}/…)` };
54
+ }
55
+ // The base branch belongs to the branch-first gate — card-first here
56
+ // would only double-report (and HEAD covers detached checkouts in CI).
57
+ if ([config.base_branch || "main", "main", "master", "HEAD"].includes(branch)) {
58
+ return { ok: true, mode: "exempt", reason: "base branch — the branch-first gate owns this case" };
59
+ }
60
+
61
+ const backend = config.state_backend || "hu-board";
62
+ const policy = config.method_gates?.card_first || "auto";
63
+
64
+ if (backend === "hu-board") {
65
+ const hit = await findLiveCardInBranch(projectDir, branch);
66
+ if (hit?.live) return { ok: true, mode: "pass", ref: hit.ref };
67
+ const effective = policy === "auto" ? "block" : policy;
68
+ const reason = hit
69
+ ? `branch references ${hit.ref} but its card is "${hit.status}" — card-first needs a live card (reopen it or create one: kj hu add)`
70
+ : `no live card referenced in branch "${branch}" — create it first (kj hu add "…" --id <REF>) and name the branch after it`;
71
+ return effective === "block"
72
+ ? { ok: false, mode: "block", reason }
73
+ : { ok: true, mode: "warn", reason };
74
+ }
75
+
76
+ // planning-game / external: presence check only — liveness lives in the
77
+ // project's own board, out of local reach.
78
+ if (CARD_REF_RE.test(branch)) return { ok: true, mode: "pass", ref: branch.match(CARD_REF_RE)[0] };
79
+ const effective = policy === "auto" ? "warn" : policy;
80
+ const boardName = config.board?.name || backend;
81
+ const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
82
+ return effective === "block"
83
+ ? { ok: false, mode: "block", reason }
84
+ : { ok: true, mode: "warn", reason };
85
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Tests-with-code gate (KJC-TSK-0687, MG-B of épica KJC-PCS-0068). The
3
+ * verifiable half of TDD is deterministic: a staged diff that touches
4
+ * source files without touching a single test warns — or blocks, via
5
+ * `method_gates.tests_with_code`. Test-FIRST remains the reviewer's
6
+ * judgment; test-PRESENT belongs to the gate. Patterns come from the
7
+ * project's own `development.*` config (same ones headless enforces).
8
+ * KJ_ALLOW_NO_TESTS=1 is the explicit escape hatch.
9
+ */
10
+
11
+ const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
12
+ const DEFAULT_SOURCE_EXTS = [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"];
13
+
14
+ export function checkTestsWithCode({ config = {}, stagedFiles = [], env = process.env }) {
15
+ if (env.KJ_ALLOW_NO_TESTS === "1") {
16
+ return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
17
+ }
18
+ const patterns = config.development?.test_file_patterns || DEFAULT_TEST_PATTERNS;
19
+ const exts = config.development?.source_file_extensions || DEFAULT_SOURCE_EXTS;
20
+ // Staged paths are repo-relative (no leading slash) — normalize so the
21
+ // "/tests/" style patterns also match the top-level tests directory.
22
+ const isTest = (f) => patterns.some((p) => `/${f}`.includes(p));
23
+ const sources = stagedFiles.filter((f) => !isTest(f) && exts.some((e) => f.endsWith(e)));
24
+ const hasTests = stagedFiles.some(isTest);
25
+
26
+ if (sources.length === 0 || hasTests) return { ok: true, mode: "pass" };
27
+
28
+ const policy = config.method_gates?.tests_with_code || "warn";
29
+ const reason = `source changes without any test change (${sources.slice(0, 5).join(", ")}${sources.length > 5 ? "…" : ""}) — the failing test comes first; add one or KJ_ALLOW_NO_TESTS=1 for a deliberate exception`;
30
+ return policy === "block"
31
+ ? { ok: false, mode: "block", sources, reason }
32
+ : { ok: true, mode: "warn", sources, reason };
33
+ }