karajan-code 4.33.0 → 4.35.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.
@@ -196,9 +196,15 @@ export const SHRINK_BUDGET_WORKFLOW = [
196
196
  " BASE_REF: ${{ github.base_ref }}",
197
197
  " run: |",
198
198
  // KJC-TSK-0795 AC4 — measured in GREBLA: a 414-line warning that was almost
199
- // all pnpm-lock.yaml. Generated files are weight nobody wrote: the generated
200
- // gate applies the same exclusions kj's own repo applies.
201
- " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' || true)",
199
+ // all pnpm-lock.yaml. Generated files are weight nobody wrote.
200
+ //
201
+ // KJC-BUG-0205: this line used to exclude `**/*.md` wholesale while claiming
202
+ // to apply "the same exclusions kj's own repo applies". It did not: kj counts
203
+ // AI-rule files (CLAUDE.md, AGENTS.md, templates/**) ON PURPOSE, because they
204
+ // enter the agent's context every run and unbounded growth there dilutes the
205
+ // signal. Documentation is exempt where documentation lives; markdown that
206
+ // instructs an agent is code.
207
+ " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/docs/**/*.md' ':!**/docs/**/*.mdx' ':!**/docs/**/*.txt' ':!**/docs/**/*.rst' ':!README.md' ':!README.*.md' ':!CHANGELOG.md' ':!CONTRIBUTING.md' ':!SECURITY.md' ':!CODE_OF_CONDUCT.md' ':!MIGRATION*.md' ':!TODO*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' ':!**/*.min.js' ':!**/*.map' || true)",
202
208
  ' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
203
209
  ' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
204
210
  ' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
@@ -5,7 +5,7 @@
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { computeBaseRef, setSnapshot } from "../review/diff-generator.js";
8
- import { ensureContractBlockPresent } from "../review/gate-gitignore.js";
8
+ import { ensureContractBlockPresent, excludeLocalArtifacts } from "../review/gate-gitignore.js";
9
9
  import { revParse } from "../utils/git.js";
10
10
  import { buildCoderPrompt } from "../prompts/coder.js";
11
11
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
@@ -55,26 +55,18 @@ export async function autoInit(projectDir, logger) {
55
55
  }
56
56
  }
57
57
 
58
- // Ensure .gitignore exists with universal entries only (stack-specific added after planner)
59
- // KJC-BUG-0123: `.karajan/` is NOT in this list on purpose — a bare
60
- // dir-exclude breaks the gate contract (git cannot re-include children);
61
- // the canonical block is appended below from its single source.
62
- const gitignorePath = path.join(projectDir, ".gitignore");
63
- const universalIgnores = [".env", "*.log", ".DS_Store", ".reviews/"];
58
+ // KJC-BUG-0213 (issue #1734): el .gitignore es del equipo y viaja con el repo.
59
+ // Ahi solo entra el contrato v4, que el equipo entero hereda; los artefactos
60
+ // de una ejecucion son nuestros y locales, asi que van a .git/info/exclude.
61
+ // Lo que kj metia antes (.env, *.log, .DS_Store) era opinion sobre un
62
+ // proyecto ajeno, y aparecia en el diff de otra persona sin haberlo pedido.
64
63
  try {
65
- let content = "";
66
- if (await exists(gitignorePath)) {
67
- content = await fs.readFile(gitignorePath, "utf8");
68
- }
69
- const missing = universalIgnores.filter(entry => !content.includes(entry));
70
- if (missing.length > 0) {
71
- const append = (content && !content.endsWith("\n") ? "\n" : "") + missing.join("\n") + "\n";
72
- await fs.appendFile(gitignorePath, append, "utf8");
73
- logger.info(`Created .gitignore with universal entries`);
74
- }
75
- await ensureContractBlockPresent(projectDir);
64
+ const contract = await ensureContractBlockPresent(projectDir);
65
+ if (contract.changed) logger.info("El contrato v4 se ha anadido a .gitignore (lo hereda quien clone)");
66
+ const local = await excludeLocalArtifacts(projectDir);
67
+ if (local.changed) logger.info(`Artefactos de kj excluidos en .git/info/exclude: ${local.added.join(", ")}`);
76
68
  } catch (err) {
77
- logger.warn(`Failed to create .gitignore: ${err.message}`);
69
+ logger.warn(`Failed to prepare .gitignore: ${err.message}`);
78
70
  }
79
71
 
80
72
  const karajanDir = path.join(projectDir, ".karajan");
@@ -125,89 +117,11 @@ export async function autoInit(projectDir, logger) {
125
117
  }
126
118
  }
127
119
 
128
- // Stack-specific .gitignore patterns keyed by language/framework
129
- const STACK_GITIGNORE = {
130
- javascript: ["node_modules/", "dist/", "build/", "coverage/", ".cache/", "*.tsbuildinfo"],
131
- typescript: ["node_modules/", "dist/", "build/", "coverage/", ".cache/", "*.tsbuildinfo"],
132
- python: ["__pycache__/", "*.pyc", ".venv/", "venv/", "*.egg-info/", ".pytest_cache/", "htmlcov/", ".mypy_cache/"],
133
- java: ["target/", "*.class", "*.jar", "*.war", ".gradle/", "build/", ".settings/", ".classpath", ".project"],
134
- kotlin: ["target/", "*.class", "build/", ".gradle/", ".kotlin/"],
135
- go: ["bin/", "*.exe", "vendor/"],
136
- rust: ["target/", "Cargo.lock"],
137
- ruby: ["vendor/bundle/", ".bundle/", "coverage/", "tmp/"],
138
- php: ["vendor/", ".phpunit.result.cache", "storage/logs/"],
139
- csharp: ["bin/", "obj/", "*.suo", "*.user", "packages/", ".vs/"],
140
- swift: [".build/", "Packages/", "*.xcodeproj/", "DerivedData/"],
141
- dart: [".dart_tool/", "build/", ".packages"],
142
- };
143
-
144
- /**
145
- * Update .gitignore with stack-specific entries after planner/architect decides the stack.
146
- * Detects stack from: triage taskType, architect output, planner output, or task keywords.
147
- */
148
- export async function updateGitignoreForStack(projectDir, { stageResults, task, logger }) {
149
- const gitignorePath = path.join(projectDir, ".gitignore");
150
- const detected = new Set();
151
-
152
- // From architect — if it chose layers/patterns, it may hint at the language
153
- const arch = stageResults?.architect?.architecture;
154
- if (arch) {
155
- const archText = JSON.stringify(arch).toLowerCase();
156
- for (const lang of Object.keys(STACK_GITIGNORE)) {
157
- if (archText.includes(lang)) detected.add(lang);
158
- }
159
- }
160
-
161
- // From planner — scan plan text for language keywords
162
- const planText = (stageResults?.planner?.plan || "").toLowerCase();
163
- // From task description
164
- const taskText = (task || "").toLowerCase();
165
- const combined = `${planText} ${taskText}`;
166
-
167
- const langKeywords = {
168
- javascript: ["node", "npm", "express", "react", "vue", "next", "vite", "vitest", "jest", "pnpm", "yarn", "javascript", "js"],
169
- typescript: ["typescript", "tsx", "tsc"],
170
- python: ["python", "django", "flask", "fastapi", "pip", "pytest", "poetry"],
171
- java: ["java", "spring", "maven", "gradle", "junit"],
172
- kotlin: ["kotlin", "ktor"],
173
- go: ["golang", "go mod", "gin", "fiber"],
174
- rust: ["rust", "cargo", "tokio"],
175
- ruby: ["ruby", "rails", "gem", "bundler", "rspec"],
176
- php: ["php", "laravel", "composer", "symfony"],
177
- csharp: ["c#", "csharp", "dotnet", ".net", "aspnet"],
178
- swift: ["swift", "swiftui", "vapor"],
179
- dart: ["dart", "flutter"],
180
- };
181
-
182
- for (const [lang, keywords] of Object.entries(langKeywords)) {
183
- if (keywords.some(kw => combined.includes(kw))) detected.add(lang);
184
- }
185
-
186
- if (detected.size === 0) return;
187
-
188
- // Collect all entries for detected stacks
189
- const entries = [];
190
- for (const lang of detected) {
191
- entries.push(...(STACK_GITIGNORE[lang] || []));
192
- }
193
- const unique = [...new Set(entries)];
194
-
195
- try {
196
- let content = "";
197
- if (await exists(gitignorePath)) {
198
- content = await fs.readFile(gitignorePath, "utf8");
199
- }
200
- const missing = unique.filter(entry => !content.includes(entry));
201
- if (missing.length > 0) {
202
- const header = `\n# ${[...detected].join(" + ")} project\n`;
203
- const append = header + missing.join("\n") + "\n";
204
- await fs.appendFile(gitignorePath, append, "utf8");
205
- logger.info(`Updated .gitignore for ${[...detected].join(" + ")}: ${missing.join(", ")}`);
206
- }
207
- } catch (err) {
208
- logger.warn(`Failed to update .gitignore for stack: ${err.message}`);
209
- }
210
- }
120
+ // KJC-BUG-0213 (issue #1734): aqui vivia updateGitignoreForStack, que deducia
121
+ // el stack buscando palabras como "ts" o "js" en el texto del plan y de la
122
+ // tarea. Cualquier frase las contiene, asi que un proyecto Laravel acabo con
123
+ // reglas de TypeScript en SU .gitignore. Un acierto por casualidad no vale para
124
+ // escribir en un fichero que no es nuestro: la funcion se retira entera.
211
125
 
212
126
  /**
213
127
  * Load product context from well-known file locations.
@@ -24,10 +24,17 @@ export async function runGuardStages({ config, logger, emitter, eventBase, sessi
24
24
 
25
25
  if (!outputEnabled && !perfEnabled) return { action: "ok" };
26
26
 
27
+ // KJC-BUG-0214 (issue #1733): los guards juzgan LO QUE HA HECHO ESTE RUN, no
28
+ // la rama entera. Con el merge-base contra la base, cualquier linea anadida
29
+ // en cualquier momento de la rama contaba como recien escrita, y una
30
+ // ejecucion se bloqueo por codigo preexistente que el coder no toco. La
31
+ // sesion ya guarda HEAD al arrancar precisamente para esto.
27
32
  const baseBranch = config.base_branch || "main";
33
+ const runStart = session?.head_at_start || session?.session_start_sha || null;
28
34
  let diff;
29
35
  try {
30
- const baseRef = await computeBaseRef({ baseBranch });
36
+ const baseRef = runStart || await computeBaseRef({ baseBranch });
37
+ if (!runStart) logger.warn("Guards: sin punto de arranque del run, se juzga la rama entera contra la base");
31
38
  diff = await generateDiff({ baseRef, projectDir: config?.projectDir || null });
32
39
  } catch {
33
40
  logger.warn("Guards: could not generate diff, skipping");
@@ -57,7 +57,7 @@ import { maybeGenerateAutoHuBatch } from "./pre-loop-phases/auto-hu-batch.js";
57
57
 
58
58
  import {
59
59
  applyTriageOverrides, applyAutoSimplify, applyFlagOverrides,
60
- resolvePipelinePolicies, updateGitignoreForStack,
60
+ resolvePipelinePolicies,
61
61
  } from "../config-init.js";
62
62
  import { tryCiComment } from "../ci-integration.js";
63
63
  import { getIntegration } from "../integrations.js";
@@ -350,7 +350,6 @@ export async function runPreLoopStages({ config, logger, emitter, eventBase, ses
350
350
 
351
351
  // --- Update .gitignore with stack-specific entries based on planner/architect output ---
352
352
  const projectDir = updatedConfig.projectDir || process.cwd();
353
- await updateGitignoreForStack(projectDir, { stageResults, task, logger });
354
353
 
355
354
  // --- Auto-HU: when triage recommended decomposition and no manual huFile, generate HU batch ---
356
355
  await maybeGenerateAutoHuBatch({
@@ -9,9 +9,11 @@ import { lstatSync, readFileSync } from "node:fs";
9
9
  import { resolve, sep } from "node:path";
10
10
 
11
11
  import { renderCanonicalHook } from "../harden/harden-engine.js";
12
+ import { canonicalHarnessBody } from "../harden/sentinel-hooks.js";
12
13
  import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
13
14
 
14
15
  const HOOKS_PREFIX = ".karajan/hooks/";
16
+ const HARNESS_PREFIX = ".karajan/harness/";
15
17
  // globalHooksDir es el único texto libre que se interpola en el render:
16
18
  // vocabulario cerrado de ruta o la provenance no verifica NADA.
17
19
  const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
@@ -34,36 +36,63 @@ export function verifiedSupervisorFiles({ projectDir }) {
34
36
  return { files: new Set(), reason: "globalHooksDir no verificable en la provenance" };
35
37
  }
36
38
  const ok = new Set();
39
+ const unjudgeable = new Set();
37
40
  const entries = Array.isArray(prov?.files) ? prov.files : [];
38
- const hooksRoot = resolve(projectDir, HOOKS_PREFIX);
41
+ // KJC-BUG-0211: desde KJC-BUG-0197 el sello cubre tambien los guardias del
42
+ // supervisor, y aqui solo se sabian verificar los hooks. Como `complete`
43
+ // exige que TODAS las entradas verifiquen, el sello no se podia ni commitear.
44
+ const ROOTS = [HOOKS_PREFIX, HARNESS_PREFIX].map((p) => [p, resolve(projectDir, p)]);
39
45
  for (const entry of entries) {
40
- if (typeof entry?.file !== "string" || !entry.file.startsWith(HOOKS_PREFIX)) continue;
46
+ if (typeof entry?.file !== "string") continue;
47
+ const kind = ROOTS.find(([prefix]) => entry.file.startsWith(prefix));
48
+ if (!kind) continue;
49
+ const [prefix, root] = kind;
41
50
  // Contención por resolución, no por prefijo textual (catch de codex).
42
51
  const abs = resolve(projectDir, entry.file);
43
- if (!abs.startsWith(hooksRoot + sep)) continue;
52
+ if (!abs.startsWith(root + sep)) continue;
44
53
  let st = null;
45
54
  try { st = lstatSync(abs); } catch { /* no hay objeto en la ruta */ }
46
55
  if (entry.deleted === true) {
47
56
  if (st === null) ok.add(entry.file);
48
57
  continue;
49
58
  }
59
+ // KJC-BUG-0212: los guardias estan en .gitignore, viven solo en la maquina
60
+ // que ejecuta kj harden. Que no esten AQUI no es que no verifiquen: es que
61
+ // este checkout (CI, otro clon) no puede juzgarlos. Un hook ausente si
62
+ // significa algo, porque los hooks si viajan con el repo.
63
+ // Pero "no esta aqui" no vale como cheque en blanco: el hash declarado
64
+ // tiene que ser el de un guardia que kj CONOCE, o una procedencia forjada
65
+ // colaria entradas ausentes con cualquier hash (catch de la review).
66
+ if (prefix === HARNESS_PREFIX && st === null) {
67
+ const body = canonicalHarnessBody(entry.file.slice(prefix.length));
68
+ if (body != null && typeof entry.sha256 === "string" && sha256(Buffer.from(body, "utf8")) === entry.sha256) unjudgeable.add(entry.file);
69
+ continue;
70
+ }
50
71
  if (typeof entry.sha256 !== "string" || !st?.isFile()) continue;
51
72
  if (sha256(readFileSync(abs)) !== entry.sha256) continue;
73
+ // No se confia en el hash sellado: se recomputa lo que kj escribiria.
52
74
  let canonical;
53
75
  try {
54
- canonical = renderCanonicalHook(entry.file.slice(HOOKS_PREFIX.length), gen);
76
+ const name = entry.file.slice(prefix.length);
77
+ canonical = prefix === HOOKS_PREFIX ? renderCanonicalHook(name, gen) : canonicalHarnessBody(name);
55
78
  } catch {
56
79
  continue;
57
80
  }
58
- if (sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
81
+ if (canonical != null && sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
59
82
  }
60
- // `complete` = CADA entrada verificó: solo entonces la provenance se
61
- // describe con honestidad y su propio diff puede alzarse.
62
- const complete = entries.length > 0 && entries.every((e) => typeof e?.file === "string" && ok.has(e.file));
63
- return { files: ok, complete };
83
+ // `complete` = ninguna entrada FALLO y alguna verifico. Exigir que todas
84
+ // verifiquen hacia imposible sellar desde cualquier sitio que no fuera la
85
+ // maquina que sello, porque los guardias no viajan con el repo (0212).
86
+ const judged = (e) => ok.has(e.file) || unjudgeable.has(e.file);
87
+ const complete = ok.size > 0 && entries.every((e) => typeof e?.file === "string" && judged(e));
88
+ return { files: ok, complete, unjudgeable };
64
89
  }
65
90
 
66
- /** Filtra violaciones de supervisor respaldadas — {violations, lifted}. */
91
+ /**
92
+ * Filtra violaciones de supervisor respaldadas — {violations, lifted, note?}.
93
+ * `note` dice lo que este sitio NO ha podido juzgar, para que un levantamiento
94
+ * mas laxo de lo que parece no pase por silencio (KJC-BUG-0212).
95
+ */
67
96
  export function liftSealedSupervisorViolations({ projectDir, violations }) {
68
97
  const RULE = "defaults.supervisor.write";
69
98
  if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
@@ -75,5 +104,10 @@ export function liftSealedSupervisorViolations({ projectDir, violations }) {
75
104
  && v.file
76
105
  && (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
77
106
  const kept = violations.filter((v) => !liftable(v));
78
- return { violations: kept, lifted: violations.length - kept.length };
107
+ const unjudged = sealed.unjudgeable?.size ?? 0;
108
+ return {
109
+ violations: kept,
110
+ lifted: violations.length - kept.length,
111
+ ...(unjudged > 0 ? { note: `${unjudged} guardia(s) del sello no existen en este checkout y no se han podido comprobar aquí` } : {}),
112
+ };
79
113
  }
@@ -8,7 +8,7 @@
8
8
  * matchers are the single truth, here and in the indexer.
9
9
  */
10
10
  import { execa } from "execa";
11
- import { isAbsolute, relative } from "node:path";
11
+ import { extname, isAbsolute, normalize, relative } from "node:path";
12
12
  import { detectAdaptersForProject, buildMatchers } from "../lang/registry.js";
13
13
  import { getLastIndexedCommit, projectSlug } from "./vec-store.js";
14
14
 
@@ -46,3 +46,34 @@ export async function ragIndexCoverage(projectDir, { db }) {
46
46
 
47
47
  return { project, total: sources.length, indexed: sources.length - missing.length, missing, stale, lastIndexedCommit, absent: indexed.size === 0 };
48
48
  }
49
+
50
+ /**
51
+ * KJC-BUG-0216 (issue #1807) — POR QUE el RAG no puede responder sobre un
52
+ * fichero. El gate rag-first exigia una respuesta sobre cualquier fichero
53
+ * tocado, y para un .astro (o .php, o cualquier lenguaje sin adapter) eso es
54
+ * imposible: el indexador nunca lo toma. La unica salida era KJ_ALLOW_NO_RAG
55
+ * puesta por costumbre en todos los comandos, y con ella puesta el gate deja de
56
+ * informar y borra la diferencia entre "no pude consultar" y "no quise".
57
+ *
58
+ * `canAnswer` es la respuesta que el gate necesita: solo cuando es true tiene
59
+ * sentido exigir que el RAG haya hablado de ese fichero.
60
+ *
61
+ * @returns {{state: "indexed"|"stale"|"not-indexable"|"index-empty", rel: string, canAnswer: boolean, reason: string}}
62
+ */
63
+ export function fileIndexState(projectDir, file, { db }) {
64
+ const project = projectSlug(projectDir);
65
+ // `./src/a.js` y `src/a.js` son el mismo fichero: sin normalizar, el segundo
66
+ // salia "stale" estando indexado (catch de la review).
67
+ const rel = normalize(isAbsolute(file) ? relative(projectDir, file) : file);
68
+ const matchers = buildMatchers(detectAdaptersForProject(projectDir));
69
+ const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE project_slug = ?").all(project);
70
+ const indexed = new Set(rows.map((r) => normalize(isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
71
+
72
+ if (indexed.has(rel)) return { state: "indexed", rel, canAnswer: true, reason: `the index holds chunks for ${rel}` };
73
+ if (indexed.size === 0) return { state: "index-empty", rel, canAnswer: false, reason: `nothing of this project is indexed yet: kj rag index` };
74
+ if (matchers.shouldSkip(rel)) return { state: "not-indexable", rel, canAnswer: false, reason: `${rel} lives under a path the indexer always skips` };
75
+ if (!matchers.isCodeFile(rel) && extname(rel).toLowerCase() !== ".md") {
76
+ return { state: "not-indexable", rel, canAnswer: false, reason: `no language adapter covers ${extname(rel) || "a file with no extension"}, so the indexer never takes ${rel}` };
77
+ }
78
+ return { state: "stale", rel, canAnswer: false, reason: `${rel} is indexable but absent from the index: kj rag index --since auto` };
79
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Which pending board moves actually block (KJC-BUG-0198).
3
+ *
4
+ * board-sync blocked every advance until a merged PR's card reached a closing
5
+ * state, which assumes one PR per card while the project's rule splits any card
6
+ * over ~150 lines. With KJC-BUG-0197 (#1802 and #1803) that demanded a move that
7
+ * would have been a lie, and the way out was `KJ_ALLOW_BOARD=1`, which switches
8
+ * the whole gate off. A card still being delivered is not a lying board; a TURN
9
+ * ending with work nobody recorded is, and that stays the Stop gate's job.
10
+ *
11
+ * One verifiable fact carries a pending, never a promise in prose: the same card
12
+ * has another OPEN pull request. A branch named after the card is not enough,
13
+ * because the merged branch carries that name too, so accepting it would forgive
14
+ * every merge made from its own lane.
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+
21
+ import { CARD_REF_RE } from "./card-first.js";
22
+
23
+ const cardOf = (text) => {
24
+ const m = CARD_REF_RE.exec(String(text || ""));
25
+ return m ? m[0].toUpperCase() : null;
26
+ };
27
+
28
+ /**
29
+ * `openPrHeads` null means the list could not be read, and then nothing is
30
+ * forgiven. `openPrNumbers` pairs with it so a pending never forgives itself.
31
+ *
32
+ * @returns {{blocking: Array<object>, carried: Array<{card: string, why: string}>, reason?: string}}
33
+ */
34
+ export function blockingMoves({ pendings = [], openPrHeads = null, openPrNumbers = [] }) {
35
+ if (pendings.length === 0) return { blocking: [], carried: [] };
36
+ if (openPrHeads === null) return { blocking: [...pendings], carried: [], reason: "no se pudo comprobar si la card sigue viva en otra PR abierta (gh no respondió) — se bloquea" };
37
+ const openCards = new Set();
38
+ for (const [i, head] of openPrHeads.entries()) {
39
+ // A pending must never forgive itself: the PR it came from is merged, and if
40
+ // a stale listing still shows it, it does not count as "still open".
41
+ if (openPrNumbers[i] !== undefined && pendings.some((p) => Number(p.pr) === Number(openPrNumbers[i]))) continue;
42
+ const card = cardOf(head);
43
+ if (card) openCards.add(card);
44
+ }
45
+ const blocking = [], carried = [];
46
+ for (const p of pendings) {
47
+ const card = p.card ? String(p.card).toUpperCase() : null;
48
+ if (!card) { blocking.push(p); continue; } // nothing to check against
49
+ if (openCards.has(card)) carried.push({ card, why: "sigue entregándose en una PR abierta" });
50
+ else blocking.push(p);
51
+ }
52
+ return { blocking, carried };
53
+ }
54
+
55
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } };
56
+
57
+ /** The decision the guard asks for, gathering the facts here so the rule ships
58
+ * with npm instead of waiting for the human to reseal the harness. */
59
+ export function boardGate({ projectDir = process.cwd(), sessionId = "default", deps = {} } = {}) {
60
+ const state = deps.readState?.() ?? readJson(join(projectDir, ".karajan", "harness", "sentinel-state.json"));
61
+ const pendings = state?.sessions?.[sessionId]?.pending_moves;
62
+ if (!Array.isArray(pendings) || pendings.length === 0) return { blocking: [], carried: [] };
63
+ const run = deps.run ?? ((cmd, args) => execFileSync(cmd, args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }));
64
+ let open = null;
65
+ try { open = JSON.parse(run("gh", ["pr", "list", "--state", "open", "--limit", "100", "--json", "number,headRefName"])); } catch { /* fail closed */ }
66
+ const listed = Array.isArray(open) ? open : null;
67
+ return blockingMoves({ pendings, openPrHeads: listed?.map((p) => p.headRefName) ?? null, openPrNumbers: listed?.map((p) => Number(p.number)) ?? [] });
68
+ }
@@ -93,6 +93,18 @@ export async function checkCardFirst({ config = {}, projectDir = process.cwd(),
93
93
  ? { ok: false, mode: "block", reason, level: v.level }
94
94
  : { ok: true, mode: "warn", reason, level: v.level };
95
95
  }
96
+ // KJC-TSK-0878 (issue #1371): "no he podido verificar" y "he verificado y
97
+ // esta mal" no son lo mismo, y ambas pueden merecer una parada. El proyecto
98
+ // decide si la primera para, y el mensaje dice cual de las dos es: un gate
99
+ // que aprueba lo que no ha comprobado da una confianza que no ha ganado.
100
+ // Sin adaptador declarado no hay nada que bloquear: la opcion no castiga a
101
+ // quien aun no tiene verify_cmd.
102
+ if (v.verified === null && config.board?.unverifiable === "block" && config.board?.verify_cmd) {
103
+ return {
104
+ ok: false, mode: "block", level: v.level,
105
+ reason: `branch references ${ref} but the card could not be verified against ${boardName} — ${v.note} (board.unverifiable: block refuses to approve a check kj did not make)`,
106
+ };
107
+ }
96
108
  return { ok: true, mode: "pass", ref, level: v.level, ...(v.note ? { note: v.note } : {}) };
97
109
  }
98
110
  const reason = `no card reference in branch "${branch}" — card-first on ${boardName}: create the card there and name the branch after it (e.g. feat/ABC-123-summary)`;
@@ -12,6 +12,8 @@
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
 
15
+ import { runCommand } from "../utils/process.js";
16
+
15
17
  // KJC-BUG-0123 (issue #1268): exported as the SINGLE source of truth — kj
16
18
  // init and the orchestrator's autoInit write this same block instead of a
17
19
  // bare `.karajan/` exclude (which git cannot re-include children of).
@@ -48,6 +50,37 @@ export async function ensureContractBlockPresent(projectDir) {
48
50
  return { changed: true };
49
51
  }
50
52
 
53
+ /**
54
+ * KJC-BUG-0213 (issue #1734): lo que kj deja en el repo durante una ejecucion
55
+ * es suyo y es local, asi que se excluye donde no molesta a nadie:
56
+ * `.git/info/exclude` no viaja con el repo ni aparece en el diff de otro.
57
+ * La ruta se pregunta a git para que funcione tambien en un worktree, donde
58
+ * `.git` es un fichero y no un directorio.
59
+ */
60
+ const LOCAL_ARTIFACTS = [".reviews/", ".kj/", ".kj-ready.json"];
61
+
62
+ export async function excludeLocalArtifacts(projectDir, run = runCommand) {
63
+ let file = path.join(projectDir, ".git", "info", "exclude");
64
+ try {
65
+ const res = await run("git", ["-C", projectDir, "rev-parse", "--git-path", "info/exclude"]);
66
+ const out = res?.stdout?.trim();
67
+ if (res?.exitCode === 0 && out) file = path.isAbsolute(out) ? out : path.join(projectDir, out);
68
+ } catch { /* sin git utilizable: se intenta la ruta por defecto */ }
69
+ let text = "";
70
+ try { text = (await fs.readFile(file, "utf8")) || ""; } catch { /* aun no existe */ }
71
+ const lines = new Set(text.split("\n").map((l) => l.trim()));
72
+ const missing = LOCAL_ARTIFACTS.filter((a) => !lines.has(a));
73
+ if (missing.length === 0) return { changed: false, added: [] };
74
+ try {
75
+ await fs.mkdir(path.dirname(file), { recursive: true });
76
+ const sep = text && !text.endsWith("\n") ? "\n" : "";
77
+ await fs.appendFile(file, `${sep}# Karajan run artifacts (local only, never shared)\n${missing.join("\n")}\n`, "utf8");
78
+ return { changed: true, added: missing };
79
+ } catch {
80
+ return { changed: false, added: [] };
81
+ }
82
+ }
83
+
51
84
  export async function ensureGateTrackable(projectDir) {
52
85
  const file = path.join(projectDir, ".gitignore");
53
86
  let text;
@@ -0,0 +1,97 @@
1
+ /**
2
+ * What counts toward the PR size budget (KJC-BUG-0205).
3
+ *
4
+ * The budget exists to cap CODE growth. Two implementations of that rule had
5
+ * drifted apart: CI excluded lockfiles, build output and the docs tree, while
6
+ * the local gate summed every line of the diff. A doc-only PR was warned at 234
7
+ * lines locally and counted 104 in CI, and a gate that cries wolf in one place
8
+ * and not the other teaches people to believe neither.
9
+ *
10
+ * Two rules decide what is exempt, and they are not the same rule:
11
+ * - GENERATED output never counts: nobody wrote it, and the reviewer reads the
12
+ * source that produced it.
13
+ * - HUMAN documentation never counts either. Rationing docs is how a project
14
+ * ends up shipping features nobody can discover (which is exactly what
15
+ * happened in 4.33.0, see KJC-TSK-0871).
16
+ *
17
+ * And one thing that DOES count, on purpose: AI-rule files (CLAUDE.md,
18
+ * AGENTS.md, templates/**). They enter the agent's context on every run, so
19
+ * unbounded growth there dilutes the signal the agent receives.
20
+ */
21
+
22
+ const EXEMPT = [
23
+ // Generated or vendored: nobody typed it.
24
+ /(^|\/)dist\//,
25
+ /(^|\/)build\//,
26
+ /(^|\/)coverage\//,
27
+ /(^|\/)node_modules\//,
28
+ /(^|\/)\.astro\//,
29
+ /(^|\/)public\/docs\//,
30
+ /\.min\.(js|css)$/,
31
+ /\.map$/,
32
+ /\.snap(shot)?$/,
33
+ /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|npm-shrinkwrap\.json)$/,
34
+ /\.lock$/,
35
+ /(^|\/)tests\/_diet\//,
36
+ // Human documentation, wherever the project keeps it. The landing's docs
37
+ // moved into the monorepo (MONO-3) and the old root-anchored `docs/` rule
38
+ // stopped matching them, which is how documenting started costing budget.
39
+ /(^|\/)docs\/.*\.(md|mdx|txt|rst)$/,
40
+ /^(CHANGELOG|README|CODE_OF_CONDUCT|CONTRIBUTING|SECURITY)(\.[a-z-]+)?\.md$/,
41
+ /^(MIGRATION|TODO).*\.md$/,
42
+ ];
43
+
44
+ // The exceptions to the exception: these ARE the agent's context.
45
+ const AI_RULES = [/^CLAUDE\.md$/, /^AGENTS\.md$/, /^GEMINI\.md$/, /(^|\/)templates\//];
46
+
47
+ /** @param {string} file @returns {boolean} */
48
+ export function countsTowardBudget(file) {
49
+ if (!file) return false;
50
+ if (AI_RULES.some((re) => re.test(file))) return true;
51
+ return !EXEMPT.some((re) => re.test(file));
52
+ }
53
+
54
+ /**
55
+ * KJC-BUG-0208 — the policy invariant `net_lines_added` summed the whole
56
+ * numstat, so the same diff printed 174 in the size gate and 202 in the policy,
57
+ * two lines apart. One rule, one number: the metric is netted over the files
58
+ * that count, and what does not count is still said out loud.
59
+ *
60
+ * @param {string} numstat output of `git diff --numstat`
61
+ * @returns {{net: number, exempt: number}}
62
+ */
63
+ export function budgetedNet(numstat) {
64
+ let net = 0;
65
+ let exempt = 0;
66
+ for (const line of String(numstat || "").split("\n")) {
67
+ if (!line.trim()) continue;
68
+ const [a, r, ...rest] = line.split("\t");
69
+ const added = Number(a);
70
+ const removed = Number(r);
71
+ if (!Number.isFinite(added) || !Number.isFinite(removed)) continue; // binary
72
+ if (!countsTowardBudget(rest.join("\t"))) { exempt += added; continue; }
73
+ net += added - removed;
74
+ }
75
+ return { net, exempt };
76
+ }
77
+
78
+ /**
79
+ * @param {string} numstat output of `git diff --numstat`
80
+ * @returns {{added: number, exempt: number, testAdded: number}}
81
+ */
82
+ export function budgetedAdded(numstat) {
83
+ let added = 0;
84
+ let exempt = 0;
85
+ let testAdded = 0;
86
+ for (const line of String(numstat || "").split("\n")) {
87
+ if (!line.trim()) continue;
88
+ const [a, , ...rest] = line.split("\t");
89
+ const n = Number(a);
90
+ if (!Number.isFinite(n)) continue; // binary files emit '-'
91
+ const file = rest.join("\t");
92
+ if (!countsTowardBudget(file)) { exempt += n; continue; }
93
+ added += n;
94
+ if (/\/tests?\/|__tests__\/|\.test\.|\.spec\./.test(`/${file}`)) testAdded += n;
95
+ }
96
+ return { added, exempt, testAdded };
97
+ }
@@ -1,6 +1,9 @@
1
1
  import { mkdirSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { AgentRole } from "./agent-role.js";
4
+ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
5
+ import { buildRoleFallbackChain } from "../brain/role-fallback-chain.js";
6
+ import { ONE_SHOT_POLICY } from "../brain/one-shot-policy.js";
4
7
  import { securityAuditMarkerPath } from "../steward/invariants.js";
5
8
  import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
6
9
  import { measureBasalCost, loadPreviousAudit, saveAuditSnapshot, computeGrowthDelta } from "../audit/basal-cost.js";
@@ -193,13 +196,36 @@ export class AuditRole extends AgentRole {
193
196
  const runArgs = { prompt, role: "audit" };
194
197
  if (onOutput) runArgs.onOutput = onOutput;
195
198
  const startedAt = Date.now();
196
- const result = await agent.runTask(runArgs);
199
+ // KJC-BUG-0194: this used to call agent.runTask() directly, which made
200
+ // audit the ONE role that bypassed the brain. No classification (so an MCP
201
+ // caller saw a quota wall as category "unknown") and no declared chain
202
+ // (so roles.audit.fallback was ignored even after KJC-TSK-0859 wired it,
203
+ // because the wiring lives in AgentRole.execute and audit overrides it).
204
+ // The one-shot policy matters here: `kj audit` must take the next
205
+ // candidate at once rather than hibernate for hours inside a command.
206
+ const result = await withBrainRecovery({
207
+ agent: { runTask: (args) => agent.runTask(args), provider, model: this.config?.roles?.audit?.model ?? null },
208
+ taskArgs: runArgs,
209
+ role: "audit",
210
+ provider,
211
+ logger: this.logger,
212
+ emitter: this.emitter,
213
+ policy: ONE_SHOT_POLICY,
214
+ fallback: buildRoleFallbackChain({ config: this.config, role: "audit", createAgentFn: this._createAgent, logger: this.logger }),
215
+ });
197
216
  const durationMs = Date.now() - startedAt;
198
217
 
199
218
  const usage = extractUsage(result, { provider, durationMs });
200
219
 
201
220
  if (!result.ok) {
202
- return { ok: false, result: { error: result.error || result.output || "Audit failed", provider }, summary: `Audit failed: ${result.error || "unknown error"}`, usage };
221
+ // The classification and the itinerary travel with the failure, so a
222
+ // caller (CLI or MCP) can say WHY it stopped and what was tried.
223
+ return {
224
+ ok: false,
225
+ result: { error: result.error || result.output || "Audit failed", provider, recovery: result.recovery || null, tried: result.tried || null },
226
+ summary: `Audit failed: ${result.recovery?.class ? `${result.recovery.class} — ` : ""}${result.error || "unknown error"}`,
227
+ usage,
228
+ };
203
229
  }
204
230
 
205
231
  try {