@karajan-family/code 4.70.2 → 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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/checks/ai-surface.js +9 -7
  3. package/src/checks/config-files.js +8 -7
  4. package/src/cli/register-pipeline.js +4 -0
  5. package/src/commands/env.js +3 -1
  6. package/src/commands/go.js +5 -2
  7. package/src/commands/harden.js +20 -6
  8. package/src/commands/pr-size.js +4 -3
  9. package/src/commands/review-gate.js +18 -13
  10. package/src/config/overrides.js +2 -1
  11. package/src/environment/board-access.js +11 -9
  12. package/src/git/automation.js +5 -4
  13. package/src/harden/branch-protection.js +36 -3
  14. package/src/harden/harden-engine.js +5 -21
  15. package/src/harden/hook-templates.js +18 -30
  16. package/src/harden/phone-sign.js +3 -1
  17. package/src/harden/published-pin.js +34 -0
  18. package/src/harden/sentinel/posttooluse.mjs +4 -2
  19. package/src/harden/sentinel/pretooluse-sentinel.mjs +22 -3
  20. package/src/harden/sentinel/sentinel-lib.mjs +23 -0
  21. package/src/harden/sentinel/sentinel-shell.mjs +109 -0
  22. package/src/harden/workflow-engine.js +34 -7
  23. package/src/mcp/handlers/hu-handlers.js +43 -20
  24. package/src/mcp/tools.js +4 -4
  25. package/src/orchestrator/stages/suite-green-stage.js +3 -1
  26. package/src/policy/engine.js +9 -3
  27. package/src/policy/supervisor-verify.js +2 -2
  28. package/src/review/dual-review.js +5 -4
  29. package/src/review/gate-hook.js +39 -0
  30. package/src/review/one-shot-review.js +7 -3
  31. package/src/review/pr-requirements.js +6 -6
  32. package/src/review/publish-pr.js +4 -1
  33. package/src/review/reviewer-fallback.js +18 -2
  34. package/src/review/verify-pr.js +39 -9
  35. package/src/sonar/scanner.js +2 -1
  36. package/src/utils/agent-home.js +33 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karajan-family/code",
3
- "version": "4.70.2",
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
@@ -292,6 +295,7 @@ export function registerPipeline(program, { pkgVersion }) {
292
295
  const r = await verifyPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.verifyPr });
293
296
  if (r.ok) logger.info(`✓ PR #${flags.verifyPr}: ${r.commits} commit(s), each with its cross-AI verdict`);
294
297
  else logger.error(`✗ PR #${flags.verifyPr}:\n - ${r.problems.join("\n - ")}`);
298
+ if (r.sealCommits) logger.info(` seal commit(s) signed by the roster, a human act with no AI verdict: ${r.sealCommits.join(", ")}`);
295
299
  process.exitCode = r.ok ? 0 : 1;
296
300
  return;
297
301
  }
@@ -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 } 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
  }
@@ -97,6 +107,7 @@ export async function hardenCommand({
97
107
  // KJC-BUG-0289: the contract files already dirty before anything was generated
98
108
  // (kj init captures them before writing its own). What is dirty NOW is the person's.
99
109
  contractBefore = null,
110
+ fetchFn = fetch,
100
111
  logger = console,
101
112
  } = {}) {
102
113
  const dirtyBefore = contractBefore ?? contractChanges(projectDir);
@@ -177,8 +188,11 @@ export async function hardenCommand({
177
188
  const withConfig = config && profile !== "minimal";
178
189
  const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
179
190
  const withCi = ci && profile !== "minimal";
191
+ // KJC-BUG-0420: consumer CI installs kj from npm, so the workflows pin a published version.
192
+ const pin = withCi ? await resolveKjPin({ projectDir, fetchFn }) : null;
193
+ if (pin?.note) logger.warn?.(`⚠ ${pin.note}`);
180
194
  const wf = withCi
181
- ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun })
195
+ ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun, kjVersion: pin?.version ?? null })
182
196
  : null;
183
197
  const withGuidelines = guidelines && profile !== "minimal";
184
198
  // KJC-BUG-0199 (issue #1773): the same detected language the workflows and
@@ -8,7 +8,7 @@ import { execFileSync } from "node:child_process";
8
8
  import { readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
 
11
- import { budgetedAdded } from "../review/loc-budget.js";
11
+ import { budgetedAdded, budgetedNet } from "../review/loc-budget.js";
12
12
 
13
13
  const git = (projectDir, args) =>
14
14
  execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -40,12 +40,13 @@ export async function branchSize({ projectDir = process.cwd(), base = "main", ex
40
40
  const n = text === "" ? 0 : text.split("\n").length - (text.endsWith("\n") ? 1 : 0);
41
41
  numstat += `${n}\t0\t${file}\n`;
42
42
  }
43
- return { ...budgetedAdded(numstat), base };
43
+ // KJC-BUG-0434: `net` is what the CI gate and the policy measure; `added` stays for its readers.
44
+ return { ...budgetedAdded(numstat), net: budgetedNet(numstat).net, base };
44
45
  }
45
46
 
46
47
  export async function prSizeCommand({ config, logger = console, flags = {} }) {
47
48
  const s = await branchSize({ projectDir: config?.projectDir || process.cwd(), base: config?.base_branch || "main" });
48
49
  if (flags.json) process.stdout.write(`${JSON.stringify(s)}\n`);
49
- else logger.info(`pr-size: ${s.added} line(s) added against ${s.base} (${s.testAdded} in tests, ${s.exempt} exempt)`);
50
+ else logger.info(`pr-size: ${s.net} net line(s) against ${s.base}, as the CI gate counts them (${s.added} added, ${s.testAdded} in tests, ${s.exempt} exempt)`);
50
51
  return s;
51
52
  }
@@ -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
@@ -268,26 +271,28 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
268
271
  // documentation are exempt — the same rule CI applies. Counting everything
269
272
  // here warned 234 lines on a doc-only PR that CI counted at 104, and two
270
273
  // implementations of one rule that disagree teach people to trust neither.
271
- const { added, exempt, testAdded } = budgetedAdded(numstat);
274
+ // KJC-BUG-0434: measured NET, as CI and the policy's net_lines_added do — a rewrite in place is not growth.
275
+ const { added, testAdded } = budgetedAdded(numstat);
276
+ const { net, exempt } = budgetedNet(numstat);
272
277
  // Said out loud: an exemption nobody sees is indistinguishable from a gate
273
278
  // that is not looking.
274
279
  if (exempt > 0) console.log(`ℓ pr-size: ${exempt} line(s) exempt from the budget (generated output and human docs; AI-rule files always count)`);
275
- if (added > sizeIdeal && added <= sizeWarn) {
276
- console.log(`ℓ pr-size: ${added} lines added, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
280
+ if (net > sizeIdeal && net <= sizeWarn) {
281
+ console.log(`ℓ pr-size: ${net} net lines, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
277
282
  }
278
- if (added > sizeWarn) {
283
+ if (net > sizeWarn) {
279
284
  // KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
280
285
  // partitioning is a decision, and it must never split code from its tests.
281
- const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
286
+ const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} test lines added — partition by feature, never code from its tests)` : "";
282
287
  const sizePolicy = config?.method_gates?.pr_size || "warn";
283
288
  // ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
284
289
  if (sizePolicy === "block") {
285
- const reason = `${added} lines added${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
290
+ const reason = `${net} net lines${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
286
291
  console.log(`✗ pr-size gate: ${reason}`);
287
292
  process.exitCode = 1;
288
293
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
289
294
  } else {
290
- console.log(`⚠ pr-size: ${added} lines added${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
295
+ console.log(`⚠ pr-size: ${net} net lines${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
291
296
  }
292
297
  }
293
298
  }
@@ -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]);
@@ -348,10 +348,11 @@ export async function finalizeGitAutomation({ config, gitCtx, task, logger, sess
348
348
  } catch (err) {
349
349
  logger.warn(`pr-size: the branch could not be measured (${err.message}); the budget is not checked for this commit`);
350
350
  }
351
- if (size && size.added > limit) {
352
- logger.warn(`pr-size: ${size.added} line(s) added against ${gitCtx.baseBranch}, over the budget of ${limit}: nothing committed. Split the step (several commits, several HUs); the changes stay in the tree`);
353
- await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, limit });
354
- return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, limit } };
351
+ // KJC-BUG-0434: net, as the CI gate counts it.
352
+ if (size && size.net > limit) {
353
+ logger.warn(`pr-size: ${size.net} net line(s) against ${gitCtx.baseBranch}, over the budget of ${limit}: nothing committed. Split the step (several commits, several HUs); the changes stay in the tree`);
354
+ await addCheckpoint(session, { stage: "git-commit", committed: false, skipped: "over-budget", added: size.added, net: size.net, limit });
355
+ return { committed: false, branch: gitCtx.branch, prUrl: null, pr: null, commits: [], overBudget: { added: size.added, net: size.net, limit } };
355
356
  }
356
357
  // ENV-F1: this path only runs after the pipeline's reviewer approved,
357
358
  // so stamp that verdict for the staged diff — the v4 pre-commit gate
@@ -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}`);
@@ -12,7 +12,7 @@ import { join } from "node:path";
12
12
 
13
13
  import { upsertManagedBlock } from "../utils/managed-markers.js";
14
14
  import { runCommand } from "../utils/process.js";
15
- import { hookBody, PROFILE_HOOKS, SHEBANG, toPortableHooksDir } from "./hook-templates.js";
15
+ import { hookBody, PROFILE_HOOKS, SHEBANG } from "./hook-templates.js";
16
16
 
17
17
  const BLOCK_VERSION = 1;
18
18
  const HOOKS_DIR = join(".karajan", "hooks");
@@ -29,12 +29,12 @@ export function isGitRepo(projectDir) {
29
29
  * compara por hash: un fichero con una sola coma manual ya no coincide.
30
30
  */
31
31
  export function renderCanonicalHook(hook, generation = {}) {
32
- const { cmds = {}, baseBranch = null, globalHooksDir = null } = generation;
32
+ const { cmds = {}, baseBranch = null } = generation;
33
33
  const { content } = upsertManagedBlock({
34
34
  source: HOOK_PREAMBLE,
35
35
  blockId: `hook:${hook}`,
36
36
  version: BLOCK_VERSION,
37
- body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
37
+ body: hookBody(hook, cmds, { baseBranch }),
38
38
  style: "hash",
39
39
  });
40
40
  return content;
@@ -57,22 +57,6 @@ export async function installHooks({
57
57
  const hooks = PROFILE_HOOKS[profile];
58
58
  if (!hooks) throw new Error(`Unknown harden profile: ${profile}`);
59
59
 
60
- // KJC-TSK-0645: setting a repo-local hooksPath ECLIPSES the machine's
61
- // global hooks dir — personal guards (AI-attribution commit-msg, protected
62
- // branch pre-push) would silently stop applying here. Detect the previous
63
- // global dir and have every generated hook chain to it. `~` is emitted as
64
- // `$HOME` so the committed hook stays portable across machines (the -x
65
- // guard silences it where the dir doesn't exist).
66
- let globalHooksDir = null;
67
- const globalCfg = await runCommand("git", ["config", "--global", "core.hooksPath"], { cwd: projectDir });
68
- const rawGlobal = globalCfg.exitCode === 0 ? globalCfg.stdout.trim() : "";
69
- if (rawGlobal && rawGlobal !== HOOKS_DIR) {
70
- // KJC-BUG-0169: normalize to a machine-independent form HERE (generation,
71
- // on the human's machine) so the provenance stores the portable dir and CI
72
- // recomputes the SAME bytes — an absolute home path is not verifiable there.
73
- globalHooksDir = toPortableHooksDir(rawGlobal);
74
- }
75
-
76
60
  const absHooksDir = join(projectDir, HOOKS_DIR);
77
61
  const results = [];
78
62
  for (const hook of hooks) {
@@ -82,7 +66,7 @@ export async function installHooks({
82
66
  source,
83
67
  blockId: `hook:${hook}`,
84
68
  version: BLOCK_VERSION,
85
- body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
69
+ body: hookBody(hook, cmds, { baseBranch }),
86
70
  style: "hash",
87
71
  });
88
72
  results.push({ hook, target, action });
@@ -99,5 +83,5 @@ export async function installHooks({
99
83
 
100
84
  // KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
101
85
  // provenance necesita registrar para que CI pueda recomputar y comparar.
102
- return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch, globalHooksDir } };
86
+ return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch } };
103
87
  }
@@ -8,8 +8,6 @@
8
8
  * is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
9
9
  */
10
10
 
11
- import { homedir } from "node:os";
12
-
13
11
  // KJC-TSK-1076: exported so the third-party PR checks apply the same rules.
14
12
  export const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
15
13
  export const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
@@ -25,32 +23,21 @@ export const PROFILE_HOOKS = {
25
23
 
26
24
  /** Build the managed body for a single hook. */
27
25
  // KJC-TSK-0645: `core.hooksPath .karajan/hooks` eclipses the user's global
28
- // hooks dir, silently disabling personal guards. When a previous global dir
29
- // is known, every generated hook ends by chaining its namesake there —
30
- // guarded, so machines without it are unaffected.
31
- // KJC-BUG-0161 / 0169 (ADR 0009): a committed hook must not bake a machine's
32
- // absolute home path — a dir under the home resolves through $HOME so the same
33
- // generated content is valid on every machine and its provenance stays
34
- // verifiable IN CI (a different home), with no local filesystem layout leaking
35
- // into a public repo. Normalization happens ONCE, at generation, on the human's
36
- // machine; the stored/rendered value is already portable, so a render on any
37
- // other home (CI) reproduces identical bytes. `home` is injectable for tests.
38
- export function toPortableHooksDir(dir, home = homedir()) {
39
- if (!dir) return dir;
40
- if (dir.startsWith("$HOME")) return dir;
41
- if (dir.startsWith("~")) return `$HOME${dir.slice(1)}`;
42
- if (dir === home || dir.startsWith(`${home}/`)) return `$HOME${dir.slice(home.length)}`;
43
- return dir;
44
- }
45
-
46
- function chainToGlobal(hook, globalHooksDir) {
47
- if (!globalHooksDir) return [];
48
- const dir = toPortableHooksDir(globalHooksDir);
26
+ // hooks dir, silently disabling personal guards. Every generated hook ends by
27
+ // chaining its namesake in the machine's global dir.
28
+ // KJC-BUG-0161 / 0169 (ADR 0009), HU-2762: the dir is looked up when the hook
29
+ // RUNS, never when kj harden ran — so the generated bytes do not depend on the
30
+ // machine (HOME, GIT_CONFIG_GLOBAL), the provenance verifies in CI, and no
31
+ // local filesystem layout leaks into a public repo. `|| true`: the preamble
32
+ // sets `set -e`. `--type=path` expands `~`; the pwd -P test stops a hook from
33
+ // calling itself when the global setting is this repo's own hooks dir.
34
+ function chainToGlobal(hook) {
49
35
  // KJC-TSK-1085: pre-push already read the refs from stdin; the chained hook gets them back.
50
- const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "${dir}/${hook}" "$@"; rc=$?` : `"${dir}/${hook}" "$@"; rc=$?`;
36
+ const call = hook === "pre-push" ? `printf '%s\\n' "$kj_push_refs" | "$kj_global/${hook}" "$@"; rc=$?` : `"$kj_global/${hook}" "$@"; rc=$?`;
51
37
  return [
52
- "# Chain the machine's previous global hook (kj harden keeps it active).",
53
- `if [ -x "${dir}/${hook}" ]; then`,
38
+ "# Chain the machine's global hook, looked up when the hook runs, not when kj harden ran (KJC-TSK-0645, HU-2762).",
39
+ "kj_global=$(git config --global --type=path --get core.hooksPath 2>/dev/null || true)",
40
+ `if [ -n "$kj_global" ] && [ -x "$kj_global/${hook}" ] && [ "$(cd "$kj_global" 2>/dev/null && pwd -P)" != "$(cd "$(dirname "$0")" && pwd -P)" ]; then`,
54
41
  ` ${call}`,
55
42
  ' [ "$rc" -eq 0 ] || exit "$rc"',
56
43
  "fi",
@@ -109,8 +96,9 @@ function identityGuard(hook) {
109
96
  ' if [ -n "$kj_declared_email" ]; then',
110
97
  ' printf "%s\\n" "$kj_push_refs" | while read -r kj_lref kj_lsha kj_rref kj_rsha; do',
111
98
  ' case "$kj_lsha" in *[!0]*) ;; *) continue ;; esac',
112
- // A new ref: what the PUSH TARGET ($1, the remote git hands the hook) does not have yet.
113
- ' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_rsha..$kj_lsha") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
99
+ // What the PUSH TARGET ($1, the remote git hands the hook) does not have yet. KJC-BUG-0436:
100
+ // an existing ref too, or merging main into a pushed branch judges main's own merges.
101
+ ' case "$kj_rsha" in *[!0]*) kj_new=$(git rev-list "$kj_lsha" --not "$kj_rsha" --remotes="${1:-origin}") ;; *) kj_new=$(git rev-list "$kj_lsha" --not --remotes="${1:-origin}") ;; esac',
114
102
  " for kj_c in $kj_new; do",
115
103
  ' kj_who=$(git log -1 --format="%ae %ce" "$kj_c")',
116
104
  ' if [ "$kj_who" != "$kj_declared_email $kj_declared_email" ]; then',
@@ -129,8 +117,8 @@ function identityGuard(hook) {
129
117
  ];
130
118
  }
131
119
 
132
- export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch = null } = {}) {
133
- const chain = chainToGlobal(hook, globalHooksDir);
120
+ export function hookBody(hook, cmds = {}, { baseBranch = null } = {}) {
121
+ const chain = chainToGlobal(hook);
134
122
  switch (hook) {
135
123
  case "pre-commit": {
136
124
  const lines = [...baseBranchGuard(baseBranch), ...identityGuard("pre-commit"), "# Lint + format the working tree with the project's native tools."];