karajan-code 4.49.0 → 4.49.2

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.49.0",
3
+ "version": "4.49.2",
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",
@@ -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
- logger?.warn?.(`[brain] ${role} (${effectiveProvider}) → ${cls.class} attempt ${attempt}/${classPolicy.maxRetries}: ${cls.message}`);
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) {
@@ -246,7 +246,11 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
246
246
  const locInv = pol.errors.length === 0
247
247
  ? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
248
248
  : null;
249
- const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_warn ?? 150;
249
+ // KJC-BUG-0326 (#2085): two tiers. ~150 is the ideal (method_gates.pr_size_warn);
250
+ // up to the limit (the policy's net_lines_added max, else method_gates.pr_size_max,
251
+ // 200) is allowed without asking; above the limit, partition.
252
+ const sizeIdeal = config?.method_gates?.pr_size_warn ?? 150;
253
+ const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_max ?? 200;
250
254
  const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
251
255
  if (sizeWarn > 0) {
252
256
  const numstat = await rawDiff(flags.range, ["--numstat"]);
@@ -258,6 +262,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
258
262
  // Said out loud: an exemption nobody sees is indistinguishable from a gate
259
263
  // that is not looking.
260
264
  if (exempt > 0) console.log(`ℓ pr-size: ${exempt} line(s) exempt from the budget (generated output and human docs; AI-rule files always count)`);
265
+ if (added > sizeIdeal && added <= sizeWarn) {
266
+ console.log(`ℓ pr-size: ${added} lines added, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
267
+ }
261
268
  if (added > sizeWarn) {
262
269
  // KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
263
270
  // partitioning is a decision, and it must never split code from its tests.
@@ -265,12 +272,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
265
272
  const sizePolicy = config?.method_gates?.pr_size || "warn";
266
273
  // ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
267
274
  if (sizePolicy === "block") {
268
- const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
275
+ const reason = `${added} lines added${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
269
276
  console.log(`✗ pr-size gate: ${reason}`);
270
277
  process.exitCode = 1;
271
278
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
272
279
  } else {
273
- console.log(`⚠ pr-size: ${added} lines added${split} (guideline ~${sizeWarn}, source: ${sizeSource}) — an oversized warning is not an opinion: partition, or ask your user (method_gates.pr_size: block to harden)`);
280
+ console.log(`⚠ pr-size: ${added} lines added${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
274
281
  }
275
282
  }
276
283
  }
@@ -81,9 +81,11 @@ const DEFAULTS = {
81
81
  card_first_exempt_branches: ["chore/release-"],
82
82
  // MG-B: source changes without a test change — "warn" | "block".
83
83
  tests_with_code: "warn",
84
- // MG-C: informative nudge when the staged diff exceeds this many
85
- // added lines (0 disables). The project's CI owns any hard budget.
84
+ // MG-C + KJC-BUG-0326: ~pr_size_warn added lines is the ideal; up to
85
+ // pr_size_max is allowed without asking; above it, partition (warn, or
86
+ // block with pr_size: "block"). A policy net_lines_added max wins over pr_size_max.
86
87
  pr_size_warn: 150,
88
+ pr_size_max: 200,
87
89
  // CLM-B (claims-with-evidence ADR): the Stop gate crosses the hard data of
88
90
  // the turn's final message against that turn's tool outputs. "off" (adoption
89
91
  // is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
@@ -76,8 +76,8 @@ ${panel ? `${panel}\n` : ""}- Security findings are never overridable — not ev
76
76
  \`kj audit --security\` (zero tokens) and remediate BEFORE the review.
77
77
  - A design decision the card's AC don't cover belongs to the USER: record
78
78
  it as a proposed ADR (\`kj adr add\`) and ask — never bury it in a PR
79
- bullet. An oversized-diff warning is not an opinion either: partition,
80
- or get an explicit OK.
79
+ bullet. Diff size: ~150 net lines of code is the ideal, up to 200 is
80
+ allowed without asking, above 200 partition, always.
81
81
  - A non-trivial plan names at least two approaches — one as if the
82
82
  codebase didn't exist — and says why the winner won. Following the
83
83
  legacy line is a choice, never a default.
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import { commitAll, pushBranch, createPullRequest, hasChanges } from "../utils/git.js";
6
6
  import { runCommand } from "../utils/process.js";
7
+ import { stampStagedVerdict } from "../review/verdict-store.js";
7
8
 
8
9
  /**
9
10
  * Build a git-safe branch name for an HU story.
@@ -184,7 +185,16 @@ export async function finalizeHuCommit({ story, branchName, config, logger, cwd
184
185
  const title = story.title || story.id;
185
186
  const commitMsg = `feat(${story.id}): ${title}`;
186
187
  if (config.git?.auto_commit) {
187
- const commitRes = await commitAll(commitMsg, cwd);
188
+ // KJC-BUG-0323: this commit runs after the HU's reviewer approved, so it
189
+ // records that verdict for the staged diff, as the other post-review commits do.
190
+ const commitRes = await commitAll(commitMsg, cwd, {
191
+ beforeCommit: ({ paths } = {}) => stampStagedVerdict({
192
+ projectDir: cwd || config.projectDir || process.cwd(),
193
+ paths,
194
+ reviewer: config.reviewer || "pipeline-reviewer",
195
+ summary: `kj run HU ${story.id}: reviewer approved`,
196
+ }),
197
+ });
188
198
  if (commitRes) {
189
199
  result.committed = true;
190
200
  logger.info(`HU ${story.id}: committed on '${branchName}'`);
@@ -166,9 +166,28 @@ export function classifyArtifact(searchDir, artifact, altDirs = [searchDir]) {
166
166
  * own root (monorepo-aware, mirroring installConfigsForRoots). `minimal`
167
167
  * profile ships no config, so there is nothing to compare.
168
168
  */
169
+ // KJC-BUG-0335 (#2094): the method's own pieces, not only the configs. Without
170
+ // them the report said "0 missing" while the review gate demanded the Sentinel.
171
+ const METHOD_PIECES = [
172
+ { id: "sentinel", what: "the Sentinel (it records the session, the RAG queries the review gate needs among them)",
173
+ files: [".karajan/harness/pretooluse-sentinel.mjs", ".karajan/harness/posttooluse.mjs", ".karajan/harness/stop.mjs"] },
174
+ { id: "git-hooks", what: "the git hooks", files: [".karajan/hooks/pre-commit", ".karajan/hooks/commit-msg", ".karajan/hooks/pre-push"] },
175
+ ];
176
+
177
+ function methodPiece(projectDir, { id, what, files }) {
178
+ const head = { dir: ".", id, file: files[0], currentVersion: null, managedVersion: null, upToDate: null, improvements: [], mergeable: false };
179
+ const missing = files.filter((f) => !existsSync(join(projectDir, f)));
180
+ return missing.length === 0
181
+ ? { ...head, status: "KJ_MANAGED", foundAt: files[0], recommendation: "keep", rationale: `${what}: installed` }
182
+ : { ...head, status: "MISSING", foundAt: null, missing, recommendation: "install", rationale: `kj harden (without --report) installs ${what}; missing: ${missing.join(", ")}` };
183
+ }
184
+
169
185
  export function compareHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [] } = {}) {
170
186
  if (profile === "minimal") return { artifacts: [] };
171
- const artifacts = UNIVERSAL_CONFIGS.map((cfg) => ({ dir: ".", ...classifyArtifact(projectDir, toArtifact(cfg)) }));
187
+ const artifacts = [
188
+ ...METHOD_PIECES.map((piece) => methodPiece(projectDir, piece)),
189
+ ...UNIVERSAL_CONFIGS.map((cfg) => ({ dir: ".", ...classifyArtifact(projectDir, toArtifact(cfg)) })),
190
+ ];
172
191
  for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
173
192
  const searchDir = dir === "." ? projectDir : join(projectDir, dir);
174
193
  for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
@@ -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
- const isPublish = (cmd) => { const got = wordsOf(cmd); return PUBLISH_VERBS.some((verb) => inOrder(verb, got)); };
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
- let corpus = ghCmd;
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
- try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
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
- if (attrib.test(corpus)) {
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
- const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame))\\b[^;|&<>$\`(){}\\n\\r]*$/;
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
@@ -1423,8 +1426,11 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1423
1426
  };
1424
1427
  const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) => {
1425
1428
  if (keepsItsSeal(name)) {
1426
- deferred.push(name);
1427
- return join(projectDir, ".karajan", "harness", name);
1429
+ const abs = join(projectDir, ".karajan", "harness", name);
1430
+ // KJC-BUG-0325: only a sealed guard the current template would change is
1431
+ // waiting for the human; one identical to it has nothing to advance.
1432
+ if (readFileSync(abs, "utf8") !== body) deferred.push(name);
1433
+ return abs;
1428
1434
  }
1429
1435
  return writeHarnessScript(projectDir, name, body);
1430
1436
  });
@@ -36,6 +36,17 @@ import { getIntegration } from "../integrations.js";
36
36
  import { tryAutoStartBoard } from "./post-loop.js";
37
37
  import { runPreLoopStages } from "./pre-loop.js";
38
38
 
39
+ /**
40
+ * KJC-BUG-0324: the coder is built before the pre-loop, with the run's first
41
+ * config, so what the pre-loop resolves (the Domain Curator's domainContext,
42
+ * the methodology) never reached its prompt. The run and the coder both take
43
+ * the resolved config; the coder is initialized after this.
44
+ */
45
+ export function adoptPreLoopConfig(ctx, updatedConfig) {
46
+ ctx.config = updatedConfig;
47
+ ctx.coderRoleInstance.config = updatedConfig;
48
+ }
49
+
39
50
  export async function initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags }) {
40
51
  // Auto-init .karajan/ if missing (copies coder-rules, review-rules, role templates)
41
52
  const initProjectDir = config.projectDir || process.cwd();
@@ -182,7 +193,7 @@ export async function initFlowContext({ task, config, logger, emitter, askQuesti
182
193
 
183
194
  const preLoopResult = await runPreLoopStages({ config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session, flags, pipelineFlags: ctx.pipelineFlags, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults: ctx.stageResults, brainCtx: ctx.brainCtx });
184
195
  ctx.plannedTask = preLoopResult.plannedTask;
185
- ctx.config = preLoopResult.updatedConfig;
196
+ adoptPreLoopConfig(ctx, preLoopResult.updatedConfig);
186
197
 
187
198
  // --- Session Journal: persist pre-loop outputs + display plan summary ---
188
199
  const reportDir = ctx.config.output?.report_dir || ".reviews";
@@ -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.development?.require_green_suite !== false && config?.testHarness?.disableSuiteGreenStage !== true) {
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) ?? { roleOverrides: null, stageResult: null };
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();
@@ -14,8 +14,22 @@ import { withRetry } from "../utils/retry.js";
14
14
  const DEFAULT_API_URL = "http://localhost:3000/api";
15
15
  const DEFAULT_TIMEOUT_MS = 10000;
16
16
 
17
- function getApiUrl() {
18
- return process.env.PG_API_URL || DEFAULT_API_URL;
17
+ // KJC-BUG-0322: PG_API_URL > planning_game.api_url (passed by the caller) > localhost.
18
+ function getApiUrl(apiUrl) {
19
+ const fromEnv = process.env.PG_API_URL?.trim();
20
+ if (fromEnv) return fromEnv;
21
+ const fromConfig = typeof apiUrl === "string" ? apiUrl.trim() : "";
22
+ return fromConfig ? fromConfig : DEFAULT_API_URL;
23
+ }
24
+
25
+ // Origin and path only: credentials or a query token in a configured URL never reach a log.
26
+ function urlForMessage(url) {
27
+ try {
28
+ const u = new URL(url);
29
+ return `${u.origin}${u.pathname}`;
30
+ } catch {
31
+ return "(invalid Planning Game URL)";
32
+ }
19
33
  }
20
34
 
21
35
  async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
@@ -37,7 +51,7 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
37
51
  err.httpStatus = 408;
38
52
  throw err;
39
53
  }
40
- throw new Error(`Planning Game network error: ${error?.message || "unknown error"}`, { cause: error });
54
+ throw new Error(`Planning Game network error at ${urlForMessage(url)}: ${error?.message ?? "unknown error"}`, { cause: error });
41
55
  } finally {
42
56
  clearTimeout(timeoutId);
43
57
  }
@@ -58,15 +72,15 @@ async function parseJsonResponse(response) {
58
72
  }
59
73
  }
60
74
 
61
- export async function fetchCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
62
- const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(cardId)}`;
75
+ export async function fetchCard({ projectId, cardId, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
76
+ const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(cardId)}`;
63
77
  const response = await fetchWithRetry(url, {}, timeoutMs);
64
78
  const data = await parseJsonResponse(response);
65
79
  return data?.card || data;
66
80
  }
67
81
 
68
- export async function updateCard({ projectId, cardId: _cardId, firebaseId, updates, timeoutMs = DEFAULT_TIMEOUT_MS }) {
69
- const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
82
+ export async function updateCard({ projectId, cardId: _cardId, firebaseId, updates, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
83
+ const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
70
84
  const response = await fetchWithRetry(
71
85
  url,
72
86
  {
@@ -79,8 +93,8 @@ export async function updateCard({ projectId, cardId: _cardId, firebaseId, updat
79
93
  return parseJsonResponse(response);
80
94
  }
81
95
 
82
- export async function createCard({ projectId, card, timeoutMs = DEFAULT_TIMEOUT_MS }) {
83
- const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards`;
96
+ export async function createCard({ projectId, card, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
97
+ const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards`;
84
98
  const response = await fetchWithRetry(
85
99
  url,
86
100
  {
@@ -93,8 +107,8 @@ export async function createCard({ projectId, card, timeoutMs = DEFAULT_TIMEOUT_
93
107
  return parseJsonResponse(response);
94
108
  }
95
109
 
96
- export async function relateCards({ projectId, sourceCardId, targetCardId, relationType, timeoutMs = DEFAULT_TIMEOUT_MS }) {
97
- const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/relate`;
110
+ export async function relateCards({ projectId, sourceCardId, targetCardId, relationType, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
111
+ const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards/relate`;
98
112
  const response = await fetchWithRetry(
99
113
  url,
100
114
  {
@@ -63,9 +63,10 @@ async function handlePgDecomposition({ triageResult, pgTaskId, pgProject, config
63
63
  const normalizedAnswer = (answer || "").trim().toLowerCase();
64
64
  const isAccept = normalizedAnswer === "1" || normalizedAnswer === "yes" || normalizedAnswer === "sí" || normalizedAnswer === "si";
65
65
  if (isAccept) {
66
- const parentCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId }).catch(() => null);
66
+ const apiUrl = config.planning_game?.api_url;
67
+ const parentCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId, apiUrl }).catch(() => null);
67
68
  const createdSubtasks = await createDecompositionSubtasks({
68
- client: { createCard, relateCards },
69
+ client: { createCard: (args) => createCard({ ...args, apiUrl }), relateCards: (args) => relateCards({ ...args, apiUrl }) },
69
70
  projectId: pgProject,
70
71
  parentCardId: pgTaskId,
71
72
  parentFirebaseId: parentCard?.firebaseId || null,
@@ -129,7 +130,8 @@ export async function markPgCardToValidate({ pgCard, pgProject, config, session,
129
130
  projectId: pgProject,
130
131
  cardId: session.pg_task_id,
131
132
  firebaseId: pgCard.firebaseId,
132
- updates: pgUpdates
133
+ updates: pgUpdates,
134
+ apiUrl: config.planning_game?.api_url
133
135
  });
134
136
  logger.info(`Planning Game: ${session.pg_task_id} → To Validate`);
135
137
  } catch (err) {
@@ -201,9 +203,9 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
201
203
  // its own HUs and said approved. With a card named, no card is a loud stop.
202
204
  let pgCard;
203
205
  try {
204
- pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
206
+ pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId, apiUrl: config.planning_game?.api_url });
205
207
  } catch (err) {
206
- throw new Error(`Planning Game: card ${pgTaskId} could not be read (${err.message}): the run would implement without the card's acceptance criteria. Check PG_API_URL, or run without pgTask.`, { cause: err });
208
+ throw new Error(`Planning Game: card ${pgTaskId} could not be read (${err.message}): the run would implement without the card's acceptance criteria. Check planning_game.api_url in the config (PG_API_URL overrides it), or run without pgTask.`, { cause: err });
207
209
  }
208
210
  if (!pgCard) throw new Error(`Planning Game: card ${pgTaskId} not found in project ${pgProject}: the run would implement without the card's acceptance criteria. Check the id, or run without pgTask.`);
209
211
  try {
@@ -212,6 +214,7 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
212
214
  projectId: pgProject,
213
215
  cardId: pgTaskId,
214
216
  firebaseId: pgCard.firebaseId,
217
+ apiUrl: config.planning_game?.api_url,
215
218
  updates: {
216
219
  status: "In Progress",
217
220
  startDate: new Date().toISOString(),
@@ -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
- if (tool === "Bash") return evalShell(policy, role, input.command);
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
  }
@@ -6,7 +6,8 @@
6
6
  */
7
7
  import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
- import { homedir, userInfo } from "node:os";
9
+ import { userInfo } from "node:os";
10
+ import { getKarajanHome } from "../utils/paths.js";
10
11
  import { spawnSync } from "node:child_process";
11
12
  import { recordPolicyException as kernelRecord } from "@karajan-family/governance";
12
13
 
@@ -36,7 +37,10 @@ export function loadStandingExceptions(projectDir, { home } = {}) {
36
37
  * Ruta del almacén GLOBAL (~/.karajan): excepciones de la máquina, no de un
37
38
  * proyecto. `home` es inyectable SOLO para tests.
38
39
  */
39
- export const globalExceptionsFile = ({ home } = {}) => join(home ?? homedir(), ".karajan", "policy-exceptions.jsonl");
40
+ // KJC-BUG-0337: with no injected home it is the Karajan home (honours KARAJAN_HOME,
41
+ // and the suite's temp home), not homedir(): the tests read the machine's real grants.
42
+ const globalDir = (home) => (home ? join(home, ".karajan") : getKarajanHome());
43
+ export const globalExceptionsFile = ({ home } = {}) => join(globalDir(home), "policy-exceptions.jsonl");
40
44
 
41
45
  function readRecordsAt(file, origin) {
42
46
  let raw;
@@ -85,7 +89,7 @@ function defaultAppend(projectDir, line) {
85
89
  }
86
90
 
87
91
  const globalAppend = (home) => (_projectDir, line) => {
88
- const dir = join(home ?? homedir(), ".karajan");
92
+ const dir = globalDir(home);
89
93
  mkdirSync(dir, { recursive: true });
90
94
  appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
91
95
  };
@@ -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;
@@ -11,7 +11,9 @@ import { listPlans, loadPlan } from "../plan/plan-store.js";
11
11
  import { escapeRegExp } from "../utils/escape-regexp.js";
12
12
  import { verifyCardAlive } from "./card-verify.js";
13
13
 
14
- const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
14
+ // KJC-BUG-0323: coding/reviewing are the run working the HU, certified is ready
15
+ // to run; the gate blocked the pipeline's own commit on its own HU.
16
+ const LIVE_STATUSES = new Set(["pending", "running", "failed", "certified", "coding", "reviewing"]);
15
17
  // Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
16
18
  // KJC-TSK-0684 (optional middle segments) — tight enough to skip slugs.
17
19
  // Shared with the method report (MG-D): one pattern, one truth.
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
- const body = await sonarFetch(config, `/api/issues/search?projectKeys=${effectiveProjectKey}&statuses=OPEN`);
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
- parsedIssues = parsed.issues || [];
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
 
@@ -51,6 +51,13 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
51
51
  return { proceed: true, skipped: true };
52
52
  }
53
53
  if (!spec || !String(spec).trim()) return { proceed: true, skipped: true };
54
+ // KJC-BUG-0321 (#2074): with --plan the spec is the plan's original task, which
55
+ // `kj plan` reviewed when it generated the plan, and the HUs were certified since.
56
+ // Reviewing it again flags the plan, not the HUs being run.
57
+ if (flags.plan) {
58
+ logger?.info?.(`Spec review skipped: plan ${flags.plan} was reviewed when it was generated and its HUs certified.`);
59
+ return { proceed: true, skipped: true };
60
+ }
54
61
 
55
62
  const role = new SpecReviewerRole({ config, logger });
56
63
  await role.init({ task: spec });
@@ -52,7 +52,7 @@ Karajan projects MAY enforce a CI gate that fails any PR whose net delta exceeds
52
52
 
53
53
  - Stay under **~150 LOC per iteration** (safety margin against the 200 hard limit).
54
54
  - The gate counts the SUM of every changed file, not per-file. Tests count too. 5 files × 40 LOC = 200 = on the limit.
55
- - If the task clearly requires >150 LOC, STOP and report the partition needed. Don't ship a 500-LOC PR — the gate rejects it and the work is redone, burning tokens twice.
55
+ - Up to 200 LOC is allowed without asking. If the task clearly requires >200 LOC, STOP and report the partition needed. Don't ship a 500-LOC PR — the gate rejects it and the work is redone, burning tokens twice.
56
56
  - Excluded from the count: lockfiles, snapshots, `dist/`, `node_modules/`, generated `tests/_diet/`, `public/docs/`. Source + tests count.
57
57
 
58
58
  ## Task completeness