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.
- package/package.json +1 -1
- package/src/agents/base-agent.js +22 -0
- package/src/agents/kimi-agent.js +12 -3
- package/src/brain/one-shot-policy.js +26 -0
- package/src/brain/with-brain-recovery.js +4 -1
- package/src/checks/release-check.js +51 -5
- package/src/cli/register-meta.js +53 -3
- package/src/commands/code.js +18 -16
- package/src/commands/harden.js +9 -2
- package/src/commands/policy.js +21 -14
- package/src/commands/rag.js +21 -0
- package/src/commands/report.js +28 -0
- package/src/commands/review-gate.js +13 -11
- package/src/config/defaults.js +13 -1
- package/src/environment/panel.js +33 -0
- package/src/guards/output-guard.js +56 -4
- package/src/harden/guidelines-engine.js +7 -1
- package/src/harden/guidelines-templates.js +10 -1
- package/src/harden/harness-hooks.js +6 -0
- package/src/harden/sentinel-hooks.js +243 -26
- package/src/harden/supervisor-commit.js +41 -1
- package/src/harden/workflow-templates.js +9 -3
- package/src/orchestrator/config-init.js +16 -102
- package/src/orchestrator/drivers/iteration-phases/guards.js +8 -1
- package/src/orchestrator/drivers/pre-loop.js +1 -2
- package/src/policy/supervisor-verify.js +45 -11
- package/src/rag/coverage.js +32 -1
- package/src/review/board-pending.js +68 -0
- package/src/review/card-first.js +12 -0
- package/src/review/gate-gitignore.js +33 -0
- package/src/review/loc-budget.js +97 -0
- package/src/roles/audit-role.js +28 -2
|
@@ -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
|
|
200
|
-
//
|
|
201
|
-
|
|
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
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
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
|
-
|
|
66
|
-
if (
|
|
67
|
-
|
|
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
|
|
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
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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,
|
|
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
|
-
|
|
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"
|
|
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(
|
|
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
|
-
|
|
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` =
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/src/rag/coverage.js
CHANGED
|
@@ -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
|
+
}
|
package/src/review/card-first.js
CHANGED
|
@@ -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
|
+
}
|
package/src/roles/audit-role.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|