karajan-code 4.49.1 → 4.49.3
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/agents/claude-agent.js +16 -10
- package/src/brain/agent-error-classifier.js +9 -0
- package/src/brain/with-brain-recovery.js +4 -1
- package/src/checks/release-check.js +27 -1
- package/src/environment/contract-commit.js +3 -0
- package/src/harden/sentinel/sentinel-shell.mjs +32 -0
- package/src/harden/sentinel-hooks.js +17 -14
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +2 -2
- package/src/orchestrator/drivers/pre-loop.js +19 -1
- package/src/orchestrator/drivers/run-hu-batch.js +11 -0
- package/src/orchestrator/stages/suite-green-stage.js +5 -0
- package/src/policy/engine.js +5 -1
- package/src/privacy/scan.js +17 -0
- package/src/review/loc-budget.js +12 -2
- package/src/review/one-shot-review.js +3 -1
- package/src/review/prompt-diff.js +26 -0
- package/src/review/solomon-arbitration.js +3 -1
- package/src/sonar/api.js +9 -2
package/package.json
CHANGED
|
@@ -289,10 +289,12 @@ export function createStreamJsonFilter(onOutput) {
|
|
|
289
289
|
* sessions. The spawned `claude -p` is a separate non-interactive
|
|
290
290
|
* invocation, not a true nested session.
|
|
291
291
|
*
|
|
292
|
-
* 2.
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
292
|
+
* 2. Never inherit stdin — When launched from Node.js (which is how Claude
|
|
293
|
+
* Code / Karajan MCP runs), an inherited stdin made `claude -p` block
|
|
294
|
+
* reading what the parent was consuming. KJC-BUG-0344: the prompt now
|
|
295
|
+
* travels ON stdin (input), which also closes it, so there is nothing to
|
|
296
|
+
* wait on, and a large prompt no longer hits the kernel's per-argument
|
|
297
|
+
* limit (E2BIG).
|
|
296
298
|
*
|
|
297
299
|
* 3. Claude Code 2.x writes all structured output (stream-json, json) to
|
|
298
300
|
* stderr, NOT stdout. The agent must read from stderr for the actual
|
|
@@ -303,7 +305,7 @@ function cleanExecaOpts(extra = {}) {
|
|
|
303
305
|
// PAR-H (KJC-TSK-0631): extra.env ADDS to the inherited env (lane slot
|
|
304
306
|
// vars) instead of replacing it wholesale.
|
|
305
307
|
const { env: extraEnv, ...rest } = extra;
|
|
306
|
-
return { env: extraEnv ? { ...env, ...extraEnv } : env,
|
|
308
|
+
return { env: extraEnv ? { ...env, ...extraEnv } : env, ...rest };
|
|
307
309
|
}
|
|
308
310
|
|
|
309
311
|
/**
|
|
@@ -332,11 +334,12 @@ const ALLOWED_TOOLS = [
|
|
|
332
334
|
* the volatile block goes in the -p message. Without the split, legacy
|
|
333
335
|
* single-prompt behavior is unchanged.
|
|
334
336
|
*/
|
|
337
|
+
// KJC-BUG-0344: the -p message goes on stdin (`input`), never as an argument.
|
|
335
338
|
function buildPromptArgs(task) {
|
|
336
339
|
if (task.stablePrompt && task.volatilePrompt) {
|
|
337
|
-
return ["-p",
|
|
340
|
+
return { args: ["-p", "--append-system-prompt", task.stablePrompt, "--allowedTools", ...ALLOWED_TOOLS], input: task.volatilePrompt };
|
|
338
341
|
}
|
|
339
|
-
return ["-p",
|
|
342
|
+
return { args: ["-p", "--allowedTools", ...ALLOWED_TOOLS], input: String(task.prompt ?? "") };
|
|
340
343
|
}
|
|
341
344
|
|
|
342
345
|
export class ClaudeAgent extends BaseAgent {
|
|
@@ -353,7 +356,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
353
356
|
}
|
|
354
357
|
|
|
355
358
|
async _runTaskExec(task, model, _role) {
|
|
356
|
-
const args = buildPromptArgs(task);
|
|
359
|
+
const { args, input } = buildPromptArgs(task);
|
|
357
360
|
if (model) args.push("--model", model);
|
|
358
361
|
// PL-C (KJC-TSK-0735): el rol del subproceso viaja en KJ_POLICY_ROLE —
|
|
359
362
|
// si su harness corre hooks (claude -p), el tier A evalúa la policy con
|
|
@@ -366,6 +369,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
366
369
|
args.push("--output-format", "stream-json", "--verbose");
|
|
367
370
|
const streamFilter = createStreamJsonFilter(task.onOutput);
|
|
368
371
|
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({
|
|
372
|
+
input,
|
|
369
373
|
onOutput: streamFilter,
|
|
370
374
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
371
375
|
timeout: task.timeoutMs,
|
|
@@ -380,7 +384,7 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
380
384
|
|
|
381
385
|
// Without streaming, use json output to get structured response via stderr
|
|
382
386
|
args.push("--output-format", "json");
|
|
383
|
-
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ env: roleEnv, cwd: task.cwd }));
|
|
387
|
+
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({ input, env: roleEnv, cwd: task.cwd }));
|
|
384
388
|
const raw = pickOutput(res);
|
|
385
389
|
const output = extractTextFromStreamJson(raw);
|
|
386
390
|
const usage = extractUsageFromStreamJson(raw);
|
|
@@ -388,9 +392,11 @@ export class ClaudeAgent extends BaseAgent {
|
|
|
388
392
|
}
|
|
389
393
|
|
|
390
394
|
async _reviewTaskExec(task, model) {
|
|
391
|
-
const
|
|
395
|
+
const prompt = buildPromptArgs(task);
|
|
396
|
+
const args = [...prompt.args, "--output-format", "stream-json", "--verbose"];
|
|
392
397
|
if (model) args.push("--model", model);
|
|
393
398
|
const res = await this.runCommand(resolveBin("claude"), args, cleanExecaOpts({
|
|
399
|
+
input: prompt.input,
|
|
394
400
|
onOutput: task.onOutput,
|
|
395
401
|
silenceTimeoutMs: task.silenceTimeoutMs,
|
|
396
402
|
timeout: task.timeoutMs,
|
|
@@ -26,6 +26,9 @@ export const ERROR_CLASS = Object.freeze({
|
|
|
26
26
|
// un problema de cuota — es motivo para tomar el siguiente eslabón de la
|
|
27
27
|
// cadena declarada. No tiene cooldown: esperar no lo va a resucitar.
|
|
28
28
|
MODEL_UNAVAILABLE: "MODEL_UNAVAILABLE",
|
|
29
|
+
// KJC-BUG-0327: the OS refused the invocation (E2BIG: the prompt, passed as an
|
|
30
|
+
// argument, is too large). The same command will fail again: no waiting.
|
|
31
|
+
INVOCATION_TOO_LARGE: "INVOCATION_TOO_LARGE",
|
|
29
32
|
API_DOWN: "API_DOWN",
|
|
30
33
|
AUTH_FAILED: "AUTH_FAILED",
|
|
31
34
|
NETWORK_TIMEOUT: "NETWORK_TIMEOUT",
|
|
@@ -74,6 +77,12 @@ export function classifyAgentError({ provider = "unknown", stdout = "", stderr =
|
|
|
74
77
|
const combined = `${stderr}\n${stdout}`;
|
|
75
78
|
const base = { provider, exitCode, retryAfter: null, retryUntil: null };
|
|
76
79
|
|
|
80
|
+
// 0. KJC-BUG-0327 (#2087): E2BIG first. The error echoes the command, prompt
|
|
81
|
+
// included, so any word the prompt carries (rate limit, 401...) would win.
|
|
82
|
+
if (/\bE2BIG\b|argument list too long/i.test(combined)) {
|
|
83
|
+
return { ...base, class: ERROR_CLASS.INVOCATION_TOO_LARGE, message: "the prompt is too large to pass as a command argument (E2BIG)", recoverable: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
77
86
|
// 1. AUTH_FAILED tiene prioridad absoluta — no es recuperable, escalar al usuario.
|
|
78
87
|
if (AUTH_PATTERNS.test(combined)) {
|
|
79
88
|
return { ...base, class: ERROR_CLASS.AUTH_FAILED, message: pickMessage(combined, AUTH_PATTERNS), recoverable: false };
|
|
@@ -36,6 +36,8 @@ export const DEFAULT_RECOVERY_POLICY = Object.freeze({
|
|
|
36
36
|
// the next link straight away. Without a chain it aborts, because
|
|
37
37
|
// retrying the same dead model is how a run burns quota saying nothing.
|
|
38
38
|
[ERROR_CLASS.MODEL_UNAVAILABLE]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
|
|
39
|
+
// KJC-BUG-0327: the same oversized invocation fails again; take the next link.
|
|
40
|
+
[ERROR_CLASS.INVOCATION_TOO_LARGE]: { mode: "abort", maxRetries: 0, fallbackEligible: true, fallbackImmediate: true },
|
|
39
41
|
[ERROR_CLASS.API_DOWN]: { mode: "backoff", maxRetries: 3, baseMs: 5_000, factor: 3, jitterPct: 0.2 },
|
|
40
42
|
[ERROR_CLASS.NETWORK_TIMEOUT]: { mode: "backoff", maxRetries: 3, baseMs: 5_000, factor: 3, jitterPct: 0.2 },
|
|
41
43
|
[ERROR_CLASS.SILENCED]: { mode: "backoff", maxRetries: 2, baseMs: 30_000, factor: 2, jitterPct: 0.15 },
|
|
@@ -144,7 +146,8 @@ export async function withBrainRecovery({
|
|
|
144
146
|
const attempt = attemptsByClass[cls.class];
|
|
145
147
|
|
|
146
148
|
emit(emitter, "brain:agent-error", eventBase, { role, class: cls.class, attempt, message: cls.message, provider: effectiveProvider });
|
|
147
|
-
|
|
149
|
+
// KJC-BUG-0327: "attempt 4/3" read as one over budget; it is failure 4 with 3 retries allowed.
|
|
150
|
+
logger?.warn?.(`[brain] ${role} (${effectiveProvider}) → ${cls.class} failure ${attempt} (retries allowed: ${classPolicy.maxRetries}): ${cls.message}`);
|
|
148
151
|
|
|
149
152
|
// KJC-TSK-0859: un modelo retirado se resuelve ANTES de abortar. No hay
|
|
150
153
|
// cooldown que esperar, así que la condición de la cadena por cuota
|
|
@@ -16,11 +16,37 @@ import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
|
16
16
|
// policy IN FORCE now, not the one each PR was merged under. Artifact rules
|
|
17
17
|
// only: diff-threshold invariants are PR-scoped by definition — skipped, and
|
|
18
18
|
// said — so no line metric is needed (without a tag, the whole tree counts).
|
|
19
|
+
/** The commit that introduced the kj pre-commit hook (kj setup), or null. */
|
|
20
|
+
async function setupCommitOf(projectDir) {
|
|
21
|
+
const res = await runCommand("git", ["-C", projectDir, "log", "--diff-filter=A", "--format=%H", "--", ".karajan/hooks/pre-commit"]);
|
|
22
|
+
if (res.exitCode !== 0) return null;
|
|
23
|
+
return res.stdout.trim().split("\n").filter(Boolean).at(-1) ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Supervisor violations on files the setup commit added and nothing changed since. */
|
|
27
|
+
async function withoutSetupInstalls(projectDir, setup, violations) {
|
|
28
|
+
const added = await runCommand("git", ["-C", projectDir, "diff-tree", "--root", "--no-commit-id", "--name-only", "-r", "--diff-filter=A", setup]);
|
|
29
|
+
const installed = new Set(added.exitCode === 0 ? added.stdout.split("\n").map((s) => s.trim()).filter(Boolean) : []);
|
|
30
|
+
const kept = [];
|
|
31
|
+
for (const v of violations) {
|
|
32
|
+
if (v.rule_id === "defaults.supervisor.write" && installed.has(v.file)) {
|
|
33
|
+
const changed = await runCommand("git", ["-C", projectDir, "diff", "--quiet", setup, "HEAD", "--", v.file]);
|
|
34
|
+
if (changed.exitCode === 0) continue;
|
|
35
|
+
}
|
|
36
|
+
kept.push(v);
|
|
37
|
+
}
|
|
38
|
+
return kept;
|
|
39
|
+
}
|
|
40
|
+
|
|
19
41
|
async function policyRangeCheck(projectDir) {
|
|
20
42
|
const { policy, errors } = loadPolicy({ projectDir });
|
|
21
43
|
if (errors.length > 0) return { name: "policy", ok: false, detail: `policy.yml invalid — ${errors[0]}` };
|
|
22
44
|
const described = await runCommand("git", ["-C", projectDir, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*"]);
|
|
23
45
|
const tag = described.exitCode === 0 ? described.stdout.trim() : null;
|
|
46
|
+
// KJC-BUG-0342 (#2105): with no tag the whole tree is judged, and the supervisor
|
|
47
|
+
// files the human's kj setup commit installed read as writes. Only those, and only
|
|
48
|
+
// while unchanged since that commit, are the setup and not judged.
|
|
49
|
+
const setup = tag ? null : await setupCommitOf(projectDir);
|
|
24
50
|
const scope = tag ? `${tag}..HEAD` : "whole history (no previous tag)";
|
|
25
51
|
const prScoped = new Set((policy.invariants ?? []).filter((i) => i.kind === "diff-threshold").map((i) => i.id));
|
|
26
52
|
try {
|
|
@@ -37,7 +63,7 @@ async function policyRangeCheck(projectDir) {
|
|
|
37
63
|
// KJC-BUG-0259: the history is judged on what is versioned (hooks, provenance);
|
|
38
64
|
// the machine-local guards were verified when the seal was reviewed.
|
|
39
65
|
const sup = liftSealedSupervisorViolations({ projectDir, violations: raw, trackedOnly: true });
|
|
40
|
-
const violations = sup.violations;
|
|
66
|
+
const violations = setup ? await withoutSetupInstalls(projectDir, setup, sup.violations) : sup.violations;
|
|
41
67
|
const hard = violations.filter((v) => v.enforcement === "deny");
|
|
42
68
|
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
|
43
69
|
if (hard.length > 0) {
|
|
@@ -32,6 +32,9 @@ export const CONTRACT_PATHS = [
|
|
|
32
32
|
".karajan/supervisor-signers.json",
|
|
33
33
|
// KJC-TSK-0995 (HUM-D, ADR 0018): the security level the whole team inherits.
|
|
34
34
|
".karajan/security-level.json",
|
|
35
|
+
// KJC-BUG-0346: kj env install wires kj_rag_query as a native tool here; a clone
|
|
36
|
+
// without it loses the tool. Only committed when kj changed it in this run.
|
|
37
|
+
".mcp.json",
|
|
35
38
|
];
|
|
36
39
|
|
|
37
40
|
const FRESH_MESSAGE = "chore(bootstrap): el contrato del método, para que quien clone lo herede";
|
|
@@ -86,6 +86,38 @@ export const isGhIssueCreate = (cmd) => shellSegments(cmd).some((words) => {
|
|
|
86
86
|
return subcommand[0] === "issue" && subcommand[1] === "create";
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
+
// KJC-BUG-0204: the real command puts flags between the verb's words
|
|
90
|
+
// (firebase --account a@b deploy), so they are read in order, not adjacent.
|
|
91
|
+
const PUBLISH_VERBS = [["npm", "publish"], ["firebase", "deploy"], ["gh", "release", "create"]];
|
|
92
|
+
// Known options that take a separate value: that value is not a subcommand word
|
|
93
|
+
// (npm --tag publish test does not publish). An unknown option is not skipped, so
|
|
94
|
+
// a real publish behind it is still read (a false positive costs less than a miss).
|
|
95
|
+
const VALUE_OPTIONS = {
|
|
96
|
+
npm: ["--prefix", "--tag", "--registry", "--userconfig", "--workspace", "-w", "--otp", "--access", "--cache", "--loglevel", "-C"],
|
|
97
|
+
firebase: ["--account", "--project", "-P", "--config", "-c", "--only", "--except", "--token", "--message", "-m"],
|
|
98
|
+
gh: ["--repo", "-R", "--hostname"],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* KJC-BUG-0333 (#2093): a simple command whose head is npm, firebase or gh and
|
|
103
|
+
* whose own words then say publish/deploy/release create. Words inside a quoted
|
|
104
|
+
* argument of another command (a kj rag query text, a commit message) are one
|
|
105
|
+
* word of THAT command, so they never read as a publish.
|
|
106
|
+
* @param {string} cmd
|
|
107
|
+
*/
|
|
108
|
+
export const isPublishCommand = (cmd) => shellSegments(cmd).some((words) => PUBLISH_VERBS.some(([head, ...rest]) => {
|
|
109
|
+
const i = headIndex(words, [head]);
|
|
110
|
+
if (words[i]?.split("/").at(-1) !== head) return false;
|
|
111
|
+
let k = 0;
|
|
112
|
+
let skipValue = false;
|
|
113
|
+
for (const w of words.slice(i + 1)) {
|
|
114
|
+
if (skipValue) { skipValue = false; continue; }
|
|
115
|
+
if (VALUE_OPTIONS[head].includes(w)) { skipValue = true; continue; }
|
|
116
|
+
if (w === rest[k] && ++k === rest.length) return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}));
|
|
120
|
+
|
|
89
121
|
// KJC-BUG-0286: a $ expands only when something expandable follows it ($x, ${x},
|
|
90
122
|
// $(cmd), $1, $@...). A lone "$ " in a grep pattern is a literal dollar sign.
|
|
91
123
|
const LIVE_EXPANSION = /\$(?=[A-Za-z0-9_{(@*#?$!-])|`/;
|
|
@@ -473,20 +473,14 @@ import { homedir } from "node:os";
|
|
|
473
473
|
import { fileURLToPath } from "node:url";
|
|
474
474
|
import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
|
|
475
475
|
// KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
|
|
476
|
-
import { isGhIssueCreate, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
476
|
+
import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
|
|
477
477
|
import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
|
|
478
478
|
import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
|
|
479
479
|
const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
|
|
480
|
-
// KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
|
|
481
|
-
// (firebase --account a@b --project p deploy --only hosting:main), asi que la
|
|
482
|
-
// adyacencia dejaba pasar sin mirar justo el despliegue que el gate vigila.
|
|
483
|
-
// Las palabras del verbo tienen que aparecer en orden, no pegadas.
|
|
484
|
-
const PUBLISH_VERBS = [["npm", "publish"], ["firebase", "deploy"], ["gh", "release", "create"]];
|
|
485
|
-
// Cada palabra se lee por su basename: /usr/bin/npm publish es la misma
|
|
486
|
-
// publicacion que npm publish (catch de la review).
|
|
487
|
-
const wordsOf = (text) => String(text).split(/[^A-Za-z0-9_.@:/-]+/).filter(Boolean).map((w) => w.slice(w.lastIndexOf("/") + 1)).filter(Boolean);
|
|
488
480
|
const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[i] && ++i === want.length) return true; return false; };
|
|
489
|
-
|
|
481
|
+
// KJC-BUG-0333: a publish is read per simple command by the shell reader, so
|
|
482
|
+
// words inside a quoted argument of another command are not a publish.
|
|
483
|
+
const isPublish = isPublishCommand;
|
|
490
484
|
const PUSH = /\\bgit\\s+push\\b/;
|
|
491
485
|
const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
|
|
492
486
|
let raw = "";
|
|
@@ -565,15 +559,22 @@ process.stdin.on("end", () => {
|
|
|
565
559
|
const ghCmd = String(input.command || "");
|
|
566
560
|
if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
|
|
567
561
|
const attrib = /co-authored-by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated with [[]?claude|🤖/is;
|
|
568
|
-
|
|
562
|
+
// KJC-BUG-0340: a body file's PATH is not published text (a scratch dir
|
|
563
|
+
// under .../claude-1000/... read as an attribution next to a title saying
|
|
564
|
+
// "generated by kj"); its content is. Each piece is judged on its own, so a
|
|
565
|
+
// match never spans from the command into a file.
|
|
566
|
+
const pieces = [];
|
|
567
|
+
let cmdText = ghCmd;
|
|
569
568
|
// KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
|
|
570
569
|
for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
|
|
571
|
-
|
|
570
|
+
cmdText = cmdText.split(bodyPath).join(" ");
|
|
571
|
+
try { pieces.push(readFileSync(bodyPath, "utf8")); } catch {
|
|
572
572
|
console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
|
|
573
573
|
process.exit(2);
|
|
574
574
|
}
|
|
575
575
|
}
|
|
576
|
-
|
|
576
|
+
pieces.push(cmdText);
|
|
577
|
+
if (pieces.some((p) => attrib.test(p))) {
|
|
577
578
|
console.error("karajan sentinel: atribucion a IA detectada en contenido a PUBLICAR (PR/issue/release) — prohibida por regla determinista del proyecto: limpia el texto." + doc("attribution"));
|
|
578
579
|
process.exit(2);
|
|
579
580
|
}
|
|
@@ -798,7 +799,9 @@ process.stdin.on("end", () => {
|
|
|
798
799
|
// puede ejecutar cualquier cosa (reviewer catch: grep foo $(rm ...)).
|
|
799
800
|
// (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
|
|
800
801
|
// carril los caza el escaner de abajo.)
|
|
801
|
-
|
|
802
|
+
// KJC-BUG-0338: kj rag query only reads, and the rag-first gate asks for it
|
|
803
|
+
// with a quoted question that names the file.
|
|
804
|
+
const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame)|kj rag query)\\b[^;|&<>$\`(){}\\n\\r]*$/;
|
|
802
805
|
// KJC-BUG-0286 (#1982): a command whose EVERY segment only reads mutates
|
|
803
806
|
// nothing, however it is chained (grep | grep, git show | grep, cd && git
|
|
804
807
|
// status). A cd with a literal target is one such segment: the paths after
|
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
import { runImpeccableStage } from "../../post-loop-stages.js";
|
|
22
22
|
import { runPerfStage } from "../../stages/perf-stage.js";
|
|
23
23
|
import { runTddDisciplineStage } from "../../stages/tdd-discipline-stage.js";
|
|
24
|
-
import { runSuiteGreenStage } from "../../stages/suite-green-stage.js";
|
|
24
|
+
import { runSuiteGreenStage, suiteGreenRequired } from "../../stages/suite-green-stage.js";
|
|
25
25
|
import { runToolJudgeStage } from "../../stages/tool-judge-stage.js";
|
|
26
26
|
import { tryCiComment } from "../../ci-integration.js";
|
|
27
27
|
|
|
@@ -31,7 +31,7 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
|
|
|
31
31
|
if (tddResult.action === "continue") return { action: "continue" };
|
|
32
32
|
|
|
33
33
|
// KJC-BUG-0298 (#1993): with TDD, a red suite never reaches the reviewer.
|
|
34
|
-
if (config.development?.methodology === "tdd" && config
|
|
34
|
+
if (config.development?.methodology === "tdd" && suiteGreenRequired(config)) {
|
|
35
35
|
const green = await runSuiteGreenStage({ config, logger, emitter, eventBase, session, iteration: i });
|
|
36
36
|
if (green.action === "continue") return { action: "continue" };
|
|
37
37
|
}
|
|
@@ -62,6 +62,23 @@ import {
|
|
|
62
62
|
import { tryCiComment } from "../ci-integration.js";
|
|
63
63
|
import { getIntegration } from "../integrations.js";
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* KJC-BUG-0332 (#2092, root of #2090/#2091): a plan run implements the plan's
|
|
67
|
+
* certified HUs. Triaging the plan's original task auto-generated other HUs
|
|
68
|
+
* (HU-01..) that ran instead, and could call it analysis-only, skipping the
|
|
69
|
+
* coder and approving an empty diff. In a plan run there is no triage; each HU
|
|
70
|
+
* carries its own task type, which the HU batch applies.
|
|
71
|
+
*/
|
|
72
|
+
export async function triageForRun({ flags, runTriage }) {
|
|
73
|
+
if (flags?.plan) {
|
|
74
|
+
return {
|
|
75
|
+
roleOverrides: {}, // applyTriageOverrides reads its keys: no override, never null
|
|
76
|
+
stageResult: { ok: true, taskType: "sw", level: null, roles: [], shouldDecompose: false, subtasks: [], fromPlan: flags.plan, summary: "plan run: the plan's HUs are the work" },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return (await runTriage()) ?? { roleOverrides: null, stageResult: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
65
82
|
export async function runPreLoopStages({ config, logger, emitter, eventBase, session, flags, pipelineFlags, coderRole, trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults, brainCtx }) {
|
|
66
83
|
// KJC-BUG-0058: on `kj resume` `stageResults` is rehydrated from
|
|
67
84
|
// `session.stage_results`. Persist after every successful pre-loop stage
|
|
@@ -121,7 +138,8 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
|
|
|
121
138
|
// stages it gates (researcher, architect, planner) ARE skipped if
|
|
122
139
|
// already complete.
|
|
123
140
|
const triageCtx = { config, logger, emitter, eventBase, session, coderRole, trackBudget, pipelineFlags };
|
|
124
|
-
const triageResult = await runStage(stageRegistry.get("triage"), triageCtx)
|
|
141
|
+
const triageResult = await triageForRun({ flags, runTriage: () => runStage(stageRegistry.get("triage"), triageCtx) });
|
|
142
|
+
if (triageResult.stageResult?.fromPlan) logger.info(`Triage skipped: plan ${flags.plan} runs its own certified HUs`);
|
|
125
143
|
applyTriageOverrides(pipelineFlags, triageResult.roleOverrides);
|
|
126
144
|
await persistStage("triage", triageResult.stageResult);
|
|
127
145
|
|
|
@@ -26,6 +26,7 @@ import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
|
26
26
|
import { needsSubPipeline, runHuSubPipeline } from "../hu-sub-pipeline.js";
|
|
27
27
|
import { runCoderStage } from "../stages/coder-stage.js";
|
|
28
28
|
import { runSonarStage } from "../stages/sonar-stage.js";
|
|
29
|
+
import { runSuiteGreenStage, suiteGreenRequired } from "../stages/suite-green-stage.js";
|
|
29
30
|
import { runIterationLoop } from "./iteration-loop.js";
|
|
30
31
|
import { classifyFailure, createFailureTracker } from "../repair/repair-detector.js";
|
|
31
32
|
import { runRepair } from "../repair/repair-runner.js";
|
|
@@ -235,6 +236,16 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
|
|
|
235
236
|
}
|
|
236
237
|
}
|
|
237
238
|
|
|
239
|
+
// KJC-BUG-0328 (#2088): a code HU is never approved over a red project
|
|
240
|
+
// suite; it goes back to the coder, as in the iteration loop.
|
|
241
|
+
if ((huPolicies.tdd || huPolicies.testsRequired) && suiteGreenRequired(laneConfig)) {
|
|
242
|
+
const green = await runSuiteGreenStage({ config: laneConfig, logger, emitter, eventBase: ctx.eventBase, session: laneSession, iteration: attempt });
|
|
243
|
+
if (green.action === "continue") {
|
|
244
|
+
lanePlannedTask = `${huTask}\n\n--- TEST SUITE RED ---\n${laneSession.last_reviewer_feedback}`;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
238
249
|
// Brain runs acceptance tests
|
|
239
250
|
logger.info(`HU ${story.id}: running ${story.acceptance_tests.length} acceptance tests`);
|
|
240
251
|
emitProgress(emitter, makeEvent("hu:acceptance-start", { ...ctx.eventBase, stage: "acceptance" }, {
|
|
@@ -17,6 +17,11 @@ import { resolveCmds } from "../../commands/harden.js";
|
|
|
17
17
|
const TAIL_CHARS = 2000;
|
|
18
18
|
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
19
19
|
|
|
20
|
+
/** Whether the project asks for a green suite (opt-out, and off in the test harness). */
|
|
21
|
+
export function suiteGreenRequired(config) {
|
|
22
|
+
return config?.development?.require_green_suite !== false && config?.testHarness?.disableSuiteGreenStage !== true;
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
/** @returns {Promise<{action: "ok"|"continue", result: object}>} */
|
|
21
26
|
export async function runSuiteGreenStage({ config, logger, emitter, eventBase, session, iteration, resolveCmdsImpl = resolveCmds, runTestsImpl = null }) {
|
|
22
27
|
const projectDir = config?.projectDir || process.cwd();
|
package/src/policy/engine.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { stripTextOptionValues } from "../harden/sentinel/sentinel-shell.mjs";
|
|
12
13
|
import {
|
|
13
14
|
ALLOW_VERDICT,
|
|
14
15
|
checkArtifacts,
|
|
@@ -72,7 +73,10 @@ export function loadPolicy({ projectDir = process.cwd(), deps = {} } = {}) {
|
|
|
72
73
|
* es no-verificable ⇒ deny (roles sin declarar quedan como hoy). */
|
|
73
74
|
export function evalToolCall(policy, { role = "coder", tool, input = {}, root = null }) {
|
|
74
75
|
if (WRITE_TOOLS.has(tool)) return evalWrite(policy, role, input.file_path || input.notebook_path, root);
|
|
75
|
-
|
|
76
|
+
// KJC-BUG-0343: the inert prose of a text option (--title, --error, -m...) is
|
|
77
|
+
// not a target: a report naming a supervisor path is not a write to it. A path
|
|
78
|
+
// anywhere else, or a value that can expand, is still judged.
|
|
79
|
+
if (tool === "Bash") return evalShell(policy, role, stripTextOptionValues(String(input.command ?? "")));
|
|
76
80
|
if (READONLY_TOOLS.has(tool) || !policy.roles?.[role]) return ALLOW_VERDICT;
|
|
77
81
|
return denyVerdict(`roles.${role}.tools`, `tool "${tool}" fuera del registro de la policy — no verificable para el rol declarado ${role}`);
|
|
78
82
|
}
|
package/src/privacy/scan.js
CHANGED
|
@@ -70,6 +70,17 @@ const SECRET_HEURISTICS = [
|
|
|
70
70
|
const FIREBASE_WEB_MARKERS = /firebaseConfig|authDomain|firebasestorage|firestore\.googleapis\.com|firebaseapp\.com/;
|
|
71
71
|
const FIREBASE_KEY_HINT = "si es la clave web de Firebase (publica por diseno): declarala en el allow de ~/.karajan/privacy.yml y restringela por dominio en la consola de Google";
|
|
72
72
|
|
|
73
|
+
// KJC-BUG-0329 (#2089): a lockfile dependency named *-token/*-auth/*-secret has a
|
|
74
|
+
// version for value. Only a WHOLE value made of versions (digits.x[.y], optional
|
|
75
|
+
// prerelease) joined by range operators is discarded (and counted); digits with
|
|
76
|
+
// no dot, or a version glued to text, are still a secret assignment.
|
|
77
|
+
// Prerelease and build metadata with the full SemVer charset (1.2.3-beta-1+build.5).
|
|
78
|
+
const SEMVER_TOKEN = String.raw`\d+\.(?:\d+|x|\*)(?:\.(?:\d+|x|\*))?(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
|
|
79
|
+
const SEMVER_OP = String.raw`(?:[\^~]|[<>]=?|=)`;
|
|
80
|
+
// Between two versions there is always an operator (||, a hyphen range, or a comparator).
|
|
81
|
+
const SEMVER_JOIN = String.raw`\s*(?:(?:\|\||-)\s*${SEMVER_OP}?|${SEMVER_OP})\s*`;
|
|
82
|
+
const SEMVER_VALUE = new RegExp(String.raw`^\s*${SEMVER_OP}?\s*${SEMVER_TOKEN}(?:${SEMVER_JOIN}${SEMVER_TOKEN})*\s*$`);
|
|
83
|
+
|
|
73
84
|
const CONTEXT_DISCARDS = [
|
|
74
85
|
{ type: "git-sha", re: /\b(?:[0-9a-f]{64}|[0-9a-f]{40})\b/g },
|
|
75
86
|
{ type: "doc-domain-email", re: /\b[A-Za-z0-9._%+-]+@(?:[A-Za-z0-9-]+\.)*(?:example\.(?:com|org|net)|test|invalid|localhost|example)\b/g },
|
|
@@ -138,6 +149,12 @@ export function scanText(text, { list = loadPrivacyList(), source = "<text>" } =
|
|
|
138
149
|
re.lastIndex = 0;
|
|
139
150
|
let m;
|
|
140
151
|
while ((m = re.exec(probe)) !== null) {
|
|
152
|
+
if (type === "secret-assignment" && SEMVER_VALUE.test(m[1])) {
|
|
153
|
+
discarded += 1;
|
|
154
|
+
probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
|
|
155
|
+
re.lastIndex = m.index + m[0].length;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
141
158
|
findings.push({ severity: "warn", type, source, line: i + 1, masked: `${maskValue(m[1])} — move it to .env or a secret manager` });
|
|
142
159
|
probe = probe.slice(0, m.index) + " ".repeat(m[0].length) + probe.slice(m.index + m[0].length);
|
|
143
160
|
re.lastIndex = m.index + m[0].length;
|
package/src/review/loc-budget.js
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* unbounded growth there dilutes the signal the agent receives.
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
// Generated or vendored: nobody typed it. KJC-BUG-0347: also what the review and
|
|
23
|
+
// Solomon prompts summarize instead of pasting whole.
|
|
24
|
+
const GENERATED = [
|
|
24
25
|
/(^|\/)dist\//,
|
|
25
26
|
/(^|\/)build\//,
|
|
26
27
|
/(^|\/)coverage\//,
|
|
@@ -35,6 +36,10 @@ const EXEMPT = [
|
|
|
35
36
|
/(^|\/)tests\/_diet\//,
|
|
36
37
|
// KJC-BUG-0318: the plan `kj plan share` writes, at the project root only.
|
|
37
38
|
/^\.karajan-shared\/plans\/[^/]+\.json$/,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const EXEMPT = [
|
|
42
|
+
...GENERATED,
|
|
38
43
|
// Human documentation, wherever the project keeps it. The landing's docs
|
|
39
44
|
// moved into the monorepo (MONO-3) and the old root-anchored `docs/` rule
|
|
40
45
|
// stopped matching them, which is how documenting started costing budget.
|
|
@@ -46,6 +51,11 @@ const EXEMPT = [
|
|
|
46
51
|
// The exceptions to the exception: these ARE the agent's context.
|
|
47
52
|
const AI_RULES = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^GEMINI\.md$/, /(^|\/)templates\//];
|
|
48
53
|
|
|
54
|
+
/** Whether nobody typed this file (lockfile, build output, snapshot...). */
|
|
55
|
+
export function isGenerated(file) {
|
|
56
|
+
return Boolean(file) && GENERATED.some((re) => re.test(file));
|
|
57
|
+
}
|
|
58
|
+
|
|
49
59
|
/** @param {string} file @returns {boolean} */
|
|
50
60
|
export function countsTowardBudget(file) {
|
|
51
61
|
if (!file) return false;
|
|
@@ -15,6 +15,7 @@ import { resolveReviewProfile } from "./profiles.js";
|
|
|
15
15
|
import { parseMaybeJsonString, normalizeReviewPayload } from "./parser.js";
|
|
16
16
|
import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
|
|
17
17
|
import { saveVerdict, diffHash } from "./verdict-store.js";
|
|
18
|
+
import { summarizeGeneratedFiles } from "./prompt-diff.js";
|
|
18
19
|
import { reportUnparseableVerdict } from "./unparseable-verdict.js";
|
|
19
20
|
import { detectWorkspace } from "./workspace.js";
|
|
20
21
|
import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
|
|
@@ -69,7 +70,8 @@ export async function runOneShotReview({
|
|
|
69
70
|
const attempt = async (who, cfg = config) => {
|
|
70
71
|
const prompt = await buildReviewerPrompt({
|
|
71
72
|
task: task || "Review the following diff for correctness, security and maintainability.",
|
|
72
|
-
|
|
73
|
+
// KJC-BUG-0347: generated files summarized; the verdict still binds to the full diff.
|
|
74
|
+
diff: summarizeGeneratedFiles(diff), reviewRules: rules, mode: "standard", provider: who, projectDir,
|
|
73
75
|
});
|
|
74
76
|
logger?.info?.(`kj review: host=${hostAgent || "none"} → reviewer=${who} (cross-AI)`);
|
|
75
77
|
return createAgentFn(who, cfg, logger).reviewTask({ prompt, role: "reviewer" });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0347 (#2101): the diff a reviewer or Solomon READS. A generated file
|
|
3
|
+
* (lockfile, build output, snapshot) enters as one summary line instead of its
|
|
4
|
+
* thousands of lines: the reviewer kept losing the real hunks behind a 4800-line
|
|
5
|
+
* package-lock.json, and Solomon's prompt blew the argument limit (E2BIG).
|
|
6
|
+
* The verdict still binds to the full diff; this only shapes the prompt.
|
|
7
|
+
*/
|
|
8
|
+
import { isGenerated } from "./loc-budget.js";
|
|
9
|
+
|
|
10
|
+
const HEADER = /^diff --git a\/(.+?) b\/(.+)$/;
|
|
11
|
+
|
|
12
|
+
/** @param {string} diff unified diff @returns {string} */
|
|
13
|
+
export function summarizeGeneratedFiles(diff) {
|
|
14
|
+
const sections = String(diff).split(/(?=^diff --git )/m);
|
|
15
|
+
let changed = false;
|
|
16
|
+
const out = sections.map((sec) => {
|
|
17
|
+
const file = sec.split("\n", 1)[0].match(HEADER)?.[2];
|
|
18
|
+
if (!file || !isGenerated(file)) return sec;
|
|
19
|
+
changed = true;
|
|
20
|
+
const lines = sec.split("\n");
|
|
21
|
+
const added = lines.filter((l) => l.startsWith("+") && !l.startsWith("+++")).length;
|
|
22
|
+
const removed = lines.filter((l) => l.startsWith("-") && !l.startsWith("---")).length;
|
|
23
|
+
return `diff --git a/${file} b/${file}\n# ${file}: generated file, +${added} -${removed} lines, contents left out of this prompt\n`;
|
|
24
|
+
});
|
|
25
|
+
return changed ? out.join("") : diff;
|
|
26
|
+
}
|
|
@@ -16,6 +16,7 @@ import { createAgent } from "../agents/index.js";
|
|
|
16
16
|
import { parseMaybeJsonString } from "./parser.js";
|
|
17
17
|
import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
|
|
18
18
|
import { diffHash, loadVerdict, saveVerdict } from "./verdict-store.js";
|
|
19
|
+
import { summarizeGeneratedFiles } from "./prompt-diff.js";
|
|
19
20
|
|
|
20
21
|
const SECURITY_PATTERN = /injection|xss|csrf|secret|credential|password|token leak|auth(entication|orization)?|crypt|session|sanitiz|traversal|rce\b/i;
|
|
21
22
|
|
|
@@ -75,7 +76,8 @@ export async function runSolomonArbitration({
|
|
|
75
76
|
"", "## Reviewer's blocking issues",
|
|
76
77
|
JSON.stringify(verdict.issues || [], null, 2),
|
|
77
78
|
"", "## Brain's position", position,
|
|
78
|
-
|
|
79
|
+
// KJC-BUG-0347: generated files summarized; the ruling still binds to the full diff.
|
|
80
|
+
"", "## The diff under dispute", summarizeGeneratedFiles(diff),
|
|
79
81
|
].join("\n");
|
|
80
82
|
|
|
81
83
|
logger?.info?.(`kj solomon: brain=${hostAgent || "?"} vs reviewer=${verdict.reviewer} → arbiter=${solomon}`);
|
package/src/sonar/api.js
CHANGED
|
@@ -126,13 +126,20 @@ export async function getQualityGateStatus(config, projectKey = null) {
|
|
|
126
126
|
|
|
127
127
|
export async function getOpenIssues(config, projectKey = null) {
|
|
128
128
|
const effectiveProjectKey = await resolveSonarProjectKey(config, { projectKey, cwd: queryCwd(config) });
|
|
129
|
-
|
|
129
|
+
// KJC-BUG-0339 (#2099): current SonarQube ignores projectKeys and answered with
|
|
130
|
+
// every project's issues, so the audit of one repo listed others' paths. The
|
|
131
|
+
// project goes in `components` (and projectKeys stays for old servers), and the
|
|
132
|
+
// answer is filtered by the key too: an issue of another project never leaves.
|
|
133
|
+
const key = encodeURIComponent(effectiveProjectKey);
|
|
134
|
+
const body = await sonarFetch(config, `/api/issues/search?components=${key}&projectKeys=${key}&statuses=OPEN`);
|
|
130
135
|
|
|
131
136
|
let parsedIssues = [];
|
|
132
137
|
let parsedRaw = body;
|
|
133
138
|
try {
|
|
134
139
|
const parsed = JSON.parse(body);
|
|
135
|
-
|
|
140
|
+
// A project key may hold colons (Maven-style): match the whole key as a prefix.
|
|
141
|
+
const ownComponent = (c) => c === effectiveProjectKey || String(c ?? "").startsWith(`${effectiveProjectKey}:`);
|
|
142
|
+
parsedIssues = (parsed.issues || []).filter((i) => (i.project != null ? i.project === effectiveProjectKey : ownComponent(i.component)));
|
|
136
143
|
parsedRaw = parsed;
|
|
137
144
|
} catch { /* SonarQube response is not valid JSON */ }
|
|
138
145
|
|