karajan-code 4.70.3 → 4.70.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.70.3",
3
+ "version": "4.70.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",
@@ -11,11 +11,13 @@
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import os from "node:os";
13
13
  import path from "node:path";
14
+ import { claudeConfigFile, codexHome } from "../utils/agent-home.js";
14
15
 
15
- const sources = (projectDir, home) => [
16
+ // KJC-TSK-1344: claude and codex configs of the account CLAUDE_CONFIG_DIR / CODEX_HOME select.
17
+ const sources = (projectDir, home, env = process.env) => [
16
18
  { file: path.join(projectDir, ".mcp.json"), format: "json" },
17
- { file: path.join(home, ".claude.json"), format: "json" },
18
- { file: path.join(home, ".codex", "config.toml"), format: "toml" },
19
+ { file: claudeConfigFile({ home, env }), format: "json" },
20
+ { file: path.join(codexHome({ home, env }), "config.toml"), format: "toml" },
19
21
  { file: path.join(home, ".gemini", "settings.json"), format: "json" },
20
22
  ];
21
23
 
@@ -34,9 +36,9 @@ const tomlServerNames = (text) =>
34
36
  new Set([...text.matchAll(/^\s*\[mcp_servers\.["']?([^\]"']+)["']?\]/gm)].map((m) => m[1]));
35
37
 
36
38
  /** Sorted `name (config-file)` entries for every MCP reachable in this project. */
37
- export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir() } = {}) {
39
+ export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir(), env = process.env } = {}) {
38
40
  const surface = new Set();
39
- for (const { file, format } of sources(projectDir, home)) {
41
+ for (const { file, format } of sources(projectDir, home, env)) {
40
42
  if (!existsSync(file)) continue;
41
43
  let text;
42
44
  try { text = readFileSync(file, "utf8"); } catch { continue; }
@@ -50,11 +52,11 @@ export function collectAiSurface({ projectDir = process.cwd(), home = os.homedir
50
52
  * Diff the current surface against the last-seen snapshot and persist the
51
53
  * new state. First run records the baseline silently.
52
54
  */
53
- export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), statePath = path.join(os.homedir(), ".karajan", "ai-surface.json"), extraSurface = [] } = {}) {
55
+ export function checkAiSurface({ projectDir = process.cwd(), home = os.homedir(), env = process.env, statePath = path.join(os.homedir(), ".karajan", "ai-surface.json"), extraSurface = [] } = {}) {
54
56
  // KJC-TSK-0728: extraSurface carries entries the (async) caller collected
55
57
  // outside config files — e.g. observed agent CLIs as "grok (cli)" — so
56
58
  // they ride the same snapshot and the same "NEW since last check" drift.
57
- const surface = [...new Set([...collectAiSurface({ projectDir, home }), ...extraSurface])].sort();
59
+ const surface = [...new Set([...collectAiSurface({ projectDir, home, env }), ...extraSurface])].sort();
58
60
  let state = {};
59
61
  try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* first run or corrupt → baseline */ }
60
62
  const prev = Array.isArray(state[projectDir]) ? state[projectDir] : null;
@@ -18,6 +18,7 @@ import { getConfigPath } from "../config.js";
18
18
  import { resolveRoleMdPath, loadFirstExisting } from "../roles/base-role.js";
19
19
  import { withDocLink } from "../utils/doc-links.js";
20
20
  import { STRATEGY } from "./types.js";
21
+ import { codexHome } from "../utils/agent-home.js";
21
22
 
22
23
  /**
23
24
  * kj.config.yml presence check.
@@ -218,16 +219,16 @@ function createStaleMcpPathCheck() {
218
219
  }
219
220
 
220
221
  /**
221
- * Codex config (~/.codex/config.toml) validity.
222
+ * Codex config ($CODEX_HOME/config.toml, ~/.codex by default) validity.
223
+ * KJC-TSK-1344: the account CODEX_HOME selects is the one whose config counts.
222
224
  */
223
- function createCodexConfigCheck() {
225
+ export function createCodexConfigCheck({ env = process.env, home = os.homedir() } = {}) {
226
+ const codexTomlPath = path.join(codexHome({ home, env }), "config.toml");
224
227
  return {
225
228
  name: "agent-config:codex",
226
- label: "Agent config: codex (~/.codex/config.toml)",
229
+ label: `Agent config: codex (${codexTomlPath})`,
227
230
  strategy: STRATEGY.MANUAL,
228
231
  async detect() {
229
- const home = os.homedir();
230
- const codexTomlPath = path.join(home, ".codex", "config.toml");
231
232
  try {
232
233
  const raw = await fs.readFile(codexTomlPath, "utf8");
233
234
  const duplicates = findDuplicateTomlKeys(raw);
@@ -236,7 +237,7 @@ function createCodexConfigCheck() {
236
237
  ok: false,
237
238
  severity: "warn",
238
239
  detail: `Duplicate TOML keys: ${duplicates.join(", ")}`,
239
- fix: `Remove duplicate entries in ~/.codex/config.toml: ${duplicates.join(", ")}`,
240
+ fix: `Remove duplicate entries in ${codexTomlPath}: ${duplicates.join(", ")}`,
240
241
  };
241
242
  }
242
243
  return { ok: true, severity: "info", detail: "Valid TOML (no duplicate keys)" };
@@ -248,7 +249,7 @@ function createCodexConfigCheck() {
248
249
  ok: false,
249
250
  severity: "warn",
250
251
  detail: `Cannot read: ${err.message.split("\n")[0]}`,
251
- fix: "Check file permissions on ~/.codex/config.toml",
252
+ fix: `Check file permissions on ${codexTomlPath}`,
252
253
  };
253
254
  }
254
255
  },
@@ -136,6 +136,9 @@ export function registerPipeline(program, { pkgVersion }) {
136
136
  .option("-q, --quiet", "Show only stage status lines, suppress raw agent output (default)")
137
137
  .option("-v, --verbose", "Show full agent output (stream-json, raw lines)")
138
138
  .action(async (task, flags) => {
139
+ // KJC-BUG-0448: run's --profile is the model profile; harden and check use
140
+ // --profile for the harness, so the config loader reads modelProfile.
141
+ if (flags.profile) flags.modelProfile = flags.profile;
139
142
  await withConfig(pkgVersion, "run", flags, async ({ config, logger }) => {
140
143
  // If `--plan <id>` was passed and the user didn't provide a task
141
144
  // argument or --task-file, fall back to the task embedded in the
@@ -135,7 +135,9 @@ export async function envInstallCommand({ config = null, logger = null, flags =
135
135
  console.log(result.pendingBlock);
136
136
  return result;
137
137
  }
138
- await reviewGateCommand({ config, flags: { installGate: true } });
138
+ const gate = await reviewGateCommand({ config, flags: { installGate: true } });
139
+ // KJC-BUG-0447: a gate no hook runs is decorative; never call it active.
140
+ if (!gate?.enforced) throw new Error("the review gate is declared but no pre-commit hook runs it");
139
141
  console.log("✓ enforcement active: git hooks + cross-AI verdict gate — a commit outside the method is rejected, not narrated");
140
142
  } catch (err) {
141
143
  result.exitCode = PENDING_EXIT_CODE;
@@ -11,6 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { spawn } from "node:child_process";
13
13
  import { checkBinary } from "../utils/agent-detect.js";
14
+ import { agentAuthFiles } from "../utils/agent-home.js";
14
15
  import { createCliAskQuestion } from "../utils/cli-ask-question.js";
15
16
  import { bootstrapCommand } from "./bootstrap.js";
16
17
  import { boardCommand } from "./board.js";
@@ -21,12 +22,14 @@ export const MAGGLE_AGENTS = [
21
22
  { name: "claude", label: "Claude Code (Anthropic)", authPaths: [".claude.json", ".claude"], install: "npm install -g @anthropic-ai/claude-code", login: "claude (sigue el enlace de inicio de sesión que te muestre)" },
22
23
  { name: "codex", label: "Codex (OpenAI)", authPaths: [".codex/auth.json"], install: "npm install -g @openai/codex", login: "codex login" },
23
24
  ];
24
- export async function detectMaggleAgents({ home = os.homedir(), checkBin = checkBinary } = {}) {
25
+ export async function detectMaggleAgents({ home = os.homedir(), env = process.env, checkBin = checkBinary } = {}) {
25
26
  return Promise.all(
26
27
  MAGGLE_AGENTS.map(async (a) => {
27
28
  let installed = false;
28
29
  try { installed = (await checkBin(a.name)).ok; } catch { /* not installed */ }
29
- const authenticated = installed && a.authPaths.some((p) => existsSync(path.join(home, p)));
30
+ // KJC-TSK-1344: the session lives where CLAUDE_CONFIG_DIR / CODEX_HOME point.
31
+ const files = agentAuthFiles(a.name, { home, env }) ?? a.authPaths.map((p) => path.join(home, p));
32
+ const authenticated = installed && files.some((f) => existsSync(f));
30
33
  return { ...a, installed, authenticated };
31
34
  }),
32
35
  );
@@ -25,12 +25,20 @@ import { installHarnessHooks } from "../harden/harness-hooks.js";
25
25
  import { installSentinelHooks } from "../harden/sentinel-hooks.js";
26
26
  import { installSandbox } from "../harden/sandbox.js";
27
27
  import { detectStackRoots } from "../harden/stack-roots.js";
28
- import { installWorkflows, resolveKjPin } from "../harden/workflow-engine.js";
28
+ import { detectPackageManager, installWorkflows, resolveKjPin } from "../harden/workflow-engine.js";
29
29
  import { adviseBranchProtection } from "../harden/branch-protection.js";
30
30
  import { detectTestFramework } from "../utils/project-detect.js";
31
31
  import { ensureIdentity } from "../identity/bootstrap.js";
32
32
  import { commitContract, contractChanges } from "../environment/contract-commit.js";
33
33
 
34
+ // How each package manager runs a package.json script in a hook (`bun test` is bun's own runner, not the script).
35
+ const HOOK_RUNNERS = {
36
+ npm: { run: (s) => `npm run -s ${s}`, test: "npm test" },
37
+ pnpm: { run: (s) => `pnpm run -s ${s}`, test: "pnpm test" },
38
+ yarn: { run: (s) => `yarn run ${s}`, test: "yarn test" },
39
+ bun: { run: (s) => `bun run ${s}`, test: "bun run test" },
40
+ };
41
+
34
42
  const JS_TEST_CMD = {
35
43
  vitest: "npx vitest run",
36
44
  jest: "npx jest",
@@ -51,10 +59,12 @@ export async function resolveCmds(projectDir, language) {
51
59
  if (existsSync(pkgPath)) {
52
60
  try {
53
61
  const scripts = JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
54
- if (scripts.lint) cmds.lint = "npm run -s lint";
55
- if (scripts["format:check"]) cmds.format = "npm run -s format:check";
56
- else if (scripts.format) cmds.format = "npm run -s format";
57
- if (scripts.test) cmds.test = "npm test";
62
+ // KJC-BUG-0450 (#2789): the project's own package manager, as the workflows use it.
63
+ const pm = HOOK_RUNNERS[detectPackageManager(projectDir)] ?? HOOK_RUNNERS.npm;
64
+ if (scripts.lint) cmds.lint = pm.run("lint");
65
+ if (scripts["format:check"]) cmds.format = pm.run("format:check");
66
+ else if (scripts.format) cmds.format = pm.run("format");
67
+ if (scripts.test) cmds.test = pm.test;
58
68
  } catch {
59
69
  /* unreadable package.json → fall back to framework detection */
60
70
  }
@@ -34,6 +34,7 @@ import { evaluatePolicyGate } from "../review/policy-gate.js";
34
34
  import { correctionNote, reviewCorrectionRounds } from "../review/correction-rounds.js";
35
35
  import { cooldownAdvisories, cooldownGate, DEFAULT_COOLDOWN_HOURS, formatCooldown } from "../review/dep-cooldown.js";
36
36
  import { branchOverlaps, formatOverlaps } from "../review/branch-overlap.js";
37
+ import { gateHookStatus } from "../review/gate-hook.js";
37
38
 
38
39
  // KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
39
40
  // (before spending sonar/reviewer effort) AND on --check — the pre-commit
@@ -195,18 +196,20 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
195
196
  const marker = join(projectDir, ".karajan", "review-gate");
196
197
  await fs.mkdir(join(projectDir, ".karajan"), { recursive: true });
197
198
  await fs.writeFile(marker, "# Cross-AI review gate enabled (ENV-C1). Commit this file so the whole team inherits the gate.\n");
198
- console.log("✓ review gate enabled — commits now require an approved cross-AI verdict (kj review --staged)");
199
+ // KJC-BUG-0447 (#2655): the marker declares the gate; only a pre-commit that runs it enforces it.
200
+ const hook = gateHookStatus(projectDir);
201
+ if (hook.enforced) console.log("✓ review gate enabled — commits now require an approved cross-AI verdict (kj review --staged)");
202
+ else {
203
+ console.log(`✗ review gate declared (.karajan/review-gate) but NOT enforced: ${hook.reason} — run \`kj harden\` so the pre-commit hook runs it`);
204
+ process.exitCode = 1;
205
+ }
199
206
  // KJC-TSK-0646: a `.karajan/` dir-exclude would silently keep the
200
207
  // contract out of git — rewrite it so the team actually inherits it.
201
208
  const gi = await ensureGateTrackable(projectDir);
202
209
  if (gi.changed) {
203
210
  console.log("✓ .gitignore adjusted: the gate contract (.karajan/review-gate, hooks) is now trackable — commit it");
204
211
  }
205
- const hookProbe = await runCommand("git", ["config", "core.hooksPath"]);
206
- if (!hookProbe.stdout?.trim()) {
207
- console.log("⚠ no core.hooksPath configured — run `kj harden` so the pre-commit hook enforces the gate");
208
- }
209
- return { installed: true };
212
+ return { installed: true, enforced: hook.enforced };
210
213
  }
211
214
 
212
215
  // KJC-BUG-0173: manual GC of the verdict store. Opportunistic pruning runs
@@ -102,7 +102,8 @@ function applyRoleOverrides(out, flags) {
102
102
  if (flags.reviewerModel) out.reviewer_options.model = String(flags.reviewerModel);
103
103
 
104
104
  // KJC-TSK-0699: a profile fills only the models nothing above has set.
105
- const profile = flags.profile ?? out.model_profile;
105
+ // KJC-BUG-0448: its own key — `--profile` belongs to harden/check (the harness profile).
106
+ const profile = flags.modelProfile ?? out.model_profile;
106
107
  if (profile) {
107
108
  applyModelProfile(out, String(profile));
108
109
  out.model_profile = String(profile);
@@ -14,6 +14,7 @@
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
+ import { claudeConfigFile, codexHome } from "../utils/agent-home.js";
17
18
 
18
19
  const TOKEN_CONVENTIONS = {
19
20
  linear: ["LINEAR_API_KEY"],
@@ -23,18 +24,19 @@ const TOKEN_CONVENTIONS = {
23
24
  asana: ["ASANA_ACCESS_TOKEN"],
24
25
  };
25
26
 
26
- function mcpConfigPaths(projectDir, home) {
27
+ // KJC-TSK-1344: claude and codex configs of the account CLAUDE_CONFIG_DIR / CODEX_HOME select.
28
+ function mcpConfigPaths(projectDir, home, env = process.env) {
27
29
  return [
28
30
  path.join(projectDir, ".mcp.json"),
29
- path.join(home, ".claude.json"),
30
- path.join(home, ".codex", "config.toml"),
31
+ claudeConfigFile({ home, env }),
32
+ path.join(codexHome({ home, env }), "config.toml"),
31
33
  path.join(home, ".gemini", "settings.json"),
32
34
  ];
33
35
  }
34
36
 
35
- function mcpConfigsMention(slug, projectDir, home) {
37
+ function mcpConfigsMention(slug, projectDir, home, env) {
36
38
  const needle = slug.toLowerCase();
37
- for (const p of mcpConfigPaths(projectDir, home)) {
39
+ for (const p of mcpConfigPaths(projectDir, home, env)) {
38
40
  try {
39
41
  if (existsSync(p) && readFileSync(p, "utf8").toLowerCase().includes(needle)) return p;
40
42
  } catch { /* unreadable config — keep looking */ }
@@ -50,13 +52,13 @@ function mcpConfigsMention(slug, projectDir, home) {
50
52
  */
51
53
  export function detectAvailableBoards({ projectDir = process.cwd(), env = process.env, home = os.homedir() } = {}) {
52
54
  const boards = [{ value: "hu-board", label: "HU Board (bundled, zero setup)" }];
53
- if (mcpConfigsMention("planning-game", projectDir, home)) {
55
+ if (mcpConfigsMention("planning-game", projectDir, home, env)) {
54
56
  boards.push({ value: "planning-game", label: "Planning Game (MCP detected)" });
55
57
  }
56
58
  for (const [slug, envs] of Object.entries(TOKEN_CONVENTIONS)) {
57
59
  const token = envs.find((k) => env[k]);
58
60
  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)` });
61
+ else if (mcpConfigsMention(slug, projectDir, home, env)) boards.push({ value: `external:${slug}`, label: `${slug} (MCP detected)` });
60
62
  }
61
63
  return boards;
62
64
  }
@@ -65,7 +67,7 @@ export function verifyBoardAccess({ config = {}, projectDir = process.cwd(), env
65
67
  const backend = config.state_backend || "hu-board";
66
68
 
67
69
  if (backend === "planning-game") {
68
- const hit = mcpConfigsMention("planning-game", projectDir, home);
70
+ const hit = mcpConfigsMention("planning-game", projectDir, home, env);
69
71
  if (hit) return { ok: true, backend, via: `planning-game MCP (${hit})` };
70
72
  return {
71
73
  ok: false, backend,
@@ -77,7 +79,7 @@ export function verifyBoardAccess({ config = {}, projectDir = process.cwd(), env
77
79
  const name = String(config.board?.name || "").trim();
78
80
  if (!name) return { ok: false, backend, needed: ["declare board.name in kj.config.yml (e.g. Linear, Trello, Jira, GitHub Issues)"] };
79
81
  const slug = name.toLowerCase().split(/\s+/)[0];
80
- const hit = mcpConfigsMention(slug, projectDir, home);
82
+ const hit = mcpConfigsMention(slug, projectDir, home, env);
81
83
  if (hit) return { ok: true, backend, via: `${name} MCP detected (${hit})` };
82
84
  const tokenEnvs = config.board?.token_env ? [config.board.token_env] : (TOKEN_CONVENTIONS[slug] || []);
83
85
  const found = tokenEnvs.find((k) => env[k]);
@@ -43,6 +43,34 @@ const addCommand = ({ missing, ...target }) =>
43
43
  `gh api -X POST ${protectionPath(target)}/required_status_checks/contexts ${missing.map((c) => `-f ${shellQuote(`contexts[]=${c}`)}`).join(" ")}`;
44
44
 
45
45
  const settingsUrl = ({ owner, repo }) => `https://github.com/${owner}/${repo}/settings/branches`;
46
+ const rulesUrl = ({ owner, repo }) => `https://github.com/${owner}/${repo}/settings/rules`;
47
+
48
+ /**
49
+ * KJC-BUG-0449 (#2787): every rule that applies to the branch, rulesets included.
50
+ * [] when none applies (a 404 included); null when it cannot be read.
51
+ */
52
+ async function branchRules({ owner, repo, branch }, run) {
53
+ let res;
54
+ try { res = await run("gh", ["api", `repos/${owner}/${repo}/rules/branches/${encodeURIComponent(branch)}`]); } catch { return null; }
55
+ if (res.exitCode !== 0) return /404/.test(`${res.stderr}${res.stdout}`) ? [] : null;
56
+ try {
57
+ const rules = JSON.parse(res.stdout || "[]");
58
+ return Array.isArray(rules) ? rules : null;
59
+ } catch { return null; }
60
+ }
61
+
62
+ const rulesetChecks = (rules) => rules
63
+ .filter((r) => r?.type === "required_status_checks")
64
+ .flatMap((r) => (r.parameters?.required_status_checks ?? []).map((c) => c.context));
65
+
66
+ /** No classic protection: the rulesets decide, and they are fixed in their own page, never with a PUT. */
67
+ function rulesetAdvice({ rules, checks, target }) {
68
+ if (rules === null) return { status: "unverified", reason: "the branch rules could not be read", settings: rulesUrl(target), missing: checks };
69
+ if (rules.length === 0) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
70
+ const required = new Set(rulesetChecks(rules));
71
+ const missing = checks.filter((c) => !required.has(c));
72
+ return missing.length > 0 ? { status: "ruleset-missing", missing, settings: rulesUrl(target) } : { status: "ok", via: "ruleset" };
73
+ }
46
74
 
47
75
  /**
48
76
  * What the base branch needs for the checks to be required, and how. A command
@@ -61,14 +89,18 @@ export async function protectionAdvice({ owner, repo, branch, checks, run = runC
61
89
  return { status: "unverified", reason: err.message, settings: settingsUrl(target), missing: checks };
62
90
  }
63
91
  if (res.exitCode !== 0) {
64
- if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
92
+ if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return rulesetAdvice({ rules: await branchRules(target, run), checks, target });
65
93
  return { status: "unverified", reason: (res.stderr || res.stdout || "gh failed").trim(), settings: settingsUrl(target), missing: checks };
66
94
  }
67
95
  const protection = JSON.parse(res.stdout || "{}");
96
+ const rules = await branchRules(target, run);
97
+ const fromRules = rulesetChecks(rules ?? []);
68
98
  const statusChecks = protection.required_status_checks;
69
- if (!statusChecks) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
70
- const existing = new Set(statusChecks.contexts ?? []);
99
+ const existing = new Set([...(statusChecks?.contexts ?? []), ...fromRules]);
71
100
  const missing = checks.filter((c) => !existing.has(c));
101
+ // Unreadable rules: a ruleset may require what the classic layer lacks, so nothing is "missing" for sure.
102
+ if (rules === null && missing.length > 0) return { status: "unverified", reason: "the branch rules could not be read", settings: rulesUrl(target), missing };
103
+ if (!statusChecks && fromRules.length === 0) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
72
104
  // KJC-BUG-0401: required checks an admin skips are not required for an agent holding an admin's token.
73
105
  const bindAdmins = protection.enforce_admins?.enabled === false ? `gh api -X POST ${protectionPath(target)}/enforce_admins` : null;
74
106
  if (missing.length > 0) return { status: "missing", missing, command: [addCommand({ ...target, missing }), bindAdmins].filter(Boolean).join(" && ") };
@@ -105,6 +137,7 @@ export async function adviseBranchProtection({ projectDir, branch, logger, run =
105
137
  missing: `${branch} does not require ${advice.missing?.length} kj check(s) (${list}). An admin runs (kj never does):`,
106
138
  "no-checks": `${branch} is protected but requires no status checks. An admin enables "Require status checks" and adds ${list} in:`,
107
139
  unverified: `could not read the protection of ${branch} (${advice.reason}). An admin checks that ${list} are required in:`,
140
+ "ruleset-missing": `${branch} is protected by a ruleset that does not require ${advice.missing?.length} kj check(s) (${list}). An admin adds them to the ruleset (no second protection layer) in:`,
108
141
  "admins-exempt": `${branch} requires the kj checks, but admins can merge without them, and an agent working with an admin's token would. An admin runs (kj never does):`,
109
142
  }[advice.status];
110
143
  logger.warn?.(` ⚠ branch protection: ${why}`);
@@ -11,9 +11,9 @@ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { homedir } from "node:os";
13
13
  import { fileURLToPath } from "node:url";
14
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
14
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText, stagedVerdict } from "./sentinel-lib.mjs";
15
15
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
16
- import { isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
16
+ import { commitStaging, isGhIssueCreate, isPublishCommand, isReadOnlyCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
17
17
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
18
18
  import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
19
19
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -635,6 +635,24 @@ process.stdin.on("end", () => {
635
635
  console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
636
636
  process.exit(2);
637
637
  }
638
+ // KJC-BUG-0455 (#2933): the verdict of the staged diff, checked HERE as well as in
639
+ // the pre-commit hook: a repo whose hook does not run the gate, or a review piped into
640
+ // tail that hid its failure, let a commit through with no verdict at all.
641
+ const plan = commitStaging(cmd);
642
+ if (plan.commits) {
643
+ // 4.x: the hook payload carries no cwd; the session works in the project root.
644
+ const verdict = stagedVerdict(plan.dir ? resolve(ROOT, plan.dir) : ROOT);
645
+ if (verdict.gate && plan.stages) {
646
+ console.error("karajan sentinel: stagea y commitea en comandos separados — el veredicto se comprueba sobre lo que ya esta stageado, y este comando stagea a la vez que commitea (git add/rm/mv, -a o rutas en git commit). Stagea por nombre, pasa kj review --staged y luego git commit." + doc("commit-verdict"));
647
+ process.exit(2);
648
+ }
649
+ // Only an approved verdict passes, or nothing staged (a merge: git itself decides).
650
+ if (verdict.gate && verdict.state !== "approved" && verdict.state !== "empty") {
651
+ const why = { rejected: "el review de este diff fue RECHAZADO por " + (verdict.reviewer || "el reviewer") + " — corrige y vuelve a pasar kj review --staged", unknown: "git no pudo leer el diff stageado, asi que el veredicto no se puede comprobar" }[verdict.state] || "no hay veredicto para el diff stageado — pasa kj review --staged (solo, sin tuberias que oculten si falla)";
652
+ console.error("karajan sentinel: commit sin veredicto aprobado: " + why + "." + doc("commit-verdict"));
653
+ process.exit(2);
654
+ }
655
+ }
638
656
  // KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
639
657
  // git add -A / git add . / git add -u put a Claude scheduler lock into
640
658
  // main; and the agent's runtime files (the .claude locks, .kj/, the local
@@ -4,6 +4,7 @@
4
4
  import process from "node:process";
5
5
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
6
  import { execSync, spawnSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
7
8
  import { basename, dirname, join } from "node:path";
8
9
  import { fileURLToPath } from "node:url";
9
10
  const here = dirname(fileURLToPath(import.meta.url));
@@ -65,6 +66,28 @@ export const bootstrapPhase = () => {
65
66
  try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
66
67
  catch { return true; }
67
68
  };
69
+ // KJC-BUG-0455 (#2933): the cross-AI verdict of the diff staged in `dir`, read the way
70
+ // `kj review --check` reads it (sha256 of `git diff --cached`, trailing blanks trimmed,
71
+ // in <top>/.karajan/reviews). gate:false when the repo has not committed the gate marker.
72
+ // state: approved | rejected | missing | empty (nothing staged) | unknown (git failed).
73
+ export const stagedVerdict = (dir) => {
74
+ const git = (args) => spawnSync("git", ["-C", dir, ...args], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
75
+ // No commit yet (or no repo): no committed marker can exist, as the hook reads it.
76
+ if (git(["rev-parse", "--verify", "-q", "HEAD"]).status !== 0) return { gate: false };
77
+ // A git error is not a missing marker: it fails closed.
78
+ const marker = git(["ls-tree", "--name-only", "HEAD", ".karajan/review-gate"]);
79
+ if (marker.status !== 0) return { gate: true, state: "unknown" };
80
+ if (!marker.stdout.trim()) return { gate: false };
81
+ const top = git(["rev-parse", "--show-toplevel"]).stdout?.trim();
82
+ const diff = git(["diff", "--cached"]);
83
+ if (!top || diff.status !== 0) return { gate: true, state: "unknown" };
84
+ if (!diff.stdout.trim()) return { gate: true, state: "empty" };
85
+ const hash = createHash("sha256").update(diff.stdout.trimEnd(), "utf8").digest("hex");
86
+ try {
87
+ const v = JSON.parse(readFileSync(join(top, ".karajan", "reviews", hash + ".json"), "utf8"));
88
+ return { gate: true, state: v.verdict === "approved" ? "approved" : "rejected", reviewer: v.reviewer || null };
89
+ } catch { return { gate: true, state: "missing" }; }
90
+ };
68
91
  // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
69
92
  // open. kj decides with that checkable fact, the same answer for commit and push
70
93
  // (the Stop gate still holds the turn: the board owes the move); when kj does not
@@ -300,3 +300,78 @@ export const isReadOnlyCommand = (cmd) => {
300
300
 
301
301
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
302
302
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
303
+
304
+ // KJC-BUG-0455 (#2933): git commit's options that take a value, and the git verbs that
305
+ // change the index. A commit that stages in the same command commits a diff nobody
306
+ // could have reviewed yet: the verdict is checked against what is staged BEFORE it runs.
307
+ const COMMIT_VALUE_OPTS = new Set(["-m", "-F", "-C", "-c", "-t", "--message", "--file", "--reuse-message", "--reedit-message", "--template", "--author", "--date", "--cleanup", "--fixup", "--squash", "--trailer", "--pathspec-from-file"]);
308
+ const COMMIT_VALUE_SHORT = new Set(["m", "F", "C", "c", "t"]);
309
+ const INDEX_VERBS = new Set(["add", "rm", "mv", "stage", "restore", "reset", "apply", "update-index"]);
310
+
311
+ /** Where `git` is in a segment and its verb, skipping git's global options; null when not git. */
312
+ const gitVerbOf = (words) => {
313
+ if (words[0]?.slice(words[0].lastIndexOf("/") + 1) !== "git") return null;
314
+ let i = 1;
315
+ let dir = null;
316
+ while (i < words.length) {
317
+ const w = words[i];
318
+ if (GIT_GLOBAL_WITH_VALUE.has(w)) {
319
+ if (w === "-C") {
320
+ dir = words[i + 1] ?? null;
321
+ }
322
+ i += 2; // the option and its value
323
+ } else if (GIT_GLOBAL_FLAG.test(w)) {
324
+ i += 1;
325
+ } else {
326
+ break;
327
+ }
328
+ }
329
+ return { verb: words[i], at: i, dir };
330
+ };
331
+
332
+ /** How many words a git commit argument takes, and whether it makes the commit stage on its own. */
333
+ const commitArg = (a) => {
334
+ if (a === "--all") return { stages: true, words: 1 };
335
+ if (a.startsWith("--")) {
336
+ // --message=x carries its value; --message x takes the next word.
337
+ return { stages: false, words: COMMIT_VALUE_OPTS.has(a) ? 2 : 1 };
338
+ }
339
+ if (/^-[a-zA-Z]/.test(a)) {
340
+ const cluster = a.slice(1);
341
+ const valueAt = [...cluster].findIndex((c) => COMMIT_VALUE_SHORT.has(c));
342
+ const flags = valueAt < 0 ? cluster : cluster.slice(0, valueAt);
343
+ if (flags.includes("a")) return { stages: true, words: 1 };
344
+ // -m x takes the next word; -mx carries its value.
345
+ return { stages: false, words: valueAt === cluster.length - 1 ? 2 : 1 };
346
+ }
347
+ return { stages: true, words: 1 }; // a pathspec
348
+ };
349
+
350
+ /** -a/--all, or a path (with or without --), makes git commit stage on its own. */
351
+ const commitStagesItself = (args) => {
352
+ let i = 0;
353
+ while (i < args.length) {
354
+ if (args[i] === "--") {
355
+ return i + 1 < args.length;
356
+ }
357
+ const { stages, words } = commitArg(args[i]);
358
+ if (stages) {
359
+ return true;
360
+ }
361
+ i += words;
362
+ }
363
+ return false;
364
+ };
365
+
366
+ /**
367
+ * Does the command run git commit, and does it stage in the same command?
368
+ * @param {string} cmd
369
+ * @returns {{commits: boolean, stages: boolean, dir: string|null}}
370
+ */
371
+ export const commitStaging = (cmd) => {
372
+ const gits = shellSegments(cmd).map((words) => ({ words, g: gitVerbOf(words) })).filter((s) => s.g);
373
+ const commits = gits.filter((s) => s.g.verb === "commit");
374
+ if (commits.length === 0) return { commits: false, stages: false, dir: null };
375
+ const stages = gits.some((s) => INDEX_VERBS.has(s.g.verb)) || commits.some((s) => commitStagesItself(s.words.slice(s.g.at + 1)));
376
+ return { commits: true, stages, dir: commits[0].g.dir };
377
+ };
@@ -57,9 +57,20 @@ export function isPublishableNpm(projectDir) {
57
57
  }
58
58
  }
59
59
 
60
- /** Lockfile-detected package manager (KJC-BUG-0131): npm ci kills pnpm/yarn CI. */
60
+ const KNOWN_PMS = new Set(["npm", "pnpm", "yarn", "bun"]);
61
+
62
+ /**
63
+ * The project's package manager (KJC-BUG-0131: npm ci kills pnpm/yarn CI).
64
+ * KJC-BUG-0450 (#2789): the same answer for hooks and workflows; the
65
+ * packageManager field of package.json first, then the lockfile.
66
+ */
61
67
  export function detectPackageManager(projectDir) {
68
+ try {
69
+ const declared = String(JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8")).packageManager || "").split("@")[0];
70
+ if (KNOWN_PMS.has(declared)) return declared;
71
+ } catch { /* no or unreadable package.json: the lockfile decides */ }
62
72
  if (existsSync(join(projectDir, "pnpm-lock.yaml"))) return "pnpm";
73
+ if (existsSync(join(projectDir, "bun.lock")) || existsSync(join(projectDir, "bun.lockb"))) return "bun";
63
74
  if (existsSync(join(projectDir, "yarn.lock"))) return "yarn";
64
75
  return "npm";
65
76
  }
@@ -4,44 +4,67 @@
4
4
  */
5
5
 
6
6
  import { handleSuggestion } from "../suggest-handler.js";
7
+ import { runKjCommand } from "../run-kj.js";
7
8
  import {
8
9
  resolveProjectDir,
9
10
  failPayload,
10
11
  } from "../shared-helpers.js";
11
12
 
13
+ export const HU_STATUSES = ["pending", "running", "done", "failed", "skipped"];
14
+
15
+ /**
16
+ * KJC-BUG-0452 (#2815): kj_hu used the old kj run store, so an agent saw an empty
17
+ * board while `kj hu list` showed every card. It runs the CLI's own `kj hu` in a
18
+ * subprocess: the same board (local, shared or a lane's), and stdout, which is
19
+ * this server's protocol channel, stays clean.
20
+ */
21
+ async function kjHu(projectDir, args) {
22
+ const res = await runKjCommand({ command: "hu", commandArgs: [...args, "--json"], options: { projectDir } });
23
+ if (!res.ok) return { error: `kj hu ${args[0]} failed: ${res.errorSummary || res.stderr || "no output"}` };
24
+ const line = String(res.stdout || "").trim().split("\n").reverse().find((l) => /^[[{]/.test(l.trim()));
25
+ try { return { data: JSON.parse(line) }; } catch { return { error: `kj hu ${args[0]} answered no JSON: ${String(res.stdout || "").slice(0, 200)}` }; }
26
+ }
27
+
28
+ const notOnBoard = (id) => `HU ${id} is not on the board — kj_hu list shows its cards`;
29
+
30
+ async function createHu(projectDir, a) {
31
+ if (!a.title) return failPayload("Missing required field: title (required for create)");
32
+ const args = ["add", a.title, ...(a.acceptanceCriteria ? ["--ac", a.acceptanceCriteria] : []), ...(a.ref ? ["--ref", a.ref] : [])];
33
+ const added = await kjHu(projectDir, args);
34
+ if (added.error) return failPayload(added.error);
35
+ if (!a.status || a.status === "pending") return { ok: true, hu: added.data };
36
+ return moveHu(projectDir, added.data.short_id || added.data.id, a.status);
37
+ }
38
+
39
+ async function moveHu(projectDir, huId, status) {
40
+ if (!HU_STATUSES.includes(status)) return failPayload(`Unknown status "${status}": use ${HU_STATUSES.join(", ")}`);
41
+ const moved = await kjHu(projectDir, ["move", huId, status]);
42
+ return moved.error ? failPayload(moved.error) : { ok: true, hu: moved.data };
43
+ }
44
+
12
45
  export async function handleHu(a, server) {
13
46
  const action = a.action;
14
47
  if (!action) return failPayload("Missing required field: action");
15
-
16
48
  const projectDir = await resolveProjectDir(server, a.projectDir);
17
- const { createManualHu, listHus, getHu, updateHuStatus } = await import("../../hu/store.js");
18
49
 
19
50
  switch (action) {
20
- case "create": {
21
- if (!a.title) return failPayload("Missing required field: title (required for create)");
22
- const hu = await createManualHu(projectDir, {
23
- title: a.title,
24
- description: a.description,
25
- status: a.status,
26
- acceptanceCriteria: a.acceptanceCriteria
27
- });
28
- return { ok: true, hu };
29
- }
51
+ case "create":
52
+ return createHu(projectDir, a);
30
53
  case "list": {
31
- const hus = await listHus(projectDir);
32
- return { ok: true, hus };
54
+ const listed = await kjHu(projectDir, ["list"]);
55
+ return listed.error ? failPayload(listed.error) : { ok: true, hus: listed.data };
33
56
  }
34
57
  case "get": {
35
58
  if (!a.huId) return failPayload("Missing required field: huId (required for get)");
36
- const hu = await getHu(projectDir, a.huId);
37
- return { ok: true, hu };
59
+ const listed = await kjHu(projectDir, ["list"]);
60
+ if (listed.error) return failPayload(listed.error);
61
+ const hu = listed.data.find((h) => h.id === a.huId || h.short_id === a.huId);
62
+ return hu ? { ok: true, hu } : failPayload(notOnBoard(a.huId));
38
63
  }
39
- case "update": {
64
+ case "update":
40
65
  if (!a.huId) return failPayload("Missing required field: huId (required for update)");
41
66
  if (!a.status) return failPayload("Missing required field: status (required for update)");
42
- const hu = await updateHuStatus(projectDir, a.huId, a.status);
43
- return { ok: true, hu };
44
- }
67
+ return moveHu(projectDir, a.huId, a.status);
45
68
  default:
46
69
  return failPayload(`Unknown hu action: ${action}`);
47
70
  }
package/src/mcp/tools.js CHANGED
@@ -335,17 +335,17 @@ export const tools = [
335
335
  },
336
336
  {
337
337
  name: "kj_hu",
338
- description: "Manage user stories (HUs) in the local board",
338
+ description: "Manage user stories (HUs) in the HU Board, the same board `kj hu` uses (local or shared)",
339
339
  inputSchema: {
340
340
  type: "object",
341
341
  required: ["action"],
342
342
  properties: {
343
343
  action: { type: "string", enum: ["create", "update", "list", "get"], description: "Action to perform" },
344
344
  title: { type: "string", description: "HU title (required for create)" },
345
- description: { type: "string", description: "HU description" },
346
- acceptanceCriteria: { type: "string", description: "Acceptance criteria" },
345
+ acceptanceCriteria: { type: "string", description: "Acceptance criteria, one per line" },
346
+ ref: { type: "string", description: "Id of this card's replica in the external tracker (Planning Game, Jira, Linear...)" },
347
347
  huId: { type: "string", description: "HU ID (required for update/get)" },
348
- status: { type: "string", description: "HU status (for create/update)", enum: ["pending", "coding", "reviewing", "done", "failed", "blocked"] },
348
+ status: { type: "string", description: "HU status (for create/update)", enum: ["pending", "running", "done", "failed", "skipped"] },
349
349
  projectDir: { type: "string", description: "Project directory (defaults to cwd)" }
350
350
  }
351
351
  }
@@ -47,10 +47,11 @@ function verdictOf(sides, findings, coverage) {
47
47
  if (blockers.length) return { status: "rejected", approved: false, reason: `confirmed blocker(s): ${blockers.map(where).join(", ")}`, summary };
48
48
  const rejecting = sides.filter((s) => !s.verdict?.approved).map((s) => s.reviewer);
49
49
  if (rejecting.length === 0 && !coverage.truncated) return { status: "approved", approved: true, summary };
50
- const reason = rejecting.length
51
- ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family`
52
- : "not every file was reviewed";
53
- return { status: "partial", approved: false, reason, summary };
50
+ const dispute = rejecting.length ? `${rejecting.join(" and ")} rejected, but no blocking finding was confirmed by the other family` : null;
51
+ // KJC-BUG-0453 (#2921): "partial" only when files were left out (they are in coverage.unreviewed);
52
+ // everything reviewed and the reviewers disagreeing is "disputed".
53
+ if (coverage.truncated) return { status: "partial", approved: false, reason: ["not every file was reviewed", dispute].filter(Boolean).join("; "), summary };
54
+ return { status: "disputed", approved: false, reason: dispute, summary };
54
55
  }
55
56
 
56
57
  /** The two sides (reviewer, model, verdict, coverage, findings, outcomes, verification) as one result. */
@@ -0,0 +1,39 @@
1
+ /**
2
+ * KJC-BUG-0447 (issue #2655): is the review gate actually ENFORCED? The marker
3
+ * only declares it; what refuses a commit is a pre-commit hook that runs
4
+ * `kj review --check`. Checked where git will look (core.hooksPath, local or
5
+ * global, or .git/hooks), never assumed from a hooks path being configured.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import { execFileSync } from "node:child_process";
10
+
11
+ // The contract: a line that RUNS the check and stops the commit when it fails, as the
12
+ // pre-commit kj harden writes (`kj review --check || { ...; exit 1; }`) or `|| exit 1`.
13
+ // A comment, the phrase inside an echo, `|| true`, `|| exit 0` or a group that does
14
+ // not END in `exit N` (N > 0) enforce nothing. Right after `||`, a bare `exit` keeps
15
+ // the failing check's status; inside a group it would take the previous command's.
16
+ const GATE_LINE = /^\s*kj review --check\s*\|\|\s*(?:exit(?:\s+[1-9]\d*)?\s*(?:;|#|$)|\{(?:[^}]*;)?\s*exit\s+[1-9]\d*\s*;?\s*\})/;
17
+ const runsGate = (body) => body.split("\n").some((line) => GATE_LINE.test(line));
18
+
19
+ /** @returns {{enforced: boolean, hooksDir: string|null, reason: string|null}} */
20
+ export function gateHookStatus(projectDir) {
21
+ let hooksDir;
22
+ try {
23
+ // --git-path hooks honours core.hooksPath (repo or global) and worktrees.
24
+ const rel = execFileSync("git", ["rev-parse", "--git-path", "hooks"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
25
+ hooksDir = path.resolve(projectDir, rel);
26
+ } catch {
27
+ return { enforced: false, hooksDir: null, reason: "not a git repository: no hook can enforce the gate" };
28
+ }
29
+ const hook = path.join(hooksDir, "pre-commit");
30
+ let body;
31
+ try { body = fs.readFileSync(hook, "utf8"); } catch {
32
+ return { enforced: false, hooksDir, reason: `no pre-commit hook in ${hooksDir}, where git looks for hooks` };
33
+ }
34
+ if (!runsGate(body)) return { enforced: false, hooksDir, reason: `${hook} does not run kj review --check` };
35
+ try { fs.accessSync(hook, fs.constants.X_OK); } catch {
36
+ return { enforced: false, hooksDir, reason: `${hook} is not executable, so git skips it` };
37
+ }
38
+ return { enforced: true, hooksDir, reason: null };
39
+ }
@@ -14,12 +14,13 @@ import { buildReviewerPrompt } from "../prompts/reviewer.js";
14
14
  import { resolveReviewProfile } from "./profiles.js";
15
15
  import { parseReviewVerdict } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
+ import { accountLabel } from "../utils/agent-home.js";
17
18
  import { saveVerdict, diffHash } from "./verdict-store.js";
18
19
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
19
20
  import { reportUnparseableVerdict } from "./unparseable-verdict.js";
20
21
  import { detectWorkspace } from "./workspace.js";
21
22
  import { branchOf } from "./correction-rounds.js";
22
- import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
23
+ import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu, reviewerFailureMessage } from "./reviewer-fallback.js";
23
24
 
24
25
  // Cross-AI preference when the configured reviewer IS the host.
25
26
  const CROSS_ORDER = ["codex", "claude", "gemini", "opencode", "aider"];
@@ -60,6 +61,7 @@ export async function runOneShotReview({
60
61
  // review can never unlock a commit of one's own.
61
62
  reviewerOverride = null,
62
63
  persist = true,
64
+ env = process.env,
63
65
  }) {
64
66
  if (!diff || !diff.trim()) {
65
67
  throw new Error("nothing to review — the diff is empty (stage your changes or pass --range)");
@@ -79,7 +81,9 @@ export async function runOneShotReview({
79
81
  // KJC-BUG-0347: generated files summarized; the verdict still binds to the full diff.
80
82
  diff: summarizeGeneratedFiles(diff), reviewRules: rules, mode: "standard", provider: who, projectDir,
81
83
  });
82
- logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)`);
84
+ // KJC-TSK-1344: with two subscriptions per CLI, say which account reviews.
85
+ const account = accountLabel(who, { env });
86
+ logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)${account ? ` [${account}]` : ""}`);
83
87
  return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
84
88
  };
85
89
 
@@ -115,7 +119,7 @@ export async function runOneShotReview({
115
119
  }
116
120
 
117
121
  if (!result?.ok) {
118
- throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
122
+ throw new Error(reviewerFailureMessage(activeReviewer, result?.error));
119
123
  }
120
124
 
121
125
  // KJC-BUG-0146, second half: parseMaybeJsonString only PARSES — it returns whatever JSON came
@@ -9,6 +9,8 @@ import { diffHash, loadVerdict } from "./verdict-store.js";
9
9
  import { isCleanMerge } from "./clean-merge.js";
10
10
 
11
11
  export const MARKER = (head) => `<!-- kj-review head=${head} -->`;
12
+ /** Who may publish a kj review that counts: verify-pr ignores anyone else. */
13
+ export const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
12
14
 
13
15
  // gh runs in the project's own directory, so it reads and writes the same repo the verdicts belong to.
14
16
  async function json(run, cmd, args, cwd) {
@@ -39,7 +41,8 @@ export async function publishPrReview({ projectDir, pr, run = runCommand }) {
39
41
  }
40
42
  if (uncovered.length > 0) throw new Error(`PR #${pr}: ${uncovered.length} commit(s) without an approved verdict of their exact diff, nothing published: ${uncovered.join(", ")}`);
41
43
  const reviews = await json(run, "gh", ["api", `repos/{owner}/{repo}/pulls/${pr}/reviews`], projectDir);
42
- if ((reviews ?? []).some((r) => String(r.body ?? "").includes(MARKER(info.headRefOid)))) {
44
+ // KJC-BUG-0456: one a non-member posted (gh switched to another account) does not count in CI.
45
+ if ((reviews ?? []).some((r) => TRUSTED.has(r.author_association) && String(r.body ?? "").includes(MARKER(info.headRefOid)))) {
43
46
  return { published: false, reason: `a kj review for head ${info.headRefOid.slice(0, 7)} is already published` };
44
47
  }
45
48
  const body = [
@@ -14,6 +14,7 @@ import { existsSync } from "node:fs";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { checkBinary } from "../utils/agent-detect.js";
17
+ import { agentAuthFiles } from "../utils/agent-home.js";
17
18
 
18
19
  export const REVIEWER_CANDIDATES = [
19
20
  {
@@ -70,9 +71,22 @@ export const REVIEWER_CANDIDATES = [
70
71
 
71
72
  const QUOTA_RE = /usage limit|quota|rate.?limit|credits? (?:exhausted|left: ?0)|hit your .{0,20}limit/i;
72
73
 
74
+ // KJC-BUG-0451 (#2799): codex puts a `rate_limits` telemetry object in every JSON event;
75
+ // it says how much quota is LEFT, so it is no evidence that the quota ran out.
76
+ const RATE_LIMITS_TELEMETRY = /"rate_limits"\s*:\s*(?:\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}|null)/g;
77
+
73
78
  /** True when the agent error smells like exhausted quota, not a real failure. */
74
79
  export function isQuotaExhausted(text) {
75
- return typeof text === "string" && QUOTA_RE.test(text);
80
+ return typeof text === "string" && QUOTA_RE.test(text.replace(RATE_LIMITS_TELEMETRY, ""));
81
+ }
82
+
83
+ // The CLI could not reach its service at all: a sandboxed shell or the network, not the reviewer.
84
+ const UNREACHABLE_RE = /routing discovery failed|ENOTFOUND|ECONNREFUSED|ECONNRESET|EAI_AGAIN|getaddrinfo|stream disconnected|network (?:error|is unreachable)/i;
85
+
86
+ /** The real error, plus a hint when the reviewer CLI never reached its service. */
87
+ export function reviewerFailureMessage(reviewer, error) {
88
+ const base = `reviewer ${reviewer} failed: ${error || "no output"}`;
89
+ return UNREACHABLE_RE.test(String(error || "")) ? `${base} — ${reviewer} could not reach its service (a sandboxed shell or the network?); run kj review from an unsandboxed shell` : base;
76
90
  }
77
91
 
78
92
  /** installed × authenticated for every candidate, from cheap local checks. */
@@ -86,8 +100,10 @@ export async function candidateStatus({ home = os.homedir(), checkBin = checkBin
86
100
  // Session-file heuristics, or env keys for CLIs (aider) that carry
87
101
  // no session file. Key present ≠ credit left — it is menu signal;
88
102
  // the failover only sticks if the candidate actually answers.
103
+ // KJC-TSK-1344: codex and claude keep the account where CODEX_HOME / CLAUDE_CONFIG_DIR say.
104
+ const files = agentAuthFiles(c.name, { home, env }) ?? c.authPaths.map((p) => path.join(home, p));
89
105
  const authenticated =
90
- c.authPaths.some((p) => existsSync(path.join(home, p))) ||
106
+ files.some((f) => existsSync(f)) ||
91
107
  (c.authEnv || []).some((name) => Boolean(env[name]));
92
108
  return { ...c, installed, authenticated };
93
109
  }),
@@ -7,12 +7,11 @@
7
7
  */
8
8
  import { runCommand } from "../utils/process.js";
9
9
  import { diffHash } from "./verdict-store.js";
10
- import { MARKER } from "./publish-pr.js";
10
+ import { MARKER, TRUSTED } from "./publish-pr.js";
11
11
  import { isCleanMerge } from "./clean-merge.js";
12
12
  import { verifiedSupervisorFiles } from "../policy/supervisor-verify.js";
13
13
  import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
14
14
 
15
- const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
16
15
  // The marker of a kj review for any head: what MARKER writes up to the head itself.
17
16
  const MARKER_PREFIX = MARKER("\0").split("\0")[0];
18
17
  // One line per commit, as publish-pr writes it: - `<sha7>` headline: approved by X (diff <hash12>)...
@@ -0,0 +1,33 @@
1
+ /**
2
+ * KJC-TSK-1344 — where each CLI keeps its account. One person may hold two
3
+ * subscriptions per CLI (personal and work): claude switches account with
4
+ * CLAUDE_CONFIG_DIR and codex with CODEX_HOME. kj passes both to the agents
5
+ * (role-env allows CLAUDE* and CODEX*), so it must also LOOK there for the
6
+ * session and the config, never at a fixed ~/.codex or ~/.claude.json.
7
+ */
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+
11
+ const ACCOUNT_VAR = { codex: "CODEX_HOME", claude: "CLAUDE_CONFIG_DIR" };
12
+
13
+ export const codexHome = ({ home = os.homedir(), env = process.env } = {}) =>
14
+ env.CODEX_HOME || path.join(home, ".codex");
15
+
16
+ export const claudeConfigFile = ({ home = os.homedir(), env = process.env } = {}) =>
17
+ path.join(env.CLAUDE_CONFIG_DIR || home, ".claude.json");
18
+
19
+ /** Absolute files that mark a logged-in session, or null for agents without an account variable. */
20
+ export function agentAuthFiles(agent, { home = os.homedir(), env = process.env } = {}) {
21
+ if (agent === "codex") return [path.join(codexHome({ home, env }), "auth.json")];
22
+ if (agent === "claude") {
23
+ const dir = env.CLAUDE_CONFIG_DIR;
24
+ return dir ? [path.join(dir, ".claude.json"), path.join(dir, ".credentials.json")] : [path.join(home, ".claude.json"), path.join(home, ".claude")];
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /** "CODEX_HOME=/path" when the variable moves the account, null on the default. */
30
+ export function accountLabel(agent, { env = process.env } = {}) {
31
+ const name = ACCOUNT_VAR[agent];
32
+ return name && env[name] ? `${name}=${env[name]}` : null;
33
+ }