@karajan-family/code 4.52.0 → 4.53.1

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 (66) hide show
  1. package/package.json +2 -1
  2. package/scripts/contract-claude.mjs +24 -0
  3. package/scripts/esbuild-sea.config.mjs +7 -0
  4. package/scripts/record-agent-fixtures.mjs +98 -0
  5. package/src/agents/claude-agent.js +8 -0
  6. package/src/agents/model-registry.js +5 -0
  7. package/src/agents/opencode-agent.js +16 -1
  8. package/src/audit/issue-filter.js +19 -1
  9. package/src/checks/audit-cycle.js +41 -0
  10. package/src/checks/claude-contract-check.js +27 -0
  11. package/src/checks/method.js +3 -2
  12. package/src/checks/release-check.js +7 -1
  13. package/src/checks/sonar.js +30 -12
  14. package/src/checks/supply-chain-check.js +47 -0
  15. package/src/cli/register-meta.js +6 -1
  16. package/src/cli/register-pipeline.js +9 -0
  17. package/src/cli/register-plan.js +10 -0
  18. package/src/commands/check.js +11 -1
  19. package/src/commands/config.js +2 -0
  20. package/src/commands/harden.js +3 -0
  21. package/src/commands/hu.js +24 -3
  22. package/src/commands/identity.js +4 -1
  23. package/src/commands/init.js +34 -1
  24. package/src/commands/plan/generate.js +5 -0
  25. package/src/commands/plan/license.js +35 -0
  26. package/src/commands/plan/ready.js +1 -1
  27. package/src/commands/plan/validate.js +1 -1
  28. package/src/commands/report.js +14 -0
  29. package/src/commands/review-gate.js +22 -0
  30. package/src/commands/worktree.js +15 -6
  31. package/src/config/defaults.js +10 -1
  32. package/src/config/overrides.js +8 -0
  33. package/src/config/schema.js +7 -0
  34. package/src/harden/act-sign.js +2 -2
  35. package/src/harden/branch-protection.js +97 -0
  36. package/src/harden/phone-invite.js +15 -8
  37. package/src/harden/phone-texts.js +17 -0
  38. package/src/harden/security-level.js +18 -2
  39. package/src/harden/sentinel/sentinel-shell.mjs +59 -0
  40. package/src/harden/sentinel-hooks.js +19 -9
  41. package/src/hu/complexity-tier.js +32 -0
  42. package/src/hu/lane-prune.js +24 -0
  43. package/src/hu/model-router.js +7 -16
  44. package/src/hu/routing-metrics.js +16 -0
  45. package/src/hu/store.js +2 -0
  46. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
  47. package/src/orchestrator/hu-sub-pipeline.js +19 -0
  48. package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
  49. package/src/orchestrator/stages/sonar-stage.js +19 -1
  50. package/src/plan/plan-schema.js +4 -1
  51. package/src/plan/rework-license.js +38 -0
  52. package/src/prompts/hu-reviewer.js +5 -2
  53. package/src/prompts/planner.js +9 -1
  54. package/src/report/model-tally.js +39 -0
  55. package/src/review/board-pending.js +42 -6
  56. package/src/review/branch-overlap.js +40 -0
  57. package/src/review/dep-cooldown.js +87 -0
  58. package/src/review/one-shot-review.js +2 -2
  59. package/src/review/parser.js +5 -0
  60. package/src/review/publish-pr.js +54 -0
  61. package/src/review/verdict-store.js +19 -7
  62. package/src/roles/sonar-role.js +4 -2
  63. package/src/sonar/api.js +7 -0
  64. package/src/utils/injection-guard.js +59 -43
  65. package/src/utils/injection-patterns.json +36 -0
  66. package/src/utils/model-selector.js +47 -3
@@ -11,7 +11,9 @@
11
11
  * One verifiable fact carries a pending, never a promise in prose: the same card
12
12
  * has another OPEN pull request. A branch named after the card is not enough,
13
13
  * because the merged branch carries that name too, so accepting it would forgive
14
- * every merge made from its own lane.
14
+ * every merge made from its own lane. KJC-BUG-0362 narrows that, never widens
15
+ * it: the current branch carries only with unmerged commits and when it is not
16
+ * the merged PR's head.
15
17
  */
16
18
 
17
19
  import { execFileSync } from "node:child_process";
@@ -31,9 +33,24 @@ const cardOf = (text) => {
31
33
  *
32
34
  * @returns {{blocking: Array<object>, carried: Array<{card: string, why: string}>, reason?: string}}
33
35
  */
34
- export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [] }) {
36
+ /**
37
+ * KJC-BUG-0362 (#2199): the next PR of a split card may not exist yet (committed,
38
+ * not pushed). The branch the session stands on carries the card when it names
39
+ * that card, holds commits the base does not have, and is NOT the merged PR's
40
+ * head: the merged lane stays unforgiven, as above.
41
+ */
42
+ function carriedByLocalBranch(pending, card, localBranch, mergedHeads) {
43
+ if (!localBranch?.name || !(localBranch.unique > 0)) return null;
44
+ const mergedHead = mergedHeads[Number(pending.pr)];
45
+ if (!mergedHead || mergedHead === localBranch.name || cardOf(localBranch.name) !== card) return null;
46
+ return { card, why: `sigue entregándose en la rama ${localBranch.name}, con ${localBranch.unique} commit(s) sin mergear` };
47
+ }
48
+
49
+ export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [], localBranch = null, mergedHeads = {} }) {
35
50
  if (pendings.length === 0) return { blocking: [], carried: [] };
36
- if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta (gh no respondió) — se bloquea" };
51
+ // KJC-BUG-0363 (#2201): the guard runs BEFORE the command, so a gh auth switch
52
+ // inside it has not happened yet, and other sessions move the active account.
53
+ if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta: gh no pudo listar las PR con la cuenta activa. Si el repo es de otra cuenta, cambiala en un comando aparte (gh auth switch --user <cuenta>) y repite: el guard corre antes que el comando" };
37
54
  const openCards = new Set();
38
55
  for (const [i, head] of openPrHeads.entries()) {
39
56
  // A pending must never forgive itself: the PR it came from is merged, and if
@@ -46,7 +63,9 @@ export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers
46
63
  for (const p of pendings) {
47
64
  const card = p.card ? String(p.card).toUpperCase() : null;
48
65
  if (!card) { blocking.push(p); continue; } // nothing to check against
49
- if (openCards.has(card)) carried.push({ card, why: "sigue entregándose en una PR abierta" });
66
+ if (openCards.has(card)) { carried.push({ card, why: "sigue entregándose en una PR abierta" }); continue; }
67
+ const byBranch = carriedByLocalBranch(p, card, localBranch, mergedHeads);
68
+ if (byBranch) carried.push(byBranch);
50
69
  else blocking.push(p);
51
70
  }
52
71
  return { blocking, carried };
@@ -60,9 +79,26 @@ export function boardGate({ projectDir = process.cwd(), sessionId = "default", d
60
79
  const state = deps.readState?.() ?? readJson(join(projectDir, ".karajan", "harness", "sentinel-state.json"));
61
80
  const pendings = state?.sessions?.[sessionId]?.pending_moves;
62
81
  if (!Array.isArray(pendings) || pendings.length === 0) return { blocking: [], carried: [] };
63
- const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }));
82
+ // KJC-BUG-0364: gh can take several seconds; a timeout reads as "could not check" and blocks.
83
+ const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15000 }));
64
84
  let open = null;
65
85
  try { open = JSON.parse(run("gh", ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,headRefName"])); } catch { /* fail closed */ }
66
86
  const listed = Array.isArray(open) ? open : null;
67
- return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [] });
87
+ return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [], ...localFacts(run, pendings) });
88
+ }
89
+
90
+ /** The current branch, its commits outside the base, and each merged PR's head; unreadable facts forgive nothing. */
91
+ function localFacts(run, pendings) {
92
+ const facts = { localBranch: null, mergedHeads: {} };
93
+ try {
94
+ const name = run("git", ["rev-parse", "--abbrev-ref", "HEAD"]).trim();
95
+ let base = "origin/main"; // a clone without origin/HEAD measures against origin/main
96
+ try { base = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]).trim() || base; } catch { /* keep origin/main */ }
97
+ const unique = Number(run("git", ["rev-list", "--count", `${base}..HEAD`]).trim());
98
+ if (name && name !== "HEAD" && Number.isInteger(unique)) facts.localBranch = { name, unique };
99
+ } catch { return facts; }
100
+ for (const p of pendings) {
101
+ try { facts.mergedHeads[Number(p.pr)] = JSON.parse(run("gh", ["pr", "view", String(p.pr), "--json", "headRefName"])).headRefName; } catch { /* unknown head: not forgiven */ }
102
+ }
103
+ return facts;
68
104
  }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * KJC-TSK-1052 (TEAM-F): a team on one repo collides in the merge when two
3
+ * branches change the same files. kj warns at review time: the remote branches
4
+ * still in progress (commits outside origin/<base>, active in the last days)
5
+ * whose changes share files with what the commit stages. A warning, never a block.
6
+ */
7
+ import { runCommand } from "../utils/process.js";
8
+
9
+ const MAX_AGE_DAYS = 14;
10
+ const MAX_BRANCHES = 20;
11
+
12
+ /**
13
+ * @returns {Promise<{branch: string, author: string, files: string[]}[]>}
14
+ */
15
+ export async function branchOverlaps({ projectDir, stagedFiles, base = "main", now = Date.now(), run = runCommand }) {
16
+ const git = async (args) => {
17
+ const res = await run("git", ["-C", projectDir, ...args]);
18
+ return res.exitCode === 0 ? res.stdout.trim() : null;
19
+ };
20
+ const baseRef = `origin/${base}`;
21
+ if (stagedFiles.length === 0 || (await git(["rev-parse", "--verify", "--quiet", baseRef])) === null) return [];
22
+ const current = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
23
+ const refs = (await git(["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)|%(committerdate:unix)|%(authorname)", "refs/remotes/origin"])) ?? "";
24
+ const staged = new Set(stagedFiles);
25
+ const candidates = refs.split("\n").filter(Boolean)
26
+ .map((line) => { const [branch, when, author] = line.split("|"); return { branch, when: Number(when) * 1000, author }; })
27
+ .filter((r) => r.branch !== baseRef && r.branch !== "origin/HEAD" && r.branch !== `origin/${current}`)
28
+ .filter((r) => now - r.when < MAX_AGE_DAYS * 86_400_000)
29
+ .slice(0, MAX_BRANCHES);
30
+ const overlaps = [];
31
+ for (const { branch, author } of candidates) {
32
+ if (Number(await git(["rev-list", "--count", `${baseRef}..${branch}`])) === 0) continue;
33
+ const files = ((await git(["diff", "--name-only", `${baseRef}...${branch}`])) ?? "").split("\n").filter((f) => staged.has(f));
34
+ if (files.length > 0) overlaps.push({ branch, author, files });
35
+ }
36
+ return overlaps;
37
+ }
38
+
39
+ /** The kj review warning, one line per branch. */
40
+ export const formatOverlaps = (overlaps) => overlaps.map((o) => `⚠ ${o.branch} (${o.author}) also changes ${o.files.join(", ")}: coordinate before the merge collides`);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * KJC-TSK-0722 (step 1): supply-chain campaigns (Shai-Hulud, CanisterWorm) are
3
+ * caught in their first hours, so a dependency published minutes ago is the
4
+ * riskiest one to take. kj reads what a package-lock.json diff installs and
5
+ * asks npm how old each version is. A signal, never a crash: without the
6
+ * registry, the package is listed as unverified with the reason.
7
+ */
8
+ import { runCommand } from "../utils/process.js";
9
+
10
+ export const DEFAULT_COOLDOWN_HOURS = 72;
11
+ // npm view per package: a lockfile refresh of hundreds is said, not queried one by one.
12
+ const MAX_QUERIES = 40;
13
+
14
+ const KEY = /^[ +]\s*"(?:[^"]*\/)?node_modules\/((?:@[^/"]+\/)?[^/"]+)":\s*\{/;
15
+ const VERSION = /^\+\s*"version":\s*"([^"]+)"/;
16
+
17
+ /** The packages a diff of package-lock.json adds or moves to another version. */
18
+ export function lockfileChanges(diff) {
19
+ const changes = [];
20
+ for (const chunk of String(diff ?? "").split(/^diff --git /m)) {
21
+ if (!/^a\/(?:\S*\/)?package-lock\.json /.test(chunk)) continue;
22
+ let current = null;
23
+ for (const line of chunk.split("\n")) {
24
+ const key = KEY.exec(line);
25
+ if (key) { current = key[1]; continue; }
26
+ const version = VERSION.exec(line);
27
+ if (version && current) { changes.push({ name: current, version: version[1] }); current = null; }
28
+ }
29
+ }
30
+ return changes;
31
+ }
32
+
33
+ async function publishedAt({ name, version }, run) {
34
+ const res = await run("npm", ["view", `${name}@${version}`, "time", "--json"]);
35
+ if (res.exitCode !== 0) throw new Error((res.stderr || res.stdout || "npm view failed").trim());
36
+ const when = Date.parse(JSON.parse(res.stdout || "{}")[version]);
37
+ if (Number.isNaN(when)) throw new Error(`npm gives no publish time for ${version}`);
38
+ return when;
39
+ }
40
+
41
+ export const COOLDOWN_RULE_ID = "method.dep_cooldown";
42
+
43
+ /**
44
+ * KJC-TSK-1056: with method_gates.dep_cooldown: block, a young dependency stops
45
+ * the review; only a live human grant on method.dep_cooldown lifts it. What
46
+ * could not be verified (no registry) never blocks: a false positive would
47
+ * stop legitimate work for a network blip.
48
+ */
49
+ export function cooldownGate({ result, mode, standingExceptions = [], now = new Date() }) {
50
+ if (mode !== "block" || result.young.length === 0) return { block: false };
51
+ const grant = standingExceptions.find((e) => e.rule_id === COOLDOWN_RULE_ID && new Date(e.expiresAt).getTime() > now.getTime());
52
+ if (grant) return { block: false, grant };
53
+ const list = result.young.map((p) => `${p.name}@${p.version} (${p.hours} h)`).join(", ");
54
+ return { block: true, reason: `${result.young.length} dependency(ies) under the cooldown: ${list} (method_gates.dep_cooldown: block). Wait it out, pin an older version, or a human lifts it: kj policy grant --rule ${COOLDOWN_RULE_ID} --until <iso> --reason "<why>"` };
55
+ }
56
+
57
+ /** What kj review prints, and the note the reviewer gets; empty when there is nothing to say. */
58
+ export function formatCooldown({ young, unverified, skipped = 0 }, thresholdHours = DEFAULT_COOLDOWN_HOURS) {
59
+ const lines = [
60
+ ...young.map((p) => `⚠ supply-chain cooldown: ${p.name}@${p.version} was published ${p.hours} h ago (under ${thresholdHours} h): campaigns are caught in their first hours`),
61
+ ...unverified.map((p) => `⚠ supply-chain cooldown: ${p.name}@${p.version} unverified (${p.reason})`),
62
+ ...(skipped > 0 ? [`⚠ supply-chain cooldown: ${skipped} packages changed (a regenerated lockfile?), too many to check one by one: none was checked`] : []),
63
+ ];
64
+ const note = young.length === 0 ? "" : `Dependencies published under ${thresholdHours} h ago (weigh them as a supply-chain risk): ${young.map((p) => `${p.name}@${p.version} (${p.hours} h)`).join(", ")}`;
65
+ return { lines, note };
66
+ }
67
+
68
+ /**
69
+ * Over MAX_QUERIES changed packages (a regenerated lockfile) none is queried:
70
+ * one line says so, instead of a line per package and a call per package.
71
+ * @returns {Promise<{young: {name, version, hours}[], unverified: {name, version, reason}[], skipped?: number}>}
72
+ */
73
+ export async function cooldownAdvisories(diff, { thresholdHours = DEFAULT_COOLDOWN_HOURS, run = runCommand, now = Date.now() } = {}) {
74
+ const changes = lockfileChanges(diff);
75
+ if (changes.length > MAX_QUERIES) return { young: [], unverified: [], skipped: changes.length };
76
+ const young = [];
77
+ const unverified = [];
78
+ for (const pkg of changes) {
79
+ try {
80
+ const hours = Math.floor((now - await publishedAt(pkg, run)) / 3_600_000);
81
+ if (hours < thresholdHours) young.push({ ...pkg, hours });
82
+ } catch (err) {
83
+ unverified.push({ ...pkg, reason: err.message });
84
+ }
85
+ }
86
+ return { young, unverified };
87
+ }
@@ -12,7 +12,7 @@ import { createAgent } from "../agents/index.js";
12
12
  import { resolveRole } from "../config/role-resolver.js";
13
13
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
14
14
  import { resolveReviewProfile } from "./profiles.js";
15
- import { parseMaybeJsonString, normalizeReviewPayload, agentAnswerText } from "./parser.js";
15
+ import { parseReviewVerdict } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
17
  import { saveVerdict, diffHash } from "./verdict-store.js";
18
18
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
@@ -117,7 +117,7 @@ export async function runOneShotReview({
117
117
  // back, wrapper and all. Yesterday's fix taught normalizeReviewPayload to unwrap {ok, result},
118
118
  // but this path never called it, so the bug survived with a green test on the wrong function.
119
119
  // A test can only prove the code it actually exercises.
120
- const parsed = normalizeReviewPayload(parseMaybeJsonString(agentAnswerText(result.output)));
120
+ const parsed = parseReviewVerdict(result.output);
121
121
  if (!parsed || typeof parsed.approved !== "boolean") {
122
122
  // KJC-BUG-0146: keep the answer. Throwing it away is what left eight occurrences undiagnosed.
123
123
  throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
@@ -26,6 +26,11 @@ export function agentAnswerText(raw) {
26
26
  return raw;
27
27
  }
28
28
 
29
+ /** The review verdict in an agent's answer, or null: the one path kj review reads it by. */
30
+ export function parseReviewVerdict(output) {
31
+ return normalizeReviewPayload(parseMaybeJsonString(agentAnswerText(output)));
32
+ }
33
+
29
34
  function isReviewPayload(obj) {
30
35
  return obj?.approved !== undefined && obj?.blocking_issues !== undefined;
31
36
  }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * KJC-TSK-0913: the cross-AI verdict kj holds for each commit of a PR, published
3
+ * on the PR as ONE GitHub review (event COMMENT), so the review that already
4
+ * happened counts in reviews.totalCount instead of looking like a blind merge.
5
+ * A loose conversation comment does not count, so it is never used.
6
+ */
7
+ import { runCommand } from "../utils/process.js";
8
+ import { diffHash, loadVerdict } from "./verdict-store.js";
9
+
10
+ const MARKER = (head) => `<!-- kj-review head=${head} -->`;
11
+
12
+ // gh runs in the project's own directory, so it reads and writes the same repo the verdicts belong to.
13
+ async function json(run, cmd, args, cwd) {
14
+ const res = await run(cmd, args, { cwd });
15
+ if (res.exitCode !== 0) throw new Error(`${cmd} ${args.slice(0, 3).join(" ")} failed: ${(res.stderr || res.stdout || "").trim()}`);
16
+ return JSON.parse(res.stdout || "null");
17
+ }
18
+
19
+ /** The verdict of a commit: the one recorded for the diff it brought, staged byte for byte. */
20
+ async function verdictOf(projectDir, sha, run) {
21
+ const res = await run("git", ["-C", projectDir, "diff", `${sha}^`, sha]);
22
+ return res.exitCode === 0 && res.stdout ? loadVerdict(projectDir, diffHash(res.stdout)) : null;
23
+ }
24
+
25
+ /**
26
+ * @returns {Promise<{published: boolean, commits?: number, reason?: string}>}
27
+ */
28
+ export async function publishPrReview({ projectDir, pr, run = runCommand }) {
29
+ const info = await json(run, "gh", ["pr", "view", String(pr), "--json", "headRefOid,commits"], projectDir);
30
+ const rows = [];
31
+ const uncovered = [];
32
+ for (const c of info.commits ?? []) {
33
+ const v = await verdictOf(projectDir, c.oid, run);
34
+ if (v?.verdict === "approved") rows.push({ ...c, v });
35
+ else uncovered.push(`${c.oid.slice(0, 7)} (${c.messageHeadline})`);
36
+ }
37
+ 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(", ")}`);
38
+ const reviews = await json(run, "gh", ["api", `repos/{owner}/{repo}/pulls/${pr}/reviews`], projectDir);
39
+ if ((reviews ?? []).some((r) => String(r.body ?? "").includes(MARKER(info.headRefOid)))) {
40
+ return { published: false, reason: `a kj review for head ${info.headRefOid.slice(0, 7)} is already published` };
41
+ }
42
+ const body = [
43
+ `Cross-AI review by Karajan, one verdict per commit, each bound to the exact diff it brought.`,
44
+ "",
45
+ ...rows.map((r) => {
46
+ const summary = r.v.summary ? ". " + r.v.summary : "";
47
+ return `- \`${r.oid.slice(0, 7)}\` ${r.messageHeadline}: approved by ${r.v.reviewer} (diff ${r.v.diffHash.slice(0, 12)})${summary}`;
48
+ }),
49
+ "",
50
+ MARKER(info.headRefOid),
51
+ ].join("\n");
52
+ await json(run, "gh", ["api", "-X", "POST", `repos/{owner}/{repo}/pulls/${pr}/reviews`, "-f", "event=COMMENT", "-f", `commit_id=${info.headRefOid}`, "-f", `body=${body}`], projectDir);
53
+ return { published: true, commits: rows.length };
54
+ }
@@ -14,6 +14,7 @@ import fs from "node:fs/promises";
14
14
  import path from "node:path";
15
15
  import { runCommand } from "../utils/process.js";
16
16
  import { kjStamp, stampDiffers, stampLabel } from "./kj-provenance.js";
17
+ import { sourceFilesOf } from "./tests-with-code.js";
17
18
 
18
19
  const STORE_DIR = path.join(".karajan", "reviews");
19
20
 
@@ -107,13 +108,15 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", s
107
108
  }
108
109
  // KJC-BUG-0294: the pipeline commits by path (`--only`); the verdict must bind
109
110
  // to that diff, not to other files the person had staged.
110
- const res = await runCommand("git", ["diff", "--cached", ...(paths?.length ? ["--", ...paths] : [])], { cwd: dir });
111
+ const pathArgs = paths?.length ? ["--", ...paths] : [];
112
+ const res = await runCommand("git", ["diff", "--cached", ...pathArgs], { cwd: dir });
111
113
  if (res.exitCode !== 0 || !res.stdout?.trim()) return { stamped: false };
114
+ // A deleted file cannot be indexed, so it never counts as a source to prove.
115
+ const names = sonar?.indexed ? (await runCommand("git", ["diff", "--cached", "--name-only", "--diff-filter=ACMR", ...pathArgs], { cwd: dir })).stdout.split("\n").filter(Boolean) : [];
112
116
  await saveVerdict(dir, res.stdout, {
113
117
  verdict: "approved", reviewer, host: "kj-pipeline", issues: [], summary,
114
- // KJC-TSK-0838: what the pipeline's sonar stage saw, so --check can tell
115
- // a run whose gate ran from one that skipped it.
116
- ...(sonar ? { sonar } : {}),
118
+ // KJC-TSK-0838/0839: what the pipeline's sonar stage saw, proved per staged source.
119
+ ...(sonar ? { sonar: coverageFor(sonar, names) } : {}),
117
120
  });
118
121
  return { stamped: true };
119
122
  }
@@ -122,8 +125,8 @@ export async function stampStagedVerdict({ projectDir, reviewer, summary = "", s
122
125
  * KJC-TSK-0838: the pipeline sonar stage result as a verdict sonar block.
123
126
  * SKIPPED (no remote, no key) or absent is `ran:false` WITH the reason — the
124
127
  * pre-commit check then refuses code, as it would for any other diff.
125
- * Coverage lists stay empty: the pipeline scans the whole project, and the
126
- * per-file proof (verbose index) is not wired into SonarRole yet.
128
+ * KJC-TSK-0839: a stage that ran carries the files its verbose scan indexed;
129
+ * stampStagedVerdict turns them into covered/uncovered for the staged sources.
127
130
  */
128
131
  export function pipelineSonarBlock(stageResult) {
129
132
  if (!stageResult) return { ran: false, source: "pipeline", reason: "the pipeline recorded no sonar stage" };
@@ -131,10 +134,19 @@ export function pipelineSonarBlock(stageResult) {
131
134
  return { ran: false, source: "pipeline", reason: stageResult.reason || stageResult.error || "the sonar stage did not run" };
132
135
  }
133
136
  return {
134
- ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, covered: [], uncovered: [],
137
+ ran: true, source: "pipeline", projectKey: stageResult.projectKey || null, gateStatus: stageResult.gateStatus, indexed: stageResult.indexedFiles ?? [],
135
138
  };
136
139
  }
137
140
 
141
+ /** KJC-TSK-0839: the per-source proof of a pipeline block, against the files this commit stages. */
142
+ function coverageFor(sonar, stagedNames) {
143
+ if (!sonar?.ran || !Array.isArray(sonar.indexed)) return sonar;
144
+ const { indexed, ...block } = sonar;
145
+ const seen = new Set(indexed);
146
+ const { sources } = sourceFilesOf({}, stagedNames);
147
+ return { ...block, covered: sources.filter((f) => seen.has(f)), uncovered: sources.filter((f) => !seen.has(f)) };
148
+ }
149
+
138
150
  /**
139
151
  * Is there an APPROVED verdict for exactly this diff?
140
152
  * @returns {Promise<{ok: boolean, verdict?: object, reason?: string}>}
@@ -1,5 +1,5 @@
1
1
  import { BaseRole } from "./base-role.js";
2
- import { runSonarScan } from "../sonar/scanner.js";
2
+ import { indexedFilesFrom, runSonarScan } from "../sonar/scanner.js";
3
3
  import { getQualityGateStatus, getOpenIssues } from "../sonar/api.js";
4
4
  import { shouldBlockByProfile, summarizeIssues } from "../sonar/enforcer.js";
5
5
 
@@ -20,7 +20,8 @@ export class SonarRole extends BaseRole {
20
20
  }
21
21
 
22
22
  async execute(_input) {
23
- const scan = await runSonarScan(this.config);
23
+ // KJC-TSK-0839: verbose, so the scanner names every file it indexed.
24
+ const scan = await runSonarScan(this.config, null, { verbose: true });
24
25
 
25
26
  if (!scan.ok) {
26
27
  return {
@@ -53,6 +54,7 @@ export class SonarRole extends BaseRole {
53
54
  ok: !blocking,
54
55
  result: {
55
56
  projectKey: scan.projectKey,
57
+ indexedFiles: [...indexedFilesFrom(scan.stdout)],
56
58
  gateStatus: gate.status,
57
59
  conditions: gate.conditions || [],
58
60
  issues,
package/src/sonar/api.js CHANGED
@@ -124,6 +124,13 @@ export async function getQualityGateStatus(config, projectKey = null) {
124
124
  }
125
125
  }
126
126
 
127
+ /** KJC-TSK-1051 (TEAM-E): the names of the quality profiles the project runs on. */
128
+ export async function getProjectQualityProfiles(config, projectKey = null) {
129
+ const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
130
+ const body = await sonarFetch(config, `/api/qualityprofiles/search?project=${encodeURIComponent(effectiveProjectKey)}`);
131
+ return (JSON.parse(body).profiles ?? []).map((p) => p.name);
132
+ }
133
+
127
134
  export async function getOpenIssues(config, projectKey = null) {
128
135
  const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
129
136
  // KJC-BUG-0339 (#2099): current SonarQube ignores projectKeys and answered with
@@ -8,52 +8,52 @@
8
8
  * @module utils/injection-guard
9
9
  */
10
10
 
11
- /** Phrases that attempt to override system/reviewer instructions. */
12
- const DIRECTIVE_PATTERNS = [
13
- /ignore\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?|guidelines?)/i,
14
- /disregard\s+(all\s+)?(previous|prior|above|earlier)\s+(instructions?|rules?|prompts?)/i,
15
- /forget\s+(all\s+)?(previous|prior|your)\s+(instructions?|rules?|context)/i,
16
- /override\s+(the\s+)?(system|reviewer|review)\s+(prompt|instructions?|rules?)/i,
17
- /do\s+not\s+follow\s+(the\s+)?(above|previous|system)\s+(instructions?|rules?)/i,
18
- /new\s+instructions?\s*:/i,
19
- /system\s*:\s*you\s+are/i,
20
- /\bact\s+as\s+(a\s+)?(different|new|my|an?\s+)/i,
21
- /you\s+are\s+now\s+(a\s+)?/i,
22
- /from\s+now\s+on\s*(,|\s)?\s*you\s+(will|should|must|are)/i,
23
- /pretend\s+(you\s+are|to\s+be)/i,
24
- /respond\s+(only\s+)?with\s+["']?approved["']?/i,
25
- /always\s+(return|respond|output)\s+.*approved/i,
26
- /set\s+approved\s*[=:]\s*true/i,
27
- /\bapproved["']?\s*:\s*true\b.*ignore/i,
28
- /output\s+the\s+following\s+json/i,
29
- /return\s+this\s+exact\s+(json|response|output)/i,
30
- ];
31
-
32
- /** Unicode categories that can hide or disguise content. */
33
- const UNICODE_PATTERNS = [
34
- /[\u200B-\u200F]/, // zero-width spaces, LTR/RTL marks
35
- /[\u202A-\u202E]/, // bidi embedding/override
36
- /[\u2066-\u2069]/, // bidi isolate
37
- /[\uFEFF]/, // BOM mid-text
38
- /[\u00AD]{3,}/, // repeated soft hyphens
39
- /[\u2060-\u2064]/, // invisible operators
40
- /[\u{E0000}-\u{E007F}]/u, // tag characters
41
- ];
42
-
43
- /** Maximum comment block size (chars) before flagging. */
44
- const MAX_COMMENT_BLOCK = 2000;
11
+ import { readFileSync } from "node:fs";
45
12
 
46
13
  /**
47
- * Comment block patterns for common languages.
48
- * Each regex captures the full block content.
14
+ * KJC-TSK-0703: the patterns live in injection-patterns.json, the family's
15
+ * versioned catalog (karajan-radar reads it from Python). Only constructs both
16
+ * JavaScript RegExp and Python re understand are accepted.
49
17
  */
50
- const COMMENT_BLOCK_RE = [
51
- /\/\*[\s\S]{0,20000}?\*\//g, // C-style /* ... */
52
- /<!--[\s\S]{0,20000}?-->/g, // HTML <!-- ... -->
53
- /"""\s*[\s\S]{0,20000}?\s*"""/g, // Python docstrings
54
- /'''\s*[\s\S]{0,20000}?\s*'''/g, // Python docstrings (single)
55
- /=begin[\s\S]{0,20000}?=end/g, // Ruby block comments
18
+ const NON_PORTABLE = [
19
+ [/\\u\{/, "\\u{...} escapes (put the literal character in the JSON instead)"],
20
+ [/\\[pP]\{/, "\\p{...} property escapes"],
21
+ [/\(\?<(?![=!])/, "a named group (Python spells it (?P<name>)"],
22
+ [/\\k</, "a named backreference"],
56
23
  ];
24
+ const PORTABLE_FLAGS = new Set(["i", "m", "s", "u"]);
25
+
26
+ /** Throws, naming the pattern, when an entry would not work outside JavaScript. */
27
+ export function assertPortable({ id, expression, flags = "" }) {
28
+ for (const flag of flags) {
29
+ if (!PORTABLE_FLAGS.has(flag)) throw new Error(`${id}: flag "${flag}" is not portable (only i, m, s, u)`);
30
+ }
31
+ for (const [construct, what] of NON_PORTABLE) {
32
+ if (construct.test(expression)) throw new Error(`${id}: ${what} is not portable: ${expression}`);
33
+ }
34
+ return new RegExp(expression, flags);
35
+ }
36
+
37
+ /** The catalog compiled per type; a missing or broken catalog fails loudly, never scans empty. */
38
+ // The SEA bundle has no sibling files: scripts/esbuild-sea.config.mjs inlines this exact read.
39
+ const readCatalog = () => readFileSync(new URL("./injection-patterns.json", import.meta.url), "utf8");
40
+
41
+ export function loadInjectionCatalog() {
42
+ const catalog = JSON.parse(readCatalog());
43
+ const compiled = { directive: [], unicode: [], comment_block: [], maxCommentBlock: catalog.max_comment_block, version: catalog.version };
44
+ for (const entry of catalog.patterns) {
45
+ const re = assertPortable(entry);
46
+ // Comment blocks are walked match by match: the global flag is the engine's, not the catalog's.
47
+ compiled[entry.type].push(entry.type === "comment_block" ? new RegExp(re.source, `${re.flags}g`) : re);
48
+ }
49
+ return compiled;
50
+ }
51
+
52
+ const CATALOG = loadInjectionCatalog();
53
+ const DIRECTIVE_PATTERNS = CATALOG.directive;
54
+ const UNICODE_PATTERNS = CATALOG.unicode;
55
+ const MAX_COMMENT_BLOCK = CATALOG.maxCommentBlock;
56
+ const COMMENT_BLOCK_RE = CATALOG.comment_block;
57
57
 
58
58
  /**
59
59
  * @typedef {Object} InjectionFinding
@@ -118,7 +118,7 @@ export function scanForInjection(text, opts = {}) {
118
118
  for (const re of COMMENT_BLOCK_RE) {
119
119
  let m;
120
120
  while ((m = re.exec(text)) !== null) {
121
- if (m[0].length > maxBlock) {
121
+ if (proseLength(m[0]) > maxBlock) {
122
122
  findings.push({
123
123
  type: "comment_block",
124
124
  pattern: `block ${m[0].length} chars (max ${maxBlock})`,
@@ -175,6 +175,22 @@ export function scanDiff(diff, opts = {}) {
175
175
 
176
176
  // --- Helpers ---
177
177
 
178
+ /**
179
+ * KJC-TSK-0743: the size of a comment block that counts against the limit. In a
180
+ * JSDoc block (`/**`), tag lines (`* @typedef`, `* @property`...) are structure,
181
+ * not prose: they are left out unless a single one runs past 300 chars. Any
182
+ * instruction inside a tag line is still caught by the directive patterns.
183
+ */
184
+ const JSDOC_TAG_LINE = /^\s*\*\s*@[A-Za-z]+/;
185
+ const MAX_TAG_LINE = 300;
186
+ function proseLength(block) {
187
+ if (!block.startsWith("/**")) return block.length;
188
+ return block
189
+ .split("\n")
190
+ .filter((line) => !(JSDOC_TAG_LINE.test(line) && line.length <= MAX_TAG_LINE))
191
+ .join("\n").length;
192
+ }
193
+
178
194
  function excerpt(text, index) {
179
195
  const start = Math.max(0, index - 20);
180
196
  const end = Math.min(text.length, index + 100);
@@ -0,0 +1,36 @@
1
+ {
2
+ "$comment": "KJC-TSK-0703: the canonical prompt-injection catalog of the karajan family. Data, not code: each language keeps only its small scanning engine. Expressions must stay portable (JavaScript RegExp and Python re): invisible characters go as literal characters (JSON escapes), never as regex escapes like \\u{...}; flags are only i, m, s, u. Bump version on any change.",
3
+ "version": "1.0.0",
4
+ "max_comment_block": 2000,
5
+ "patterns": [
6
+ { "id": "directive-ignore-previous", "type": "directive", "expression": "ignore\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|rules?|prompts?|guidelines?)", "flags": "i", "severity": "high", "why": "Tells the model to drop the instructions it was given, the classic override." },
7
+ { "id": "directive-disregard-previous", "type": "directive", "expression": "disregard\\s+(all\\s+)?(previous|prior|above|earlier)\\s+(instructions?|rules?|prompts?)", "flags": "i", "severity": "high", "why": "Same override as ignore-previous, worded to slip past a single keyword." },
8
+ { "id": "directive-forget-instructions", "type": "directive", "expression": "forget\\s+(all\\s+)?(previous|prior|your)\\s+(instructions?|rules?|context)", "flags": "i", "severity": "high", "why": "Asks the model to wipe its context or rules before obeying new ones." },
9
+ { "id": "directive-override-system", "type": "directive", "expression": "override\\s+(the\\s+)?(system|reviewer|review)\\s+(prompt|instructions?|rules?)", "flags": "i", "severity": "high", "why": "Names the system or reviewer prompt as something to replace." },
10
+ { "id": "directive-do-not-follow", "type": "directive", "expression": "do\\s+not\\s+follow\\s+(the\\s+)?(above|previous|system)\\s+(instructions?|rules?)", "flags": "i", "severity": "high", "why": "Negates the instructions above it, steering the model away from its task." },
11
+ { "id": "directive-new-instructions", "type": "directive", "expression": "new\\s+instructions?\\s*:", "flags": "i", "severity": "medium", "why": "Announces a fresh instruction block inside content that should carry none." },
12
+ { "id": "directive-system-you-are", "type": "directive", "expression": "system\\s*:\\s*you\\s+are", "flags": "i", "severity": "high", "why": "Forges a system message that redefines who the model is." },
13
+ { "id": "directive-act-as", "type": "directive", "expression": "\\bact\\s+as\\s+(a\\s+)?(different|new|my|an?\\s+)", "flags": "i", "severity": "medium", "why": "Role reassignment: asks the model to become another persona." },
14
+ { "id": "directive-you-are-now", "type": "directive", "expression": "you\\s+are\\s+now\\s+(a\\s+)?", "flags": "i", "severity": "medium", "why": "Role reassignment phrased as a fact the model should accept." },
15
+ { "id": "directive-from-now-on", "type": "directive", "expression": "from\\s+now\\s+on\\s*(,|\\s)?\\s*you\\s+(will|should|must|are)", "flags": "i", "severity": "medium", "why": "Installs a standing behaviour change for the rest of the conversation." },
16
+ { "id": "directive-pretend", "type": "directive", "expression": "pretend\\s+(you\\s+are|to\\s+be)", "flags": "i", "severity": "medium", "why": "Role-play framing used to step around the model's instructions." },
17
+ { "id": "directive-respond-approved", "type": "directive", "expression": "respond\\s+(only\\s+)?with\\s+[\"']?approved[\"']?", "flags": "i", "severity": "high", "why": "Dictates the reviewer's verdict instead of letting it judge the change." },
18
+ { "id": "directive-always-approved", "type": "directive", "expression": "always\\s+(return|respond|output)\\s+.*approved", "flags": "i", "severity": "high", "why": "Fixes the output to approved whatever the content says." },
19
+ { "id": "directive-set-approved", "type": "directive", "expression": "set\\s+approved\\s*[=:]\\s*true", "flags": "i", "severity": "high", "why": "Tries to write the verdict field directly." },
20
+ { "id": "directive-approved-true-ignore", "type": "directive", "expression": "\\bapproved[\"']?\\s*:\\s*true\\b.*ignore", "flags": "i", "severity": "high", "why": "A forged approved verdict next to an instruction to ignore something." },
21
+ { "id": "directive-output-json", "type": "directive", "expression": "output\\s+the\\s+following\\s+json", "flags": "i", "severity": "medium", "why": "Hands the model a ready answer to emit instead of its own." },
22
+ { "id": "directive-return-exact", "type": "directive", "expression": "return\\s+this\\s+exact\\s+(json|response|output)", "flags": "i", "severity": "medium", "why": "Same as output-json: a scripted response smuggled into the content." },
23
+ { "id": "unicode-zero-width", "type": "unicode", "expression": "[\u200B-\u200F]", "flags": "", "severity": "high", "why": "Zero-width spaces and LTR/RTL marks hide text a human reviewer cannot see." },
24
+ { "id": "unicode-bidi-embedding", "type": "unicode", "expression": "[\u202A-\u202E]", "flags": "", "severity": "high", "why": "Bidi embedding and override characters reorder what is displayed (Trojan Source)." },
25
+ { "id": "unicode-bidi-isolate", "type": "unicode", "expression": "[\u2066-\u2069]", "flags": "", "severity": "high", "why": "Bidi isolates, the other half of the Trojan Source trick." },
26
+ { "id": "unicode-bom-midtext", "type": "unicode", "expression": "[\uFEFF]", "flags": "", "severity": "medium", "why": "A byte order mark in the middle of text is invisible and has no honest use there." },
27
+ { "id": "unicode-soft-hyphens", "type": "unicode", "expression": "[\u00AD]{3,}", "flags": "", "severity": "low", "why": "Runs of soft hyphens split words so keyword filters miss them." },
28
+ { "id": "unicode-invisible-operators", "type": "unicode", "expression": "[\u2060-\u2064]", "flags": "", "severity": "medium", "why": "Word joiner and invisible operators carry no visible glyph." },
29
+ { "id": "unicode-tag-characters", "type": "unicode", "expression": "[\uDB40\uDC00-\uDB40\uDC7F]", "flags": "u", "severity": "high", "why": "Unicode tag characters (U+E0000 to U+E007F) can encode a whole hidden instruction." },
30
+ { "id": "comment-block-c", "type": "comment_block", "expression": "/\\*[\\s\\S]{0,20000}?\\*/", "flags": "", "severity": "low", "why": "An oversized C-style comment can hide a payload addressed to the model." },
31
+ { "id": "comment-block-html", "type": "comment_block", "expression": "<!--[\\s\\S]{0,20000}?-->", "flags": "", "severity": "low", "why": "An oversized HTML comment is invisible when rendered but read by the model." },
32
+ { "id": "comment-block-docstring-double", "type": "comment_block", "expression": "\"\"\"\\s*[\\s\\S]{0,20000}?\\s*\"\"\"", "flags": "", "severity": "low", "why": "An oversized Python docstring can carry instructions as prose." },
33
+ { "id": "comment-block-docstring-single", "type": "comment_block", "expression": "'''\\s*[\\s\\S]{0,20000}?\\s*'''", "flags": "", "severity": "low", "why": "Same as the double-quoted docstring, with single quotes." },
34
+ { "id": "comment-block-ruby", "type": "comment_block", "expression": "=begin[\\s\\S]{0,20000}?=end", "flags": "", "severity": "low", "why": "An oversized Ruby block comment, the same hiding place." }
35
+ ]
36
+ }
@@ -1,8 +1,19 @@
1
+ // KJC-TSK-0699: the ONE table of default models per provider and level (the HU
2
+ // router reads it too). Ids verified 2026-10-16: claude takes its CLI aliases,
3
+ // which follow the current version; codex takes the models its CLI lists
4
+ // (models_cache.json: luna fast, terra balanced, sol the most capable). null
5
+ // leaves the CLI's own default where no id could be verified: aider and
6
+ // opencode run on each user's providers, gemini and qwen no longer serve
7
+ // individuals, copilot publishes no list.
8
+ const NO_MODEL = { trivial: null, simple: null, medium: null, complex: null };
1
9
  const DEFAULT_MODEL_TIERS = {
2
10
  claude: { trivial: "haiku", simple: "haiku", medium: "sonnet", complex: "opus" },
3
- codex: { trivial: "o4-mini", simple: "o4-mini", medium: "o4-mini", complex: "o3" },
4
- gemini: { trivial: "gemini-2.0-flash", simple: "gemini-2.0-flash", medium: "gemini-2.5-pro", complex: "gemini-2.5-pro" },
5
- aider: { trivial: null, simple: null, medium: null, complex: null }
11
+ codex: { trivial: "gpt-5.6-luna", simple: "gpt-5.6-luna", medium: "gpt-5.6-terra", complex: "gpt-5.6-sol" },
12
+ gemini: NO_MODEL,
13
+ qwen: NO_MODEL,
14
+ copilot: NO_MODEL,
15
+ opencode: NO_MODEL,
16
+ aider: NO_MODEL
6
17
  };
7
18
 
8
19
  const DEFAULT_ROLE_OVERRIDES = {
@@ -12,6 +23,39 @@ const DEFAULT_ROLE_OVERRIDES = {
12
23
 
13
24
  const VALID_LEVELS = new Set(["trivial", "simple", "medium", "complex"]);
14
25
 
26
+ /**
27
+ * KJC-TSK-0699 (MR-B): named profiles over the levels, so the user talks of
28
+ * profiles, not model ids. Resolution: flag > roles.<role>.model > profile >
29
+ * the CLI's own default. The reviewer keeps its floor (never below medium).
30
+ */
31
+ export const MODEL_PROFILES = { frugal: "trivial", standard: "medium", deep: "complex" };
32
+ const CODER_PROVIDER_ROLES = new Set(["planner", "refactorer", "solomon", "researcher", "tester", "security", "impeccable", "triage", "discover", "architect", "audit"]);
33
+
34
+ /** Fills the model of every role that has none yet; returns what it set. */
35
+ export function applyModelProfile(config, profile) {
36
+ const level = MODEL_PROFILES[profile];
37
+ if (!level) throw new Error(`model profile "${profile}" does not exist: use one of ${Object.keys(MODEL_PROFILES).join(", ")}`);
38
+ const coderProvider = config.roles?.coder?.provider || config.coder || null;
39
+ const set = {};
40
+ for (const [role, entry] of Object.entries(config.roles ?? {})) {
41
+ if (entry?.model) continue;
42
+ const provider = entry?.provider || (role === "reviewer" ? config.reviewer : null) || (CODER_PROVIDER_ROLES.has(role) || role === "coder" ? coderProvider : null);
43
+ const model = resolveModelForRole({ role, provider, level });
44
+ // A new object: the entry may be shared with the shipped defaults.
45
+ if (!model) continue;
46
+ set[role] = model;
47
+ config.roles[role] = { ...entry, model };
48
+ }
49
+ return set;
50
+ }
51
+
52
+ /** The line kj config prints about the effective profile. */
53
+ export function formatModelProfile(config) {
54
+ const profile = config?.model_profile;
55
+ if (!profile) return "Model profile: none (each CLI uses its own default model)";
56
+ return `Model profile: ${profile} (${MODEL_PROFILES[profile]} models; reviewer never below standard)`;
57
+ }
58
+
15
59
  export function getDefaultModelTiers() {
16
60
  return structuredClone(DEFAULT_MODEL_TIERS);
17
61
  }