karajan-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
@@ -9,7 +9,7 @@ import { join } from "node:path";
9
9
  import { AI_ATTRIBUTION, CONVENTIONAL_TYPES } from "../harden/hook-templates.js";
10
10
  import { loadPolicy } from "../policy/engine.js";
11
11
  import { runCommand } from "../utils/process.js";
12
- import { budgetedAdded } from "./loc-budget.js";
12
+ import { budgetedNet } from "./loc-budget.js";
13
13
  import { checkTestsWithCode } from "./tests-with-code.js";
14
14
 
15
15
  const HEADER_MAX = 100;
@@ -37,13 +37,13 @@ export function prSizeLimit({ projectDir, config }) {
37
37
  };
38
38
  }
39
39
 
40
- /** Lines added that count against the budget (generated output and human docs exempt). */
40
+ /** Net lines that count against the budget, as the CI gate counts them (generated output and human docs exempt). */
41
41
  export function sizeCheck({ numstat, limit }) {
42
- const { added, exempt } = budgetedAdded(numstat);
42
+ const { net, exempt } = budgetedNet(numstat);
43
43
  const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
44
- return added > limit
45
- ? { id: "size", ok: false, detail: `${added} lines added${exemptNote}, over the ${limit}-line limit: partition the PR` }
46
- : { id: "size", ok: true, detail: `${added} lines added${exemptNote}, within the ${limit}-line limit` };
44
+ return net > limit
45
+ ? { id: "size", ok: false, detail: `${net} net lines${exemptNote}, over the ${limit}-line limit: partition the PR` }
46
+ : { id: "size", ok: true, detail: `${net} net lines${exemptNote}, within the ${limit}-line limit` };
47
47
  }
48
48
 
49
49
  /** Conventional Commits headers (at most 100 characters) and no AI attribution in base..head. */
@@ -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,10 +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
+ import { verifiedSupervisorFiles } from "../policy/supervisor-verify.js";
13
+ import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
12
14
 
13
- const TRUSTED = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
14
15
  // The marker of a kj review for any head: what MARKER writes up to the head itself.
15
16
  const MARKER_PREFIX = MARKER("\0").split("\0")[0];
16
17
  // One line per commit, as publish-pr writes it: - `<sha7>` headline: approved by X (diff <hash12>)...
@@ -38,10 +39,33 @@ async function hashesOf(projectDir, sha, run) {
38
39
  return hashes;
39
40
  }
40
41
 
42
+ // KJC-BUG-0388: kj harden --commit is the human's act, signed from the phone, and no AI
43
+ // reviews it. The proof is the signature, never the message: a commit that only touches
44
+ // files the roster-signed seal vouches for at the head is that act.
45
+ async function isSealCommit(projectDir, sha, run, sealed) {
46
+ if (sealed.signed !== true) return false;
47
+ const res = await run("git", ["-C", projectDir, "diff", "--name-only", `${sha}^`, sha]);
48
+ if (res.exitCode !== 0) throw new Error(`git diff --name-only ${sha.slice(0, 7)} failed: ${(res.stderr || "").trim()}`);
49
+ const files = (res.stdout || "").split("\n").filter(Boolean);
50
+ // KJC-BUG-0438: the seal act writes the signed provenance; a commit without it only touched files.
51
+ if (!files.includes(PROVENANCE_FILE)) return false;
52
+ if (!files.every((f) => sealed.files.has(f) || (f === PROVENANCE_FILE && sealed.complete === true))) return false;
53
+ // What the commit leaves is byte for byte what the signed seal vouches for at the head: a
54
+ // precursor whose provenance or hooks a later commit replaced is not the seal act.
55
+ for (const f of files) if ((await blobAt(projectDir, sha, f, run)) !== (await blobAt(projectDir, "HEAD", f, run))) return false;
56
+ return true;
57
+ }
58
+
59
+ /** The blob id of a file at a revision, or null when the file is absent there. */
60
+ async function blobAt(projectDir, rev, file, run) {
61
+ const res = await run("git", ["-C", projectDir, "rev-parse", "--verify", "--quiet", `${rev}:${file}`]);
62
+ return res.exitCode === 0 ? String(res.stdout || "").trim() : null;
63
+ }
64
+
41
65
  /**
42
- * @returns {Promise<{ok: boolean, commits: number, problems: string[]}>}
66
+ * @returns {Promise<{ok: boolean, commits: number, problems: string[], sealCommits?: string[]}>}
43
67
  */
44
- export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
68
+ export async function verifyPrReview({ projectDir, pr, run = runCommand, sealed = null }) {
45
69
  const info = await json(run, ["pr", "view", String(pr), "--json", "headRefOid,commits"], projectDir);
46
70
  const commits = info.commits ?? [];
47
71
  // One review per line, every page: --slurp is missing from the gh that distros ship.
@@ -52,15 +76,21 @@ export async function verifyPrReview({ projectDir, pr, run = runCommand }) {
52
76
  // today's head: the diff hash of each commit is what proves it, and a commit pushed after
53
77
  // the review is simply not listed. "Update branch" no longer leaves the check red.
54
78
  const kjReviews = reviews.filter((r) => TRUSTED.has(r.author_association) && String(r.body ?? "").includes(MARKER_PREFIX));
55
- if (kjReviews.length === 0) {
56
- return { ok: false, commits: commits.length, problems: [`no kj review published by a member: run kj review --publish-pr ${pr}`] };
57
- }
58
79
  const listed = new Map(kjReviews.flatMap((r) => [...r.body.matchAll(LISTED)].map((m) => [m[1], m[2]])));
59
80
  const problems = [];
81
+ const sealCommits = [];
82
+ let seal = sealed;
60
83
  for (const c of commits) {
61
84
  if (await isCleanMerge(projectDir, c.oid, run)) continue;
62
85
  const hash = listed.get(c.oid.slice(0, 7));
63
- if (!hash || !(await hashesOf(projectDir, c.oid, run)).has(hash)) problems.push(`${c.oid.slice(0, 7)} (${c.messageHeadline}): no approved verdict for the diff it brings`);
86
+ if (hash && (await hashesOf(projectDir, c.oid, run)).has(hash)) continue;
87
+ seal ??= verifiedSupervisorFiles({ projectDir, trackedOnly: true });
88
+ if (await isSealCommit(projectDir, c.oid, run, seal)) sealCommits.push(c.oid.slice(0, 7));
89
+ else problems.push(`${c.oid.slice(0, 7)} (${c.messageHeadline}): no approved verdict for the diff it brings`);
64
90
  }
65
- return { ok: problems.length === 0, commits: commits.length, problems };
91
+ // A PR that only brings the seal needs no review; anything else without one (a clean merge
92
+ // included, KJC-BUG-0438) is told to publish it.
93
+ const onlySeals = sealCommits.length > 0 && sealCommits.length === commits.length;
94
+ if (kjReviews.length === 0 && !onlySeals) problems.splice(0, problems.length, `no kj review published by a member: run kj review --publish-pr ${pr}`);
95
+ return { ok: problems.length === 0, commits: commits.length, problems, ...(sealCommits.length > 0 ? { sealCommits } : {}) };
66
96
  }
@@ -198,7 +198,8 @@ async function maybeRunCoverage(config) {
198
198
 
199
199
  const command = String(coverage.command || "").trim();
200
200
  const timeout = Number(coverage.timeout_ms) > 0 ? Number(coverage.timeout_ms) : 5 * 60 * 1000;
201
- const run = await runCommand("bash", ["-lc", command], { timeout });
201
+ // KJC-BUG-0427: a plain shell in kj's environment; a login one may rewrite PATH.
202
+ const run = await runCommand("bash", ["-c", command], { timeout });
202
203
 
203
204
  if (run.exitCode !== 0) {
204
205
  if (blockOnFailure) {
@@ -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
+ }