karajan-code 4.1.2 → 4.1.4

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.2",
3
+ "version": "4.1.4",
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",
@@ -31,7 +31,7 @@ export const ADVANCED_GROUPS = [
31
31
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
32
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
33
  { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar"] },
34
- { title: "Sesión / board", commands: ["resume", "report", "board", "undo", "standby"] },
34
+ { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "undo", "standby"] },
35
35
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
36
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
37
37
  ];
@@ -22,6 +22,8 @@ import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/tel
22
22
  import { envInstallCommand, briefCommand } from "../commands/env.js";
23
23
  import { agentRunCommand } from "../commands/agent-run.js";
24
24
  import { reportIssueCommand } from "../commands/report-issue.js";
25
+ import { huCommand } from "../commands/hu.js";
26
+ import { addAdr, listAdrs } from "../environment/adr.js";
25
27
  import { formatAdvancedIndex } from "../commands/advanced.js";
26
28
  import { withConfig } from "./_shared.js";
27
29
 
@@ -133,7 +135,10 @@ export function registerMeta(program, { pkgVersion }) {
133
135
  .option("--no-rag", "Skip building the RAG index when the project has none (ENV-E1)")
134
136
  .action(async (flags) => {
135
137
  await withConfig(pkgVersion, "env-install", flags, async ({ config, logger }) => {
136
- await envInstallCommand({ config, logger, flags });
138
+ const r = await envInstallCommand({ config, logger, flags });
139
+ // KJC-TSK-0659: exit 3 = pending user action (RAG cannot index) —
140
+ // a driving agent must stop and wait, never continue degraded.
141
+ if (Number.isInteger(r?.exitCode) && r.exitCode !== 0) process.exit(r.exitCode);
137
142
  });
138
143
  });
139
144
 
@@ -149,6 +154,55 @@ export function registerMeta(program, { pkgVersion }) {
149
154
  });
150
155
  });
151
156
 
157
+ // AB-H (KJC-TSK-0658): board writes + repo ADRs for the brain.
158
+ const hu = program.command("hu").description("Track work in the HU Board from any host agent (card first)");
159
+ hu.command("add <title>")
160
+ .option("--id <shortId>", "Human-readable short id")
161
+ .option("--criteria <text>", "Acceptance criteria")
162
+ .option("--json", "Machine-readable output")
163
+ .action(async (title, flags) => {
164
+ await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
165
+ await huCommand({ config, action: "add", args: [title], flags });
166
+ });
167
+ });
168
+ hu.command("move <id> <status>")
169
+ .option("--json", "Machine-readable output")
170
+ .action(async (id, status, flags) => {
171
+ await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
172
+ await huCommand({ config, action: "move", args: [id, status], flags });
173
+ });
174
+ });
175
+ hu.command("list")
176
+ .option("--json", "Machine-readable output")
177
+ .action(async (flags) => {
178
+ await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
179
+ await huCommand({ config, action: "list", flags });
180
+ });
181
+ });
182
+
183
+ const adr = program.command("adr").description("Architecture decision records in .karajan/adrs/ (git-tracked)");
184
+ adr.command("add <title>")
185
+ .requiredOption("--decision <text>", "The decision itself")
186
+ .option("--context <text>", "Why this came up")
187
+ .option("--consequences <text>", "Trade-offs accepted")
188
+ .option("--json", "Machine-readable output")
189
+ .action(async (title, flags) => {
190
+ await withConfig(pkgVersion, "adr", flags, async ({ config }) => {
191
+ const res = await addAdr(config?.projectDir || process.cwd(), { title, ...flags });
192
+ console.log(flags.json ? JSON.stringify(res) : `✓ ADR ${res.number} created: ${res.file} — commit it`);
193
+ });
194
+ });
195
+ adr.command("list")
196
+ .option("--json", "Machine-readable output")
197
+ .action(async (flags) => {
198
+ await withConfig(pkgVersion, "adr", flags, async ({ config }) => {
199
+ const adrs = await listAdrs(config?.projectDir || process.cwd());
200
+ if (flags.json) { console.log(JSON.stringify(adrs)); return; }
201
+ for (const a of adrs) console.log(`${String(a.number).padStart(4, "0")} ${a.status.padEnd(10)} ${a.title}`);
202
+ if (adrs.length === 0) console.log("no ADRs yet — create one with: kj adr add \"<title>\" --decision \"...\"");
203
+ });
204
+ });
205
+
152
206
  // AB-F (KJC-TSK-0655): self-healing — the brain files kj frictions upstream.
153
207
  program
154
208
  .command("report-issue")
@@ -8,6 +8,7 @@ import { installPlaybook } from "../environment/playbook.js";
8
8
  import { renderBrief, listBriefs } from "../environment/briefs.js";
9
9
  import { openVecStore, projectSlug, getLastIndexedCommit } from "../rag/vec-store.js";
10
10
  import { ragIndexCommand } from "./rag.js";
11
+ import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
11
12
 
12
13
  function hasRagIndex(config, projectDir) {
13
14
  const db = openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
@@ -42,21 +43,38 @@ export async function envInstallCommand({ config = null, logger = null, flags =
42
43
  console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
43
44
 
44
45
  // ENV-E1: RAG-first — the playbook orders "query the RAG before coding",
45
- // so installing the environment guarantees the index exists. An indexing
46
- // failure is reported but never blocks the playbook install.
46
+ // so installing the environment guarantees the index exists. KJC-TSK-0659
47
+ // stop-on-sudo: an index that cannot be built is a BLOCKING condition —
48
+ // "success" with 0 chunks would leave every future session running the
49
+ // method against an empty RAG (field-reproduced: 0/727 without Ollama).
50
+ // The playbook stays installed; the command exits 3 so the driving agent
51
+ // stops, shows the block, and waits for the user.
47
52
  if (flags.rag !== false) {
53
+ const provider = config?.rag?.embedder?.provider || "ollama";
54
+ const blockRag = (why) => {
55
+ result.ragError = why;
56
+ result.exitCode = PENDING_EXIT_CODE;
57
+ const item = provider === "ollama"
58
+ ? { tool: "ollama", action: "needs-user", reason: `the RAG cannot index: ${why}` }
59
+ : { tool: `${provider} embedder`, action: "needs-user", reason: `the RAG cannot index: ${why} — check rag.embedder in kj config`, manualUrl: "kj config" };
60
+ console.log(renderPendingBlock([item], { retry: "kj rag index --with-sources (then re-run: kj env install)" }));
61
+ };
48
62
  try {
49
63
  if (hasRagIndex(config, projectDir)) {
50
64
  console.log("✓ RAG index present");
51
65
  } else {
52
66
  console.log("⏳ no RAG index for this project — building it (first time only)…");
53
- await ragIndexCommand({ config, logger, flags: { withSources: true } });
67
+ const totals = await ragIndexCommand({ config, logger, flags: { withSources: true } });
68
+ if ((totals?.indexed ?? 0) === 0 && (totals?.files ?? 0) > 0) {
69
+ blockRag(`0 of ${totals.files} files indexed — is the embedder running?`);
70
+ }
54
71
  }
55
72
  } catch (err) {
56
- result.ragError = err.message;
57
- console.log(`⚠ RAG index could not be built (${err.message}) — run \`kj rag index --with-sources\` later`);
73
+ blockRag(err.message);
58
74
  }
59
75
  }
60
- console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
76
+ if (result.exitCode !== PENDING_EXIT_CODE) {
77
+ console.log(" The host agent now follows the method: RAG first, TDD, cross-AI review before commit.");
78
+ }
61
79
  return result;
62
80
  }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `kj hu add|move|list` (AB-H, KJC-TSK-0658) — board writes for the brain.
3
+ * The v4 playbook orders "card first", but until now only the headless
4
+ * planner could create HUs. These commands operate on a per-project
5
+ * "brain-backlog" plan (created on first use) so any host agent can track
6
+ * work in the HU Board without the subprocess pipeline.
7
+ */
8
+ import { addHu, updateHuStatus } from "../plan/plan-hu-ops.js";
9
+ import { generatePlanId } from "../plan/plan-id.js";
10
+ import { savePlan, listPlans, loadPlan } from "../plan/plan-store.js";
11
+
12
+ export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
13
+ const BACKLOG_NAME = "brain-backlog";
14
+
15
+ async function backlogPlan(projectDir) {
16
+ const plans = await listPlans(projectDir);
17
+ const existing = plans.find((p) => p.alias === BACKLOG_NAME || p.name === BACKLOG_NAME);
18
+ if (existing) return loadPlan(projectDir, existing.planId);
19
+ const plan = {
20
+ version: 2, planId: generatePlanId(), name: BACKLOG_NAME,
21
+ task: "Host-agent tracked work (v4 environment)",
22
+ status: "ready", hus: [], createdAt: new Date().toISOString(),
23
+ };
24
+ const planId = await savePlan(projectDir, plan);
25
+ return loadPlan(projectDir, planId);
26
+ }
27
+
28
+ export async function huCommand({ config = null, action, args = [], flags = {} }) {
29
+ const projectDir = config?.projectDir || process.cwd();
30
+ const emit = (obj, human) => { console.log(flags.json ? JSON.stringify(obj) : human); return obj; };
31
+
32
+ if (action === "list") {
33
+ const plans = await listPlans(projectDir);
34
+ const rows = [];
35
+ for (const meta of plans) {
36
+ const plan = await loadPlan(projectDir, meta.planId);
37
+ for (const h of plan.hus || []) {
38
+ rows.push({ id: h.id, short_id: h.short_id, title: h.title, status: h.status, plan: plan.alias || plan.planId });
39
+ }
40
+ }
41
+ if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
42
+ for (const r of rows) console.log(`${(r.short_id || r.id).padEnd(28)} ${r.status.padEnd(8)} ${r.title}`);
43
+ if (rows.length === 0) console.log("no HUs yet — create one with: kj hu add \"<story>\"");
44
+ return rows;
45
+ }
46
+
47
+ if (action === "add") {
48
+ const title = args[0];
49
+ if (!title || !title.trim()) throw new Error("kj hu add requires a title: kj hu add \"<story>\"");
50
+ const plan = await backlogPlan(projectDir);
51
+ const hu = addHu(plan, {
52
+ title,
53
+ short_id: flags.id || null,
54
+ acceptance_criteria: flags.criteria ? [flags.criteria] : [],
55
+ });
56
+ await savePlan(projectDir, plan);
57
+ return emit({ id: hu.id, short_id: hu.short_id, status: hu.status },
58
+ `✓ HU created: ${hu.short_id || hu.id} (pending) — it shows up in \`kj board\``);
59
+ }
60
+
61
+ if (action === "move") {
62
+ const [ref, status] = args;
63
+ if (!ref || !status) throw new Error("usage: kj hu move <id> <status>");
64
+ if (!HU_STATUSES.includes(status)) {
65
+ throw new Error(`invalid status "${status}" — valid: ${HU_STATUSES.join(", ")}`);
66
+ }
67
+ // Exact canonical id wins outright (it IS the disambiguator); only then
68
+ // fall back to short_id matches — which can repeat between plans, so
69
+ // silently moving the first hit could move the wrong card.
70
+ const byId = [];
71
+ const byShort = [];
72
+ for (const meta of await listPlans(projectDir)) {
73
+ const plan = await loadPlan(projectDir, meta.planId);
74
+ for (const hu of plan.hus || []) {
75
+ if (hu.id === ref) byId.push({ plan, hu });
76
+ else if (hu.short_id === ref) byShort.push({ plan, hu });
77
+ }
78
+ }
79
+ const matches = byId.length > 0 ? byId : byShort;
80
+ if (matches.length === 0) throw new Error(`HU "${ref}" not found — see kj hu list`);
81
+ if (matches.length > 1) {
82
+ const ids = matches.map((m) => m.hu.id).join(", ");
83
+ throw new Error(`"${ref}" is ambiguous (${matches.length} matches: ${ids}) — use the full id`);
84
+ }
85
+ const { plan, hu } = matches[0];
86
+ updateHuStatus(plan, hu.id, status);
87
+ await savePlan(projectDir, plan);
88
+ return emit({ id: hu.id, status }, `✓ ${hu.short_id || hu.id} → ${status}`);
89
+ }
90
+
91
+ throw new Error(`unknown action "${action}" — use: add | move | list`);
92
+ }
@@ -23,6 +23,7 @@ import { detectProjectStack } from "../utils/stack-detect.js";
23
23
  import { resolveStandalone } from "../utils/binary-sources.js";
24
24
  import { downloadBinary, binDir, runInstallCommand } from "../utils/tool-installer.js";
25
25
  import { dockerInstallPlan } from "../utils/docker-install.js";
26
+ import { collectPending, renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
26
27
 
27
28
  const execFileAsync = promisify(execFile);
28
29
 
@@ -416,8 +417,16 @@ export async function installToolsCommand(opts = {}) {
416
417
  }
417
418
  }
418
419
 
420
+ // KJC-TSK-0659 stop-on-sudo: anything that still needs the user's hands
421
+ // becomes ONE block with the exact per-OS commands, and a distinctive exit
422
+ // code so a driving agent stops and waits instead of continuing degraded.
423
+ const pending = dryRun ? [] : collectPending(results, { yes });
424
+ if (pending.length > 0) {
425
+ logger.warn?.(renderPendingBlock(pending));
426
+ return { results, pending, exitCode: PENDING_EXIT_CODE };
427
+ }
419
428
  const failures = results.filter((r) => r.action === "failed").length;
420
- return { results, exitCode: failures > 0 ? 1 : 0 };
429
+ return { results, pending, exitCode: failures > 0 ? 1 : 0 };
421
430
  }
422
431
 
423
432
  export const __test = { parseOnlyList, isInstalled, ALL_TOOLS };
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ADRs in the repo (AB-H, KJC-TSK-0658) — `kj adr add|list`. Architecture
3
+ * decisions live as numbered markdown under .karajan/adrs/, git-tracked so
4
+ * the whole team (and every brain) inherits them. The architect brief's
5
+ * "check the ADRs before deciding" now has a target on every backend.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+
10
+ const ADR_DIR = path.join(".karajan", "adrs");
11
+
12
+ const slugify = (t) => t.toLowerCase().replaceAll(/[^a-z0-9]+/g, "-").replaceAll(/^-|-$/g, "").slice(0, 60);
13
+
14
+ export async function listAdrs(projectDir) {
15
+ const dir = path.join(projectDir, ADR_DIR);
16
+ let files;
17
+ try { files = await fs.readdir(dir); } catch { return []; }
18
+ const adrs = [];
19
+ for (const f of files.filter((f) => /^\d{4}-.*\.md$/.test(f)).sort()) {
20
+ const text = await fs.readFile(path.join(dir, f), "utf8");
21
+ const title = text.match(/^# (.+)$/m)?.[1] || f;
22
+ const status = text.match(/^Status: (.+)$/m)?.[1] || "accepted";
23
+ adrs.push({ file: path.join(ADR_DIR, f), number: Number(f.slice(0, 4)), title, status });
24
+ }
25
+ return adrs;
26
+ }
27
+
28
+ export async function addAdr(projectDir, { title, decision, context = "", consequences = "" }) {
29
+ if (!title?.trim() || !decision?.trim()) {
30
+ throw new Error("kj adr add requires a title and --decision");
31
+ }
32
+ const existing = await listAdrs(projectDir);
33
+ const number = (existing.at(-1)?.number || 0) + 1;
34
+ const file = path.join(ADR_DIR, `${String(number).padStart(4, "0")}-${slugify(title)}.md`);
35
+ const body = [
36
+ `# ${title}`, "",
37
+ `Status: accepted`, `Date: ${new Date().toISOString().slice(0, 10)}`, "",
38
+ ...(context ? ["## Context", "", context, ""] : []),
39
+ "## Decision", "", decision, "",
40
+ ...(consequences ? ["## Consequences", "", consequences, ""] : []),
41
+ ].join("\n");
42
+ await fs.mkdir(path.join(projectDir, ADR_DIR), { recursive: true });
43
+ await fs.writeFile(path.join(projectDir, file), body);
44
+ return { number, file, title };
45
+ }
@@ -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 board`) before it starts.",
32
+ "hu-board": "Every piece of work has a tracked story/bug in the HU Board (`kj hu add` / `kj board`) before it starts.",
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
 
@@ -58,8 +58,9 @@ Invariants (the git gates enforce these — they are not suggestions):
58
58
  through an atomic PR (~150 net lines, Conventional Commits).
59
59
 
60
60
  Commands: \`kj rag query\` · \`kj brief <role>\` (triage, planner, researcher,
61
- architect, tester, security, audit) · \`kj review --staged\` · \`kj review --check\` ·
62
- \`kj solomon --position\` · \`kj agent run <agent>\` · \`kj report\` · \`kj check\`
61
+ architect, tester, security, audit) · \`kj hu add|move|list\` · \`kj adr add|list\` ·
62
+ \`kj review --staged\` · \`kj review --check\` · \`kj solomon --position\` ·
63
+ \`kj agent run <agent>\` · \`kj report\` · \`kj check\`
63
64
 
64
65
  Hit a kj bug or friction? Diagnose it and file it upstream with
65
66
  \`kj report-issue\` (sanitized; ask your user before \`--publish\`).
@@ -21,11 +21,38 @@ const BLOCK_VERSION = 1; // current version every markered config ships at
21
21
  // Other filenames/formats for the same artifact, so kj never reports a false
22
22
  // "missing". `pkgKey` is a package.json field that can hold inline config.
23
23
  const EQUIVALENTS = {
24
- commitlint: { files: [".commitlintrc", ".commitlintrc.js", ".commitlintrc.json", ".commitlintrc.yaml"], pkgKey: "commitlint" },
25
- eslint: { files: ["eslint.config.mjs", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml"], pkgKey: "eslintConfig" },
26
- prettier: { files: [".prettierrc", ".prettierrc.js", ".prettierrc.yaml", "prettier.config.js"], pkgKey: "prettier" },
24
+ commitlint: { files: [".commitlintrc", ".commitlintrc.js", ".commitlintrc.cjs", ".commitlintrc.mjs", ".commitlintrc.json", ".commitlintrc.yaml", ".commitlintrc.yml", "commitlint.config.mjs", "commitlint.config.cjs", "commitlint.config.ts"], pkgKey: "commitlint" },
25
+ eslint: { files: ["eslint.config.mjs", "eslint.config.cjs", "eslint.config.ts", "eslint.config.mts", "eslint.config.cts", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml", ".eslintrc.yml"], pkgKey: "eslintConfig" },
26
+ prettier: { files: [".prettierrc", ".prettierrc.js", ".prettierrc.yaml", ".prettierrc.yml", ".prettierrc.json5", ".prettierrc.toml", "prettier.config.js", "prettier.config.mjs", "prettier.config.cjs", "prettier.config.ts"], pkgKey: "prettier" },
27
27
  };
28
28
 
29
+ /**
30
+ * KJC-BUG-0119 — same-tool filename variant for `artifactId` in any of
31
+ * `searchDirs` (file or inline package.json config); null when none exists.
32
+ * Seeding kj's default filename NEXT TO a variant eclipses the user's real
33
+ * config (eslint resolves eslint.config.js before .mjs), so config-engine
34
+ * must consult this before treating an artifact as absent.
35
+ */
36
+ export function findEquivalentIn(searchDirs, artifactId) {
37
+ const spec = EQUIVALENTS[artifactId];
38
+ if (!spec) return null;
39
+ for (const dir of searchDirs) {
40
+ for (const rel of spec.files) {
41
+ if (existsSync(join(dir, rel))) return rel;
42
+ }
43
+ if (spec.pkgKey && existsSync(join(dir, "package.json"))) {
44
+ try {
45
+ if (JSON.parse(readFileSync(join(dir, "package.json"), "utf8"))[spec.pkgKey] != null) {
46
+ return `package.json#${spec.pkgKey}`;
47
+ }
48
+ } catch {
49
+ /* unreadable package.json → not a match */
50
+ }
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
29
56
  // Concrete gains kj's standard would add to a USER_OWNED config, detected by
30
57
  // substring probes (no AST parse): each probe absent from the user's content
31
58
  // becomes one improvement. Heuristic by design — conservative, never a false
@@ -70,19 +97,8 @@ function toArtifact(cfg) {
70
97
 
71
98
  /** Locate the user's file for an artifact under `searchDir`; null if absent. */
72
99
  function findArtifactFile(searchDir, artifact) {
73
- for (const rel of [artifact.file, ...artifact.equivalents]) {
74
- if (existsSync(join(searchDir, rel))) return rel;
75
- }
76
- if (artifact.pkgKey && existsSync(join(searchDir, "package.json"))) {
77
- try {
78
- if (JSON.parse(readFileSync(join(searchDir, "package.json"), "utf8"))[artifact.pkgKey] != null) {
79
- return `package.json#${artifact.pkgKey}`;
80
- }
81
- } catch {
82
- /* unreadable package.json → not a match */
83
- }
84
- }
85
- return null;
100
+ if (existsSync(join(searchDir, artifact.file))) return artifact.file;
101
+ return findEquivalentIn([searchDir], artifact.id);
86
102
  }
87
103
 
88
104
  /** Read the artifact body, or the JSON of an inline package.json key. */
@@ -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 { findEquivalentIn } from "./advisory.js";
12
13
  import { artifactIdForConfig, findAlternative } from "./alternatives.js";
13
14
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
14
15
 
@@ -37,6 +38,15 @@ function seedInto(targetDir, configs, dryRun, prefix, results, altDirs = [target
37
38
  results.push({ file, action: "covered", by: alt.foundAt });
38
39
  continue;
39
40
  }
41
+ // KJC-BUG-0119: a same-tool variant (eslint.config.mjs, .eslintrc.json,
42
+ // package.json#eslintConfig…) means the config already EXISTS — seeding
43
+ // kj's default filename next to it would silently ECLIPSE the user's
44
+ // real config (eslint resolves eslint.config.js before .mjs).
45
+ const variant = findEquivalentIn(altDirs, artifactIdForConfig(cfg));
46
+ if (variant) {
47
+ results.push({ file, action: "covered", by: variant });
48
+ continue;
49
+ }
40
50
  }
41
51
 
42
52
  if (cfg.json) {
Binary file
@@ -44,6 +44,12 @@ export function osvScannerSource(platform = process.platform, arch = process.arc
44
44
  */
45
45
  export function semgrepFallback(available = {}) {
46
46
  if (available.docker) return { via: "docker", command: "docker pull semgrep/semgrep" };
47
+ // KJC-BUG-0120 (issue #1256): on Debian/Ubuntu-like systems the system
48
+ // Python is externally managed (PEP 668) and often ships without pip at
49
+ // all — `python3 -m pip install --user` is guaranteed to fail there.
50
+ // pipx must come from the distro's own package manager.
51
+ if (available.apt) return { via: "apt", command: "sudo apt update && sudo apt install -y pipx && pipx install semgrep" };
52
+ if (available.dnf) return { via: "dnf", command: "sudo dnf install -y pipx && pipx install semgrep" };
47
53
  return { via: "pipx", command: "python3 -m pip install --user pipx && pipx install semgrep" };
48
54
  }
49
55
 
@@ -4,7 +4,6 @@ import { fileURLToPath } from "node:url";
4
4
  import { printBanner } from "../banner.js";
5
5
  import { ANSI } from "./formatters.js";
6
6
 
7
- // TODO: i18n display messages
8
7
  const DISPLAY_PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../package.json");
9
8
  const DISPLAY_VERSION = JSON.parse(readFileSync(DISPLAY_PKG_PATH, "utf8")).version;
10
9
 
@@ -80,8 +80,13 @@ export async function getInstallHint(tool, available = null) {
80
80
  for (const { manager, command } of candidates) {
81
81
  if (avail[manager]) return { command, manager, manualUrl: MANUAL_URLS[tool] };
82
82
  }
83
- // Nothing matched — fall back to the first candidate's command as a
84
- // suggestion, so the user at least sees a working recipe.
83
+ // Nothing matched — suggest a recipe that actually works on this system.
84
+ // KJC-BUG-0120 (issue #1256): distro-aware fallbacks first (PEP 668 makes
85
+ // the generic pip bootstrap fail on Debian/Ubuntu); these are display-only
86
+ // (manager: null), never auto-run — sudo stays in the user's hands.
87
+ for (const { when, command } of DISTRO_FALLBACKS[tool] || []) {
88
+ if (avail[when]) return { command, manager: null, manualUrl: MANUAL_URLS[tool] };
89
+ }
85
90
  const first = candidates[0];
86
91
  return {
87
92
  command: first ? first.command : null,
@@ -90,6 +95,13 @@ export async function getInstallHint(tool, available = null) {
90
95
  };
91
96
  }
92
97
 
98
+ const DISTRO_FALLBACKS = {
99
+ semgrep: [
100
+ { when: "apt", command: "sudo apt update && sudo apt install -y pipx && pipx install semgrep" },
101
+ { when: "dnf", command: "sudo dnf install -y pipx && pipx install semgrep" },
102
+ ],
103
+ };
104
+
93
105
  const INSTALL_CANDIDATES = {
94
106
  semgrep: [
95
107
  { manager: "pipx", command: "pipx install semgrep" },
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Stop-on-sudo policy (KJC-TSK-0659). When a tool cannot be installed
3
+ * automatically (sudo needed, no package-manager route, or the attempt
4
+ * failed), kj must NOT continue degraded — a RAG with 0 chunks because
5
+ * Docker was missing helps nobody. Instead: emit ONE block with the exact
6
+ * commands for this machine's OS and exit with a distinctive code so a
7
+ * driving agent stops, shows the block, and waits for the user.
8
+ */
9
+
10
+ export const PENDING_EXIT_CODE = 3;
11
+
12
+ const PENDING = new Set(["manual", "failed", "needs-user"]);
13
+
14
+ // Exact per-OS commands for the tools kj cannot install unattended.
15
+ // Multiple lines when the route depends on the distro/package manager.
16
+ const OS_COMMANDS = {
17
+ docker: {
18
+ linux: ["sudo apt-get install -y docker.io # Debian/Ubuntu", "sudo dnf install -y docker # Fedora/RHEL"],
19
+ darwin: ["brew install --cask docker # Docker Desktop, then open it once"],
20
+ win32: ["winget install Docker.DockerDesktop", "wsl --install # if WSL2 is not set up yet"],
21
+ },
22
+ git: {
23
+ linux: ["sudo apt-get install -y git # Debian/Ubuntu", "sudo dnf install -y git # Fedora/RHEL"],
24
+ darwin: ["brew install git # or: xcode-select --install"],
25
+ win32: ["winget install Git.Git"],
26
+ },
27
+ semgrep: {
28
+ // PEP 668 (#1256): system Python is externally managed on modern
29
+ // distros — pipx comes from the distro package manager, never from pip.
30
+ linux: ["sudo apt update && sudo apt install -y pipx && pipx install semgrep # Debian/Ubuntu", "sudo dnf install -y pipx && pipx install semgrep # Fedora/RHEL"],
31
+ darwin: ["brew install semgrep"],
32
+ win32: ["python -m pip install --user semgrep"],
33
+ },
34
+ "osv-scanner": {
35
+ linux: ["go install github.com/google/osv-scanner/v2/cmd/osv-scanner@v2 # or download from GitHub releases"],
36
+ darwin: ["brew install osv-scanner"],
37
+ win32: ["winget install Google.OSVScanner # or download from GitHub releases"],
38
+ },
39
+ lighthouse: {
40
+ linux: ["npm install -g lighthouse"],
41
+ darwin: ["npm install -g lighthouse"],
42
+ win32: ["npm install -g lighthouse"],
43
+ },
44
+ ollama: {
45
+ linux: ["curl -fsSL https://ollama.com/install.sh | sh # official installer (uses sudo)", "ollama pull nomic-embed-text"],
46
+ darwin: ["brew install ollama && brew services start ollama", "ollama pull nomic-embed-text"],
47
+ win32: ["winget install Ollama.Ollama", "ollama pull nomic-embed-text"],
48
+ },
49
+ };
50
+
51
+ /** Exact commands to install `tool` on `platform` (empty when unknown). */
52
+ export function osCommandsFor(tool, platform = process.platform) {
53
+ return OS_COMMANDS[tool]?.[platform] ?? [];
54
+ }
55
+
56
+ /**
57
+ * Results that require the USER's hands. `manual`/`failed`/`needs-user`
58
+ * always qualify; `declined` only when nobody actually declined — a non-TTY
59
+ * run without --yes auto-answers the default, which for sudo installs is
60
+ * "no". A real interactive decline stays the user's own decision.
61
+ */
62
+ export function collectPending(results, { tty = Boolean(process.stdin.isTTY), yes = false } = {}) {
63
+ return results.filter((r) => PENDING.has(r.action) || (r.action === "declined" && !tty && !yes));
64
+ }
65
+
66
+ /** The PENDING USER ACTION block: exact commands for THIS OS, then wait. */
67
+ export function renderPendingBlock(pending, { platform = process.platform, retry = "kj install-tools" } = {}) {
68
+ const lines = [
69
+ "════ PENDING USER ACTION ════════════════════════════════════════",
70
+ "kj could not finish this install by itself (sudo or a platform",
71
+ "installer is required). Karajan needs a COMPLETE environment —",
72
+ "do not continue degraded.",
73
+ "",
74
+ "Run in YOUR terminal:",
75
+ "",
76
+ ];
77
+ for (const r of pending) {
78
+ const why = r.error ? `install failed: ${r.error}` : r.reason || "no automatic route on this machine";
79
+ lines.push(` ▸ ${r.tool} (${why})`);
80
+ const os = osCommandsFor(r.tool, platform);
81
+ const own = r.commands ?? (r.command ? [r.command] : null) ?? (r.suggested ? [r.suggested] : null);
82
+ // A FAILED install must not be re-suggested verbatim (#1256: the pip
83
+ // route that just broke) — the curated per-OS route goes first there.
84
+ const commands = (r.action === "failed" ? (os.length > 0 ? os : own) : (own ?? (os.length > 0 ? os : null)))
85
+ ?? [`see ${r.manualUrl || "the tool's install docs"}`];
86
+ for (const c of commands) lines.push(` ${c}`);
87
+ lines.push("");
88
+ }
89
+ lines.push(
90
+ `When done, re-run: ${retry}`,
91
+ "Agents: STOP here, show this block to your user, and WAIT for their",
92
+ `go-ahead. Exit code ${PENDING_EXIT_CODE} means "pending user action" — never proceed past it.`,
93
+ );
94
+ return lines.join("\n");
95
+ }