karajan-code 2.20.0 → 2.21.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
@@ -345,6 +345,22 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
345
345
 
346
346
  ## Recent releases
347
347
 
348
+ > **v2.21.0 released** — Minor. **Brownfield Onboarder role**. Karajan now ships a dedicated path to analyze any existing codebase and produce a Markdown Architecture Brief that the planner can consume as automatic context. Closes KJC-TSK-0384 (3 PRs).
349
+ >
350
+ > KJC-TSK-0384 (PRs #804 + #805 + #806): **`kj onboard`** runs five deterministic collectors over a project root — directory walk (ignoring `node_modules` / `.git` / `dist` / `build`), git log (commits, branches, hot files via `--name-only` over the last 200 commits), 18 well-known config patterns + `package.json` scripts, ADR-style filenames under `docs/adr/`, `docs/adrs/`, `docs/architecture/`, plus a one-shot bundle. Then optionally synthesises a Markdown Architecture Brief via the new OnboarderRole. Output lands at `~/.karajan/onboarding/<slug>.md`. Flags: `--no-synth` (skip the LLM call, dump the raw collectors — useful for CI), `--output <path>` (override default target). Greenfield projects produce `# Project is greenfield` instead of erroring.
351
+ >
352
+ > **New `--use-onboarding` flag on `kj plan generate`**: reads the cached Architecture Brief and prepends it to the planner context under a `## Architecture Brief (from kj onboard)` heading. Silent on cache miss without the flag; loud `warn` when the flag is set but no cache exists, so a missed `kj onboard` invocation surfaces immediately.
353
+ >
354
+ > Workflow:
355
+ >
356
+ > ```bash
357
+ > kj onboard # produces ~/.karajan/onboarding/<slug>.md once
358
+ > kj plan generate task.md \
359
+ > --use-onboarding # next plan uses it as context
360
+ > ```
361
+ >
362
+ > **What's next** — The **Project RAG** epic (KJC-PCS-0049) starts in v2.22.0: vector store + Ollama embedder + indexer + retriever + CLI / MCP / HU Board consumers. Onboarder is its prerequisite (the brief feeds the indexer's first pass).
363
+ >
348
364
  > **v2.20.0 released** — Minor. **HU Board polish + UX papercuts** cluster: 5 cards closed across two net-new features, two PG housekeeping syncs for work that had already landed, and one docs refresh.
349
365
  >
350
366
  > KJC-TSK-0397 (PR #801): **`kj plan generate` now prepends a `[PREFLIGHT-000]` HU to every plan** and gates every functional HU on it. The HU's acceptance tests are stack-aware shell commands — Node gets `node --version` + `npm install` + conditional `npm test` / `npm run lint`; Python gets `python --version` + `pip install -r requirements.txt` (or `poetry install`) + `pytest --collect-only`; Firebase projects get `firebase projects:list`; GCP projects get `gcloud auth list`; everyone gets `git status --porcelain`. Idempotent: a plan that already has a HU titled `PREFLIGHT-000` / "verificar entorno" is left untouched. Opt out per-invocation with `--no-preflight-hu`. The point: Karajan owns the plumbing, the user no longer has to remember to add a 'verify env' step to every task file.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.20.0",
3
+ "version": "2.21.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",
@@ -2,6 +2,7 @@ import { discoverCommand } from "../commands/discover.js";
2
2
  import { triageCommand } from "../commands/triage.js";
3
3
  import { researcherCommand } from "../commands/researcher.js";
4
4
  import { architectCommand } from "../commands/architect.js";
5
+ import { onboardCommand } from "../commands/onboard.js";
5
6
  import { auditCommand } from "../commands/audit.js";
6
7
  import { resumeCommand } from "../commands/resume.js";
7
8
  import { boardCommand } from "../commands/board.js";
@@ -86,6 +87,17 @@ export function registerMeta(program, { pkgVersion }) {
86
87
  });
87
88
  });
88
89
 
90
+ program
91
+ .command("onboard")
92
+ .description("Analyze an existing codebase (brownfield) and produce an Architecture Brief")
93
+ .option("--no-synth", "Skip the LLM synthesis step, dump raw collectors only")
94
+ .option("--output <path>", "Override the default ~/.karajan/onboarding/<slug>.md target")
95
+ .action(async (flags) => {
96
+ await withConfig(pkgVersion, "onboard", flags, async ({ config, logger }) => {
97
+ await onboardCommand({ config, logger, flags });
98
+ });
99
+ });
100
+
89
101
  program
90
102
  .command("audit")
91
103
  .description("Analyze codebase health (read-only)")
@@ -43,6 +43,7 @@ export function registerPlan(program, { pkgVersion }) {
43
43
  .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
44
44
  .option("--quick", "Sketch mode — skip every quality pass after the initial planner call")
45
45
  .option("--no-preflight-hu", "Skip auto-injection of [PREFLIGHT-000] HU at the top of the generated plan")
46
+ .option("--use-onboarding", "Inject the cached Architecture Brief (~/.karajan/onboarding/<slug>.md) into the planner context")
46
47
  .option("-y, --yes", "Skip the project-name prompt (use the auto-derived default).")
47
48
  .option("--no-interactive", "Force non-interactive mode (no prompts, use defaults).")
48
49
  .action(async (task, flags) => {
@@ -102,6 +103,7 @@ export function registerPlan(program, { pkgVersion }) {
102
103
  yes: Boolean(flags.yes),
103
104
  interactive: flags.interactive,
104
105
  preflightHu: flags.preflightHu !== false, // KJC-TSK-0397
106
+ useOnboarding: Boolean(flags.useOnboarding), // KJC-TSK-0384 PR 3
105
107
  },
106
108
  });
107
109
  });
@@ -0,0 +1,35 @@
1
+ // KJC-TSK-0384 PR 2 — `kj onboard` runs collectors + optional LLM synth.
2
+ import { writeFile, mkdir, readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import path from "node:path";
5
+ import { collectAll } from "../onboarder/collectors/index.js";
6
+ import { OnboarderRole } from "../roles/onboarder-role.js";
7
+ import { resolveRole } from "../config.js";
8
+ import { getKarajanHome } from "../utils/paths.js";
9
+
10
+ export function briefPath(projectDir) {
11
+ const slug = path.basename(projectDir).replace(/[^a-zA-Z0-9._-]/g, "-").toLowerCase() || "project";
12
+ return path.join(getKarajanHome(), "onboarding", `${slug}.md`);
13
+ }
14
+
15
+ export async function onboardCommand({ config, logger, flags = {} }) {
16
+ const projectDir = config?.projectDir || process.cwd();
17
+ const out = flags.output || briefPath(projectDir);
18
+ logger.info(`[onboard] collecting from ${projectDir}`);
19
+ const bundle = await collectAll(projectDir);
20
+ await mkdir(path.dirname(out), { recursive: true });
21
+ if (flags.noSynth) {
22
+ await writeFile(out, `# Onboarding bundle — ${projectDir}\n\n\`\`\`json\n${JSON.stringify(bundle, null, 2)}\n\`\`\`\n`, "utf8");
23
+ logger.info(`[onboard] wrote raw bundle to ${out} (--no-synth)`);
24
+ return { path: out, bundle, brief: null };
25
+ }
26
+ const roleCfg = resolveRole(config, "onboarder") || resolveRole(config, "architect");
27
+ const templatePath = new URL("../../templates/roles/onboarder.md", import.meta.url).pathname;
28
+ const instructions = existsSync(templatePath) ? await readFile(templatePath, "utf8") : "";
29
+ const role = new OnboarderRole({ instructions, config, ...roleCfg });
30
+ const result = await role.run({ bundle });
31
+ const brief = result?.result?.brief || "# Project is greenfield\n\nNo synth output.\n";
32
+ await writeFile(out, brief, "utf8");
33
+ logger.info(`[onboard] wrote Architecture Brief to ${out}`);
34
+ return { path: out, bundle, brief };
35
+ }
@@ -15,6 +15,7 @@ import { buildStandbyState } from "../../brain/standby-store.js";
15
15
  import { printResumeHint } from "../../utils/display/resume-hint.js";
16
16
  import { runSpecReview } from "../../spec-review/run-spec-review.js";
17
17
  import { prependPreflightHu } from "../../plan/preflight-hu.js";
18
+ import { readCachedBrief } from "../../onboarder/cache.js";
18
19
  import { formatPlan, formatHuTable } from "./_shared.js";
19
20
 
20
21
  /**
@@ -56,12 +57,29 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
56
57
  task = reviewResult.finalSpec; // downstream runs the refined spec
57
58
  }
58
59
 
60
+ // KJC-TSK-0384 PR 3: --use-onboarding folds the cached Architecture Brief
61
+ // from `kj onboard` into the planner's context. Silent when no brief is
62
+ // cached so users without one don't see noise; loud warn when they passed
63
+ // the flag explicitly but the cache is empty so the missed wiring surfaces.
64
+ let resolvedContext = context;
65
+ if (flags?.useOnboarding) {
66
+ const brief = await readCachedBrief(config?.projectDir || process.cwd());
67
+ if (brief.found) {
68
+ resolvedContext = [
69
+ resolvedContext, brief.content ? `## Architecture Brief (from kj onboard)\n\n${brief.content}` : null,
70
+ ].filter(Boolean).join("\n\n");
71
+ runLog.logText(`[planner] onboarding brief injected from ${brief.path}`);
72
+ } else {
73
+ logger?.warn?.(`--use-onboarding passed but no brief at ${brief.path}. Run 'kj onboard' first.`);
74
+ }
75
+ }
76
+
59
77
  const plannerRole = resolveRole(config, "planner");
60
78
  await assertAgentsAvailable([plannerRole.provider]);
61
79
  runLog.logText(`[planner] provider=${plannerRole.provider}`);
62
80
 
63
81
  const planner = createAgent(plannerRole.provider, config, logger);
64
- const prompt = buildPlannerPrompt({ task, context });
82
+ const prompt = buildPlannerPrompt({ task, context: resolvedContext });
65
83
  const silenceTimeoutMs = Number(config?.session?.max_agent_silence_minutes) > 0
66
84
  ? Math.round(Number(config.session.max_agent_silence_minutes) * 60 * 1000)
67
85
  : undefined;
@@ -0,0 +1,24 @@
1
+ // KJC-TSK-0384 PR 3 — read the cached Architecture Brief deterministically.
2
+ // briefPath() is exported by src/commands/onboard.js so the writer and
3
+ // reader share the same slug rule.
4
+ import { existsSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
+
7
+ import { briefPath } from "../commands/onboard.js";
8
+
9
+ /**
10
+ * Read the cached Architecture Brief for `projectDir`. Returns:
11
+ * { found: true, path, content } — when the file exists
12
+ * { found: false, path } — when it does not
13
+ * Never throws.
14
+ */
15
+ export async function readCachedBrief(projectDir) {
16
+ const p = briefPath(projectDir);
17
+ if (!existsSync(p)) return { found: false, path: p };
18
+ try {
19
+ const content = await readFile(p, "utf8");
20
+ return { found: true, path: p, content };
21
+ } catch {
22
+ return { found: false, path: p };
23
+ }
24
+ }
@@ -0,0 +1,119 @@
1
+ // Onboarder collectors — deterministic, zero-LLM extractors. Each pure,
2
+ // JSON-serialisable, fail-soft (returns null on the slot, never throws).
3
+ // Output bundle is consumed by PR 2 synthesis or dumped via `--no-synth`.
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
+ import { readdir } from "node:fs/promises";
7
+ import { join, relative } from "node:path";
8
+
9
+ import { detectProjectStack } from "../../utils/stack-detect.js";
10
+
11
+ const IGNORED_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", "__pycache__"]);
12
+
13
+ function safeRun(cmd, args, opts = {}) {
14
+ try { return execFileSync(cmd, args, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"], ...opts }).trim(); }
15
+ catch { return null; }
16
+ }
17
+
18
+ // Tree summary: immediate dirs with rough byte count. Ignores noise dirs.
19
+ export async function collectTree(projectDir, { maxDepth = 2 } = {}) {
20
+ async function walk(dir, depth) {
21
+ if (depth > maxDepth) return [];
22
+ let entries;
23
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return []; }
24
+ const out = [];
25
+ for (const ent of entries) {
26
+ if (ent.name.startsWith(".") && ent.name !== ".github") continue;
27
+ if (IGNORED_DIRS.has(ent.name)) continue;
28
+ const sub = join(dir, ent.name);
29
+ if (ent.isDirectory()) {
30
+ const children = await walk(sub, depth + 1);
31
+ let bytes = 0;
32
+ for (const c of children) bytes += c.bytes || 0;
33
+ out.push({ path: relative(projectDir, sub), kind: "dir", bytes, children });
34
+ } else if (ent.isFile()) {
35
+ try { out.push({ path: relative(projectDir, sub), kind: "file", bytes: statSync(sub).size }); } catch { /* race */ }
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+ return walk(projectDir, 0);
41
+ }
42
+
43
+ // Git log + branches + hot files. Returns null on non-git (greenfield).
44
+ export function collectGitHistory(projectDir, { maxHotFiles = 10 } = {}) {
45
+ const inside = safeRun("git", ["rev-parse", "--is-inside-work-tree"], { cwd: projectDir });
46
+ if (inside !== "true") return null;
47
+ const commitsRaw = safeRun("git", ["log", "--oneline", "-n", "100"], { cwd: projectDir }) || "";
48
+ const branchesRaw = safeRun("git", ["branch", "--list", "--all", "--format=%(refname:short)"], { cwd: projectDir }) || "";
49
+ const hotRaw = safeRun("git", ["log", "--name-only", "--pretty=format:", "-n", "200"], { cwd: projectDir }) || "";
50
+ const counts = {};
51
+ for (const line of hotRaw.split("\n").map((s) => s.trim()).filter(Boolean)) {
52
+ counts[line] = (counts[line] || 0) + 1;
53
+ }
54
+ const hotFiles = Object.entries(counts)
55
+ .sort((a, b) => b[1] - a[1]).slice(0, maxHotFiles)
56
+ .map(([file, touches]) => ({ file, touches }));
57
+ return {
58
+ commitCount: commitsRaw ? commitsRaw.split("\n").length : 0,
59
+ branches: branchesRaw.split("\n").filter(Boolean),
60
+ hotFiles,
61
+ headSha: safeRun("git", ["rev-parse", "HEAD"], { cwd: projectDir }) || null,
62
+ };
63
+ }
64
+
65
+ // Presence-check of known config files + package.json scripts.
66
+ export function collectConfigs(projectDir) {
67
+ const configFiles = [
68
+ "package.json", "pyproject.toml", "Cargo.toml", "go.mod",
69
+ "tsconfig.json", "jsconfig.json",
70
+ ".eslintrc.js", ".eslintrc.json", "eslint.config.js",
71
+ "vitest.config.js", "vitest.config.ts", "jest.config.js", "jest.config.ts",
72
+ "firebase.json", ".gcloudignore", "Dockerfile", "docker-compose.yml",
73
+ ".github/workflows", ".gitlab-ci.yml", "Makefile",
74
+ ];
75
+ const present = configFiles.filter((f) => existsSync(join(projectDir, f)));
76
+ let scripts = null;
77
+ try {
78
+ const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
79
+ scripts = pkg.scripts || null;
80
+ } catch { /* not a node project */ }
81
+ return { present, scripts };
82
+ }
83
+
84
+ // Discover ADR files under docs/adr/, docs/adrs/, docs/architecture/, root.
85
+ export async function collectAdrs(projectDir) {
86
+ const dirs = ["docs/adr", "docs/adrs", "docs/architecture", "."];
87
+ const found = [];
88
+ for (const d of dirs) {
89
+ const abs = join(projectDir, d);
90
+ if (!existsSync(abs)) continue;
91
+ try {
92
+ const entries = await readdir(abs);
93
+ for (const f of entries) {
94
+ if (/^adr-?\d|^\d{4}-.+\.md$/i.test(f) || /architecture.*\.md$/i.test(f)) {
95
+ found.push(relative(projectDir, join(abs, f)));
96
+ }
97
+ }
98
+ } catch { /* unreadable */ }
99
+ }
100
+ return found;
101
+ }
102
+
103
+ // One-shot bundle: every slot is independent; missing slots → null/[].
104
+ export async function collectAll(projectDir) {
105
+ const [stack, tree, adrs] = await Promise.all([
106
+ detectProjectStack(projectDir).catch(() => null),
107
+ collectTree(projectDir).catch(() => []),
108
+ collectAdrs(projectDir).catch(() => []),
109
+ ]);
110
+ return {
111
+ projectDir,
112
+ stack,
113
+ tree,
114
+ git: collectGitHistory(projectDir),
115
+ configs: collectConfigs(projectDir),
116
+ adrs,
117
+ collectedAt: new Date().toISOString(),
118
+ };
119
+ }
@@ -0,0 +1,61 @@
1
+ // KJC-TSK-0384 PR 2 — Onboarder role. Wraps collectors bundle + LLM
2
+ // synthesis into a Markdown Architecture Brief.
3
+ import { AgentRole } from "./agent-role.js";
4
+
5
+ const FENCE_RE = /^```(?:markdown|md)?\s*\n([\s\S]*?)\n```\s*$/;
6
+
7
+ export class OnboarderRole extends AgentRole {
8
+ constructor(opts) {
9
+ super({ ...opts, name: "onboarder" });
10
+ }
11
+
12
+ extractInput(input) {
13
+ if (typeof input === "string") return { bundle: { projectDir: input } };
14
+ return { bundle: input?.bundle || input || { projectDir: "" } };
15
+ }
16
+
17
+ async buildPrompt({ bundle }) {
18
+ const instructions = this.instructions || "";
19
+ const prompt = [
20
+ instructions,
21
+ "",
22
+ "## Bundle (deterministic collectors output)",
23
+ "",
24
+ "```json",
25
+ JSON.stringify(bundle, null, 2),
26
+ "```",
27
+ "",
28
+ "Produce the Architecture Brief now.",
29
+ ].join("\n");
30
+ return { prompt };
31
+ }
32
+
33
+ parseOutput(raw) {
34
+ if (!raw || typeof raw !== "string") return { brief: "" };
35
+ const trimmed = raw.trim();
36
+ const fenced = trimmed.match(FENCE_RE);
37
+ return { brief: fenced ? fenced[1].trim() : trimmed };
38
+ }
39
+
40
+ buildSuccessResult(parsed, provider) {
41
+ return { brief: parsed.brief, provider };
42
+ }
43
+
44
+ buildSummary(parsed) {
45
+ const lines = (parsed.brief || "").split("\n").length;
46
+ return `Architecture brief composed (${lines} line${lines === 1 ? "" : "s"})`;
47
+ }
48
+
49
+ handleParseNull(agentResult, provider) {
50
+ return {
51
+ ok: true,
52
+ result: { brief: agentResult.output || "", provider },
53
+ summary: "Architecture brief composed (raw)",
54
+ usage: agentResult.usage,
55
+ };
56
+ }
57
+
58
+ handleParseError(_err, agentResult, provider) {
59
+ return this.handleParseNull(agentResult, provider);
60
+ }
61
+ }
@@ -0,0 +1,36 @@
1
+ # Onboarder
2
+
3
+ Onboarder role of Karajan. Take a deterministic collectors bundle from a
4
+ brownfield project and emit a concise Markdown **Architecture Brief** for
5
+ downstream roles (planner, architect, researcher, coder).
6
+
7
+ ## Mode
8
+
9
+ Read-only. NO Bash/Write/Edit/MultiEdit/NotebookEdit. Output the Markdown
10
+ brief as plain text in your response, NOT via tools.
11
+
12
+ If the project is greenfield (no git, no configs, empty tree) emit
13
+ `# Project is greenfield` plus whatever signal exists. NEVER error out.
14
+
15
+ ## Input
16
+
17
+ JSON bundle with `projectDir`, `stack`, `tree`, `git` (or null), `configs`,
18
+ `adrs`. Slots may be null/empty.
19
+
20
+ ## Output
21
+
22
+ Markdown only — skip any section without data:
23
+
24
+ ```markdown
25
+ # Architecture Brief — <projectDir>
26
+ ## Stack
27
+ ## Layout
28
+ ## Configs
29
+ ## Git history
30
+ ## ADRs
31
+ ## Conventions inferred
32
+ ## Recommendations for new work
33
+ ```
34
+
35
+ Be conservative: only assert what the bundle shows. "Likely" / "appears to"
36
+ are fine; invention is not. Aim for ~40–80 lines total.