karajan-code 4.51.1 → 4.52.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.51.1",
3
+ "version": "4.52.0",
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",
@@ -227,11 +227,27 @@ export async function dualPublishCheck(projectDir, pkg, run = runCommand) {
227
227
  }
228
228
  }
229
229
 
230
+ /**
231
+ * QS-A3 (KJC-TSK-1028): a project that carries a scenario battery
232
+ * (`test:scenarios`) ships only with it green. Gate clashes and holes in the
233
+ * plan path only show end to end, and the unit suite never runs them.
234
+ */
235
+ export async function scenarioBatteryCheck(projectDir, pkg, run = runCommand) {
236
+ if (!pkg?.scripts?.["test:scenarios"]) return null;
237
+ try {
238
+ const res = await run("npm", ["run", "test:scenarios"], { cwd: projectDir });
239
+ return { name: "scenarios", ok: res.exitCode === 0, detail: res.exitCode === 0 ? "scenario battery green" : `npm run test:scenarios exited ${res.exitCode}` };
240
+ } catch (err) {
241
+ return { name: "scenarios", ok: false, detail: `npm run test:scenarios failed to run: ${err.message}` };
242
+ }
243
+ }
244
+
230
245
  export async function runReleaseCheck({ projectDir = process.cwd(), config = {}, forCommand = null } = {}) {
231
246
  const { checks, version, pkg } = await genericChecks(projectDir);
232
247
  const pack = await packPrivacyCheck(projectDir, pkg);
233
248
  const dual = await dualPublishCheck(projectDir, pkg);
234
- checks.push(...(pack ? [pack] : []), ...(dual ? [dual] : []), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
249
+ const scenarios = await scenarioBatteryCheck(projectDir, pkg);
250
+ checks.push(...[pack, dual, scenarios].filter(Boolean), await policyRangeCheck(projectDir), ...await declaredItems(projectDir, config, version));
235
251
  if (forCommand === null) return { ok: checks.every((c) => c.ok), version, checks };
236
252
  // A lifted check stays RED in the report: the fact is not falsified, it just
237
253
  // stops blocking the command that exists to repair it. The mark travels ON
@@ -306,6 +306,14 @@ export function registerMeta(program, { pkgVersion }) {
306
306
  await worktreeCommand({ config, action: "done", args: [slug], flags });
307
307
  });
308
308
  });
309
+ wt.command("prune")
310
+ .option("--dry-run", "List what would be retired and why, touching nothing")
311
+ .description("Retire kj lanes that are provably abandoned (clean, no own commits, no live process); the rest are listed with their reason")
312
+ .action(async (flags) => {
313
+ await withConfig(pkgVersion, "worktree", flags, async ({ config }) => {
314
+ await worktreeCommand({ config, action: "prune", flags });
315
+ });
316
+ });
309
317
 
310
318
  const hu = program.command("hu").description("Track work in the HU Board from any host agent (card first)");
311
319
  hu.command("add <title>")
@@ -29,6 +29,7 @@ import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
29
29
  import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
30
30
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
31
31
  import { evaluatePolicyGate } from "../review/policy-gate.js";
32
+ import { correctionNote, reviewCorrectionRounds } from "../review/correction-rounds.js";
32
33
 
33
34
  // KJC-TSK-0686 (MG-A): card-first is a gate, not a habit. Runs on --staged
34
35
  // (before spending sonar/reviewer effort) AND on --check — the pre-commit
@@ -111,11 +112,12 @@ export const formatExempted = (e) => (e.standing
111
112
  ? `⚠ policy standing [${e.rule_id}] — excepción permanente${e.standing.origin === "global" ? " GLOBAL" : ""} viva hasta ${e.standing.expiresAt} (${e.standing.justification || "sin justificación"})`
112
113
  : `⚠ policy exempt [${e.rule_id}] — excepción registrada en .karajan/policy-exceptions.jsonl (${e.justification})`);
113
114
 
114
- function printVerdict(record) {
115
+ function printVerdict(record, rounds = 0) {
115
116
  if (record.verdict === "approved") {
116
117
  const ws = record.workspace ? ` [${record.workspace}]` : "";
117
118
  console.log(`✓ APPROVED by ${record.reviewer} (diff ${record.diffHash.slice(0, 12)})${ws}`);
118
119
  if (record.summary) console.log(` ${record.summary}`);
120
+ if (rounds > 0) console.log(` ${correctionNote(rounds)}`);
119
121
  return;
120
122
  }
121
123
  console.log(`✗ REJECTED by ${record.reviewer} — ${record.issues.length} blocking issue(s):`);
@@ -577,7 +579,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
577
579
  }
578
580
 
579
581
  const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord, ui: uiRecord });
580
- printVerdict(record);
582
+ printVerdict(record, record.verdict === "approved" ? await reviewCorrectionRounds(projectDir, record) : 0);
581
583
  process.exitCode = record.verdict === "approved" ? 0 : 1;
582
584
  return record;
583
585
  }
@@ -11,13 +11,15 @@ import path from "node:path";
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { bootstrapWorktree } from "../hu/worktree-bootstrap.js";
14
+ import { classifyLanes, liveProcesses } from "../hu/lane-prune.js";
15
+ import { recordGateDecision } from "../policy/decisions.js";
14
16
 
15
17
  const execFileAsync = promisify(execFile);
16
18
  const WORKTREE_DIR = ".kj/worktrees"; // shared with the headless lanes
17
19
 
18
20
  const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
19
21
 
20
- export async function worktreeCommand({ config = null, action, args = [], flags = {} }) {
22
+ export async function worktreeCommand({ config = null, action, args = [], flags = {}, deps = {} }) {
21
23
  const projectDir = config?.projectDir || process.cwd();
22
24
  const git = (gitArgs, cwd = projectDir) => execFileAsync("git", gitArgs, { cwd });
23
25
  const lanePath = (slug) => path.join(projectDir, WORKTREE_DIR, slug);
@@ -72,5 +74,27 @@ export async function worktreeCommand({ config = null, action, args = [], flags
72
74
  return { removed: true, slug };
73
75
  }
74
76
 
75
- throw new Error(`unknown action "${action}" — use: start | list | done`);
77
+ if (action === "prune") {
78
+ const { processes = liveProcesses() } = deps;
79
+ const lanes = await classifyLanes({ git, laneRoot: path.join(projectDir, WORKTREE_DIR), base: config?.base_branch || "main", processes, now: deps.now });
80
+ const abandoned = lanes.filter((l) => l.reasons.length === 0);
81
+ const kept = lanes.filter((l) => l.reasons.length > 0);
82
+ for (const l of kept) console.log(`kept ${l.slug.padEnd(24)} ${l.reasons.join("; ")}`);
83
+ if (lanes.length === 0) console.log("no kj lanes");
84
+ if (flags.dryRun) {
85
+ for (const l of abandoned) console.log(`would retire ${l.slug} (${l.branch ?? "detached"})`);
86
+ return { wouldRemove: abandoned, kept };
87
+ }
88
+ for (const l of abandoned) {
89
+ await git(["worktree", "remove", l.path]);
90
+ if (l.branch) await git(["branch", "-D", l.branch]);
91
+ console.log(`✓ retired ${l.slug} (${l.branch ?? "detached"})`);
92
+ }
93
+ if (abandoned.length > 0) {
94
+ recordGateDecision(projectDir, { decision: "allow", chokepoint: "tool", kind: "worktree-prune", lanes: abandoned.map((l) => l.slug) });
95
+ }
96
+ return { removed: abandoned, kept };
97
+ }
98
+
99
+ throw new Error(`unknown action "${action}" — use: start | list | done | prune`);
76
100
  }
@@ -70,6 +70,8 @@ Invariants (the git gates enforce these — they are not suggestions):
70
70
  - Every diff is reviewed by a DIFFERENT AI before it is committed
71
71
  (\`kj review --staged\`): verdicts bind to the exact diff — change the code
72
72
  and it must be reviewed again. Disagree with a rejection? \`kj solomon\`.
73
+ A correction from the other AI is the method working: apply it and move
74
+ on, no apology, and never report it as your mistake.
73
75
  ${panel ? `${panel}\n` : ""}- Security findings are never overridable — not even by arbitration. You
74
76
  absorb the security role: \`kj brief security\` states what must be true.
75
77
  Task touches auth, user input, secrets, network or deps? Run
@@ -0,0 +1,74 @@
1
+ /**
2
+ * KJC-TSK-1036 (owner's decision, 14-oct): a lane kj created is retired only when
3
+ * it is provably abandoned: clean tree, no commits beyond the base, and no live
4
+ * process working inside it. Anything else is listed with its reason, never
5
+ * touched, so a session may run this without risking anyone's work.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ const IDLE_HOURS = 24;
11
+ const REGENERABLE = new Set(["node_modules", "dist", "build", "coverage", ".venv", "venv", "__pycache__", ".cache", "target"]);
12
+
13
+ /**
14
+ * This user's live processes (another user's cannot write in our worktrees): the
15
+ * cwds that can be read and how many cannot (non-dumpable ones: ssh-agent, systemd,
16
+ * some JVMs). null when the platform has no /proc.
17
+ */
18
+ export function liveProcesses() {
19
+ let pids;
20
+ try { pids = fs.readdirSync("/proc").filter((p) => /^\d+$/.test(p)); } catch { return null; }
21
+ const cwds = new Set();
22
+ let unseen = 0;
23
+ for (const pid of pids) {
24
+ try {
25
+ if (fs.statSync(`/proc/${pid}`).uid !== process.getuid()) continue;
26
+ cwds.add(fs.readlinkSync(`/proc/${pid}/cwd`));
27
+ } catch (err) {
28
+ if (err.code !== "ENOENT" && err.code !== "ESRCH") unseen += 1;
29
+ }
30
+ }
31
+ return { cwds, unseen };
32
+ }
33
+
34
+ /** Hours since anything in the lane moved: its dir, and its git index and HEAD. */
35
+ async function idleHours(git, lanePath, now) {
36
+ const { stdout } = await git(["rev-parse", "--absolute-git-dir"], lanePath);
37
+ const gitDir = stdout.trim();
38
+ const times = [lanePath, path.join(gitDir, "index"), path.join(gitDir, "HEAD")]
39
+ .map((f) => { try { return fs.statSync(f).mtimeMs; } catch { return 0; } });
40
+ return (now - Math.max(...times)) / 3_600_000;
41
+ }
42
+
43
+ const inside = (cwd, dir) => cwd === dir || cwd.startsWith(dir + path.sep);
44
+
45
+ /** @returns {Promise<{slug: string, path: string, branch: string|null, reasons: string[]}[]>} */
46
+ export async function classifyLanes({ git, laneRoot, base, processes, now = Date.now() }) {
47
+ const { stdout } = await git(["worktree", "list", "--porcelain"]);
48
+ const lanes = [];
49
+ let current = null;
50
+ for (const line of stdout.split("\n")) {
51
+ if (line.startsWith("worktree ")) {
52
+ current = { path: line.slice(9).trim(), branch: null };
53
+ if (current.path.startsWith(laneRoot + path.sep)) lanes.push(current);
54
+ } else if (line.startsWith("branch ") && current) current.branch = line.slice(7).trim().replace("refs/heads/", "");
55
+ }
56
+ return Promise.all(lanes.map(async (lane) => {
57
+ const reasons = [];
58
+ const { stdout: status } = await git(["status", "--porcelain", "--ignored=matching"], lane.path);
59
+ const entries = status.split("\n").filter(Boolean);
60
+ if (entries.some((e) => !e.startsWith("!! "))) reasons.push("uncommitted changes");
61
+ // Ignored files can be valuable (.env, local artifacts); only regenerable dirs are not.
62
+ const precious = entries.filter((e) => e.startsWith("!! ") && !REGENERABLE.has(e.slice(3).split("/")[0]));
63
+ if (precious.length > 0) reasons.push(`ignored files that are not regenerable (${precious[0].slice(3)}${precious.length > 1 ? ", …" : ""})`);
64
+ // The lane's HEAD, branch or detached: a detached lane's commits have no other name.
65
+ const { stdout: ahead } = await git(["rev-list", "--count", `${base}..HEAD`], lane.path);
66
+ if (Number(ahead) > 0) reasons.push(`${Number(ahead)} commit(s) not in ${base}`);
67
+ if (processes === null) reasons.push("cannot check for live processes on this platform");
68
+ else if ([...processes.cwds].some((cwd) => inside(cwd, lane.path))) reasons.push("a live process works in it");
69
+ else if (processes.unseen > 0 && (await idleHours(git, lane.path, now)) < IDLE_HOURS) {
70
+ reasons.push(`${processes.unseen} process(es) of yours cannot be inspected and the lane changed in the last ${IDLE_HOURS} h`);
71
+ }
72
+ return { slug: path.basename(lane.path), path: lane.path, branch: lane.branch, reasons };
73
+ }));
74
+ }
@@ -11,7 +11,7 @@ import { createAgent } from "../../agents/index.js";
11
11
  import { buildPlannerPrompt } from "../../prompts/planner.js";
12
12
  import { buildCoderPrompt } from "../../prompts/coder.js";
13
13
  import { buildReviewerPrompt } from "../../prompts/reviewer.js";
14
- import { parseMaybeJsonString } from "../../review/parser.js";
14
+ import { parseMaybeJsonString, agentAnswerText } from "../../review/parser.js";
15
15
  import { computeBaseRef, generateDiff } from "../../review/diff-generator.js";
16
16
  import { resolveReviewProfile } from "../../review/profiles.js";
17
17
  import { createRunLog } from "../../utils/run-log.js";
@@ -322,7 +322,7 @@ export async function handleReviewDirect(a, server, extra) {
322
322
  }
323
323
 
324
324
  sendTrackerLog(server, "reviewer", "done");
325
- const parsed = parseMaybeJsonString(result.output);
325
+ const parsed = parseMaybeJsonString(agentAnswerText(result.output));
326
326
  return { ok: true, review: parsed || result.output, raw: result.output };
327
327
  }
328
328
 
@@ -15,6 +15,7 @@ import { invokeSolomon } from "../solomon-escalation.js";
15
15
  import { detectRateLimit } from "../../utils/rate-limit-detector.js";
16
16
  import { createStallDetector } from "../../utils/stall-detector.js";
17
17
  import { applyQuotaSimulation } from "../../utils/quota-simulator.js";
18
+ import { correctionNote } from "../../review/correction-rounds.js";
18
19
 
19
20
  function categorizeIssues(issues) {
20
21
  const categories = { security: 0, correctness: 0, tests: 0, style: 0, other: 0 };
@@ -33,6 +34,12 @@ function categorizeIssues(issues) {
33
34
  return categories;
34
35
  }
35
36
 
37
+ // KJC-TSK-1017: the rejections before an approval are review corrections.
38
+ export function approvedMessage(session) {
39
+ const rejections = (session.checkpoints || []).filter((cp) => cp.stage === "reviewer" && cp.approved === false).length;
40
+ return ["Review approved", correctionNote(rejections)].filter(Boolean).join(" — ");
41
+ }
42
+
36
43
  function buildReviewHistory(session) {
37
44
  return (session.checkpoints || [])
38
45
  .filter(cp => cp.stage === "reviewer")
@@ -402,7 +409,7 @@ export async function runReviewerStage({ reviewerRole, config, logger, emitter,
402
409
  emitter,
403
410
  makeEvent("reviewer:end", { ...eventBase, stage: "reviewer" }, {
404
411
  status: review.approved ? "ok" : "fail",
405
- message: review.approved ? "Review approved" : `Review rejected (${review.blocking_issues.length} blocking)`,
412
+ message: review.approved ? approvedMessage(session) : `Review rejected (${review.blocking_issues.length} blocking)`,
406
413
  detail: {
407
414
  approved: review.approved,
408
415
  blockingCount: review.blocking_issues.length,
@@ -0,0 +1,40 @@
1
+ /**
2
+ * KJC-TSK-1017: a rejection followed by an approval is the method working: the
3
+ * second AI exists to catch what the first one misses, by how models work, not
4
+ * because the coder did it badly. kj names it a review correction with its
5
+ * round count, never a coder error.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { runCommand } from "../utils/process.js";
10
+
11
+ export const correctionNote = (rounds) => (rounds > 0
12
+ ? `review correction: ${rounds} round(s) before this approval, the cross-AI method working`
13
+ : "");
14
+
15
+ /** Rejections on the branch since its last approval, the current record aside. */
16
+ export function rejectionsBeforeApproval(records, { branch, hash }) {
17
+ const mine = records
18
+ .filter((r) => r.branch === branch && r.diffHash !== hash)
19
+ .toSorted((a, b) => String(a.timestamp).localeCompare(String(b.timestamp)));
20
+ let rounds = 0;
21
+ for (const r of mine) rounds = r.verdict === "approved" ? 0 : rounds + 1;
22
+ return rounds;
23
+ }
24
+
25
+ export async function branchOf(projectDir, run = runCommand) {
26
+ const res = await run("git", ["-C", projectDir, "rev-parse", "--abbrev-ref", "HEAD"]);
27
+ return res.exitCode === 0 ? res.stdout.trim() : null;
28
+ }
29
+
30
+ export async function reviewCorrectionRounds(projectDir, record) {
31
+ if (!record?.branch) return 0;
32
+ const dir = path.join(projectDir, ".karajan", "reviews");
33
+ const names = await fs.readdir(dir).catch(() => []);
34
+ const records = [];
35
+ for (const name of names.filter((n) => n.endsWith(".json"))) {
36
+ // A report, not a gate: an unreadable verdict file is left out of the count.
37
+ try { records.push(JSON.parse(await fs.readFile(path.join(dir, name), "utf8"))); } catch { /* see above */ }
38
+ }
39
+ return rejectionsBeforeApproval(records, { branch: record.branch, hash: record.diffHash });
40
+ }
@@ -12,12 +12,13 @@ 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 } from "./parser.js";
15
+ import { parseMaybeJsonString, normalizeReviewPayload, agentAnswerText } 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";
19
19
  import { reportUnparseableVerdict } from "./unparseable-verdict.js";
20
20
  import { detectWorkspace } from "./workspace.js";
21
+ import { branchOf } from "./correction-rounds.js";
21
22
  import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
22
23
 
23
24
  // Cross-AI preference when the configured reviewer IS the host.
@@ -116,7 +117,7 @@ export async function runOneShotReview({
116
117
  // back, wrapper and all. Yesterday's fix taught normalizeReviewPayload to unwrap {ok, result},
117
118
  // but this path never called it, so the bug survived with a green test on the wrong function.
118
119
  // A test can only prove the code it actually exercises.
119
- const parsed = normalizeReviewPayload(parseMaybeJsonString(result.output));
120
+ const parsed = normalizeReviewPayload(parseMaybeJsonString(agentAnswerText(result.output)));
120
121
  if (!parsed || typeof parsed.approved !== "boolean") {
121
122
  // KJC-BUG-0146: keep the answer. Throwing it away is what left eight occurrences undiagnosed.
122
123
  throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
@@ -128,6 +129,8 @@ export async function runOneShotReview({
128
129
  host: hostAgent || null,
129
130
  // KJC-TSK-0680: where the review ran — makes any isolation claim auditable.
130
131
  workspace: await detectWorkspace(projectDir),
132
+ // KJC-TSK-1017: the branch lets an approval count the corrections before it.
133
+ branch: await branchOf(projectDir),
131
134
  issues: parsed.blocking_issues || [],
132
135
  suggestions: parsed.non_blocking_suggestions || [],
133
136
  summary: parsed.summary || parsed.raw_summary || "",
@@ -10,6 +10,22 @@ export function parseMaybeJsonString(value) {
10
10
  return extractFirstJson(value);
11
11
  }
12
12
 
13
+ /**
14
+ * KJC-BUG-0357 (#2151): claude's reviewTask answers with its whole stream-json
15
+ * event stream, which opens with hook and init events, so its first JSON is no
16
+ * answer. The answer is the text of the final `result` event; any other output
17
+ * is returned as it came.
18
+ */
19
+ export function agentAnswerText(raw) {
20
+ if (typeof raw !== "string") return raw;
21
+ const lines = raw.trim().split("\n");
22
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
23
+ const event = extractFirstJson(lines[i]);
24
+ if (event?.type === "result" && typeof event.result === "string") return event.result;
25
+ }
26
+ return raw;
27
+ }
28
+
13
29
  function isReviewPayload(obj) {
14
30
  return obj?.approved !== undefined && obj?.blocking_issues !== undefined;
15
31
  }
@@ -13,7 +13,7 @@
13
13
  * reject keeps it closed. Both record the full conflict for audit.
14
14
  */
15
15
  import { createAgent } from "../agents/index.js";
16
- import { parseMaybeJsonString } from "./parser.js";
16
+ import { parseMaybeJsonString, agentAnswerText } from "./parser.js";
17
17
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
18
18
  import { diffHash, loadVerdict, saveVerdict } from "./verdict-store.js";
19
19
  import { summarizeGeneratedFiles } from "./prompt-diff.js";
@@ -95,7 +95,7 @@ export async function runSolomonArbitration({
95
95
  : `No other agent CLI on this machine can arbitrate (needs one distinct from brain "${hostAgent}" and reviewer "${verdict.reviewer}") — install a third agent CLI or fix ${solomon}.`;
96
96
  throw new Error(`solomon ${solomon} failed: ${String(result?.error || "no output").slice(0, 400)}\n${hint}`);
97
97
  }
98
- const parsed = parseMaybeJsonString(result.output);
98
+ const parsed = parseMaybeJsonString(agentAnswerText(result.output));
99
99
  if (!parsed || !["approve", "reject"].includes(parsed.ruling)) {
100
100
  throw new Error(`solomon ${solomon} returned no parseable ruling`);
101
101
  }