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 +1 -1
- package/src/checks/release-check.js +17 -1
- package/src/cli/register-meta.js +8 -0
- package/src/commands/review-gate.js +4 -2
- package/src/commands/worktree.js +26 -2
- package/src/environment/playbook.js +2 -0
- package/src/hu/lane-prune.js +74 -0
- package/src/mcp/handlers/direct-handlers.js +2 -2
- package/src/orchestrator/stages/reviewer-stage.js +8 -1
- package/src/review/correction-rounds.js +40 -0
- package/src/review/one-shot-review.js +5 -2
- package/src/review/parser.js +16 -0
- package/src/review/solomon-arbitration.js +2 -2
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
}
|
package/src/commands/worktree.js
CHANGED
|
@@ -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
|
-
|
|
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 ?
|
|
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 || "",
|
package/src/review/parser.js
CHANGED
|
@@ -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
|
}
|