karajan-code 4.1.10 → 4.2.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.1.10",
3
+ "version": "4.2.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",
@@ -403,10 +403,14 @@ export function registerMeta(program, { pkgVersion }) {
403
403
  .description("Resume a paused session")
404
404
  .argument("<sessionId>")
405
405
  .option("--answer <text>", "Answer to the question that caused the pause")
406
+ .option("--non-interactive", "Agents/CI: auto-answer prompts with their safe defaults; stop when there is none. Also enabled by KJ_NON_INTERACTIVE=1.")
406
407
  .option("--json", "Output JSON only")
407
408
  .action(async (sessionId, flags) => {
408
409
  await withConfig(pkgVersion, "resume", flags, async ({ config, logger }) => {
409
- await resumeCommand({ sessionId, answer: flags.answer, config, logger, flags });
410
+ const res = await resumeCommand({ sessionId, answer: flags.answer, config, logger, flags });
411
+ // KJC-TSK-0674 (issue #1289): same contract as kj run — an aborted
412
+ // resume must be observable by CI.
413
+ if (res?.aborted) process.exitCode = 1;
410
414
  });
411
415
  });
412
416
 
@@ -54,6 +54,7 @@ export function registerPipeline(program, { pkgVersion }) {
54
54
  .argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
55
55
  .option("--task-file <path>", "Read the task from a file (e.g. .md). Alternative to the positional task argument.")
56
56
  .option("-y, --yes", "Skip the cwd confirmation prompt (CI / scripted runs).")
57
+ .option("--non-interactive", "Agents/CI: auto-answer prompts with their safe defaults (warn → continue) and stop with exit code 1 when there is none (fail). Also enabled by KJ_NON_INTERACTIVE=1.")
57
58
  .option("--planner <name>")
58
59
  .option("--coder <name>")
59
60
  .option("--reviewer <name>")
@@ -145,7 +146,10 @@ export function registerPipeline(program, { pkgVersion }) {
145
146
  }
146
147
  const { resolveTaskInput } = await import("../utils/task-file.js");
147
148
  const resolvedTask = await resolveTaskInput({ task: effectiveTask, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
148
- await runCommandHandler({ task: resolvedTask, config, logger, flags });
149
+ const res = await runCommandHandler({ task: resolvedTask, config, logger, flags });
150
+ // KJC-TSK-0674 (issue #1289): an aborted run (spec-review cancel,
151
+ // unanswerable non-interactive gate) must be observable by CI.
152
+ if (res?.aborted) process.exitCode = 1;
149
153
  });
150
154
  });
151
155
 
@@ -207,6 +211,7 @@ export function registerPipeline(program, { pkgVersion }) {
207
211
  .option("--check", "Verify the recorded verdict matches the staged diff (exit 0/1, hook-friendly)")
208
212
  .option("--range <range>", "Review a git range (e.g. main..HEAD) instead of the staged diff")
209
213
  .option("--install-gate", "Enable the pre-commit review gate for this project (creates .karajan/review-gate)")
214
+ .option("--no-sonar", "Skip the deterministic Sonar pre-gate that runs before the cross-AI verdict")
210
215
  .action(async (task, flags) => {
211
216
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
212
217
  // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
@@ -20,6 +20,29 @@ export function creatorLabel() {
20
20
  export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
21
21
  const BACKLOG_NAME = "brain-backlog";
22
22
 
23
+ // KJC-TSK-0675 (issue #1288): agents that skip `kj hu list` created twins of
24
+ // existing work — sometimes in a DIFFERENT plan. Titles are compared
25
+ // normalized (case/punctuation-insensitive) across every live HU of every
26
+ // plan: identical → refuse; mostly-overlapping tokens → warn with candidates.
27
+ const normTitle = (t) => String(t || "").toLowerCase().replaceAll(/[^\p{L}\p{N}]+/gu, " ").trim();
28
+ const titleTokens = (t) => new Set(normTitle(t).split(" ").filter((w) => w.length > 2));
29
+
30
+ export function findTitleMatches(title, allHus) {
31
+ const norm = normTitle(title);
32
+ const toks = titleTokens(title);
33
+ const identical = [];
34
+ const similar = [];
35
+ for (const h of allHus) {
36
+ if (h.status === "skipped") continue; // discarded cards don't reserve titles
37
+ if (normTitle(h.title) === norm) { identical.push(h); continue; }
38
+ const other = titleTokens(h.title);
39
+ const shared = [...toks].filter((w) => other.has(w)).length;
40
+ const smaller = Math.min(toks.size, other.size);
41
+ if (smaller > 0 && shared / smaller >= 0.8) similar.push(h);
42
+ }
43
+ return { identical, similar };
44
+ }
45
+
23
46
  async function backlogPlan(projectDir) {
24
47
  const plans = await listPlans(projectDir);
25
48
  const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
@@ -65,6 +88,26 @@ export async function huCommand({ config = null, action, args = [], flags = {} }
65
88
  if (action === "add") {
66
89
  const title = args[0];
67
90
  if (!title || !title.trim()) throw new Error("kj hu add requires a title: kj hu add \"<story>\"");
91
+ // Cross-plan title dedup (KJC-TSK-0675) — check BEFORE creating anything.
92
+ const allHus = [];
93
+ for (const meta of await listPlans(projectDir)) {
94
+ const p = await loadPlan(projectDir, meta.planId);
95
+ for (const h of p.hus || []) allHus.push({ ...h, plan: p.alias || p.name || p.planId });
96
+ }
97
+ const { identical, similar } = findTitleMatches(title, allHus);
98
+ if (identical.length > 0) {
99
+ const ref = identical[0];
100
+ throw new Error(
101
+ `An HU with this title already exists: ${ref.short_id || ref.id} (status: ${ref.status}, plan: ${ref.plan}). `
102
+ + `HUs are never duplicated — work that card, move it with: kj hu move ${ref.short_id || ref.id} <status>, `
103
+ + `or run kj hu list before adding.`
104
+ );
105
+ }
106
+ if (similar.length > 0) {
107
+ const plural = similar.length === 1 ? "" : "s";
108
+ const candidates = similar.map((h) => ` · ${h.short_id || h.id} (${h.status}, plan: ${h.plan}) "${h.title}"`).join("\n");
109
+ console.warn(`⚠ possible duplicate${plural} — check before working it:\n${candidates}`);
110
+ }
68
111
  const plan = await backlogPlan(projectDir);
69
112
  // KJC-TSK-0669 (absolute rule): cards are permanent. The delete-and-
70
113
  // recreate "fix" an agent improvises loses history and duplicates ids —
@@ -10,10 +10,11 @@ import { checkVerdict } from "../review/verdict-store.js";
10
10
  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
+ import { runSonarPregate, formatSonarFinding } from "../review/sonar-pregate.js";
13
14
 
14
15
  // Raw git always — never a wrapped/compressing runner (KJC-BUG-0115).
15
- async function rawDiff(range) {
16
- const args = range ? ["diff", range] : ["diff", "--cached"];
16
+ async function rawDiff(range, extraArgs = []) {
17
+ const args = range ? ["diff", range, ...extraArgs] : ["diff", "--cached", ...extraArgs];
17
18
  const res = await runCommand("git", args);
18
19
  if (res.exitCode !== 0) {
19
20
  throw new Error(res.stderr?.trim() || `git ${args.join(" ")} failed`);
@@ -29,7 +30,11 @@ function printVerdict(record) {
29
30
  }
30
31
  console.log(`✗ REJECTED by ${record.reviewer} — ${record.issues.length} blocking issue(s):`);
31
32
  for (const issue of record.issues) {
32
- const where = issue.file ? ` [${issue.file}${issue.line ? `:${issue.line}` : ""}]` : "";
33
+ let where = "";
34
+ if (issue.file) {
35
+ const line = issue.line ? `:${issue.line}` : "";
36
+ where = ` [${issue.file}${line}]`;
37
+ }
33
38
  console.log(` - (${issue.severity || "high"})${where} ${issue.description || issue.id}`);
34
39
  if (issue.suggested_fix) console.log(` fix: ${issue.suggested_fix}`);
35
40
  }
@@ -48,7 +53,8 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
48
53
  if (res.ruling === "approve") {
49
54
  console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
50
55
  } else {
51
- console.log(`⚖ Solomon${res.solomon ? ` (${res.solomon})` : ""} rules for the reviewer — obey and fix:`);
56
+ const who = res.solomon ? ` (${res.solomon})` : "";
57
+ console.log(`⚖ Solomon${who} rules for the reviewer — obey and fix:`);
52
58
  }
53
59
  if (res.reasoning) console.log(` ${res.reasoning}`);
54
60
  process.exitCode = res.ruling === "approve" ? 0 : 1;
@@ -89,7 +95,39 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
89
95
  return res;
90
96
  }
91
97
 
92
- const record = await runOneShotReview({ diff, task: flags.task, config, logger, projectDir });
98
+ // KJC-TSK-0676: deterministic pre-gate — sonar findings on the changed
99
+ // files come back BEFORE any AI opinion. Blocking severities reject
100
+ // without spending reviewer tokens; the rest travel with the task so
101
+ // the cross-AI reviewer weighs them. Unavailable sonar degrades loudly.
102
+ let task = flags.task;
103
+ if (flags.sonar !== false) {
104
+ const files = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
105
+ const pre = await runSonarPregate({ config, stagedFiles: files, logger });
106
+ if (!pre.available) {
107
+ console.log(`⚠ sonar pre-gate skipped: ${pre.reason}`);
108
+ } else {
109
+ const found = [...pre.blocking, ...pre.advisory];
110
+ if (found.length > 0) {
111
+ console.log(`Sonar on the changed files — ${pre.blocking.length} blocking, ${pre.advisory.length} advisory (project total: ${pre.totalProject}):`);
112
+ for (const f of found) console.log(` - ${formatSonarFinding(f)}`);
113
+ }
114
+ if (pre.blocking.length > 0) {
115
+ console.log("✗ REJECTED by sonar (deterministic) — fix the blocking findings; the cross-AI reviewer was not invoked.");
116
+ process.exitCode = 1;
117
+ return {
118
+ verdict: "rejected", reviewer: "sonar",
119
+ issues: pre.blocking.map((i) => ({ severity: i.severity, file: undefined, description: formatSonarFinding(i) })),
120
+ };
121
+ }
122
+ if (pre.advisory.length > 0) {
123
+ task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n`
124
+ + `Deterministic Sonar findings on these files (fold them into your review):\n`
125
+ + pre.advisory.map((f) => `- ${formatSonarFinding(f)}`).join("\n");
126
+ }
127
+ }
128
+ }
129
+
130
+ const record = await runOneShotReview({ diff, task, config, logger, projectDir });
93
131
  printVerdict(record);
94
132
  process.exitCode = record.verdict === "approved" ? 0 : 1;
95
133
  return record;
@@ -87,9 +87,10 @@ const B = {
87
87
  render: () => brief("Board",
88
88
  "the HU Board is the permanent record of work: every card traces to who created it and what happened to it — a dashboard warning is fixed with the command it names, never by rebuilding the board.",
89
89
  [
90
+ "Before `kj hu add`, ALWAYS run `kj hu list`: if a card already covers the work (same scope, any plan), work THAT card instead of creating a twin.",
90
91
  "Cards are NEVER deleted or recreated — not to fix warnings, not to tidy up. Discard = `kj hu move <id> skipped` (archived, history kept).",
91
92
  "States move only via `kj hu move <id> <pending|running|done|failed|skipped>`.",
92
- "A duplicate short_id means the card already exists: update that card, never add a twin.",
93
+ "A duplicate short_id or title means the card already exists: update that card, never add a twin.",
93
94
  "A warning is information, not damage: run the command it names; if it looks like a kj bug, `kj report-issue` — never work around it by destroying state.",
94
95
  ],
95
96
  "the board reflects reality: the same cards before and after your action, with truthful states and provenance intact."),
@@ -29,7 +29,7 @@ const TARGET_FILES = {
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
31
  const BACKEND_TRACKING = {
32
- "hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts. Cards are permanent — never delete or recreate one; discard with `kj hu move <id> skipped`. `kj brief board` explains the board.",
32
+ "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
33
  "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
34
  };
35
35
 
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Sonar pre-gate for `kj review --staged` (KJC-TSK-0676, user request
3
+ * 2026-07-23). With the brain outside the core (v4), skipping sonar is one
4
+ * decision away — proven the day the brain itself shipped 3 sonar issues in
5
+ * new code. Hung off the review gate, it becomes a gate, not discipline:
6
+ * deterministic findings on the STAGED files come back before any AI
7
+ * verdict, and BLOCKER/CRITICAL ones reject without spending reviewer
8
+ * tokens. Unavailable sonar degrades loudly ({available:false, reason}) —
9
+ * a laptop without Docker still commits.
10
+ */
11
+
12
+ import { runSonarScan } from "../sonar/scanner.js";
13
+ import { getOpenIssues } from "../sonar/api.js";
14
+ import { acquireToolLock } from "../utils/tool-governor.js";
15
+
16
+ const BLOCKING_SEVERITIES = new Set(["BLOCKER", "CRITICAL"]);
17
+
18
+ /** Repo-relative path of a sonar issue (component is "<projectKey>:<path>"). */
19
+ export function issueFile(issue) {
20
+ const component = String(issue?.component || "");
21
+ const idx = component.indexOf(":");
22
+ return idx === -1 ? component : component.slice(idx + 1);
23
+ }
24
+
25
+ /** One printable line per finding: (SEVERITY) [file:line] rule — message. */
26
+ export function formatSonarFinding(issue) {
27
+ const line = issue.line ? `:${issue.line}` : "";
28
+ return `(${issue.severity}) [${issueFile(issue)}${line}] ${issue.rule} — ${issue.message}`;
29
+ }
30
+
31
+ /**
32
+ * Scan the project (single-flight via the tool governor) and return the
33
+ * open issues that live on the staged files, split by blocking severity.
34
+ * Every failure path degrades to {available:false, reason} — the pre-gate
35
+ * never breaks the review, it only refuses to stay silent.
36
+ */
37
+ export async function runSonarPregate({ config, stagedFiles = [], logger = null }) {
38
+ if (config?.review_gate?.sonar === false) {
39
+ return { available: false, reason: "disabled in config (review_gate.sonar: false)" };
40
+ }
41
+ let lock = null;
42
+ try {
43
+ lock = await acquireToolLock("sonar-scanner", { timeoutMs: 300_000 });
44
+ const scan = await runSonarScan(config);
45
+ if (!scan.ok) {
46
+ return { available: false, reason: (scan.stderr || scan.stdout || "sonar scan failed").trim() };
47
+ }
48
+ const res = await getOpenIssues(config, scan.projectKey);
49
+ const staged = new Set(stagedFiles);
50
+ const onStaged = (res.issues || []).filter((i) => staged.has(issueFile(i)));
51
+ return {
52
+ available: true,
53
+ blocking: onStaged.filter((i) => BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
54
+ advisory: onStaged.filter((i) => !BLOCKING_SEVERITIES.has(String(i.severity).toUpperCase())),
55
+ totalProject: res.total ?? (res.issues || []).length,
56
+ };
57
+ } catch (err) {
58
+ logger?.warn?.(`[sonar-pregate] ${err.message}`);
59
+ return { available: false, reason: err.message };
60
+ } finally {
61
+ lock?.release?.();
62
+ }
63
+ }
@@ -78,17 +78,24 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
78
78
  action = await decideGateAutonomously(resolve, { severity, findings });
79
79
  } else {
80
80
  if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
81
+ // KJC-TSK-0674 (issue #1289): at severity FAIL the default flips to
82
+ // cancel — continuing past fails requires an explicit answer, both
83
+ // interactively (Enter cancels) and unattended (no declared default,
84
+ // so --yes / KJ_NON_INTERACTIVE stops with exit ≠ 0). warn/info keep
85
+ // the KJC-BUG-0109 contract: --yes presses Enter → continue.
86
+ const fallback = severity === "fail" ? "cancel" : "continue";
81
87
  const answer = await askQuestion(
82
- `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
88
+ `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: ${fallback})`,
83
89
  {
84
90
  detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1, findings: findingsForPrompt(findings) },
85
- // KJC-BUG-0109: mirrors the interactive default — --yes presses Enter.
86
- defaultAnswer: "continue",
91
+ defaultAnswer: severity === "fail" ? undefined : "continue",
87
92
  },
88
93
  );
89
94
  if (answer === null) return { proceed: false, cancelled: true, severity, findings };
90
- const n = String(answer).trim().toLowerCase();
91
- action = n.startsWith("x") || n === "cancel" ? "cancel" : n.startsWith("r") || n === "refine" ? "refine" : "continue";
95
+ const n = String(answer).trim().toLowerCase() || fallback;
96
+ if (n.startsWith("x") || n === "cancel") action = "cancel";
97
+ else if (n.startsWith("r") || n === "refine") action = "refine";
98
+ else action = "continue";
92
99
  }
93
100
  if (action === "cancel") return { proceed: false, cancelled: true, severity, findings };
94
101
  if (action === "refine") {
@@ -28,22 +28,28 @@ export function createCliAskQuestion(opts = {}) {
28
28
  return async (question, context) => {
29
29
  const stdinReadable = process.stdin && process.stdin.readable !== false;
30
30
  const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
31
- if (!isInteractive) {
32
- if (flags?.yes) {
31
+ // KJC-TSK-0674 (issue #1289): agents and CI enable the same contract
32
+ // as --yes explicitly, via --non-interactive or KJ_NON_INTERACTIVE=1,
33
+ // instead of blocking forever on a board modal nobody is watching.
34
+ // Explicit mode wins even when a (pseudo-)TTY is attached.
35
+ const forcedNonInteractive = flags?.nonInteractive || process.env.KJ_NON_INTERACTIVE === "1";
36
+ if (!isInteractive || forcedNonInteractive) {
37
+ const nonInteractive = forcedNonInteractive || flags?.yes;
38
+ if (nonInteractive) {
33
39
  // KJC-BUG-0109: a question that declares a safe default is answered
34
40
  // with it — --yes means "press Enter for me", not "abort on any
35
41
  // gate". Questions without a declared default keep failing loud.
36
42
  const fallback = typeof context?.defaultAnswer === "string" && context.defaultAnswer.trim()
37
43
  ? context.defaultAnswer.trim() : null;
38
44
  if (fallback) {
39
- process.stderr.write(`\n[non-interactive --yes] Auto-answering with declared default "${fallback}": ${question}\n`);
45
+ process.stderr.write(`\n[non-interactive] Auto-answering with declared default "${fallback}": ${question}\n`);
40
46
  return fallback;
41
47
  }
42
48
  process.stderr.write(
43
- `\n[non-interactive --yes] Pipeline asked a question that cannot be auto-answered:\n`
49
+ `\n[non-interactive] Pipeline asked a question that cannot be auto-answered:\n`
44
50
  + ` ❓ ${question}\n`
45
51
  + (context?.detail ? ` Context: ${JSON.stringify(context.detail)}\n` : "")
46
- + ` Stopping the session. Re-run without --yes (TTY or HU Board) to answer it,\n`
52
+ + ` Stopping the session. Re-run interactively (TTY or HU Board) to answer it,\n`
47
53
  + ` or pass --skip-spec-review / a more complete spec so the question never appears.\n`
48
54
  );
49
55
  return null;
@@ -56,7 +62,9 @@ export function createCliAskQuestion(opts = {}) {
56
62
  console.log(
57
63
  "\n[non-interactive] Routing the prompt to the HU Board.\n"
58
64
  + " Open http://localhost:4000 — a modal will appear asking for your answer.\n"
59
- + " This process is now waiting; closing the board does NOT cancel the run."
65
+ + " This process is now waiting; closing the board does NOT cancel the run.\n"
66
+ + " Running from an agent or CI with nobody watching the board? Re-run with\n"
67
+ + " --non-interactive (or KJ_NON_INTERACTIVE=1) to auto-answer safe defaults instead."
60
68
  );
61
69
  try {
62
70
  return await askThroughBoard({ sessionId, question, context });