@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.
- package/package.json +1 -1
- package/src/checks/ai-surface.js +9 -7
- package/src/checks/config-files.js +8 -7
- package/src/cli/register-pipeline.js +4 -0
- package/src/commands/env.js +3 -1
- package/src/commands/go.js +5 -2
- package/src/commands/harden.js +20 -6
- package/src/commands/pr-size.js +4 -3
- package/src/commands/review-gate.js +18 -13
- package/src/config/overrides.js +2 -1
- package/src/environment/board-access.js +11 -9
- package/src/git/automation.js +5 -4
- package/src/harden/branch-protection.js +36 -3
- package/src/harden/harden-engine.js +5 -21
- package/src/harden/hook-templates.js +18 -30
- package/src/harden/phone-sign.js +3 -1
- package/src/harden/published-pin.js +34 -0
- package/src/harden/sentinel/posttooluse.mjs +4 -2
- package/src/harden/sentinel/pretooluse-sentinel.mjs +22 -3
- package/src/harden/sentinel/sentinel-lib.mjs +23 -0
- package/src/harden/sentinel/sentinel-shell.mjs +109 -0
- package/src/harden/workflow-engine.js +34 -7
- package/src/mcp/handlers/hu-handlers.js +43 -20
- package/src/mcp/tools.js +4 -4
- package/src/orchestrator/stages/suite-green-stage.js +3 -1
- package/src/policy/engine.js +9 -3
- package/src/policy/supervisor-verify.js +2 -2
- package/src/review/dual-review.js +5 -4
- package/src/review/gate-hook.js +39 -0
- package/src/review/one-shot-review.js +7 -3
- package/src/review/pr-requirements.js +6 -6
- package/src/review/publish-pr.js +4 -1
- package/src/review/reviewer-fallback.js +18 -2
- package/src/review/verify-pr.js +39 -9
- package/src/sonar/scanner.js +2 -1
- 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 {
|
|
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
|
-
/**
|
|
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 {
|
|
42
|
+
const { net, exempt } = budgetedNet(numstat);
|
|
43
43
|
const exemptNote = exempt > 0 ? ` (${exempt} exempt)` : "";
|
|
44
|
-
return
|
|
45
|
-
? { id: "size", ok: false, detail: `${
|
|
46
|
-
: { id: "size", ok: true, detail: `${
|
|
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. */
|
package/src/review/publish-pr.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
+
files.some((f) => existsSync(f)) ||
|
|
91
107
|
(c.authEnv || []).some((name) => Boolean(env[name]));
|
|
92
108
|
return { ...c, installed, authenticated };
|
|
93
109
|
}),
|
package/src/review/verify-pr.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
}
|
package/src/sonar/scanner.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|