karajan-code 4.10.0 → 4.12.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 +4 -1
- package/docs/README.es.md +4 -1
- package/package.json +1 -1
- package/src/checks/release-check.js +109 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +18 -0
- package/src/commands/env.js +27 -0
- package/src/config/schema.js +4 -2
- package/src/environment/board-access.js +19 -0
- package/src/environment/board-select.js +58 -0
- package/src/environment/mcp-wiring.js +44 -0
- package/src/environment/playbook.js +2 -1
package/README.md
CHANGED
|
@@ -24,12 +24,15 @@
|
|
|
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. 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.
|
|
27
|
+
- **RAG before assuming** — `kj rag query` answers what your codebase does; no agent guesses. The install wires it as a native MCP tool (`kj_rag_query`) so querying the index is the agent's cheapest path. 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. A distilled engineering canon rides along: `kj rag query --library` serves pattern cards (when it applies, when it does NOT, the canonical citation) so plans name a greenfield alternative instead of following the legacy line by inertia.
|
|
28
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
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
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
|
+
- **Least privilege for agents** — spawned agent subprocesses receive an env allowlist (their own CLI's auth, never your cloud keys or registry tokens), and `kj check` inventories every MCP the project can reach, flagging what appeared since the last check. Sensitive-surface tasks self-invoke `kj audit --security` — a zero-token pass (prompt-injection over the agent-context files + OSV + Semgrep + Sonar) — and remediate before review.
|
|
34
|
+
- **Nothing personal ships** — every outbound boundary audits before it leaves the machine: the pre-commit rejects a staged diff carrying your denylisted personal data, hardcoded platform tokens (`ghp_`, `sk-`, `AKIA`…) block outright, `verify-pack`-style tarball scans guard the publish, and `kj privacy scan <dir>` audits any build output. Your denylist lives in `~/.karajan/privacy.yml` — the install asks and writes it for you.
|
|
35
|
+
- **Installing IS activating** — `kj env install` performs the enforcement itself (git hooks, verdict gate, tool gate) instead of trusting the agent to run setup steps, and ends by printing the method into the very conversation that installed it. A commit outside the method is rejected, not narrated.
|
|
33
36
|
|
|
34
37
|
This repo runs under its own environment: every commit to karajan-code carries a cross-AI verdict.
|
|
35
38
|
|
package/docs/README.es.md
CHANGED
|
@@ -16,12 +16,15 @@
|
|
|
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. 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.
|
|
19
|
+
- **RAG antes de suponer** — `kj rag query` responde qué hace tu código; ningún agente adivina. La instalación lo cablea como herramienta MCP nativa (`kj_rag_query`), de modo que consultar el índice sea el camino más barato del agente. 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. Y viaja con un canon de ingeniería destilado: `kj rag query --library` sirve fichas de patrón (cuándo aplica, cuándo NO, la cita canónica) para que los planes nombren una alternativa greenfield en vez de seguir la línea del legacy por inercia.
|
|
20
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
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
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
|
+
- **Mínimo privilegio para agentes** — los subprocesos de agente reciben un allowlist de entorno (la auth de su propio CLI, jamás tus claves cloud ni tokens de registro), y `kj check` inventaría cada MCP alcanzable del proyecto marcando lo aparecido desde el último check. Las tareas con superficie sensible se auto-invocan `kj audit --security` — pasada de cero tokens (prompt-injection sobre los ficheros de contexto del agente + OSV + Semgrep + Sonar) — y remedian antes del review.
|
|
26
|
+
- **Nada personal se publica** — cada boundary de salida se audita antes de dejar la máquina: el pre-commit rechaza un diff con tus datos vetados, los tokens de plataforma hardcodeados (`ghp_`, `sk-`, `AKIA`…) bloquean directamente, el scan del tarball guarda el publish, y `kj privacy scan <dir>` audita cualquier build. Tu denylist vive en `~/.karajan/privacy.yml` — la instalación pregunta y la escribe por ti.
|
|
27
|
+
- **Instalar ES activar** — `kj env install` ejecuta él mismo el enforcement (hooks de git, gate de veredicto, tool gate) en vez de confiar en que el agente corra pasos de setup, y termina imprimiendo el método en la propia conversación que instaló. Un commit fuera del método se rechaza, no se narra.
|
|
25
28
|
|
|
26
29
|
Este repo corre bajo su propio entorno: cada commit de karajan-code lleva un veredicto de IA cruzada.
|
|
27
30
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* release-check (KJC-TSK-0712) — the release checklist made verifiable:
|
|
3
|
+
* a note loses salience; a check fails RED with the exact list. Generics
|
|
4
|
+
* fit any karajan project; each project extends via `release_check.items`
|
|
5
|
+
* (file_contains with {version}, or command with exit-0 semantics).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { isAbsolute, join } from "node:path";
|
|
10
|
+
import { runCommand } from "../utils/process.js";
|
|
11
|
+
import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
|
|
12
|
+
|
|
13
|
+
const semverCmp = (a, b) => {
|
|
14
|
+
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
15
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
|
|
16
|
+
return 0;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function genericChecks(projectDir) {
|
|
20
|
+
const checks = [];
|
|
21
|
+
const pkgPath = join(projectDir, "package.json");
|
|
22
|
+
if (!existsSync(pkgPath)) {
|
|
23
|
+
checks.push({ name: "manifest", ok: true, detail: "no package.json — generic version checks skipped (declared items still run)" });
|
|
24
|
+
return { checks, version: null, pkg: null };
|
|
25
|
+
}
|
|
26
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
27
|
+
const version = pkg.version;
|
|
28
|
+
checks.push({ name: "manifest", ok: Boolean(version), detail: version ? `version ${version}` : "package.json has no version" });
|
|
29
|
+
|
|
30
|
+
const clPath = join(projectDir, "CHANGELOG.md");
|
|
31
|
+
if (!existsSync(clPath)) {
|
|
32
|
+
checks.push({ name: "changelog-current", ok: false, detail: "no CHANGELOG.md — the release story must exist before the release" });
|
|
33
|
+
} else {
|
|
34
|
+
const cl = readFileSync(clPath, "utf8");
|
|
35
|
+
const topVersioned = (cl.match(/^## \[(\d+\.\d+\.\d+)\]/m) || [])[1] || null;
|
|
36
|
+
// Content still sitting under [Unreleased] means unpromoted entries —
|
|
37
|
+
// the tarball would ship changes its release notes do not tell.
|
|
38
|
+
const afterUnreleased = cl.split(/^## \[Unreleased\]\s*$/m)[1] ?? "";
|
|
39
|
+
const unreleasedBody = afterUnreleased.split(/^## \[/m)[0] ?? "";
|
|
40
|
+
const pending = /\S/.test(unreleasedBody);
|
|
41
|
+
const ok = topVersioned === version && !pending;
|
|
42
|
+
checks.push({
|
|
43
|
+
name: "changelog-current",
|
|
44
|
+
ok,
|
|
45
|
+
detail: ok
|
|
46
|
+
? `top section is [${version}], Unreleased empty`
|
|
47
|
+
: pending
|
|
48
|
+
? `[Unreleased] still carries content — promote it into [${version}] before releasing`
|
|
49
|
+
: `top CHANGELOG section is [${topVersioned ?? "none"}] but the manifest says ${version} — promote Unreleased before releasing`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tagsOut = await runCommand("git", ["-C", projectDir, "tag", "--list", "v[0-9]*"]);
|
|
54
|
+
const tags = (tagsOut.stdout || "").split("\n").map((t) => t.trim().replace(/^v/, "")).filter((t) => /^\d+\.\d+\.\d+$/.test(t));
|
|
55
|
+
const ahead = version ? tags.filter((t) => semverCmp(t, version) > 0) : [];
|
|
56
|
+
checks.push({
|
|
57
|
+
name: "tags",
|
|
58
|
+
ok: ahead.length === 0,
|
|
59
|
+
detail: ahead.length ? `tag(s) ahead of the manifest exist: v${ahead.join(", v")}` : (tags.includes(version) ? `v${version} already tagged` : `tag v${version} pending (created after publish)`),
|
|
60
|
+
});
|
|
61
|
+
return { checks, version, pkg };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function packPrivacyCheck(projectDir, pkg) {
|
|
65
|
+
// Every non-private named package is publishable (exports/files-based
|
|
66
|
+
// packages carry no main/bin) — they all get the scan.
|
|
67
|
+
if (!pkg || pkg.private === true || !pkg.name || !pkg.version) return null;
|
|
68
|
+
try {
|
|
69
|
+
const out = await runCommand("npm", ["pack", "--dry-run", "--json", "--silent"], { cwd: projectDir });
|
|
70
|
+
const files = (JSON.parse(out.stdout || "[]")[0]?.files || []).map((f) => join(projectDir, f.path));
|
|
71
|
+
const blocks = scanPaths(files, { list: loadPrivacyList() }).filter((f) => f.severity === "block");
|
|
72
|
+
return { name: "pack-privacy", ok: blocks.length === 0, detail: blocks.length ? `${blocks.length} personal-data/secret hit(s) in the publishable files` : `${files.length} publishable file(s) clean` };
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { name: "pack-privacy", ok: false, detail: `npm pack --dry-run failed: ${err.message}` };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function declaredItems(projectDir, config, version) {
|
|
79
|
+
const items = config?.release_check?.items;
|
|
80
|
+
if (!Array.isArray(items)) return [];
|
|
81
|
+
const checks = [];
|
|
82
|
+
for (const item of items) {
|
|
83
|
+
const name = item?.name || "unnamed item";
|
|
84
|
+
if (item?.file_contains?.path && item.file_contains.pattern != null) {
|
|
85
|
+
const p = isAbsolute(item.file_contains.path) ? item.file_contains.path : join(projectDir, item.file_contains.path);
|
|
86
|
+
const needle = String(item.file_contains.pattern).replaceAll("{version}", version ?? "");
|
|
87
|
+
const ok = existsSync(p) && readFileSync(p, "utf8").includes(needle);
|
|
88
|
+
checks.push({ name, ok, detail: ok ? `"${needle}" found in ${item.file_contains.path}` : `"${needle}" NOT found in ${item.file_contains.path}` });
|
|
89
|
+
} else if (item?.command) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await runCommand("sh", ["-c", String(item.command).replaceAll("{version}", version ?? "")], { cwd: projectDir });
|
|
92
|
+
checks.push({ name, ok: res.exitCode === 0, detail: res.exitCode === 0 ? "command exited 0" : `command exited ${res.exitCode}` });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
checks.push({ name, ok: false, detail: `command failed to run: ${err.message}` });
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
checks.push({ name, ok: false, detail: "unknown item shape — use file_contains {path, pattern} or command" });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return checks;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function runReleaseCheck({ projectDir = process.cwd(), config = {} } = {}) {
|
|
104
|
+
const { checks, version, pkg } = await genericChecks(projectDir);
|
|
105
|
+
const pack = await packPrivacyCheck(projectDir, pkg);
|
|
106
|
+
if (pack) checks.push(pack);
|
|
107
|
+
checks.push(...await declaredItems(projectDir, config, version));
|
|
108
|
+
return { ok: checks.every((c) => c.ok), version, checks };
|
|
109
|
+
}
|
|
@@ -30,7 +30,7 @@ export const ADVANCED_GROUPS = [
|
|
|
30
30
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan"] },
|
|
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
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release"] },
|
|
34
34
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "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"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -20,6 +20,7 @@ import { mutateCommand } from "../commands/mutate.js";
|
|
|
20
20
|
import { hardenCommand } from "../commands/harden.js";
|
|
21
21
|
import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
|
|
22
22
|
import { envInstallCommand, briefCommand } from "../commands/env.js";
|
|
23
|
+
import { runReleaseCheck } from "../checks/release-check.js";
|
|
23
24
|
import { agentRunCommand } from "../commands/agent-run.js";
|
|
24
25
|
import { reportIssueCommand } from "../commands/report-issue.js";
|
|
25
26
|
import { huCommand } from "../commands/hu.js";
|
|
@@ -262,6 +263,23 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
262
263
|
});
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
// KJC-TSK-0712 — memory is the reminder, the check is the guarantee.
|
|
267
|
+
const release = program.command("release").description("Release ceremony helpers");
|
|
268
|
+
release.command("check")
|
|
269
|
+
.description("Verify the release checklist deterministically (manifest vs CHANGELOG vs tags, privacy scan of publishable files, plus the project's release_check.items); exit 1 lists exactly what is missing")
|
|
270
|
+
.option("--json", "Machine-readable result")
|
|
271
|
+
.action(async (flags) => {
|
|
272
|
+
await withConfig(pkgVersion, "release-check", flags, async ({ config }) => {
|
|
273
|
+
const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config });
|
|
274
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
275
|
+
else {
|
|
276
|
+
for (const c of res.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
|
|
277
|
+
console.log(res.ok ? `release check: ready to release ${res.version ?? ""}`.trim() : "release check: NOT ready — fix the red items first");
|
|
278
|
+
}
|
|
279
|
+
process.exitCode = res.ok ? 0 : 1;
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
265
283
|
// KJC-TSK-0704 — the outbound privacy boundary: audit before anything ships.
|
|
266
284
|
const privacy = program.command("privacy").description("Personal-data (PII) auditing of outbound boundaries: staged diffs, build outputs, docs trees");
|
|
267
285
|
privacy.command("scan [paths...]")
|
package/src/commands/env.js
CHANGED
|
@@ -12,7 +12,9 @@ import { ragIndexCommand } from "./rag.js";
|
|
|
12
12
|
import { renderPendingBlock, PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
|
|
13
13
|
import { onnxConfig, persistOnnxChoice, resetEmptyStore } from "../rag/onnx-fallback.js";
|
|
14
14
|
import { verifyBoardAccess } from "../environment/board-access.js";
|
|
15
|
+
import { pickStateBackend } from "../environment/board-select.js";
|
|
15
16
|
import { ensurePrivacyList } from "../privacy/onboarding.js";
|
|
17
|
+
import { installProjectMcp } from "../environment/mcp-wiring.js";
|
|
16
18
|
import { createWizard } from "../utils/wizard.js";
|
|
17
19
|
import { hardenCommand } from "./harden.js";
|
|
18
20
|
import { reviewGateCommand } from "./review-gate.js";
|
|
@@ -48,6 +50,23 @@ export function briefCommand({ config = null, flags = {}, role = null }) {
|
|
|
48
50
|
|
|
49
51
|
export async function envInstallCommand({ config = null, logger = null, flags = {} }) {
|
|
50
52
|
const projectDir = config?.projectDir || process.cwd();
|
|
53
|
+
|
|
54
|
+
// KJC-TSK-0709 — the board is chosen BEFORE the playbook renders (its
|
|
55
|
+
// tracking line depends on the backend): interactive installs ask when
|
|
56
|
+
// alternatives are reachable, headless installs name the default. The
|
|
57
|
+
// choice persists so it is only ever asked once.
|
|
58
|
+
try {
|
|
59
|
+
const wizard = process.stdin.isTTY && process.stdout.isTTY ? createWizard() : null;
|
|
60
|
+
const sel = await pickStateBackend({ projectDir, wizard, isTTY: Boolean(wizard), logger: console });
|
|
61
|
+
wizard?.close();
|
|
62
|
+
// The effective backend feeds the render on EVERY non-error path —
|
|
63
|
+
// chosen, declared-in-file or default — so the playbook never falls
|
|
64
|
+
// back to hu-board while the selection said otherwise.
|
|
65
|
+
config = { ...config, state_backend: sel.backend, board: sel.boardName ? { ...(config?.board || {}), name: sel.boardName } : config?.board };
|
|
66
|
+
} catch (err) {
|
|
67
|
+
console.log(`⚠ board selection failed (${err.message}) — continuing with the configured default`);
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
const result = await installPlaybook({
|
|
52
71
|
projectDir, target: flags.target || "all",
|
|
53
72
|
stateBackend: config?.state_backend || "hu-board",
|
|
@@ -55,6 +74,14 @@ export async function envInstallCommand({ config = null, logger = null, flags =
|
|
|
55
74
|
});
|
|
56
75
|
console.log(`✓ Karajan playbook installed in: ${result.files.join(", ")}`);
|
|
57
76
|
|
|
77
|
+
// KJC-TSK-0711 — RAG as a native tool: agents use what is in their
|
|
78
|
+
// toolbox, so the official path must be cheaper than the grep shortcut.
|
|
79
|
+
try {
|
|
80
|
+
installProjectMcp({ projectDir, logger: console });
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.log(`⚠ could not wire kj-rag-mcp into .mcp.json: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
58
85
|
// KJC-TSK-0685 (user rule): Karajan does not run without a board — it is
|
|
59
86
|
// what guarantees ordered, card-first work. Verify an OPERATIONAL access
|
|
60
87
|
// path to the declared backend BEFORE anything else; no path → block
|
package/src/config/schema.js
CHANGED
|
@@ -238,8 +238,10 @@ export const ConfigSchema = v.looseObject({
|
|
|
238
238
|
// ENV-D1 (KJC-TSK-0642): where work items live — the integrated HU Board
|
|
239
239
|
// or the user's Planning Game. The v4 playbook renders per backend.
|
|
240
240
|
state_backend: v.optional(v.picklist(
|
|
241
|
-
|
|
242
|
-
|
|
241
|
+
// KJC-TSK-0709 caught the drift: "external" shipped in v4.5.0 (any
|
|
242
|
+
// board via the agent's own MCP/tools) but this picklist never learned it.
|
|
243
|
+
["hu-board", "planning-game", "external"],
|
|
244
|
+
"state_backend must be \"hu-board\", \"planning-game\" or \"external\""
|
|
243
245
|
)),
|
|
244
246
|
review_rules: v.optional(v.string()),
|
|
245
247
|
coder_rules: v.optional(v.string()),
|
|
@@ -42,6 +42,25 @@ function mcpConfigsMention(slug, projectDir, home) {
|
|
|
42
42
|
return null;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Boards reachable from this machine (KJC-TSK-0709): the bundled HU Board
|
|
47
|
+
* always; planning-game / external candidates when their MCP appears in a
|
|
48
|
+
* host config or a conventional token is exported. Presence-based, like
|
|
49
|
+
* verifyBoardAccess — an OPTION list, not a connectivity check.
|
|
50
|
+
*/
|
|
51
|
+
export function detectAvailableBoards({ projectDir = process.cwd(), env = process.env, home = os.homedir() } = {}) {
|
|
52
|
+
const boards = [{ value: "hu-board", label: "HU Board (bundled, zero setup)" }];
|
|
53
|
+
if (mcpConfigsMention("planning-game", projectDir, home)) {
|
|
54
|
+
boards.push({ value: "planning-game", label: "Planning Game (MCP detected)" });
|
|
55
|
+
}
|
|
56
|
+
for (const [slug, envs] of Object.entries(TOKEN_CONVENTIONS)) {
|
|
57
|
+
const token = envs.find((k) => env[k]);
|
|
58
|
+
if (token) boards.push({ value: `external:${slug}`, label: `${slug} (token ${token})` });
|
|
59
|
+
else if (mcpConfigsMention(slug, projectDir, home)) boards.push({ value: `external:${slug}`, label: `${slug} (MCP detected)` });
|
|
60
|
+
}
|
|
61
|
+
return boards;
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
export function verifyBoardAccess({ config = {}, projectDir = process.cwd(), env = process.env, home = os.homedir() } = {}) {
|
|
46
65
|
const backend = config.state_backend || "hu-board";
|
|
47
66
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* board-select (KJC-TSK-0709) — never hu-board silently when alternatives
|
|
3
|
+
* are reachable. Field case 2026-08-02: an install defaulted to hu-board
|
|
4
|
+
* with the user's Planning Game MCP sitting configured and detectable.
|
|
5
|
+
* Interactive installs ASK and persist the choice; headless installs name
|
|
6
|
+
* the default and the exact line to change it. Declared = never asked.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { parse as parseYaml, parseDocument } from "yaml";
|
|
13
|
+
import { detectAvailableBoards } from "./board-access.js";
|
|
14
|
+
import { getConfigPath, getProjectConfigPath } from "../config.js";
|
|
15
|
+
|
|
16
|
+
// "Declared" means the USER wrote state_backend in a config FILE — the
|
|
17
|
+
// merged config object always carries the hu-board default (defaults.js),
|
|
18
|
+
// so it can never tell a choice from a fallback.
|
|
19
|
+
async function declaredStateBackend(projectDir) {
|
|
20
|
+
for (const p of [getProjectConfigPath(projectDir), getConfigPath()]) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = parseYaml(await fs.readFile(p, "utf8")) || {};
|
|
23
|
+
if (raw.state_backend) return { backend: raw.state_backend, boardName: raw.board?.name || null };
|
|
24
|
+
} catch { /* missing or unreadable — keep looking */ }
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function persistStateBackend(projectDir, backend, boardName) {
|
|
30
|
+
const configPath = getProjectConfigPath(projectDir);
|
|
31
|
+
let source = "";
|
|
32
|
+
try { source = await fs.readFile(configPath, "utf8"); } catch { /* no config yet — create it */ }
|
|
33
|
+
const doc = parseDocument(source || "{}");
|
|
34
|
+
doc.setIn(["state_backend"], backend);
|
|
35
|
+
if (boardName) doc.setIn(["board", "name"], boardName);
|
|
36
|
+
await fs.mkdir(path.dirname(configPath), { recursive: true });
|
|
37
|
+
await fs.writeFile(configPath, doc.toString(), "utf8");
|
|
38
|
+
return configPath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function pickStateBackend({ projectDir = process.cwd(), home = os.homedir(), env = process.env, wizard = null, isTTY = false, logger = console } = {}) {
|
|
42
|
+
const declared = await declaredStateBackend(projectDir);
|
|
43
|
+
if (declared) return { ...declared, source: "declared" };
|
|
44
|
+
const boards = detectAvailableBoards({ projectDir, home, env });
|
|
45
|
+
if (boards.length <= 1) return { backend: "hu-board", boardName: null, source: "default" };
|
|
46
|
+
if (!wizard || !isTTY) {
|
|
47
|
+
logger.info?.(`⚠ board: defaulting to the bundled HU Board, but this machine can reach: ${boards.slice(1).map((b) => b.label).join(", ")} — switch with \`state_backend: planning-game\` (or \`external\` + board.name) in .karajan/kj.config.yml and re-run kj env install`);
|
|
48
|
+
return { backend: "hu-board", boardName: null, source: "default-informed" };
|
|
49
|
+
}
|
|
50
|
+
logger.info?.("Karajan needs a board (there is no 'none') — pick where card-first work lives:");
|
|
51
|
+
const value = await wizard.select("Which board governs this project?", boards);
|
|
52
|
+
const external = value.startsWith("external:");
|
|
53
|
+
const backend = external ? "external" : value;
|
|
54
|
+
const boardName = external ? value.slice("external:".length) : null;
|
|
55
|
+
await persistStateBackend(projectDir, backend, boardName);
|
|
56
|
+
logger.info?.(`✓ board: ${backend}${boardName ? ` (${boardName})` : ""} — persisted in .karajan/kj.config.yml`);
|
|
57
|
+
return { backend, boardName, source: "chosen" };
|
|
58
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-wiring — RAG as a NATIVE tool (KJC-TSK-0711). Field case 2026-08-03:
|
|
3
|
+
* agents in karajan projects grep code by hand because the RAG is only "a
|
|
4
|
+
* Bash command a text line told them about". Agents use the tools in their
|
|
5
|
+
* toolbox, so env install wires kj's RAG-only MCP server (`kj-rag-mcp`,
|
|
6
|
+
* ships with the package) into the project's `.mcp.json` — the official
|
|
7
|
+
* path must be cheaper than the shortcut. Merge preserves the user's
|
|
8
|
+
* entries; invalid JSON is left untouched (preserve, never clobber).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export function installProjectMcp({ projectDir = process.cwd(), logger = console } = {}) {
|
|
15
|
+
const mcpPath = join(projectDir, ".mcp.json");
|
|
16
|
+
let cfg = {};
|
|
17
|
+
if (existsSync(mcpPath)) {
|
|
18
|
+
try {
|
|
19
|
+
cfg = JSON.parse(readFileSync(mcpPath, "utf8"));
|
|
20
|
+
} catch {
|
|
21
|
+
logger.warn?.(`kj env: ${mcpPath} is not valid JSON — leaving it untouched (wire kj-rag-mcp manually for native RAG queries)`);
|
|
22
|
+
return { wired: false, reason: "invalid-json" };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (cfg.mcpServers && typeof cfg.mcpServers !== "object") {
|
|
26
|
+
logger.warn?.(`kj env: ${mcpPath} has a non-object mcpServers — leaving it untouched`);
|
|
27
|
+
return { wired: false, reason: "unexpected-shape" };
|
|
28
|
+
}
|
|
29
|
+
cfg.mcpServers = cfg.mcpServers || {};
|
|
30
|
+
// Wired = the LAUNCH SPEC runs kj-rag-mcp (command or an arg, so `npx
|
|
31
|
+
// kj-rag-mcp` and absolute paths count) — never env/description fields.
|
|
32
|
+
const launchesRagMcp = (s) =>
|
|
33
|
+
Boolean(s) && typeof s === "object" && (
|
|
34
|
+
(typeof s.command === "string" && s.command.includes("kj-rag-mcp")) ||
|
|
35
|
+
(Array.isArray(s.args) && s.args.some((a) => typeof a === "string" && a.includes("kj-rag-mcp")))
|
|
36
|
+
);
|
|
37
|
+
const present = Object.values(cfg.mcpServers).some(launchesRagMcp);
|
|
38
|
+
if (!present) {
|
|
39
|
+
cfg.mcpServers["kj-rag"] = { command: "kj-rag-mcp", args: [] };
|
|
40
|
+
writeFileSync(mcpPath, `${JSON.stringify(cfg, null, 2)}\n`);
|
|
41
|
+
logger.info?.("✓ kj_rag_query wired as a NATIVE tool (.mcp.json → kj-rag-mcp): querying the RAG is now the agent's cheapest path");
|
|
42
|
+
}
|
|
43
|
+
return { wired: true, added: !present };
|
|
44
|
+
}
|
|
@@ -57,7 +57,8 @@ carries a cross-AI verdict.
|
|
|
57
57
|
Invariants (the git gates enforce these — they are not suggestions):
|
|
58
58
|
|
|
59
59
|
- The project RAG answers before you assume: \`kj rag query\` — never guess
|
|
60
|
-
what the codebase does.
|
|
60
|
+
what the codebase does. In MCP hosts the same index is the native
|
|
61
|
+
\`kj_rag_query\` tool: reach for it before grepping by hand.
|
|
61
62
|
- ${trackingLine(stateBackend, boardName)}
|
|
62
63
|
- Tests prove behavior: the failing test exists first (TDD), and the suite
|
|
63
64
|
is never left red.
|