karajan-code 4.1.1 → 4.1.3

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.1",
3
+ "version": "4.1.3",
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",
@@ -1,31 +1,36 @@
1
1
  #!/bin/sh
2
- # Karajan Code — standalone binary installer (no Node required).
2
+ # Karajan Code installer — guarantees a COMPLETE install (KJC-TSK-0658).
3
3
  #
4
4
  # curl -fsSL https://karajancode.com/install.sh | sh
5
5
  #
6
- # Downloads the prebuilt `kj` binary for your OS/arch from the GitHub
7
- # release, verifies its SHA256 checksum, and installs it to ~/.local/bin.
8
- # Override with env vars: KJ_VERSION (e.g. v3.7.2), KJ_INSTALL_DIR.
6
+ # Default route: npm — the full product (CLI + RAG + HU Board + MCP).
7
+ # 1. Node >= 22.12 present → npm install -g karajan-code
8
+ # 2. No usable Node → auto-provision official Node LTS into
9
+ # ~/.karajan/node (checksum-verified, nothing system-wide touched),
10
+ # install karajan-code with it, symlink kj into ~/.local/bin.
11
+ # Standalone route (CLI only, no native-module features — RAG/board/MCP
12
+ # unavailable): opt-in with `--standalone` or KJ_STANDALONE=1.
9
13
  #
10
- # POSIX sh only — no bashisms, no Node. KJC-TSK-0593.
14
+ # POSIX sh only. Env overrides: KJ_VERSION, KJ_INSTALL_DIR.
11
15
  set -eu
12
16
 
13
17
  REPO="manufosela/karajan-code"
14
18
  VERSION="${KJ_VERSION:-latest}"
15
19
  INSTALL_DIR="${KJ_INSTALL_DIR:-$HOME/.local/bin}"
20
+ NODE_MAJOR="22"
21
+ NODE_MIN_MINOR="12"
22
+ MODE="full"
23
+ [ "${1:-}" = "--standalone" ] && MODE="standalone"
24
+ [ "${KJ_STANDALONE:-0}" = "1" ] && MODE="standalone"
16
25
 
17
- die() {
18
- echo "kj-install: $1" >&2
19
- exit 1
20
- }
26
+ die() { echo "kj-install: $1" >&2; exit 1; }
21
27
 
22
- # --- Detect OS/arch, map to the release asset names we actually build. ---
23
- os="$(uname -s)"
24
- arch="$(uname -m)"
28
+ # --- Detect OS/arch (shared by both routes). ---
29
+ os="$(uname -s)"; arch="$(uname -m)"
25
30
  case "$os" in
26
31
  Linux) os="linux" ;;
27
32
  Darwin) os="darwin" ;;
28
- *) die "unsupported OS '$os'. Supported: Linux, macOS. Use npm instead: npm install -g karajan-code" ;;
33
+ *) die "unsupported OS '$os'. Supported: Linux, macOS." ;;
29
34
  esac
30
35
  case "$arch" in
31
36
  x86_64 | amd64) arch="x64" ;;
@@ -33,80 +38,122 @@ case "$arch" in
33
38
  *) die "unsupported architecture '$arch'" ;;
34
39
  esac
35
40
 
36
- target="${os}-${arch}"
37
- case "$target" in
38
- linux-x64) ;;
39
- darwin-arm64) ;;
40
- *) die "no prebuilt binary for '$target'. Available: linux-x64, darwin-arm64. Use npm instead: npm install -g karajan-code" ;;
41
- esac
41
+ # --- Downloader + checksum tool. ---
42
+ if command -v curl >/dev/null 2>&1; then fetch() { curl -fsSL "$1" -o "$2"; }
43
+ elif command -v wget >/dev/null 2>&1; then fetch() { wget -qO "$2" "$1"; }
44
+ else die "need curl or wget"; fi
45
+ if command -v sha256sum >/dev/null 2>&1; then sha256() { sha256sum "$1" | cut -d' ' -f1; }
46
+ elif command -v shasum >/dev/null 2>&1; then sha256() { shasum -a 256 "$1" | cut -d' ' -f1; }
47
+ else die "need sha256sum or shasum to verify downloads"; fi
42
48
 
43
- # --- Resolve the download URL for the requested version (or latest). ---
44
- asset="kj-${target}"
45
- if [ "$VERSION" = "latest" ]; then
46
- base="https://github.com/${REPO}/releases/latest/download"
47
- else
48
- base="https://github.com/${REPO}/releases/download/${VERSION}"
49
- fi
49
+ tmp="$(mktemp -d "${TMPDIR:-/tmp}/kj-install.XXXXXX")"
50
+ trap 'rm -rf "$tmp"' EXIT INT TERM
50
51
 
51
- # --- Pick a downloader. ---
52
- if command -v curl >/dev/null 2>&1; then
53
- fetch() { curl -fsSL "$1" -o "$2"; }
54
- elif command -v wget >/dev/null 2>&1; then
55
- fetch() { wget -qO "$2" "$1"; }
56
- else
57
- die "need curl or wget to download the binary"
58
- fi
52
+ path_hint() {
53
+ case ":${PATH}:" in
54
+ *":$1:"*) ;;
55
+ *) echo "kj-install: add '$1' to your PATH: export PATH=\"$1:\$PATH\" (persist it in ~/.bashrc / ~/.zshrc)" ;;
56
+ esac
57
+ }
58
+
59
+ # --- Is there a usable Node (>= NODE_MAJOR.NODE_MIN_MINOR)? ---
60
+ node_ok() {
61
+ command -v node >/dev/null 2>&1 || return 1
62
+ v="$(node --version 2>/dev/null | sed 's/^v//')"
63
+ major="${v%%.*}"; rest="${v#*.}"; minor="${rest%%.*}"
64
+ [ "$major" -gt "$NODE_MAJOR" ] 2>/dev/null && return 0
65
+ [ "$major" -eq "$NODE_MAJOR" ] 2>/dev/null && [ "$minor" -ge "$NODE_MIN_MINOR" ] 2>/dev/null
66
+ }
67
+
68
+ npm_pkg() { if [ "$VERSION" = "latest" ]; then echo "karajan-code"; else echo "karajan-code@${VERSION#v}"; fi; }
69
+
70
+ if [ "$MODE" = "full" ]; then
71
+ if node_ok; then
72
+ echo "kj-install: Node $(node --version) found — installing via npm (full product)..."
73
+ npm install -g "$(npm_pkg)" || die "npm install failed. If it was a permissions error, set a user prefix (npm config set prefix ~/.local) and re-run."
74
+ echo "kj-install: installed $(kj --version 2>/dev/null || echo karajan-code). Run 'kj doctor' next."
75
+ exit 0
76
+ fi
59
77
 
60
- # --- Pick a checksum tool. ---
61
- if command -v sha256sum >/dev/null 2>&1; then
62
- sha256() { sha256sum "$1" | cut -d' ' -f1; }
63
- elif command -v shasum >/dev/null 2>&1; then
64
- sha256() { shasum -a 256 "$1" | cut -d' ' -f1; }
65
- else
66
- die "need sha256sum or shasum to verify the download"
78
+ echo "kj-install: no usable Node (need >= ${NODE_MAJOR}.${NODE_MIN_MINOR}) — provisioning official Node LTS into ~/.karajan/node (nothing system-wide)..."
79
+ dist="https://nodejs.org/dist/latest-v${NODE_MAJOR}.x"
80
+ fetch "${dist}/SHASUMS256.txt" "${tmp}/SHASUMS256.txt" || die "could not fetch the Node checksum list"
81
+ node_asset="$(grep -o "node-v[0-9.]*-${os}-${arch}\.tar\.gz" "${tmp}/SHASUMS256.txt" | head -1)"
82
+ [ -n "$node_asset" ] || die "no official Node build for ${os}-${arch}"
83
+ echo "kj-install: downloading ${node_asset}..."
84
+ fetch "${dist}/${node_asset}" "${tmp}/${node_asset}" || die "could not download Node"
85
+ expected="$(grep "${node_asset}\$" "${tmp}/SHASUMS256.txt" | head -1 | cut -d' ' -f1)"
86
+ actual="$(sha256 "${tmp}/${node_asset}")"
87
+ [ "$expected" = "$actual" ] || die "Node checksum mismatch — aborting, nothing installed"
88
+
89
+ # Stage the whole install (extract + npm install) in a sibling dir and only
90
+ # swap it into place once EVERYTHING succeeded — a failed download/extract/
91
+ # install must never destroy a previous working ~/.karajan/node.
92
+ node_home="$HOME/.karajan/node"
93
+ staging="${node_home}.staging.$$"
94
+ rm -rf "$staging"; mkdir -p "$staging"
95
+ trap 'rm -rf "$tmp" "$staging"' EXIT INT TERM
96
+ tar -xzf "${tmp}/${node_asset}" -C "$staging" --strip-components=1 || die "could not extract Node"
97
+
98
+ echo "kj-install: installing karajan-code with the provisioned Node..."
99
+ PATH="${staging}/bin:$PATH" "${staging}/bin/npm" install -g "$(npm_pkg)" || die "npm install failed with the provisioned Node"
100
+
101
+ # Swap keeping the previous install recoverable at EVERY instant: park it
102
+ # as a backup, arm a trap that restores it on any exit (signal included),
103
+ # move the staged one in, and only then disarm the trap and drop the
104
+ # backup — the user can never end up without a working install.
105
+ backup="${node_home}.old.$$"
106
+ if [ -e "$node_home" ]; then
107
+ mv "$node_home" "$backup" || die "could not park the previous install"
108
+ trap '[ -e "$node_home" ] || mv "$backup" "$node_home" 2>/dev/null; rm -rf "$tmp" "$staging"' EXIT INT TERM
109
+ fi
110
+ mv "$staging" "$node_home" || die "could not move the staged install into place (previous install restored)"
111
+ trap 'rm -rf "$tmp"' EXIT INT TERM
112
+ rm -rf "$backup"
113
+ mkdir -p "$INSTALL_DIR"
114
+ # Wrappers, not symlinks: the shebang (#!/usr/bin/env node) must find the
115
+ # provisioned Node even though it is not on the user's PATH.
116
+ for bin in kj karajan-mcp; do
117
+ [ -e "${node_home}/bin/${bin}" ] || continue
118
+ {
119
+ echo '#!/bin/sh'
120
+ echo "export PATH=\"${node_home}/bin:\$PATH\""
121
+ echo "exec \"${node_home}/bin/${bin}\" \"\$@\""
122
+ } >"${INSTALL_DIR}/${bin}"
123
+ chmod +x "${INSTALL_DIR}/${bin}"
124
+ done
125
+ installed="$("${INSTALL_DIR}/kj" --version 2>/dev/null || echo '?')"
126
+ echo "kj-install: installed kj ${installed} (full product) — kj at ${INSTALL_DIR}/kj"
127
+ path_hint "$INSTALL_DIR"
128
+ echo "kj-install: next — run 'kj doctor', then 'kj install-tools' to complete the whole stack."
129
+ exit 0
67
130
  fi
68
131
 
69
- # --- Download to a temp dir; only install after the checksum matches. ---
70
- tmp="$(mktemp -d "${TMPDIR:-/tmp}/kj-install.XXXXXX")"
71
- trap 'rm -rf "$tmp"' EXIT INT TERM
132
+ # --- Standalone route: prebuilt single binary (CLI only). ---
133
+ echo "kj-install: standalone mode — single binary, NO native-module features (RAG, HU Board and MCP need the npm install)."
134
+ target="${os}-${arch}"
135
+ case "$target" in
136
+ linux-x64 | darwin-arm64) ;;
137
+ *) die "no prebuilt binary for '$target' (available: linux-x64, darwin-arm64) — run without --standalone for the npm route" ;;
138
+ esac
139
+ asset="kj-${target}"
140
+ if [ "$VERSION" = "latest" ]; then base="https://github.com/${REPO}/releases/latest/download"
141
+ else base="https://github.com/${REPO}/releases/download/${VERSION}"; fi
72
142
 
73
143
  echo "kj-install: downloading ${asset} (${VERSION})..."
74
- fetch "${base}/${asset}" "${tmp}/kj" || die "could not download ${base}/${asset} — does that version/asset exist?"
144
+ fetch "${base}/${asset}" "${tmp}/kj" || die "could not download ${base}/${asset}"
75
145
  fetch "${base}/${asset}.sha256" "${tmp}/kj.sha256" || die "could not download the checksum for ${asset}"
76
-
77
146
  expected="$(cut -d' ' -f1 <"${tmp}/kj.sha256")"
78
147
  actual="$(sha256 "${tmp}/kj")"
79
- [ "$expected" = "$actual" ] || die "checksum mismatch (expected ${expected}, got ${actual}). Aborting, nothing installed."
148
+ [ "$expected" = "$actual" ] || die "checksum mismatch — aborting, nothing installed"
80
149
 
81
- # --- Install atomically: chmod on the temp file, then move into place. ---
82
150
  chmod +x "${tmp}/kj"
83
151
  mkdir -p "$INSTALL_DIR"
84
152
  mv -f "${tmp}/kj" "${INSTALL_DIR}/kj"
85
-
86
- # On macOS the binary is ad-hoc signed, not notarized with a paid Apple
87
- # certificate, so Gatekeeper can quarantine it. Clear the flag on the copy
88
- # we just checksummed ourselves (no-op on Linux / if the flag is absent).
89
153
  if [ "$os" = "darwin" ] && command -v xattr >/dev/null 2>&1; then
90
154
  xattr -d com.apple.quarantine "${INSTALL_DIR}/kj" 2>/dev/null || true
91
155
  fi
92
-
93
156
  installed="$("${INSTALL_DIR}/kj" --version 2>/dev/null || echo '?')"
94
- echo "kj-install: installed kj ${installed} to ${INSTALL_DIR}/kj"
95
-
96
- # --- Tell the user how to reach it if the dir is not on PATH. ---
97
- case ":${PATH}:" in
98
- *":${INSTALL_DIR}:"*) echo "kj-install: '${INSTALL_DIR}' is on your PATH — run 'kj --help' to get started." ;;
99
- *)
100
- echo "kj-install: '${INSTALL_DIR}' is not on your PATH. Add it with:"
101
- echo " export PATH=\"${INSTALL_DIR}:\$PATH\""
102
- echo " (add that line to ~/.bashrc, ~/.zshrc or ~/.profile to make it permanent)"
103
- ;;
104
- esac
105
-
106
- # --- Prerequisites: the binary bundles no toolchain. kj orchestrates ---
107
- # --- external tools, so name the hard requirements and let `kj doctor` ---
108
- # --- check them precisely for this machine.
109
- echo ""
110
- echo "kj-install: next step — run 'kj doctor' to check prerequisites."
111
- echo " Required: git, plus at least one agent CLI (Claude Code, Codex or Gemini)."
112
- echo " Optional: Docker (local models, SonarQube) and Node/npm (helper tools: Squeezr, qmd)."
157
+ echo "kj-install: installed standalone kj ${installed} to ${INSTALL_DIR}/kj"
158
+ path_hint "$INSTALL_DIR"
159
+ echo "kj-install: next — run 'kj doctor'. For the full product later: re-run this installer without --standalone."
@@ -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,12 +23,23 @@ 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
 
29
30
  // git and the agent CLI lead the list: both are `kj doctor` *required* tools,
30
31
  // so a blank machine wants them before the optional audit tools.
31
- const ALL_TOOLS = ["git", "agent-cli", "semgrep", "osv-scanner", "lighthouse", "docker", "sonar"];
32
+ // KJC-TSK-0657: rtk/squeezr/qmd (token+context optimizers, previously
33
+ // init-only) complete the list — one pass leaves the machine 100%
34
+ // operational, per the quality-default-on product rule.
35
+ const ALL_TOOLS = ["git", "agent-cli", "semgrep", "osv-scanner", "lighthouse", "docker", "sonar", "rtk", "squeezr", "qmd"];
36
+
37
+ // Optimizer tools reuse the init installers (same {ok, version, error} contract).
38
+ const OPTIMIZER_INSTALLERS = {
39
+ rtk: () => import("../utils/rtk-install.js").then((m) => m.installRtk),
40
+ squeezr: () => import("../utils/squeezr-install.js").then((m) => m.installSqueezr),
41
+ qmd: () => import("../utils/qmd-install.js").then((m) => m.installQmd),
42
+ };
32
43
 
33
44
  // The default pipeline is coder=claude, reviewer=codex, so `agent-cli` installs
34
45
  // exactly those two. gemini stays out of the default: it is a supported
@@ -335,6 +346,22 @@ export async function installToolsCommand(opts = {}) {
335
346
  continue;
336
347
  }
337
348
 
349
+ if (OPTIMIZER_INSTALLERS[tool]) {
350
+ if (dryRun) {
351
+ results.push({ tool, action: "planned", command: `kj install-tools --only ${tool}` });
352
+ logger.info?.(`▸ ${tool}: would install (init installer)`);
353
+ continue;
354
+ }
355
+ const proceed = yes || await promptYesNo(`Install ${tool}?`);
356
+ if (!proceed) { results.push({ tool, action: "declined" }); continue; }
357
+ const install = await OPTIMIZER_INSTALLERS[tool]();
358
+ const r = await install(logger);
359
+ results.push(r.ok
360
+ ? { tool, action: "installed", version: r.version }
361
+ : { tool, action: "failed", error: r.error });
362
+ continue;
363
+ }
364
+
338
365
  if (tool === "git") {
339
366
  const result = await handleGit({ available, dryRun, yes, logger });
340
367
  results.push(result);
@@ -390,8 +417,16 @@ export async function installToolsCommand(opts = {}) {
390
417
  }
391
418
  }
392
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
+ }
393
428
  const failures = results.filter((r) => r.action === "failed").length;
394
- return { results, exitCode: failures > 0 ? 1 : 0 };
429
+ return { results, pending, exitCode: failures > 0 ? 1 : 0 };
395
430
  }
396
431
 
397
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
@@ -0,0 +1,92 @@
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
+ linux: ["python3 -m pip install --user semgrep # or: pipx install semgrep"],
29
+ darwin: ["brew install semgrep"],
30
+ win32: ["python -m pip install --user semgrep"],
31
+ },
32
+ "osv-scanner": {
33
+ linux: ["go install github.com/google/osv-scanner/v2/cmd/osv-scanner@v2 # or download from GitHub releases"],
34
+ darwin: ["brew install osv-scanner"],
35
+ win32: ["winget install Google.OSVScanner # or download from GitHub releases"],
36
+ },
37
+ lighthouse: {
38
+ linux: ["npm install -g lighthouse"],
39
+ darwin: ["npm install -g lighthouse"],
40
+ win32: ["npm install -g lighthouse"],
41
+ },
42
+ ollama: {
43
+ linux: ["curl -fsSL https://ollama.com/install.sh | sh # official installer (uses sudo)", "ollama pull nomic-embed-text"],
44
+ darwin: ["brew install ollama && brew services start ollama", "ollama pull nomic-embed-text"],
45
+ win32: ["winget install Ollama.Ollama", "ollama pull nomic-embed-text"],
46
+ },
47
+ };
48
+
49
+ /** Exact commands to install `tool` on `platform` (empty when unknown). */
50
+ export function osCommandsFor(tool, platform = process.platform) {
51
+ return OS_COMMANDS[tool]?.[platform] ?? [];
52
+ }
53
+
54
+ /**
55
+ * Results that require the USER's hands. `manual`/`failed`/`needs-user`
56
+ * always qualify; `declined` only when nobody actually declined — a non-TTY
57
+ * run without --yes auto-answers the default, which for sudo installs is
58
+ * "no". A real interactive decline stays the user's own decision.
59
+ */
60
+ export function collectPending(results, { tty = Boolean(process.stdin.isTTY), yes = false } = {}) {
61
+ return results.filter((r) => PENDING.has(r.action) || (r.action === "declined" && !tty && !yes));
62
+ }
63
+
64
+ /** The PENDING USER ACTION block: exact commands for THIS OS, then wait. */
65
+ export function renderPendingBlock(pending, { platform = process.platform, retry = "kj install-tools" } = {}) {
66
+ const lines = [
67
+ "════ PENDING USER ACTION ════════════════════════════════════════",
68
+ "kj could not finish this install by itself (sudo or a platform",
69
+ "installer is required). Karajan needs a COMPLETE environment —",
70
+ "do not continue degraded.",
71
+ "",
72
+ "Run in YOUR terminal:",
73
+ "",
74
+ ];
75
+ for (const r of pending) {
76
+ const why = r.error ? `install failed: ${r.error}` : r.reason || "no automatic route on this machine";
77
+ lines.push(` ▸ ${r.tool} (${why})`);
78
+ const commands = r.commands
79
+ ?? (r.command ? [r.command] : null)
80
+ ?? (r.suggested ? [r.suggested] : null)
81
+ ?? (osCommandsFor(r.tool, platform).length > 0 ? osCommandsFor(r.tool, platform) : null)
82
+ ?? [`see ${r.manualUrl || "the tool's install docs"}`];
83
+ for (const c of commands) lines.push(` ${c}`);
84
+ lines.push("");
85
+ }
86
+ lines.push(
87
+ `When done, re-run: ${retry}`,
88
+ "Agents: STOP here, show this block to your user, and WAIT for their",
89
+ `go-ahead. Exit code ${PENDING_EXIT_CODE} means "pending user action" — never proceed past it.`,
90
+ );
91
+ return lines.join("\n");
92
+ }