karajan-code 3.12.3 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.12.3",
3
+ "version": "3.13.1",
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",
@@ -55,7 +55,10 @@ const DEFAULTS = {
55
55
  review_mode: "standard",
56
56
  max_iterations: 5,
57
57
  hu_max_iterations: 3,
58
- max_budget_usd: null,
58
+ // Hard per-run spend ceiling, ON by default (KJC-TSK-0621): a stuck or
59
+ // runaway run must never drain a subscription quota unattended. Explicit
60
+ // null in the user's config opts out (no cap).
61
+ max_budget_usd: 5,
59
62
  review_rules: "./.karajan/review-rules.md",
60
63
  coder_rules: "./.karajan/coder-rules.md",
61
64
  base_branch: "main",
@@ -172,8 +172,14 @@ export function applyRunOverrides(config, flags) {
172
172
  out.git = out.git || {};
173
173
  out.development = out.development || {};
174
174
  out.sonarqube = out.sonarqube || {};
175
- if (out.max_budget_usd === undefined || out.max_budget_usd === null) {
176
- out.max_budget_usd = out.session.max_budget_usd ?? null;
175
+ // Precedence: explicit top-level value (including null = opt-out) wins;
176
+ // the legacy session.max_budget_usd location next; the shipped default
177
+ // (KJC-TSK-0621: 5) last. When top-level still carries the default, a
178
+ // session-level value — including an explicit null — takes over.
179
+ if (out.max_budget_usd === undefined) {
180
+ out.max_budget_usd = out.session.max_budget_usd !== undefined ? out.session.max_budget_usd : DEFAULTS.max_budget_usd;
181
+ } else if (out.max_budget_usd === DEFAULTS.max_budget_usd && out.session.max_budget_usd !== undefined) {
182
+ out.max_budget_usd = out.session.max_budget_usd;
177
183
  }
178
184
  out.budget = mergeDeep(DEFAULTS.budget, out.budget || {});
179
185
  out.roles = mergeDeep(DEFAULTS.roles, out.roles || {});
@@ -12,6 +12,7 @@ import { existsSync, readFileSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
 
14
14
  import { findManagedBlock } from "../utils/managed-markers.js";
15
+ import { findAlternative } from "./alternatives.js";
15
16
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
16
17
  import { detectStackRoots } from "./stack-roots.js";
17
18
 
@@ -98,6 +99,13 @@ function readArtifactContent(searchDir, foundAt) {
98
99
 
99
100
  /** Map a classified state to a recommendation + human rationale. */
100
101
  function recommend(state) {
102
+ if (state.status === "SATISFIED_BY_ALTERNATIVE") {
103
+ return {
104
+ recommendation: "keep",
105
+ rationale: `covered by ${state.foundAt} (${state.alternativeTool}) — kj won't add a second linter/formatter`,
106
+ mergeable: false,
107
+ };
108
+ }
101
109
  if (state.status === "MISSING") return { recommendation: "install", rationale: `kj would add ${state.file}`, mergeable: true };
102
110
  if (state.status === "USER_OWNED") {
103
111
  const n = state.improvements?.length ?? 0;
@@ -109,11 +117,20 @@ function recommend(state) {
109
117
  return { recommendation: "update", rationale: `kj v${state.currentVersion} available (you have v${state.managedVersion})`, mergeable: true };
110
118
  }
111
119
 
112
- /** Classify a single artifact found (or not) under `searchDir`. */
113
- export function classifyArtifact(searchDir, artifact) {
120
+ /**
121
+ * Classify a single artifact found (or not) under `searchDir`. `altDirs`
122
+ * widens the cross-tool alternative lookup (a root-level biome.json covers a
123
+ * monorepo's language roots); the user's OWN config of the same tool still
124
+ * wins over an alternative.
125
+ */
126
+ export function classifyArtifact(searchDir, artifact, altDirs = [searchDir]) {
114
127
  const foundAt = findArtifactFile(searchDir, artifact);
115
128
  const base = { foundAt, managedVersion: null, upToDate: null, improvements: [] };
116
129
  let state = { ...base, status: "MISSING" };
130
+ if (!foundAt) {
131
+ const alt = findAlternative(altDirs, artifact.id);
132
+ if (alt) state = { ...base, status: "SATISFIED_BY_ALTERNATIVE", foundAt: alt.foundAt, alternativeTool: alt.tool };
133
+ }
117
134
  if (foundAt) {
118
135
  const content = readArtifactContent(searchDir, foundAt);
119
136
  if (artifact.blockId && content.includes(`kj:managed:${artifact.blockId}`)) {
@@ -139,8 +156,10 @@ export function compareHarden({ projectDir = process.cwd(), profile = "standard"
139
156
  for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
140
157
  const searchDir = dir === "." ? projectDir : join(projectDir, dir);
141
158
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
142
- const res = classifyArtifact(searchDir, toArtifact(cfg));
143
- artifacts.push({ dir, ...res, foundAt: res.foundAt && dir !== "." ? join(dir, res.foundAt) : res.foundAt });
159
+ const res = classifyArtifact(searchDir, toArtifact(cfg), [searchDir, projectDir]);
160
+ // Alternative matches may live at the repo root — keep their path unprefixed.
161
+ const prefix = res.foundAt && dir !== "." && res.status !== "SATISFIED_BY_ALTERNATIVE";
162
+ artifacts.push({ dir, ...res, foundAt: prefix ? join(dir, res.foundAt) : res.foundAt });
144
163
  }
145
164
  }
146
165
  return { artifacts };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Cross-tool alternatives for `kj harden` (KJC-TSK-0614).
3
+ *
4
+ * A user's Biome config replaces BOTH eslint and prettier — seeding kj's
5
+ * configs next to biome.json installs a second, conflicting linter/formatter
6
+ * that fights the user's own hooks. The advisory's EQUIVALENTS map only
7
+ * covers same-tool filename variants; this map covers whole-tool
8
+ * substitutions, and the three surfaces (advisory, config-engine, check)
9
+ * consult it before treating an artifact as missing.
10
+ *
11
+ * Detection is config-file based on purpose: deterministic, and a false
12
+ * negative just means kj proposes its default (the pre-0614 behaviour),
13
+ * never that it overwrites the user's tool.
14
+ */
15
+
16
+ import { existsSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ const ALTERNATIVES = {
20
+ eslint: [{ tool: "biome", files: ["biome.json", "biome.jsonc"] }],
21
+ prettier: [{ tool: "biome", files: ["biome.json", "biome.jsonc"] }],
22
+ };
23
+
24
+ /**
25
+ * First alternative tool that satisfies `artifactId`, searching each dir in
26
+ * order (a language root first, then the repo root — a root-level biome.json
27
+ * covers the whole monorepo). Null when none applies.
28
+ *
29
+ * @param {string[]} searchDirs
30
+ * @param {string} artifactId - "eslint" | "prettier" | ...
31
+ * @returns {{ tool: string, foundAt: string }|null}
32
+ */
33
+ export function findAlternative(searchDirs, artifactId) {
34
+ for (const alt of ALTERNATIVES[artifactId] ?? []) {
35
+ for (const dir of searchDirs) {
36
+ for (const rel of alt.files) {
37
+ if (existsSync(join(dir, rel))) return { tool: alt.tool, foundAt: rel };
38
+ }
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Artifact id for a config-template entry — same convention as the
46
+ * advisory's toArtifact: prettier is the only marker-less JSON config.
47
+ */
48
+ export function artifactIdForConfig(cfg) {
49
+ return cfg.blockId ?? "prettier";
50
+ }
@@ -11,6 +11,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
 
13
13
  import { runCommand } from "../utils/process.js";
14
+ import { artifactIdForConfig, findAlternative } from "./alternatives.js";
14
15
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
15
16
  import { PROFILE_HOOKS } from "./hook-templates.js";
16
17
  import { detectStackRoots } from "./stack-roots.js";
@@ -53,8 +54,18 @@ export async function checkHarden({ projectDir = process.cwd(), profile = "stand
53
54
  checks.push(presence(projectDir, cfg.file, `config:${cfg.file}`, "missing — run kj harden"));
54
55
  }
55
56
  for (const { dir, language } of roots) {
57
+ const rootDir = dir === "." ? projectDir : join(projectDir, dir);
56
58
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
57
59
  const rel = dir === "." ? cfg.file : join(dir, cfg.file);
60
+ // A config covered by the user's own tool (biome.json ⇒ eslint+prettier)
61
+ // is not drift — kj must not demand a second linter/formatter (KJC-TSK-0614).
62
+ if (!existsSync(join(projectDir, rel))) {
63
+ const alt = findAlternative([rootDir, projectDir], artifactIdForConfig(cfg));
64
+ if (alt) {
65
+ checks.push({ id: `config:${rel}`, ok: true, detail: `covered by ${alt.foundAt} (${alt.tool})` });
66
+ continue;
67
+ }
68
+ }
58
69
  checks.push(presence(projectDir, rel, `config:${rel}`, `missing for ${language} — run kj harden`));
59
70
  }
60
71
  }
@@ -9,6 +9,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
 
11
11
  import { upsertManagedBlock } from "../utils/managed-markers.js";
12
+ import { artifactIdForConfig, findAlternative } from "./alternatives.js";
12
13
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
13
14
 
14
15
  const BLOCK_VERSION = 1;
@@ -18,13 +19,26 @@ function writeFile(target, content) {
18
19
  writeFileSync(target, content);
19
20
  }
20
21
 
21
- /** Seed a list of config entries into `targetDir`, labelling each by `prefix`. */
22
- function seedInto(targetDir, configs, dryRun, prefix, results) {
22
+ /**
23
+ * Seed a list of config entries into `targetDir`, labelling each by `prefix`.
24
+ * `altDirs` widens the cross-tool alternative lookup (KJC-TSK-0614): a config
25
+ * covered by the user's own tool (biome.json ⇒ eslint+prettier) is never
26
+ * seeded — kj must not install a second, conflicting linter/formatter.
27
+ */
28
+ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [targetDir]) {
23
29
  for (const cfg of configs) {
24
30
  const target = join(targetDir, cfg.file);
25
31
  const exists = existsSync(target);
26
32
  const file = prefix === "." ? cfg.file : join(prefix, cfg.file);
27
33
 
34
+ if (!exists) {
35
+ const alt = findAlternative(altDirs, artifactIdForConfig(cfg));
36
+ if (alt) {
37
+ results.push({ file, action: "covered", by: alt.foundAt });
38
+ continue;
39
+ }
40
+ }
41
+
28
42
  if (cfg.json) {
29
43
  const action = exists ? "skipped" : "inserted";
30
44
  if (!dryRun && !exists) writeFile(target, `${cfg.body}\n`);
@@ -69,7 +83,9 @@ export function installConfigsForRoots({ projectDir = process.cwd(), roots = [],
69
83
  const results = [];
70
84
  seedInto(projectDir, UNIVERSAL_CONFIGS, dryRun, ".", results);
71
85
  for (const { dir, language } of roots) {
72
- seedInto(join(projectDir, dir), CONFIGS_BY_LANGUAGE[language] ?? [], dryRun, dir, results);
86
+ const rootDir = join(projectDir, dir);
87
+ // A root-level biome.json covers every language root of the monorepo.
88
+ seedInto(rootDir, CONFIGS_BY_LANGUAGE[language] ?? [], dryRun, dir, results, [rootDir, projectDir]);
73
89
  }
74
90
  return { dryRun, configs: results };
75
91
  }
@@ -153,7 +153,11 @@ export async function checkBudgetExceeded({ budgetTracker, config, session, emit
153
153
 
154
154
  await markSessionStatus(session, "failed");
155
155
  const totalCost = budgetTracker.total().cost_usd;
156
- const message = `Budget exceeded: $${totalCost.toFixed(2)} > $${budgetLimit.toFixed(2)}`;
156
+ const limit = Number(budgetLimit ?? config?.max_budget_usd);
157
+ const message =
158
+ `Budget exceeded: $${totalCost.toFixed(2)} > $${limit.toFixed(2)}. ` +
159
+ `Continue consciously with \`kj resume ${session.id}\`, or raise the cap ` +
160
+ `(max_budget_usd in .karajan/kj.config.yml; null removes it).`;
157
161
  emitProgress(
158
162
  emitter,
159
163
  makeEvent("session:end", { ...eventBase, iteration: i, stage: "budget" }, {
@@ -114,5 +114,7 @@ output:
114
114
  session:
115
115
  max_iteration_minutes: 15
116
116
  max_total_minutes: 120
117
- max_budget_usd: null
117
+ # Hard per-run spend ceiling (USD-equivalent). The run stops with a summary
118
+ # when exceeded; resume with `kj resume`. Set to null to remove the cap.
119
+ max_budget_usd: 5
118
120
  fail_fast_repeats: 2