karajan-code 4.35.1 → 4.37.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/packages/hu-board/public/utils/board-view.js +2 -1
- package/packages/hu-board/public/utils/config-editor.js +5 -2
- package/packages/hu-board/public/utils/terminal-panel.js +2 -1
- package/packages/hu-board/src/config-yaml.js +11 -9
- package/packages/hu-board/src/project-dir.js +42 -0
- package/packages/hu-board/src/routes/api.js +11 -3
- package/packages/hu-board/src/terminal-wire.js +5 -1
- package/packages/hu-board/src/terminal.js +8 -3
- package/src/checks/rag-coverage.js +5 -4
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +26 -4
- package/src/commands/board.js +20 -8
- package/src/commands/bootstrap.js +2 -0
- package/src/commands/env.js +8 -4
- package/src/commands/harden.js +26 -4
- package/src/commands/init.js +2 -1
- package/src/commands/plan/generate.js +4 -6
- package/src/commands/pr-size.js +45 -0
- package/src/commands/rag.js +69 -10
- package/src/commands/review-gate.js +47 -6
- package/src/commands/watch.js +13 -10
- package/src/config/defaults.js +3 -2
- package/src/environment/playbook.js +10 -3
- package/src/harden/guidelines-engine.js +24 -7
- package/src/harden/hook-templates.js +7 -0
- package/src/harden/phone-sign.js +28 -11
- package/src/harden/sentinel-hooks.js +82 -7
- package/src/lang/registry.js +2 -1
- package/src/mcp/handlers/rag-handler.js +14 -4
- package/src/orchestrator/drivers/post-loop.js +13 -11
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
- package/src/orchestrator/stages/rag-context-stage.js +5 -2
- package/src/rag/auto-update.js +5 -3
- package/src/rag/coverage.js +13 -12
- package/src/rag/indexable.js +128 -0
- package/src/rag/indexer.js +31 -13
- package/src/rag/library.js +19 -13
- package/src/rag/migrate.js +73 -0
- package/src/rag/onnx-fallback.js +5 -3
- package/src/rag/project-store.js +48 -0
- package/src/rag/watcher.js +25 -14
- package/src/review/comment-only.js +93 -0
- package/src/review/kj-provenance.js +26 -0
- package/src/review/tests-with-code.js +5 -2
- package/src/review/verdict-store.js +6 -1
- package/src/start/sweep.js +4 -3
- package/src/utils/rulesync.js +61 -0
package/src/rag/library.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* library — the distilled engineering canon as its own RAG collection
|
|
3
|
-
* (KJC-TSK-0697).
|
|
4
|
-
* and
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* (KJC-TSK-0697). KJC-TSK-0883 (ADR 0011): the machine canon (`<pkg>/library/`
|
|
4
|
+
* and `~/.karajan/library/`) indexes into its own ~/.karajan/library.db under
|
|
5
|
+
* project="library", reached by `kj rag query --library`; a project's own cards
|
|
6
|
+
* (`<project>/.karajan/library/`) go to that project's index under its slug. The architect
|
|
7
7
|
* consults it to ground the greenfield alternative (KJC-TSK-0696).
|
|
8
8
|
*/
|
|
9
9
|
|
|
@@ -20,10 +20,16 @@ export const LIBRARY_PROJECT = "library";
|
|
|
20
20
|
|
|
21
21
|
const SHIPPED_LIBRARY_DIR = fileURLToPath(new URL("../../library", import.meta.url));
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Existing library dirs, shipped canon first. KJC-TSK-0883: `scope` "machine"
|
|
25
|
+
* is the common canon (kj's + the user's), "project" the project's own cards.
|
|
26
|
+
*/
|
|
27
|
+
export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.homedir(), projectDir = process.cwd(), scope = "all" } = {}) {
|
|
28
|
+
const machine = [pkgLibraryDir, join(home, ".karajan", "library")];
|
|
29
|
+
const own = [join(projectDir, ".karajan", "library")];
|
|
30
|
+
const byScope = { machine, project: own, all: [...machine, ...own] };
|
|
31
|
+
if (!byScope[scope]) throw new Error(`libraryDirs: unknown scope "${scope}" (machine | project | all)`);
|
|
32
|
+
return byScope[scope].filter((d) => existsSync(d));
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
/**
|
|
@@ -31,9 +37,9 @@ export function libraryDirs({ pkgLibraryDir = SHIPPED_LIBRARY_DIR, home = os.hom
|
|
|
31
37
|
* re-index per source and identical bodies skip the embedder (content-hash
|
|
32
38
|
* dedup within the library project).
|
|
33
39
|
*/
|
|
34
|
-
export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir } = {}) {
|
|
40
|
+
export async function indexLibrary({ db, embedder, logger = console, pkgLibraryDir, home, projectDir, scope = "all", project = LIBRARY_PROJECT } = {}) {
|
|
35
41
|
let indexed = 0, failed = 0, files = 0;
|
|
36
|
-
for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir })) {
|
|
42
|
+
for (const dir of libraryDirs({ pkgLibraryDir, home, projectDir, scope })) {
|
|
37
43
|
for (const name of readdirSync(dir)) {
|
|
38
44
|
if (extname(name).toLowerCase() !== ".md") continue;
|
|
39
45
|
const path = join(dir, name);
|
|
@@ -42,13 +48,13 @@ export async function indexLibrary({ db, embedder, logger = console, pkgLibraryD
|
|
|
42
48
|
const hashed = chunks.map((ch) => ({ ch, contentHash: createHash("sha256").update(ch.text).digest("hex") }));
|
|
43
49
|
// Unchanged card (every chunk body already known) → skip before the
|
|
44
50
|
// delete, so idempotent re-runs never touch the embedder.
|
|
45
|
-
if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash,
|
|
51
|
+
if (hashed.length && hashed.every(({ contentHash }) => findChunkByHash(db, contentHash, project))) continue;
|
|
46
52
|
deleteChunksBySource(db, path);
|
|
47
53
|
for (const { ch, contentHash } of hashed) {
|
|
48
54
|
try {
|
|
49
|
-
if (findChunkByHash(db, contentHash,
|
|
55
|
+
if (findChunkByHash(db, contentHash, project)) continue;
|
|
50
56
|
const embedding = await embedder.embed(ch.text);
|
|
51
|
-
insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project
|
|
57
|
+
insertChunk(db, { source: path, kind: "library", text: ch.text, metadata: ch.metadata, embedding, project, contentHash });
|
|
52
58
|
indexed += 1;
|
|
53
59
|
} catch (err) {
|
|
54
60
|
failed += 1;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0888 (RAG-P1a, ADR 0011) — pasar al indice por proyecto sin volver a
|
|
3
|
+
* embeber. Los chunks de UN proyecto se copian de la base global de la maquina
|
|
4
|
+
* a <raiz>/.karajan/rag.db con sus embeddings tal cual, junto con su marca de
|
|
5
|
+
* last_indexed_commit para que el auto-update siga por delta. Cero recomputo
|
|
6
|
+
* (en esta maquina, karajan-code son 23.109 chunks), y la base global no se
|
|
7
|
+
* toca: borrarla es decision del usuario, no efecto de una migracion.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
import { openVecStore, insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
|
|
12
|
+
|
|
13
|
+
export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* KJC-TSK-0882: que decir cuando el indice del proyecto no tiene nada suyo.
|
|
17
|
+
* Si la base global tiene sus chunks, migrar (segundos); si no, indexar.
|
|
18
|
+
*/
|
|
19
|
+
export function emptyIndexRemedy({ slug, legacyPath, dim = 768 }) {
|
|
20
|
+
if (slug && existsSync(legacyPath)) {
|
|
21
|
+
const legacy = openVecStore({ dim, path: legacyPath });
|
|
22
|
+
try {
|
|
23
|
+
if (countProjectChunks(legacy, slug) > 0) return "kj rag migrate";
|
|
24
|
+
} finally {
|
|
25
|
+
legacy.close();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return "kj rag index --with-sources";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @returns {{state: "migrated"|"already"|"nothing", migrated: number, reason: string}}
|
|
33
|
+
*/
|
|
34
|
+
export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 }) {
|
|
35
|
+
const nothing = { state: "nothing", migrated: 0, reason: `the global index holds nothing for ${slug}: kj rag index --with-sources` };
|
|
36
|
+
if (!existsSync(legacyPath)) return nothing;
|
|
37
|
+
|
|
38
|
+
const target = openVecStore({ dim, path: targetPath });
|
|
39
|
+
try {
|
|
40
|
+
// Se cuentan los de ESTE proyecto, no el total: un rag.db viejo en
|
|
41
|
+
// .karajan/ puede traer chunks de otro (visto en esta maquina: 42 de otro
|
|
42
|
+
// repo), y contar el total diria "ya migrado" sin haber copiado nada.
|
|
43
|
+
const mine = countProjectChunks(target, slug);
|
|
44
|
+
if (mine > 0) {
|
|
45
|
+
return { state: "already", migrated: 0, reason: `the project index already holds ${mine} chunks of ${slug}: nothing to migrate` };
|
|
46
|
+
}
|
|
47
|
+
const legacy = openVecStore({ dim, path: legacyPath });
|
|
48
|
+
try {
|
|
49
|
+
const rows = legacy.prepare("SELECT id, source, kind, text, metadata, content_hash FROM chunks WHERE project_slug = ?").all(slug);
|
|
50
|
+
if (rows.length === 0) return nothing;
|
|
51
|
+
const vecOf = legacy.prepare("SELECT embedding FROM vec_chunks WHERE rowid = ?");
|
|
52
|
+
const copy = target.transaction(() => {
|
|
53
|
+
for (const r of rows) {
|
|
54
|
+
const raw = vecOf.get(BigInt(r.id))?.embedding;
|
|
55
|
+
if (!raw) continue; // un chunk sin vector no se puede buscar: no se inventa
|
|
56
|
+
const embedding = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
|
|
57
|
+
insertChunk(target, { source: r.source, kind: r.kind, text: r.text, metadata: r.metadata, embedding, project: slug, contentHash: r.content_hash });
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
copy();
|
|
61
|
+
const stamp = getLastIndexedCommit(legacy, slug);
|
|
62
|
+
if (stamp) setLastIndexedCommit(target, slug, stamp);
|
|
63
|
+
const migrated = countProjectChunks(target, slug);
|
|
64
|
+
const parts = [`${migrated} chunks of ${slug} copied with their embeddings`];
|
|
65
|
+
if (stamp) parts.push(`indexed at ${stamp.slice(0, 9)}`);
|
|
66
|
+
return { state: "migrated", migrated, reason: parts.join(", ") };
|
|
67
|
+
} finally {
|
|
68
|
+
legacy.close();
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
target.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/rag/onnx-fallback.js
CHANGED
|
@@ -12,7 +12,7 @@ import path from "node:path";
|
|
|
12
12
|
import Database from "better-sqlite3";
|
|
13
13
|
import { parseDocument } from "yaml";
|
|
14
14
|
import { getProjectConfigPath } from "../config/loader.js";
|
|
15
|
-
import {
|
|
15
|
+
import { projectDbPath } from "./project-store.js";
|
|
16
16
|
|
|
17
17
|
const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
|
|
18
18
|
|
|
@@ -24,8 +24,10 @@ const ONNX_EMBEDDER = { provider: "onnx", dim: 384 };
|
|
|
24
24
|
* that already HAS chunks is never touched — switching dims there would
|
|
25
25
|
* destroy other projects' indexes.
|
|
26
26
|
*/
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// KJC-TSK-0882 (ADR 0011): el store es DEL PROYECTO, asi que el fallback a ONNX
|
|
28
|
+
// decide mirando el indice de este proyecto y nunca el de otro.
|
|
29
|
+
export function resetEmptyStore(projectDir = process.cwd()) {
|
|
30
|
+
const storePath = projectDbPath(projectDir);
|
|
29
31
|
if (!existsSync(storePath)) return true;
|
|
30
32
|
// Read-only, schema-agnostic inspection: no DDL, no vec extension, no
|
|
31
33
|
// dimension assumptions — the store is only ever OPENED to be counted.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0882 (RAG-P1, ADR 0011) — el indice del RAG es DEL PROYECTO.
|
|
3
|
+
*
|
|
4
|
+
* Hasta aqui era una sola base por maquina (~/.karajan/rag.db): 427 MB, 19
|
|
5
|
+
* proyectos y 49.825 chunks en un indice vectorial sin particion. La KNN sacaba
|
|
6
|
+
* los vecinos mas cercanos de TODA la maquina y filtraba por proyecto despues,
|
|
7
|
+
* asi que un proyecto pequeno podia recibir cero resultados con el chunk
|
|
8
|
+
* perfecto indexado, y cinco consultas ni siquiera filtraban (una de ellas, el
|
|
9
|
+
* preload que entra en el prompt del coder).
|
|
10
|
+
*
|
|
11
|
+
* Ahora cada proyecto abre <raiz git>/.karajan/rag.db. La raiz se resuelve por
|
|
12
|
+
* git para que desde un subdirectorio se vea el mismo indice. KJ_RAG_DB sigue
|
|
13
|
+
* mandando cuando esta puesta. El store de karajan-core ya acepta `path`, asi
|
|
14
|
+
* que esto no toca core.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
import { openVecStore } from "./vec-store.js";
|
|
20
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
21
|
+
|
|
22
|
+
function projectRoot(dir) {
|
|
23
|
+
try {
|
|
24
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim() || dir;
|
|
25
|
+
} catch {
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Ruta del indice del proyecto. */
|
|
31
|
+
export function projectDbPath(projectDir = process.cwd()) {
|
|
32
|
+
if (process.env.KJ_RAG_DB) return process.env.KJ_RAG_DB;
|
|
33
|
+
return path.join(projectRoot(projectDir), ".karajan", "rag.db");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Abre el indice del proyecto. El caller es dueno del close(). */
|
|
37
|
+
/** KJC-TSK-0883: the machine canon has its own store, never a project's. */
|
|
38
|
+
export function libraryDbPath() {
|
|
39
|
+
return process.env.KJ_LIBRARY_DB || path.join(getKarajanHome(), "library.db");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function openLibraryStore({ dim = 768 } = {}) {
|
|
43
|
+
return openVecStore({ dim, path: libraryDbPath() });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function openProjectStore({ projectDir = process.cwd(), dim = 768 } = {}) {
|
|
47
|
+
return openVecStore({ dim, path: projectDbPath(projectDir) });
|
|
48
|
+
}
|
package/src/rag/watcher.js
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
// KJC-TSK-0441 (RAG v2.28.0 PR1) — chokidar watcher. Live re-index of plans
|
|
2
2
|
// + onboarding + (opt) sources, debounced. PID file arbitrates a single daemon.
|
|
3
3
|
// KJC-TSK-0482 — source matcher derivado del registry (multi-lang: JS/Py/Rust/Go/Java).
|
|
4
|
-
import { writeFileSync, readFileSync, existsSync, unlinkSync } from "node:fs";
|
|
5
|
-
import { extname, join } from "node:path";
|
|
4
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
5
|
+
import { dirname, extname, join } from "node:path";
|
|
6
6
|
import chokidar from "chokidar";
|
|
7
|
-
import {
|
|
7
|
+
import { deleteChunksBySource, projectSlug } from "./vec-store.js";
|
|
8
|
+
import { openProjectStore } from "./project-store.js";
|
|
8
9
|
import { makeGovernedEmbedder } from "./governed-embedder.js";
|
|
9
10
|
import { indexFile } from "./indexer.js";
|
|
10
11
|
import { getKarajanHome } from "../utils/paths.js";
|
|
11
12
|
import { getAllCodeExtensions } from "../lang/registry.js";
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
// KJC-TSK-0883 (ADR 0011): un watcher por proyecto, cada uno con su PID.
|
|
15
|
+
export const pidFilePath = (projectDir = process.cwd()) => join(getKarajanHome(), "watchers", `${projectSlug(projectDir)}.pid`);
|
|
16
|
+
|
|
17
|
+
/** Solo lo de ESTE proyecto: sus planes, su onboarding y (opt) sus fuentes. */
|
|
18
|
+
export function watchedPaths(projectDir, { withSources = false } = {}) {
|
|
19
|
+
const slug = projectSlug(projectDir);
|
|
20
|
+
const paths = [join(getKarajanHome(), "onboarding", `${slug}.md`), join(getKarajanHome(), "plans", slug)];
|
|
21
|
+
return withSources ? [...paths, projectDir] : paths;
|
|
22
|
+
}
|
|
14
23
|
const DEFAULT_DEBOUNCE_MS = 1000;
|
|
15
|
-
const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet"]);
|
|
24
|
+
const SKIP_SEGMENTS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".next", ".kj", "_diet", ".claude"]);
|
|
16
25
|
const SOURCE_EXTS = new Set(getAllCodeExtensions().map((e) => e.toLowerCase()));
|
|
17
26
|
|
|
18
27
|
export function isWatchable(path) {
|
|
@@ -26,11 +35,9 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
|
|
|
26
35
|
if (!projectDir) throw new Error("startWatcher: projectDir is required");
|
|
27
36
|
const slug = projectSlug(projectDir);
|
|
28
37
|
const dim = config?.rag?.embedder?.dim || 768;
|
|
29
|
-
const db =
|
|
38
|
+
const db = openProjectStore({ projectDir, dim });
|
|
30
39
|
const embedder = makeGovernedEmbedder(config);
|
|
31
|
-
const
|
|
32
|
-
if (withSources) paths.push(projectDir);
|
|
33
|
-
const watcher = chokidar.watch(paths, { ignoreInitial: true, persistent: true });
|
|
40
|
+
const watcher = chokidar.watch(watchedPaths(projectDir, { withSources }), { ignoreInitial: true, persistent: true });
|
|
34
41
|
const pending = new Map();
|
|
35
42
|
const flush = async (p) => {
|
|
36
43
|
pending.delete(p);
|
|
@@ -49,11 +56,15 @@ export function startWatcher({ projectDir, config, logger = console, debounceMs
|
|
|
49
56
|
};
|
|
50
57
|
}
|
|
51
58
|
|
|
52
|
-
export function writePidFile(pid = process.pid) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
export function writePidFile(projectDir, pid = process.pid) {
|
|
60
|
+
const file = pidFilePath(projectDir);
|
|
61
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
62
|
+
writeFileSync(file, String(pid), "utf8");
|
|
63
|
+
}
|
|
64
|
+
export function clearPidFile(projectDir) { if (existsSync(pidFilePath(projectDir))) unlinkSync(pidFilePath(projectDir)); }
|
|
65
|
+
export function readPidFile(projectDir) {
|
|
66
|
+
if (!existsSync(pidFilePath(projectDir))) return null;
|
|
67
|
+
const pid = Number(readFileSync(pidFilePath(projectDir), "utf8").trim());
|
|
57
68
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
58
69
|
}
|
|
59
70
|
export function isPidAlive(pid) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-BUG-0235 — are the lines a diff ADDS only comments? A cleanup deletes
|
|
3
|
+
* code and corrects the comments that were left lying about it; those added
|
|
4
|
+
* lines carry no behavior to test. Decided by scanning the resulting file, not
|
|
5
|
+
* by a line's first characters: " * factor" can continue a product. Strings are
|
|
6
|
+
* kept as code, so a docstring or a `//` inside a string is never a comment.
|
|
7
|
+
* An unknown syntax is not judged: everything counts as code (conservative).
|
|
8
|
+
*/
|
|
9
|
+
import { extname } from "node:path";
|
|
10
|
+
|
|
11
|
+
// JSX/TSX and PHP are out: a `//` in a JSX text child or outside `<?php` is
|
|
12
|
+
// rendered content. JSX inside a .js/.ts file is detected below.
|
|
13
|
+
const C_STYLE = new Set([".js", ".mjs", ".cjs", ".ts", ".java", ".cs", ".go", ".kt", ".swift", ".c", ".cc", ".cpp", ".h", ".hpp", ".rs", ".scala"]);
|
|
14
|
+
// Shell and Ruby are left out on purpose: a `#` line inside a heredoc is
|
|
15
|
+
// content, not a comment, and this scanner does not parse heredocs.
|
|
16
|
+
const HASH_STYLE = new Set([".py"]);
|
|
17
|
+
const OPENER_AFTER = new Set([";", "{", "}", "(", ")", ",", "=", ":", "?", "!", "&", "|"]);
|
|
18
|
+
|
|
19
|
+
/** @returns {Set<number>|null} 1-based lines holding code, or null for an unknown syntax */
|
|
20
|
+
export function codeLines(content, file) {
|
|
21
|
+
const ext = extname(file).toLowerCase();
|
|
22
|
+
const cStyle = C_STYLE.has(ext);
|
|
23
|
+
if (!cStyle && !HASH_STYLE.has(ext)) return null;
|
|
24
|
+
const lines = new Set();
|
|
25
|
+
let line = 1;
|
|
26
|
+
let state = "code"; // code | line | block | string
|
|
27
|
+
let quote = "";
|
|
28
|
+
const s = String(content);
|
|
29
|
+
let step; // characters consumed by the current iteration (set in the body)
|
|
30
|
+
let prev = ""; // last non-space character seen in code on this line
|
|
31
|
+
let word = ""; // identifier being read in code
|
|
32
|
+
let lastWord = ""; // last complete identifier in code
|
|
33
|
+
for (let i = 0; i < s.length; i += step) {
|
|
34
|
+
const c = s[i];
|
|
35
|
+
const next = s[i + 1];
|
|
36
|
+
step = 1;
|
|
37
|
+
if (c === "\n") prev = "";
|
|
38
|
+
if (c === "\n") {
|
|
39
|
+
line++;
|
|
40
|
+
if (state === "line") state = "code";
|
|
41
|
+
// A line inside a multi-line string is part of its value, even if blank.
|
|
42
|
+
if (state === "string") lines.add(line);
|
|
43
|
+
} else if (state === "block") {
|
|
44
|
+
if (c === "*" && next === "/") { state = "code"; step = 2; }
|
|
45
|
+
} else if (state === "string") {
|
|
46
|
+
lines.add(line);
|
|
47
|
+
if (c === "\\") {
|
|
48
|
+
// An escaped newline continues the string on the next physical line: count it.
|
|
49
|
+
if (next === "\n") { line++; lines.add(line); }
|
|
50
|
+
step = 2;
|
|
51
|
+
} else if (c === quote) {
|
|
52
|
+
state = "code";
|
|
53
|
+
}
|
|
54
|
+
} else if (state === "code") {
|
|
55
|
+
// A comment opens only where one can: line start or after a separator.
|
|
56
|
+
// After `\`, `[` or an identifier a slash may belong to a regex literal
|
|
57
|
+
// (/a\/*b/, /[/*]/), so it is taken as code: in doubt, code.
|
|
58
|
+
const canOpen = prev === "" || OPENER_AFTER.has(prev);
|
|
59
|
+
if (/[\w$]/.test(c)) word += c;
|
|
60
|
+
else if (word) { lastWord = word; word = ""; }
|
|
61
|
+
// A tag where an expression starts (`= <div>`, `return <App />`) is JSX:
|
|
62
|
+
// its text children are rendered, so this file is not judged.
|
|
63
|
+
if (cStyle && c === "<" && /[A-Za-z/>]/.test(next ?? "") && (canOpen || lastWord === "return")) return null;
|
|
64
|
+
if (cStyle && canOpen && c === "/" && next === "/") state = "line";
|
|
65
|
+
else if (cStyle && canOpen && c === "/" && next === "*") { state = "block"; step = 2; }
|
|
66
|
+
else if (!cStyle && c === "#") state = "line";
|
|
67
|
+
else if (c === '"' || c === "'" || c === "`") { state = "string"; quote = c; lines.add(line); }
|
|
68
|
+
else if (!/\s/.test(c)) lines.add(line);
|
|
69
|
+
if (state === "code" && !/\s/.test(c)) prev = c;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const COMMENT_START = /^(\/\/|\/\*|\*|#)/;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Every added line (1-based, in the resulting file) is a comment or blank.
|
|
79
|
+
* Two checks must agree: the scanner says no code on the line AND the line
|
|
80
|
+
* itself starts like a comment (or is blank). The second one is a net against
|
|
81
|
+
* a scanner mistake, and it costs a knowingly accepted false negative: a block
|
|
82
|
+
* comment continuation without a leading `*` counts as code. Agreed criterion
|
|
83
|
+
* (KJC-BUG-0235): better a cleanup that still asks for tests than code let through.
|
|
84
|
+
*/
|
|
85
|
+
export function addedAreCommentsOnly(content, addedLines, file) {
|
|
86
|
+
const code = codeLines(content, file);
|
|
87
|
+
if (!code) return false;
|
|
88
|
+
const text = String(content).split("\n");
|
|
89
|
+
return [...addedLines].every((n) => {
|
|
90
|
+
const t = (text[n - 1] ?? "").trim();
|
|
91
|
+
return !code.has(n) && (t === "" || COMMENT_START.test(t));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* Saltarse un gate ritualmente es peor que no tenerlo, porque parece que se
|
|
12
12
|
* cumple.
|
|
13
13
|
*/
|
|
14
|
+
import { execFileSync } from "node:child_process";
|
|
14
15
|
import { createRequire } from "node:module";
|
|
15
16
|
import { dirname, resolve } from "node:path";
|
|
16
17
|
import { fileURLToPath } from "node:url";
|
|
@@ -40,3 +41,28 @@ export function kjProvenance(moduleDir = here) {
|
|
|
40
41
|
: `${v} (${root})`;
|
|
41
42
|
return { version, root, linked, label };
|
|
42
43
|
}
|
|
44
|
+
|
|
45
|
+
const gitIn = (root) => (args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* KJC-TSK-0886: el sello de que kj emite un veredicto. En un arbol enlazado la
|
|
49
|
+
* version no basta (el mismo package.json vale para dos estados del disco), asi
|
|
50
|
+
* que lleva rama y commit del working tree. Nunca lanza: adorna, no decide.
|
|
51
|
+
*/
|
|
52
|
+
export function kjStamp({ provenance = kjProvenance(), git = gitIn(provenance.root) } = {}) {
|
|
53
|
+
const stamp = { version: provenance.version, linked: provenance.linked };
|
|
54
|
+
if (!provenance.linked) return stamp;
|
|
55
|
+
try {
|
|
56
|
+
return { ...stamp, branch: git(["rev-parse", "--abbrev-ref", "HEAD"]).trim(), commit: git(["rev-parse", "HEAD"]).trim() };
|
|
57
|
+
} catch {
|
|
58
|
+
return stamp;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const STAMP_KEYS = ["version", "linked", "branch", "commit"];
|
|
63
|
+
export const stampDiffers = (a, b) => Boolean(a && b) && STAMP_KEYS.some((k) => a[k] !== b[k]);
|
|
64
|
+
|
|
65
|
+
export function stampLabel(s) {
|
|
66
|
+
const where = s.branch ? ` (${s.branch}@${(s.commit || "").slice(0, 7)})` : "";
|
|
67
|
+
return `kj ${s.version ?? "unknown"}${where}`;
|
|
68
|
+
}
|
|
@@ -42,8 +42,11 @@ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = nu
|
|
|
42
42
|
// cleanup PRs). Callers that only know names keep the old behavior.
|
|
43
43
|
if (Array.isArray(numstat)) {
|
|
44
44
|
const bySource = numstat.filter((n) => sources.includes(n.file));
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// KJC-BUG-0235: a cleanup also corrects the comments the deletion left
|
|
46
|
+
// lying; lines the caller PROVED to be comments carry no behavior either.
|
|
47
|
+
const cleanup = (n) => (n.added || 0) === 0 || n.commentOnly === true;
|
|
48
|
+
if (bySource.length === sources.length && bySource.every(cleanup)) {
|
|
49
|
+
return { ok: true, mode: "delete-only", sources, reason: "every touched source only removes lines or corrects comments — deleting is not new behavior" };
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
52
|
|
|
@@ -13,6 +13,7 @@ import crypto from "node:crypto";
|
|
|
13
13
|
import fs from "node:fs/promises";
|
|
14
14
|
import path from "node:path";
|
|
15
15
|
import { runCommand } from "../utils/process.js";
|
|
16
|
+
import { kjStamp, stampDiffers, stampLabel } from "./kj-provenance.js";
|
|
16
17
|
|
|
17
18
|
const STORE_DIR = path.join(".karajan", "reviews");
|
|
18
19
|
|
|
@@ -35,6 +36,7 @@ export async function saveVerdict(projectDir, diff, verdict) {
|
|
|
35
36
|
const hash = diffHash(diff);
|
|
36
37
|
const record = {
|
|
37
38
|
...verdict,
|
|
39
|
+
kj: kjStamp(), // KJC-TSK-0886: que kj lo emitio; nunca lo dicta quien llama
|
|
38
40
|
diffHash: hash,
|
|
39
41
|
timestamp: new Date().toISOString(),
|
|
40
42
|
};
|
|
@@ -143,5 +145,8 @@ export async function checkVerdict(projectDir, diff) {
|
|
|
143
145
|
if (verdict.verdict !== "approved") {
|
|
144
146
|
return { ok: false, verdict, reason: `review was rejected by ${verdict.reviewer} — fix the issues and run \`kj review\` again` };
|
|
145
147
|
}
|
|
146
|
-
|
|
148
|
+
// KJC-TSK-0886: otro kj comprobando no bloquea, pero deja rastro.
|
|
149
|
+
const now = kjStamp();
|
|
150
|
+
if (!stampDiffers(verdict.kj, now)) return { ok: true, verdict };
|
|
151
|
+
return { ok: true, verdict, note: `verdict from ${stampLabel(verdict.kj)}, checked by ${stampLabel(now)}` };
|
|
147
152
|
}
|
package/src/start/sweep.js
CHANGED
|
@@ -17,9 +17,10 @@ import { compareHarden } from "../harden/advisory.js";
|
|
|
17
17
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
18
18
|
import { detectQmd } from "../utils/qmd-detect.js";
|
|
19
19
|
import {
|
|
20
|
-
|
|
20
|
+
projectSlug, countChunks, getLastIndexedCommit,
|
|
21
21
|
} from "../rag/vec-store.js";
|
|
22
22
|
import { classifyMaturity } from "./maturity.js";
|
|
23
|
+
import { openProjectStore, projectDbPath } from "../rag/project-store.js";
|
|
23
24
|
|
|
24
25
|
const CODE_EXT = new Set([
|
|
25
26
|
".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".go",
|
|
@@ -96,8 +97,8 @@ function lastCommitAgeDays(projectDir) {
|
|
|
96
97
|
/** Read-only RAG index status; null when no index file exists yet. */
|
|
97
98
|
function readRagStatus(projectDir) {
|
|
98
99
|
try {
|
|
99
|
-
if (!existsSync(
|
|
100
|
-
const db =
|
|
100
|
+
if (!existsSync(projectDbPath(projectDir))) return { indexed: false, chunks: 0, lastIndexedCommit: null };
|
|
101
|
+
const db = openProjectStore({ projectDir });
|
|
101
102
|
return {
|
|
102
103
|
indexed: true,
|
|
103
104
|
chunks: countChunks(db, { kind: "code" }),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0879 (issue #1310) — Rulesync compatibility.
|
|
3
|
+
*
|
|
4
|
+
* A Rulesync-managed repo keeps its agent rules in `.rulesync/rules/` and
|
|
5
|
+
* `rulesync generate` compiles them into CLAUDE.md, AGENTS.md, .cursor/rules and
|
|
6
|
+
* the rest, overwriting those files. A block kj writes straight into CLAUDE.md
|
|
7
|
+
* is erased by the next generate, so in such a repo kj writes into Rulesync's
|
|
8
|
+
* source instead, with the same managed markers.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
|
|
14
|
+
export const RULESYNC_RULE = join(".rulesync", "rules", "karajan.md");
|
|
15
|
+
|
|
16
|
+
// Rulesync's own rule frontmatter: not the root overview, applies to every
|
|
17
|
+
// target and every file.
|
|
18
|
+
const FRONTMATTER = [
|
|
19
|
+
"---",
|
|
20
|
+
"root: false",
|
|
21
|
+
'targets: ["*"]',
|
|
22
|
+
'description: "Karajan Code: method, guidelines and playbook (managed by kj)"',
|
|
23
|
+
'globs: ["**/*"]',
|
|
24
|
+
"---",
|
|
25
|
+
"",
|
|
26
|
+
].join("\n");
|
|
27
|
+
|
|
28
|
+
/** The project uses Rulesync when it has `.rulesync/` or `rulesync.jsonc`. */
|
|
29
|
+
export function detectRulesync(projectDir) {
|
|
30
|
+
return existsSync(join(projectDir, ".rulesync")) || existsSync(join(projectDir, "rulesync.jsonc"));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What a brand-new karajan.md starts with, before kj's managed blocks. */
|
|
34
|
+
export const rulesyncRuleSeed = () => FRONTMATTER;
|
|
35
|
+
|
|
36
|
+
const GENERATE_ARGS = ["generate", "--targets", "*", "--features", "*"];
|
|
37
|
+
const defaultRun = (cmd, args, cwd) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" });
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Compile kj's rules out to every agent, only when the project declared
|
|
41
|
+
* `rulesync.generate: true`: kj does not run someone else's compiler on its
|
|
42
|
+
* own. And never a package fetched on the fly: the local binary, or
|
|
43
|
+
* `npx --no-install`. A failure is said out loud and does not break the install.
|
|
44
|
+
*/
|
|
45
|
+
export function maybeRulesyncGenerate({ projectDir, config, logger = console, run = defaultRun }) {
|
|
46
|
+
if (!detectRulesync(projectDir)) return { ran: false, reason: "no-rulesync" };
|
|
47
|
+
if (config?.rulesync?.generate !== true) {
|
|
48
|
+
logger.info?.(`kj: its rules are in ${RULESYNC_RULE}; run rulesync generate to reach your agents, or declare rulesync.generate: true`);
|
|
49
|
+
return { ran: false, reason: "not-opted-in" };
|
|
50
|
+
}
|
|
51
|
+
const local = join(projectDir, "node_modules", ".bin", "rulesync");
|
|
52
|
+
const [cmd, args] = existsSync(local) ? [local, GENERATE_ARGS] : ["npx", ["--no-install", "rulesync", ...GENERATE_ARGS]];
|
|
53
|
+
try {
|
|
54
|
+
run(cmd, args, projectDir);
|
|
55
|
+
logger.info?.("kj: rulesync generate spread kj's rules to every agent");
|
|
56
|
+
return { ran: true };
|
|
57
|
+
} catch (err) {
|
|
58
|
+
logger.warn?.(`kj: rulesync generate failed (${err.message}); kj's rules are in ${RULESYNC_RULE}, run it yourself`);
|
|
59
|
+
return { ran: false, reason: "failed" };
|
|
60
|
+
}
|
|
61
|
+
}
|