karajan-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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.52.0",
3
+ "version": "4.53.1",
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",
@@ -76,6 +76,7 @@
76
76
  "setup": "node scripts/install.js",
77
77
  "test": "vitest run",
78
78
  "test:scenarios": "vitest run --config tests/scenarios/vitest.config.js --root .",
79
+ "contract:claude": "node scripts/contract-claude.mjs",
79
80
  "test:watch": "vitest",
80
81
  "test:coverage": "vitest run --coverage",
81
82
  "lint": "eslint src/ packages/",
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * KJC-TSK-1063 (QS-C4): npm run contract:claude. With the claude installed on
4
+ * this machine (and its session), records the contract answers again when its
5
+ * version differs from the recorded one, then replays the contract test. The
6
+ * new recordings go in a PR like any other change. --force records anyway.
7
+ */
8
+ import { spawnSync } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import { claudeContractCheck } from "../src/checks/claude-contract-check.js";
11
+
12
+ const root = fileURLToPath(new URL("..", import.meta.url));
13
+ const step = (cmd, args) => {
14
+ const res = spawnSync(cmd, args, { cwd: root, stdio: "inherit" });
15
+ if (res.status !== 0) process.exit(res.status ?? 1);
16
+ };
17
+
18
+ const check = await claudeContractCheck(root);
19
+ console.log(`${check.ok ? "✓" : "⚠"} ${check.detail}`);
20
+ if (check.ok && !process.argv.includes("--force")) process.exit(0);
21
+ step("node", ["scripts/record-agent-fixtures.mjs", "claude"]);
22
+ step("node", ["scripts/record-agent-fixtures.mjs", "claude", "--task"]);
23
+ step("npx", ["vitest", "run", "tests/agents/agent-contract-fixtures.test.js"]);
24
+ console.log("✓ kj reads the installed claude: commit tests/agents/fixtures/claude in a PR");
@@ -35,6 +35,13 @@ const seaTransformPlugin = {
35
35
  modified = true;
36
36
  }
37
37
 
38
+ // KJC-TSK-0703: the injection catalog is data read next to its module; inlined the same way.
39
+ const CATALOG_READ = /readFileSync\(new URL\("(\.\/injection-patterns\.json)", import\.meta\.url\), "utf8"\)/g;
40
+ if (CATALOG_READ.test(contents)) {
41
+ contents = contents.replace(CATALOG_READ, (_m, rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")));
42
+ modified = true;
43
+ }
44
+
38
45
  // Replace import.meta.dirname with __dirname
39
46
  if (contents.includes("import.meta.dirname")) {
40
47
  contents = contents.replaceAll("import.meta.dirname", "__dirname");
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * QS-B (KJC-TSK-1039): records what each agent CLI REALLY emits for a review,
4
+ * so the contract tests replay real answers instead of the ones we imagine.
5
+ *
6
+ * node scripts/record-agent-fixtures.mjs claude codex
7
+ *
8
+ * Each CLI runs through its own kj adapter (same argv kj uses) in a throwaway
9
+ * directory; claude is recorded twice, the second time with a SessionStart hook
10
+ * in that directory, the way a session with hooks meets it (#2151). The answer
11
+ * is redacted (home, user, emails, session ids) and must pass the privacy scan
12
+ * before tests/agents/fixtures/<cli>/review.json is written.
13
+ */
14
+ import { copyFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
15
+ import { homedir, tmpdir, userInfo } from "node:os";
16
+ import { join } from "node:path";
17
+ import { createAgent } from "../src/agents/index.js";
18
+ import { buildEnvironment, defaultEnvironment } from "../src/infrastructure/environment.js";
19
+ import { loadPrivacyList, scanPaths } from "../src/privacy/scan.js";
20
+
21
+ const PROMPT = [
22
+ "You are reviewing this diff. Answer ONLY with one JSON object:",
23
+ '{"approved": boolean, "blocking_issues": [], "non_blocking_suggestions": [], "summary": string, "confidence": number}',
24
+ "diff --git a/sum.js b/sum.js",
25
+ "+export const sum = (a, b) => a + b;",
26
+ ].join("\n");
27
+
28
+ const OUT = new URL("../tests/agents/fixtures/", import.meta.url).pathname;
29
+ const logger = { info() {}, warn() {}, error() {}, debug() {} };
30
+
31
+ export function redact(text, { home = homedir(), user = userInfo().username } = {}) {
32
+ return String(text ?? "")
33
+ .replaceAll(home, "/home/user")
34
+ .replaceAll(new RegExp(`\\b${user}\\b`, "g"), "user")
35
+ .replaceAll(/[\w.+-]+@[\w-]+\.[\w.]+/g, "user@example.com")
36
+ .replaceAll(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "redacted-id");
37
+ }
38
+
39
+ // The init event describes the recording machine (tools, MCP servers, plugins,
40
+ // paths): only its shape stays, so it leaks nothing about that machine.
41
+ const isInit = (event) => event?.type === "system" && event.subtype === "init";
42
+ // An allowlist, not a denylist: each CLI version adds fields about the machine
43
+ // (socket paths, memory paths, terminal commands). Only the event's shape stays.
44
+ const INIT_KEEP = ["type", "subtype", "session_id", "uuid", "model", "permissionMode", "apiKeySource", "claude_code_version", "output_style"];
45
+ const trimEvent = (event) => (isInit(event) ? Object.fromEntries(INIT_KEEP.filter((k) => k in event).map((k) => [k, event[k]])) : event);
46
+
47
+ // Both shapes claude emits: NDJSON (stream-json) and one JSON array (json, the coder's runTask).
48
+ export function trimInitEvents(stdout) {
49
+ const text = String(stdout ?? "");
50
+ if (text.trim().startsWith("[")) {
51
+ try { return JSON.stringify(JSON.parse(text).map(trimEvent)); } catch { /* not one array: read it by lines */ }
52
+ }
53
+ return text.split("\n").map((line) => {
54
+ let event;
55
+ try { event = JSON.parse(line); } catch { return line; }
56
+ return isInit(event) ? JSON.stringify(trimEvent(event)) : line;
57
+ }).join("\n");
58
+ }
59
+
60
+ // #2151: a SessionStart hook makes claude open its answer with hook events.
61
+ function withSessionHook(dir) {
62
+ mkdirSync(join(dir, ".claude"), { recursive: true });
63
+ const hooks = { SessionStart: [{ hooks: [{ type: "command", command: "echo kj-fixture-hook" }] }] };
64
+ writeFileSync(join(dir, ".claude", "settings.json"), `${JSON.stringify({ hooks }, null, 2)}\n`);
65
+ }
66
+
67
+ // KJC-TSK-1044: the coder's answer, with no tool to use and one exact line to say.
68
+ const TASK_PROMPT = "Reply with exactly this line and nothing else: kj-contract-ok";
69
+
70
+ async function record(cli, { hooks = false, task = false } = {}) {
71
+ process.chdir(mkdtempSync(join(tmpdir(), "kj-fixtures-")));
72
+ if (hooks) withSessionHook(process.cwd());
73
+ const calls = [];
74
+ const runner = { run: async (cmd, args, opts) => { const res = await defaultEnvironment.runner.run(cmd, args, opts); calls.push(res); return res; } };
75
+ const agent = createAgent(cli, {}, logger, buildEnvironment({ runner }));
76
+ const version = (await defaultEnvironment.runner.run(cli, ["--version"], {})).stdout?.trim() || "unknown";
77
+ if (task) await agent.runTask({ prompt: TASK_PROMPT, role: "coder" });
78
+ else await agent.reviewTask({ prompt: PROMPT, role: "reviewer" });
79
+ const last = calls.at(-1);
80
+ if (!last) throw new Error(`${cli}: the adapter ran no command`);
81
+ // Scanned in the throwaway directory: a fixture with a privacy hit never reaches the repo.
82
+ const candidate = join(process.cwd(), "fixture.json");
83
+ writeFileSync(candidate, `${JSON.stringify({ cli, version: redact(version), exitCode: last.exitCode, stdout: redact(trimInitEvents(last.stdout)), stderr: redact(last.stderr) }, null, 2)}\n`);
84
+ const blocks = scanPaths([candidate], { list: loadPrivacyList() }).filter((f) => f.severity === "block");
85
+ if (blocks.length > 0) throw new Error(`${cli}: ${blocks.length} privacy hit(s) in the recording; fix the redaction (left in ${candidate})`);
86
+ const file = join(OUT, cli, `${task ? "task" : "review"}${hooks ? "-hooks" : ""}.json`);
87
+ mkdirSync(join(OUT, cli), { recursive: true });
88
+ copyFileSync(candidate, file);
89
+ return `${cli} ${version}: exit ${last.exitCode} → ${file}`;
90
+ }
91
+
92
+ // --task records the coder (runTask) instead of the reviewer.
93
+ const task = process.argv.includes("--task");
94
+ const clis = process.argv.slice(2).filter((a) => a !== "--task");
95
+ for (const cli of clis) {
96
+ console.log(await record(cli, { task }));
97
+ if (cli === "claude" && !task) console.log(await record(cli, { hooks: true }));
98
+ }
@@ -342,7 +342,15 @@ function buildPromptArgs(task) {
342
342
  return { args: ["-p", "--allowedTools", ...ALLOWED_TOOLS], input: String(task.prompt ?? "") };
343
343
  }
344
344
 
345
+ // KJC-TSK-0914: the model claude runs on when nobody declares one; whatever the
346
+ // user declares (roles.<role>.model, coder/reviewer options, --coder-model) wins.
347
+ export const CLAUDE_DEFAULT_MODEL = "claude-opus-5-5";
348
+
345
349
  export class ClaudeAgent extends BaseAgent {
350
+ getRoleModel(role) {
351
+ return super.getRoleModel(role) ?? CLAUDE_DEFAULT_MODEL;
352
+ }
353
+
346
354
  async runTask(task) {
347
355
  const role = task.role || "coder";
348
356
  const model = this.getRoleModel(role);
@@ -86,6 +86,11 @@ registerModel("gpt-5.4-mini", { provider: "openai", pricing: { input_per_million
86
86
 
87
87
  // Default models & General aliases
88
88
  registerModel("codex", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
89
+ // KJC-TSK-0699: the models the codex CLI lists (0.146.1). List prices after the
90
+ // 2026-07-30 cut; Sol at its base rate, not the temporary promotion.
91
+ registerModel("gpt-5.6-sol", { provider: "openai", pricing: { input_per_million: 5.0, output_per_million: 30.0 } });
92
+ registerModel("gpt-5.6-terra", { provider: "openai", pricing: { input_per_million: 2.0, output_per_million: 12.0 } });
93
+ registerModel("gpt-5.6-luna", { provider: "openai", pricing: { input_per_million: 0.2, output_per_million: 1.2 } });
89
94
  registerModel("o4-mini", { provider: "openai", pricing: { input_per_million: 1.5, output_per_million: 4 } });
90
95
  registerModel("o3", { provider: "openai", pricing: { input_per_million: 10, output_per_million: 40 } });
91
96
 
@@ -33,6 +33,20 @@ function extractOpenCodeUsage(text) {
33
33
  return null;
34
34
  }
35
35
 
36
+ /**
37
+ * KJC-BUG-0358: with --format json opencode reports a failure as an error event
38
+ * on stdout ({"type":"error","error":{"name","data":{"message"}}}) and leaves
39
+ * stderr empty. Returns that message, or "" when there is no error event.
40
+ */
41
+ function openCodeErrorEvent(stdout) {
42
+ for (const line of String(stdout ?? "").split("\n")) {
43
+ let event;
44
+ try { event = JSON.parse(line); } catch { continue; }
45
+ if (event?.type === "error") return event.error?.data?.message ?? event.error?.name ?? "opencode reported an error";
46
+ }
47
+ return "";
48
+ }
49
+
36
50
  export class OpenCodeAgent extends BaseAgent {
37
51
  async runTask(task) {
38
52
  const role = task.role || "coder";
@@ -57,6 +71,7 @@ export class OpenCodeAgent extends BaseAgent {
57
71
  timeout: task.timeoutMs
58
72
  });
59
73
  const usage = extractOpenCodeUsage(res.stdout) ?? extractOpenCodeUsage(res.stderr);
60
- return { ok: res.exitCode === 0, output: res.stdout, error: res.stderr, exitCode: res.exitCode, ...usage };
74
+ const error = res.exitCode === 0 ? res.stderr : res.stderr || openCodeErrorEvent(res.stdout);
75
+ return { ok: res.exitCode === 0, output: res.stdout, error, exitCode: res.exitCode, ...usage };
61
76
  }
62
77
  }
@@ -11,6 +11,8 @@
11
11
  // 2. Inline ignore: `// karajan-audit-ignore: <tool>:<ruleId>` on the issue
12
12
  // line or the previous one. Legacy `// karajan-sonar-ignore: <ruleId>`
13
13
  // keeps working when the issue's tool is "sonar".
14
+ // 3. Sonar only: S1135 on the Spanish word «todo» (not an uppercase TODO
15
+ // tag) is suppressed by reading the issue's line (KJC-TSK-1042).
14
16
  //
15
17
  // Issues filtered out are kept in the `suppressed` list with `_suppressedBy`
16
18
  // metadata for auditability — no silent dropping.
@@ -53,6 +55,19 @@ export const DEFAULT_FALSE_POSITIVES = [
53
55
  },
54
56
  ];
55
57
 
58
+ /**
59
+ * KJC-TSK-1042: Sonar's TODO-tag rule (S1135, every language) matches the word
60
+ * regardless of case, so a Spanish comment like «todo el fichero» is reported
61
+ * as a pending task. Only the uppercase TODO tag counts. Returns the reason to
62
+ * suppress, or null; a line that cannot be read keeps the finding.
63
+ */
64
+ export function spanishTodoWord(issue, absPath) {
65
+ if (!/:S1135$/.test(issue.rule ?? "") || !issue.line || !fs.existsSync(absPath)) return null;
66
+ const text = fs.readFileSync(absPath, "utf8").split("\n")[issue.line - 1] ?? "";
67
+ if (!/\btodo\b/i.test(text) || /\bTODO\b/.test(text)) return null;
68
+ return "the Spanish word «todo», not an uppercase TODO tag";
69
+ }
70
+
56
71
  export function hasInlineIgnore(filePath, line, ruleId, tool = "sonar") {
57
72
  if (!filePath || !line || !ruleId) return false;
58
73
  if (!fs.existsSync(filePath)) return false;
@@ -109,11 +124,14 @@ export function filterFalsePositives(issues, opts = {}) {
109
124
  }
110
125
  }
111
126
 
112
- // 2. Inline ignore (only checked if no static rule matched).
127
+ // 2. Inline ignore, then the Spanish word «todo» (only if no static rule matched).
113
128
  if (!matched && issue.rule && issue.line && path) {
114
129
  const absPath = path.startsWith("/") ? path : `${projectRoot}/${path}`;
115
130
  if (hasInlineIgnore(absPath, issue.line, issue.rule, tool)) {
116
131
  matched = { tool, rule: issue.rule, path, reason: "inline `// karajan-audit-ignore`" };
132
+ } else if (tool === "sonar") {
133
+ const reason = spanishTodoWord(issue, absPath);
134
+ if (reason) matched = { tool, rule: issue.rule, path, reason };
117
135
  }
118
136
  }
119
137
 
@@ -0,0 +1,41 @@
1
+ /**
2
+ * KJC-TSK-0719 (SPD-C): the audit was due by calendar and staleness only. Now
3
+ * it is due by work cycles too: every N cards closed (distinct card refs in
4
+ * the commits) or M commits since the last recorded audit. The marker is that
5
+ * audit's timestamp, so running kj audit resets the counter. kj check shows it.
6
+ */
7
+ import { existsSync } from "node:fs";
8
+ import { runCommand } from "../utils/process.js";
9
+ import { getAuditHistoryDbPath, getLatestPreviousRun, openAuditHistoryDb } from "../audit/audit-history.js";
10
+
11
+ export const DEFAULT_EVERY_CARDS = 10;
12
+ export const DEFAULT_EVERY_COMMITS = 60;
13
+ const CARD_REF = /\b[A-Z]{2,5}-(?:TSK|BUG)-\d{4}\b/g;
14
+
15
+ /**
16
+ * The last audit that finished with a score, or null; never creates the
17
+ * database to find out. A run without a score swept nothing, so it is no marker.
18
+ */
19
+ export function readLatestAudit(projectDir) {
20
+ if (!existsSync(getAuditHistoryDbPath(projectDir))) return null;
21
+ const db = openAuditHistoryDb(projectDir);
22
+ try { return getLatestPreviousRun(db); } finally { db.close(); }
23
+ }
24
+
25
+ export async function auditCycle({ projectDir = process.cwd(), config = {}, run = runCommand, latestAudit = readLatestAudit } = {}) {
26
+ const everyCards = config?.audit_cycle?.every_cards ?? DEFAULT_EVERY_CARDS;
27
+ const everyCommits = config?.audit_cycle?.every_commits ?? DEFAULT_EVERY_COMMITS;
28
+ const last = latestAudit(projectDir);
29
+ if (!last) return { due: true, line: "⚠ audit due: no audit recorded yet: run kj audit" };
30
+ // A work commit is a non-merge one: a merge brings no work of its own, and the
31
+ // commits it brings in are already in this log (it walks every parent), so
32
+ // counting merges too would double each PR in a merge workflow.
33
+ const res = await run("git", ["-C", projectDir, "log", `--since=${last.timestamp}`, "--no-merges", "--format=%s"]);
34
+ if (res.exitCode !== 0) return { due: false, line: `⚠ audit cycle not checked: git log failed (${(res.stderr || "").trim()})` };
35
+ const lines = res.stdout.split("\n").filter(Boolean);
36
+ const cards = new Set(lines.flatMap((s) => s.match(CARD_REF) ?? [])).size;
37
+ const commits = lines.length;
38
+ const due = cards >= everyCards || commits >= everyCommits;
39
+ const counter = `${cards}/${everyCards} cards, ${commits}/${everyCommits} commits since the last audit (${last.timestamp.slice(0, 10)})`;
40
+ return { due, cards, commits, line: due ? `⚠ audit due: ${counter}: run kj audit` : `audit cycle: ${counter}` };
41
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * KJC-TSK-1063 (QS-C4): the agent contract recordings (tests/agents/fixtures)
3
+ * note the claude version they were made with. Anthropic updates claude on its
4
+ * own; a newer one may change its output and break kj. The check lives on the
5
+ * maintainer's machine, with the claude session already there, not in CI with
6
+ * a credential: kj release check is red until npm run contract:claude records
7
+ * again with the installed claude and the contract test passes.
8
+ */
9
+ import { existsSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { runCommand } from "../utils/process.js";
12
+
13
+ const RECORDING = join("tests", "agents", "fixtures", "claude", "review.json");
14
+
15
+ export const versionOf = (text) => /\d+\.\d+\.\d+/.exec(String(text ?? ""))?.[0] ?? null;
16
+
17
+ /** null where the project carries no contract recordings (any project but kj itself). */
18
+ export async function claudeContractCheck(projectDir, run = runCommand) {
19
+ const file = join(projectDir, RECORDING);
20
+ if (!existsSync(file)) return null;
21
+ const recorded = versionOf(JSON.parse(readFileSync(file, "utf8")).version);
22
+ const res = await run("claude", ["--version"], { cwd: projectDir }).catch((err) => ({ exitCode: 1, stdout: "", stderr: err.message }));
23
+ const installed = res.exitCode === 0 ? versionOf(res.stdout) : null;
24
+ if (!installed) return { name: "claude-contract", ok: false, detail: "claude --version gave no version: the contract with claude cannot be checked on this machine" };
25
+ if (installed === recorded) return { name: "claude-contract", ok: true, detail: `contract recorded with the installed claude ${installed}` };
26
+ return { name: "claude-contract", ok: false, detail: `claude ${installed} installed, contract recorded with ${recorded}: run npm run contract:claude and commit the recordings in a PR` };
27
+ }
@@ -68,8 +68,9 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
68
68
  export function sonarProof(v) {
69
69
  const s = v?.sonar;
70
70
  if (!s || v.verdict !== "approved") return null;
71
- // A pipeline stamp has no mode: the stage ran, or it did not.
72
- if (s.source === "pipeline") return s.ran ? "proved" : "unproved";
71
+ // A pipeline stamp has no mode: it ran and covered every staged source, or
72
+ // not (KJC-TSK-0839; a stamp from before it carries no lists and counts as run).
73
+ if (s.source === "pipeline") return s.ran && (s.uncovered || []).length === 0 ? "proved" : "unproved";
73
74
  // A block without a mode was written before the requirement existed
74
75
  // (the block landed one PR before fail-closed): not retroactive either.
75
76
  if (!s.mode) return null;
@@ -11,6 +11,8 @@ import { runCommand } from "../utils/process.js";
11
11
  import { loadPrivacyList, scanPaths } from "../privacy/scan.js";
12
12
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
13
13
  import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
14
+ import { npmTokenAgeCheck } from "./supply-chain-check.js";
15
+ import { claudeContractCheck } from "./claude-contract-check.js";
14
16
 
15
17
  // KJC-TSK-0769 — the effect boundary: what ships is re-evaluated against the
16
18
  // policy IN FORCE now, not the one each PR was merged under. Artifact rules
@@ -247,7 +249,11 @@ export async function runReleaseCheck({ projectDir = process.cwd(), config = {},
247
249
  const pack = await packPrivacyCheck(projectDir, pkg);
248
250
  const dual = await dualPublishCheck(projectDir, pkg);
249
251
  const scenarios = await scenarioBatteryCheck(projectDir, pkg);
250
- checks.push(...[pack, dual, scenarios].filter(Boolean), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
252
+ // KJC-TSK-1059: what publishes to npm also says how old its long-lived tokens are (a warning).
253
+ const tokens = pkg?.name && !pkg.private ? await npmTokenAgeCheck({ maxDays: config?.supply_chain?.npm_token_max_days }) : null;
254
+ // KJC-TSK-1063: kj never ships unchecked against the claude installed here.
255
+ const claudeContract = await claudeContractCheck(projectDir);
256
+ checks.push(...[pack, dual, scenarios, tokens, claudeContract].filter(Boolean), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
251
257
  if (forCommand === null) return { ok: checks.every((c) => c.ok), version, checks };
252
258
  // A lifted check stays RED in the report: the fact is not falsified, it just
253
259
  // stops blocking the command that exists to repair it. The mark travels ON
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { isSonarReachable } from "../sonar/manager.js";
11
+ import { getProjectQualityProfiles } from "../sonar/api.js";
11
12
  import { STRATEGY } from "./types.js";
12
13
  import { withDocLink } from "../utils/doc-links.js";
13
14
 
@@ -22,6 +23,24 @@ function sonarHost(config) {
22
23
  return config.sonarqube?.host || "http://localhost:9000";
23
24
  }
24
25
 
26
+ /** KJC-TSK-1051 (TEAM-E): the project runs on the team's quality profile, or the check says why not. */
27
+ async function teamProfileResult(config, { host, profile }) {
28
+ let profiles;
29
+ try {
30
+ profiles = await getProjectQualityProfiles(config);
31
+ } catch (err) {
32
+ return { ok: false, severity: "warn", detail: `Reachable at ${host}, but the quality profile could not be read (${err.message})`, extra: { host } };
33
+ }
34
+ if (profiles.includes(profile)) return { ok: true, severity: "fail", detail: `Reachable at ${host}, on the team profile ${profile}`, extra: { host } };
35
+ return {
36
+ ok: false,
37
+ severity: "warn",
38
+ detail: `Reachable at ${host}, but the project runs on ${profiles.join(", ") || "no profile"}, not the team profile ${profile}`,
39
+ fix: `An admin assigns ${profile} to the project in SonarQube (Project Settings > Quality Profiles).`,
40
+ extra: { host },
41
+ };
42
+ }
43
+
25
44
  /**
26
45
  * Informative check: Sonar enabled/disabled status.
27
46
  * Produces an OK result with disabled description when Sonar is off.
@@ -51,18 +70,17 @@ function createSonarStatusCheck() {
51
70
  } catch {
52
71
  reachable = false;
53
72
  }
54
- return {
55
- ok: reachable,
56
- severity: "fail",
57
- detail: reachable ? `Reachable at ${host}` : `Not reachable at ${host}`,
58
- fix: reachable
59
- ? undefined
60
- : withDocLink(
61
- "Run 'kj sonar start' or 'docker start karajan-sonarqube'. Use --no-sonar to skip.",
62
- "sonar_docker"
63
- ),
64
- extra: { host, reachable },
65
- };
73
+ // KJC-TSK-1051 (TEAM-E): an external SonarQube is the team's server, never started here.
74
+ const external = config.sonarqube?.external === true;
75
+ if (!reachable) {
76
+ const fix = external
77
+ ? `The team's SonarQube at ${host} is not answering (sonarqube.external: kj does not start a local one): check the network or VPN, or ask whoever runs it.`
78
+ : withDocLink("Run 'kj sonar start' or 'docker start karajan-sonarqube'. Use --no-sonar to skip.", "sonar_docker");
79
+ return { ok: false, severity: "fail", detail: `Not reachable at ${host}`, fix, extra: { host, reachable } };
80
+ }
81
+ const profile = config.sonarqube?.quality_profile;
82
+ if (!profile) return { ok: true, severity: "fail", detail: `Reachable at ${host}`, extra: { host, reachable } };
83
+ return teamProfileResult(config, { host, profile });
66
84
  },
67
85
  };
68
86
  }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * KJC-TSK-1059: the supply-chain cooldown outside the review. kj check reads
3
+ * what the branch's lockfile installs against the merge base with the base
4
+ * branch; kj release check reads how old the long-lived npm tokens are.
5
+ * Both warn, never block: they are signals, and a signal never stays silent.
6
+ */
7
+ import { runCommand } from "../utils/process.js";
8
+ import { cooldownAdvisories, DEFAULT_COOLDOWN_HOURS, formatCooldown } from "../review/dep-cooldown.js";
9
+
10
+ export const DEFAULT_TOKEN_MAX_DAYS = 90;
11
+ const DAY_MS = 86_400_000;
12
+
13
+ /** The cooldown warnings for what the branch brings in, committed or not. */
14
+ export async function branchCooldownLines({ projectDir = process.cwd(), base = "main", thresholdHours = DEFAULT_COOLDOWN_HOURS, run = runCommand, now = Date.now() } = {}) {
15
+ let from = null;
16
+ for (const ref of [`origin/${base}`, base]) {
17
+ const res = await run("git", ["-C", projectDir, "merge-base", "HEAD", ref]);
18
+ if (res.exitCode === 0 && res.stdout.trim()) { from = res.stdout.trim(); break; }
19
+ }
20
+ if (!from) return [`⚠ supply-chain cooldown not checked: no merge base with ${base}`];
21
+ const diff = await run("git", ["-C", projectDir, "diff", from, "--", "package-lock.json", "**/package-lock.json"]);
22
+ if (diff.exitCode !== 0) return [`⚠ supply-chain cooldown not checked: git diff failed (${(diff.stderr || "").trim()})`];
23
+ return formatCooldown(await cooldownAdvisories(diff.stdout, { thresholdHours, run, now }), thresholdHours).lines;
24
+ }
25
+
26
+ /**
27
+ * A token without an expiry is a long-lived (classic) one: the older it is,
28
+ * the longer a leaked copy keeps working. Each one over maxDays is named.
29
+ */
30
+ export async function npmTokenAgeCheck({ run = runCommand, now = Date.now(), maxDays = DEFAULT_TOKEN_MAX_DAYS } = {}) {
31
+ const res = await run("npm", ["token", "list", "--json"]);
32
+ if (res.exitCode !== 0) {
33
+ return { name: "npm-tokens", ok: true, warn: true, detail: `could not list npm tokens (${(res.stderr || res.stdout || "").trim()}): their age is unverified` };
34
+ }
35
+ let tokens;
36
+ try { tokens = JSON.parse(res.stdout || "[]"); } catch (err) {
37
+ return { name: "npm-tokens", ok: true, warn: true, detail: `npm token list gave no JSON (${err.message}): their age is unverified` };
38
+ }
39
+ const old = tokens
40
+ .filter((t) => !t.expires && t.created)
41
+ // Never the token field, masked or not: only a short prefix of its id reaches a log.
42
+ .map((t) => ({ label: `id ${String(t.key ?? "?").slice(0, 6)}…`, days: Math.floor((now - Date.parse(t.created)) / DAY_MS) }))
43
+ .filter((t) => t.days > maxDays);
44
+ if (old.length === 0) return { name: "npm-tokens", ok: true, warn: false, detail: `no long-lived npm token older than ${maxDays} days` };
45
+ const list = old.map((t) => `${t.label} (${t.days} days)`).join(", ");
46
+ return { name: "npm-tokens", ok: true, warn: true, detail: `${old.length} long-lived npm token(s) older than ${maxDays} days: ${list}. Rotate them, or move to granular tokens with an expiry` };
47
+ }
@@ -334,6 +334,7 @@ export function registerMeta(program, { pkgVersion }) {
334
334
  // KJC-BUG-0257: the valid states, said before the call fails.
335
335
  .description(`Move a card; <status> is one of: ${HU_STATUSES.join(", ")}`)
336
336
  .option("--json", "Machine-readable output")
337
+ .option("--release", "Take over a running card reserved by someone else (the explicit act)")
337
338
  .action(async (id, status, flags) => {
338
339
  await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
339
340
  await huCommand({ config, action: "move", args: [id, status], flags });
@@ -515,7 +516,11 @@ export function registerMeta(program, { pkgVersion }) {
515
516
  const res = await runReleaseCheck({ projectDir: config?.projectDir || process.cwd(), config, forCommand: flags.forCommand ?? null });
516
517
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
517
518
  else {
518
- for (const c of res.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`);
519
+ const mark = (c) => {
520
+ if (!c.ok) return "✗";
521
+ return c.warn ? "⚠" : "✓";
522
+ };
523
+ for (const c of res.checks) console.log(` ${mark(c)} ${c.name}: ${c.detail}`);
519
524
  if (res.lifted?.length) console.log(` ℓ no bloquean este comando porque es su remedio: ${res.lifted.join(", ")}`);
520
525
  console.log(res.ok ? `release check: ready to release ${res.version ?? ""}`.trim() : "release check: NOT ready — fix the red items first");
521
526
  }
@@ -14,6 +14,7 @@ import { runCommandHandler } from "../commands/run.js";
14
14
  import { statusCommand } from "../commands/status.js";
15
15
  import { loadPlan } from "../plan/plan-store.js";
16
16
  import { withConfig } from "./_shared.js";
17
+ import { publishPrReview } from "../review/publish-pr.js";
17
18
 
18
19
  /**
19
20
  * Register the "core pipeline" commands: everything the user runs day-to-day
@@ -66,6 +67,7 @@ export function registerPipeline(program, { pkgVersion }) {
66
67
  .option("--coder-model <name>")
67
68
  .option("--reviewer-model <name>")
68
69
  .option("--refactorer-model <name>")
70
+ .option("--profile <name>", "Model profile for every role without an explicit model: frugal | standard | deep (the reviewer never goes below standard)")
69
71
  .option("--enable-planner")
70
72
  .option("--enable-reviewer")
71
73
  .option("--enable-refactorer")
@@ -254,8 +256,15 @@ export function registerPipeline(program, { pkgVersion }) {
254
256
  // this the rule could only complain, which is the gate with no exit.
255
257
  .option("--walked <route>", "Recorrido comprobado como lo haría una persona (repetible) — queda en el veredicto", (v, prev) => [...(prev || []), v], [])
256
258
  .option("--walked-with <tool>", "Con qué se comprobó el recorrido (por ejemplo chrome-devtools)")
259
+ .option("--publish-pr <n>", "Publish the cross-AI verdicts of a PR's commits on it as one GitHub review")
257
260
  .action(async (task, flags) => {
258
261
  await withConfig(pkgVersion, "review", flags, async ({ config, logger }) => {
262
+ // KJC-TSK-0913: the verdicts kj already holds, published on the PR as one review.
263
+ if (flags.publishPr) {
264
+ const r = await publishPrReview({ projectDir: config.projectDir || process.cwd(), pr: flags.publishPr });
265
+ logger.info(r.published ? `✓ PR #${flags.publishPr}: review published (${r.commits} commit(s))` : `PR #${flags.publishPr}: ${r.reason}`);
266
+ return;
267
+ }
259
268
  // ENV-B1 (KJC-TSK-0637): the gate mode records a verdict tied to
260
269
  // the exact diff so the pre-commit hook can enforce cross-AI review.
261
270
  if (flags.staged || flags.check || flags.range || flags.installGate || flags.prune) {
@@ -12,6 +12,7 @@ import {
12
12
  planShareCommand,
13
13
  planUnshareCommand,
14
14
  } from "../commands/plan.js";
15
+ import { planLicenseCommand } from "../commands/plan/license.js";
15
16
  import { parseCanvasMarkdown } from "../canvas/parse-md.js";
16
17
  import { validateCanvas } from "../canvas/validate.js";
17
18
  import { canvasToTask } from "../canvas/to-flat.js";
@@ -195,6 +196,15 @@ export function registerPlan(program, { pkgVersion }) {
195
196
  });
196
197
  });
197
198
 
199
+ // KJC-TSK-0717 (SPD-A): the REWORK licence, from the module's mutation score.
200
+ plan.command("license").description("Score a module with kj mutate and record the REWORK licence on an HU")
201
+ .argument("<planId>").argument("<huId>").requiredOption("--module <path>", "The module the REWORK regenerates")
202
+ .action(async (planId, huId, flags) => {
203
+ await withConfig(pkgVersion, "plan", flags, async ({ config }) => {
204
+ process.exitCode = await planLicenseCommand({ config, planId, huId, module: flags.module });
205
+ });
206
+ });
207
+
198
208
  // KJC-TSK-0402: re-ejecuta reviewer + self-fix + structural pass sobre
199
209
  // un plan existente. Acepta --prompt opcional con feedback humano que
200
210
  // el reviewer inyecta como contexto extra antes de revisar.
@@ -11,6 +11,8 @@ import { checkAiSurface, formatAiSurface } from "../checks/ai-surface.js";
11
11
  import { detectObservedAgents } from "../utils/agent-detect.js";
12
12
  import { panelSummary } from "../environment/panel.js";
13
13
  import { loadConfig } from "../config.js";
14
+ import { branchCooldownLines } from "../checks/supply-chain-check.js";
15
+ import { auditCycle } from "../checks/audit-cycle.js";
14
16
 
15
17
  export async function checkCommand({ projectDir = process.cwd(), profile = "standard", json = false, logger = console } = {}) {
16
18
  const result = await checkHarden({ projectDir, profile });
@@ -34,9 +36,15 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
34
36
  aiSurface = checkAiSurface({ projectDir, extraSurface: clis });
35
37
  } catch { /* inventory is best-effort */ }
36
38
 
39
+ // KJC-TSK-1059: young dependencies the branch brings in, a warning outside the review.
40
+ const depCooldown = await branchCooldownLines({ projectDir, base: config?.base_branch ?? "main", thresholdHours: config?.supply_chain?.cooldown_hours })
41
+ .catch((err) => [`⚠ supply-chain cooldown not checked: ${err.message}`]);
42
+ // KJC-TSK-0719 (SPD-C): the audit is due by work cycles too; visibility, never the exit code.
43
+ const audit = await auditCycle({ projectDir, config }).catch((err) => ({ due: false, line: `⚠ audit cycle not checked: ${err.message}` }));
44
+
37
45
  const ok = result.ok && ragCoverage.ok;
38
46
  if (json) {
39
- logger.info?.(JSON.stringify({ ...result, ok, method, ragCoverage, aiSurface }));
47
+ logger.info?.(JSON.stringify({ ...result, ok, method, ragCoverage, aiSurface, depCooldown, auditCycle: audit }));
40
48
  return ok ? 0 : 1;
41
49
  }
42
50
 
@@ -45,6 +53,8 @@ export async function checkCommand({ projectDir = process.cwd(), profile = "stan
45
53
  if (method) logger.info?.(` method: ${formatMethodStats(method)}`);
46
54
  logger.info?.(` ${ragCoverage.ok ? "✓" : "✗"} rag-coverage: ${ragCoverage.detail}`);
47
55
  if (aiSurface) logger.info?.(` ${formatAiSurface(aiSurface)}`);
56
+ for (const line of depCooldown) logger.info?.(` ${line}`);
57
+ logger.info?.(` ${audit.line}`);
48
58
  // KJC-TSK-0865: who writes and who reviews is the user's choice, so it is
49
59
  // said out loud next to everything else that describes the project. Never
50
60
  // a check: it has no ok/fail, it informs.
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import yaml from "js-yaml";
4
4
  import { loadConfig, validateConfig, getConfigPath, writeConfig } from "../config.js";
5
5
  import { ensureDir } from "../utils/fs.js";
6
+ import { formatModelProfile } from "../utils/model-selector.js";
6
7
  import path from "node:path";
7
8
 
8
9
  function resolveEditor() {
@@ -70,5 +71,6 @@ export async function configCommand({ json = false, edit = false }) {
70
71
 
71
72
  console.log(`Config path: ${cfgPath}`);
72
73
  console.log(`Exists: ${exists}`);
74
+ console.log(formatModelProfile(config));
73
75
  console.log(JSON.stringify(config, null, 2));
74
76
  }
@@ -24,6 +24,7 @@ import { installHarnessHooks } from "../harden/harness-hooks.js";
24
24
  import { installSentinelHooks } from "../harden/sentinel-hooks.js";
25
25
  import { detectStackRoots } from "../harden/stack-roots.js";
26
26
  import { installWorkflows } from "../harden/workflow-engine.js";
27
+ import { adviseBranchProtection } from "../harden/branch-protection.js";
27
28
  import { detectTestFramework } from "../utils/project-detect.js";
28
29
  import { ensureIdentity } from "../identity/bootstrap.js";
29
30
  import { commitContract, contractChanges } from "../environment/contract-commit.js";
@@ -250,6 +251,8 @@ export async function hardenCommand({
250
251
  out.identityCommand = id.command;
251
252
  }
252
253
  }
254
+ // KJC-TSK-0928: the method's checks only stop a merge with branch protection; kj says the exact command.
255
+ if (!dryRun) out.branchProtection = await adviseBranchProtection({ projectDir, branch: await resolveBaseBranch(projectDir), logger });
253
256
  // KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
254
257
  // del supervisor con procedencia sellada. Acto humano — el guard vive en
255
258
  // commitSupervisorRegeneration y rechaza sesiones de agente.