karajan-code 4.5.0 → 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.5.0",
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
+ }
@@ -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 }),
@@ -175,11 +175,13 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
175
175
  // silently moving the first hit could move the wrong card.
176
176
  const byId = [];
177
177
  const byShort = [];
178
+ const running = [];
178
179
  for (const meta of await listPlans(projectDir)) {
179
180
  const plan = await loadPlan(projectDir, meta.planId);
180
181
  for (const hu of plan.hus || []) {
181
182
  if (hu.id === ref) byId.push({ plan, hu });
182
183
  else if (hu.short_id === ref) byShort.push({ plan, hu });
184
+ if (hu.status === "running") running.push(hu);
183
185
  }
184
186
  }
185
187
  const matches = byId.length > 0 ? byId : byShort;
@@ -189,6 +191,16 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
189
191
  throw new Error(`"${ref}" is ambiguous (${matches.length} matches: ${ids}) — use the full id`);
190
192
  }
191
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
+ }
192
204
  updateHuStatus(plan, hu.id, status);
193
205
  await savePlan(projectDir, plan);
194
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 {
@@ -67,6 +67,19 @@ const DEFAULTS = {
67
67
  // "none": Karajan does not run without a board.
68
68
  state_backend: "hu-board",
69
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
+ },
70
83
  review_rules: "./.karajan/review-rules.md",
71
84
  coder_rules: "./.karajan/coder-rules.md",
72
85
  base_branch: "main",
@@ -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
+ }