karajan-code 4.37.0 → 4.38.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.37.0",
3
+ "version": "4.38.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -26,6 +26,15 @@ const seaTransformPlugin = {
26
26
  let contents = await fs.readFile(args.path, "utf8");
27
27
  let modified = false;
28
28
 
29
+ // KJC-TSK-0915 (ADR 0014): the Sentinel's guard modules are read as TEXT at
30
+ // load time, to be copied into a project's harness. A single-file bundle has
31
+ // no sibling files, so the text is inlined here, before import.meta.url goes.
32
+ const SENTINEL_READ = /readFileSync\(new URL\("(\.\/sentinel\/[\w-]+\.mjs)", import\.meta\.url\), "utf8"\)/g;
33
+ if (SENTINEL_READ.test(contents)) {
34
+ contents = contents.replace(SENTINEL_READ, (_m, rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")));
35
+ modified = true;
36
+ }
37
+
29
38
  // Replace import.meta.dirname with __dirname
30
39
  if (contents.includes("import.meta.dirname")) {
31
40
  contents = contents.replaceAll("import.meta.dirname", "__dirname");
@@ -30,7 +30,7 @@ import { envInstallCommand, briefCommand } from "../commands/env.js";
30
30
  import { runReleaseCheck } from "../checks/release-check.js";
31
31
  import { agentRunCommand } from "../commands/agent-run.js";
32
32
  import { reportIssueCommand } from "../commands/report-issue.js";
33
- import { huCommand } from "../commands/hu.js";
33
+ import { huCommand, HU_STATUSES } from "../commands/hu.js";
34
34
  import { worktreeCommand } from "../commands/worktree.js";
35
35
  import { addAdr, listAdrs } from "../environment/adr.js";
36
36
  import { formatAdvancedIndex } from "../commands/advanced.js";
@@ -271,6 +271,8 @@ export function registerMeta(program, { pkgVersion }) {
271
271
  });
272
272
  });
273
273
  hu.command("move <id> <status>")
274
+ // KJC-BUG-0257: the valid states, said before the call fails.
275
+ .description(`Move a card; <status> is one of: ${HU_STATUSES.join(", ")}`)
274
276
  .option("--json", "Machine-readable output")
275
277
  .action(async (id, status, flags) => {
276
278
  await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
@@ -14,7 +14,7 @@ import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
14
14
  import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
15
15
  import { loadGoldenQueries, runEval } from "../rag/eval.js";
16
16
  import { getKarajanHome } from "../utils/paths.js";
17
- import { countProjectChunks, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
17
+ import { countProjectChunks, countProjectSources, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
18
18
  import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
19
19
 
20
20
  // KJC-TSK-0882 (ADR 0011): el indice es del proyecto, no de la maquina.
@@ -153,6 +153,11 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
153
153
  if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope, remedy })}\n`);
154
154
  return [];
155
155
  }
156
+ // KJC-BUG-0258: chunks of plans and briefs but none of the project's own files
157
+ // answer nothing about the code; said now, not when kj review --staged blocks.
158
+ if (!library && project && countProjectSources(db, project, config?.projectDir || process.cwd()) === 0) {
159
+ logger.warn("[rag] this project's index holds none of its own files (only plans or briefs): run kj rag index --with-sources");
160
+ }
156
161
  const mode = flags.mode || "hybrid";
157
162
  const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
158
163
  // Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
@@ -234,6 +239,19 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
234
239
  }
235
240
  }
236
241
 
242
+ /**
243
+ * KJC-BUG-0255: what `kj rag migrate` keeps. The project's own files pass the
244
+ * indexer's criterion of today; sources outside the tree (plans, onboarding)
245
+ * are kept as they are.
246
+ */
247
+ export function migrateKeep(projectDir, config) {
248
+ const exclude = ragExclude(config);
249
+ return (source) => {
250
+ const rel = relative(projectDir, source);
251
+ return rel.startsWith("..") || isAbsolute(rel) || indexableReason(rel, source, { exclude, projectDir }) === null;
252
+ };
253
+ }
254
+
237
255
  /**
238
256
  * KJC-TSK-0888 (RAG-P1a, ADR 0011) — `kj rag migrate`: copia los chunks de este
239
257
  * proyecto desde la base global a su propio indice, con sus embeddings, sin
@@ -241,11 +259,13 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
241
259
  */
242
260
  export async function ragMigrateCommand({ config, flags = {} }) {
243
261
  const projectDir = config?.projectDir || process.cwd();
262
+ const keep = migrateKeep(projectDir, config);
244
263
  const res = migrateProjectIndex({
245
264
  slug: projectSlug(projectDir),
246
265
  legacyPath: join(getKarajanHome(), "rag.db"),
247
266
  targetPath: projectDbPath(projectDir),
248
267
  dim: config?.rag?.embedder?.dim || 768,
268
+ keep,
249
269
  });
250
270
  if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
251
271
  else process.stdout.write(`rag migrate: ${res.state} — ${res.reason}\n`);
@@ -0,0 +1,106 @@
1
+ // The Sentinel's bash-write guard (KJC-BUG-0237, moved here by KJC-TSK-0919,
2
+ // ADR 0014). Inside the repo, files are written through Edit/Write only, the path
3
+ // every gate guards. Copied byte for byte into .karajan/harness; `root` injected.
4
+ // The stated limit: a program you run can write files; only a sandbox stops that.
5
+ import { existsSync, realpathSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, relative, resolve } from "node:path";
8
+ import { headIndex, shellSegments } from "./sentinel-shell.mjs";
9
+
10
+ const WRITES = ["tee", "touch", "truncate", "cp", "mv", "install", "ln", "sed", "perl", "dd", "sh", "bash", "zsh", "dash", "eval"];
11
+ const RUNNERS = ["xargs", "find", "node", "python3", "python", "ruby", "php", "deno", "bun"];
12
+ // touch/truncate -s SIZE, -r REF, -d DATE, -t STAMP: the value is not a file it writes.
13
+ const VALUED = ["-s", "--size", "-r", "--reference", "-d", "--date", "-t"];
14
+
15
+ /** Redirection targets: >&N and >&- duplicate or close a descriptor; >& FILE and >&FILE write FILE. */
16
+ const redirections = (words) => {
17
+ const out = [];
18
+ words.forEach((w, k) => {
19
+ const m = /^\d*(>>|>[|]|>)(.*)$/.exec(w);
20
+ if (!m) return;
21
+ const dup = m[2].startsWith("&") ? m[2].slice(1) : null;
22
+ if (dup === null) out.push(m[2] || words[k + 1]);
23
+ else if (dup === "") out.push(words[k + 1]);
24
+ else if (!/^\d*-?$/.test(dup)) out.push(dup);
25
+ });
26
+ return out;
27
+ };
28
+
29
+ /** Destination of cp/mv/install/ln: -t DIR / --target-directory[=]DIR, else the last plain word. */
30
+ const destination = (rest, plain) => {
31
+ const t = rest.findIndex((w) => w === "-t" || w === "--target-directory");
32
+ const tEq = rest.find((w) => w.startsWith("--target-directory="));
33
+ if (t >= 0) return [rest[t + 1]];
34
+ if (tEq) return [tEq.slice(19)];
35
+ return plain.length >= 2 ? [plain.at(-1)] : [];
36
+ };
37
+
38
+ /** Files sed/perl -i edit: the script is the word after -e/-f, else the first plain word; a lone plain word is the file. */
39
+ const inPlace = (rest, plain) => {
40
+ if (!rest.some((w) => w.startsWith("--in-place") || /^-[a-zA-Z0-9]*i/.test(w))) return [];
41
+ const script = rest.findIndex((w) => ["-e", "-f", "--expression"].includes(w));
42
+ if (script >= 0) return plain.filter((w) => w !== rest[script + 1]);
43
+ return plain.length === 1 ? plain : plain.slice(1);
44
+ };
45
+
46
+ /**
47
+ * The files a simple command (a word list) writes from the shell. "$(...)"
48
+ * marks a target that only exists at run time: unknowable, so writesRepo denies it.
49
+ * @param {string[]} words
50
+ * @returns {string[]}
51
+ */
52
+ export const shellWrites = (words) => {
53
+ const out = redirections(words);
54
+ const i = headIndex(words, [...WRITES, ...RUNNERS]);
55
+ const head = (words[i] || "").split("/").at(-1);
56
+ // xargs / find -exec run a writer on targets that arrive at run time.
57
+ if (["xargs", "find"].includes(head) && words.slice(i + 1).some((w) => WRITES.includes(w.split("/").at(-1)))) out.push(`$(${head})`);
58
+ // Arguments without redirections (< << <<< > >> and a detached operand).
59
+ const rest = [];
60
+ for (let k = i + 1; k < words.length; k++) {
61
+ if (!/^\d*[<>]/.test(words[k])) rest.push(words[k]);
62
+ else if (/^\d*(<{1,3}|>>?|>[|&])$/.test(words[k])) k++;
63
+ }
64
+ // Operands: words that are not options, and EVERY word after "--" (touch -- -file).
65
+ const cut = rest.includes("--") ? rest.indexOf("--") : rest.length;
66
+ const plain = [...rest.slice(0, cut).filter((w) => !w.startsWith("-")), ...rest.slice(cut + 1)];
67
+ if (head === "tee") out.push(...plain);
68
+ if (head === "dd") out.push(...rest.filter((w) => w.startsWith("of=")).map((w) => w.slice(3)));
69
+ // By position, not value (touch -d today today writes "today").
70
+ if (head === "touch" || head === "truncate") out.push(...rest.filter((w, k) => k > cut || (k < cut && !w.startsWith("-") && !VALUED.includes(rest[k - 1]))));
71
+ // sh -c / eval run a script of their own: its writes are this command's writes.
72
+ if (["sh", "bash", "zsh", "dash"].includes(head) && rest.includes("-c")) for (const seg of shellSegments(rest[rest.indexOf("-c") + 1] || "")) out.push(...shellWrites(seg));
73
+ if (head === "eval") for (const seg of shellSegments(rest.join(" "))) out.push(...shellWrites(seg));
74
+ // An inline script (node -e, python -c...) that names a write API.
75
+ const inline = rest[rest.findIndex((w) => ["-e", "-c", "--eval", "-p", "-r"].includes(w)) + 1] || "";
76
+ if (/^(node|deno|bun|python[\d.]*|ruby|perl|php)$/.test(head) && /write|append|open|copy|rename|truncate|unlink|mkdir|symlink/i.test(inline)) out.push(`$(${head})`);
77
+ if (["cp", "mv", "install", "ln"].includes(head)) out.push(...destination(rest, plain));
78
+ if (head === "sed" || head === "perl") out.push(...inPlace(rest, plain));
79
+ return out.filter(Boolean);
80
+ };
81
+
82
+ /**
83
+ * Inside the repo, or unknowable ($VAR, backtick, ~user): both deny. /dev/* and
84
+ * descriptor duplication (&1) are outside; ~/ is the home, where the repo may live.
85
+ * Real paths: a link outside (/tmp/link -> repo) points in.
86
+ * @param {string} t
87
+ * @param {string} root
88
+ */
89
+ export const writesRepo = (t, root) => {
90
+ if (t.includes("$") || t.includes("`")) return true;
91
+ if (t.startsWith("&") || t.startsWith("/dev/")) return false;
92
+ if (t.startsWith("~") && t !== "~" && !t.startsWith("~/")) return true;
93
+ let a = resolve(root, t.startsWith("~") ? homedir() + t.slice(1) : t);
94
+ let tail = "";
95
+ while (!existsSync(a) && dirname(a) !== a) {
96
+ tail = join(a.slice(dirname(a).length + 1), tail);
97
+ a = dirname(a);
98
+ }
99
+ let rel;
100
+ try {
101
+ rel = relative(realpathSync(root), join(realpathSync(a), tail));
102
+ } catch {
103
+ return true;
104
+ }
105
+ return !rel.startsWith("..") && !rel.startsWith("/");
106
+ };
@@ -0,0 +1,98 @@
1
+ // The Sentinel's discard guard (KJC-BUG-0238/0241/0242, moved here by KJC-TSK-0916,
2
+ // ADR 0014). An agent does not discard changes it did not make. Copied byte for
3
+ // byte into .karajan/harness; `root` is the project root, injected.
4
+ import { spawnSync } from "node:child_process";
5
+ import { relative, resolve } from "node:path";
6
+ import process from "node:process";
7
+ import { headIndex, shortOpts } from "./sentinel-shell.mjs";
8
+
9
+ export const DISCARD_VERBS = ["checkout", "restore", "reset", "stash", "switch", "clean"];
10
+
11
+ /**
12
+ * What a git command would discard, per simple command (a word list).
13
+ * null = discards nothing (or is not git); {stash} = drops saved work;
14
+ * {unknown} = cannot be read with certainty; {clean} = untracked files;
15
+ * {paths} = working-tree changes (":/" = the whole tree, whatever the cwd).
16
+ * @param {string[]} words
17
+ * @param {string} root
18
+ */
19
+ export const discardOf = (words, root) => {
20
+ let i = headIndex(words, ["git"]); // /usr/bin/git is git
21
+ // A $variable in command position beside a discard verb ($g checkout) cannot be read.
22
+ if (words[i]?.startsWith("$") && words.some((w) => DISCARD_VERBS.includes(w))) return { unknown: true };
23
+ if (words[i]?.split("/").at(-1) !== "git") return null;
24
+ let cwd = root;
25
+ for (i++; i < words.length && words[i].startsWith("-"); i++) {
26
+ if (words[i] === "-C") cwd = resolve(cwd, words[++i] || ".");
27
+ else if (words[i] === "-c") i++;
28
+ else if (!["--no-pager", "-P", "--paginate", "-p", "--no-optional-locks"].includes(words[i])) return words.some((w) => DISCARD_VERBS.includes(w)) ? { unknown: true } : null;
29
+ }
30
+ const sub = words[i];
31
+ const args = [];
32
+ for (let j = i + 1; j < words.length; j++) {
33
+ // Options that take a value: the value is not a flag (-e -n excludes "-n").
34
+ // Dropping clean's excludes only makes its probe list MORE files.
35
+ if (["-s", "--source", "-e", "--exclude"].includes(words[j]) || (sub === "clean" && /^-[a-zA-Z]*e$/.test(words[j]))) j++;
36
+ else args.push(words[j]);
37
+ }
38
+ const has = (...f) => f.some((x) => args.includes(x));
39
+ const dd = args.indexOf("--");
40
+ const pos = (dd < 0 ? args : args.slice(0, dd)).filter((a) => !a.startsWith("-"));
41
+ const after = dd < 0 ? [] : args.slice(dd + 1);
42
+ const ALL = { cwd, paths: [":/"] };
43
+ if (sub === "stash") return has("drop", "clear") ? { cwd, stash: true } : null;
44
+ if (sub === "reset") return has("--hard") ? ALL : null;
45
+ // Every clean but a dry run (-n in a short cluster BEFORE "--"): clean.requireForce=false deletes without -f.
46
+ const opts = dd < 0 ? args : args.slice(0, dd);
47
+ // Interactive clean decides at the prompt: nothing to probe, fail-closed.
48
+ if (sub === "clean" && opts.some((a) => a === "--interactive" || shortOpts(a).includes("i"))) return { unknown: true };
49
+ if (sub === "clean") return opts.some((a) => a === "--dry-run" || shortOpts(a).includes("n")) ? null : { cwd, clean: [opts, args.slice(opts.length)] };
50
+ if (sub === "switch") return has("--discard-changes", "-f", "--force") ? ALL : null;
51
+ if (sub === "restore") return has("--staged", "-S") && !has("--worktree", "-W") ? null : { cwd, paths: [...pos, ...after] };
52
+ if (sub !== "checkout") return null;
53
+ if (has("-f", "--force")) return ALL;
54
+ if (has("-b", "-B", "--orphan") || !(pos.length || after.length)) return null;
55
+ if (after.length) return { cwd, paths: after };
56
+ const isRef = spawnSync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${pos[0]}^{commit}`]).status === 0;
57
+ if (!isRef) return { cwd, paths: pos };
58
+ return pos.length > 1 ? { cwd, paths: pos.slice(1) } : null;
59
+ };
60
+
61
+ /**
62
+ * Files the discard would lose that the session did not own: dirty now and not
63
+ * clean at first touch. Anything unreadable, or another repo, is foreign.
64
+ * @param {object} d what discardOf returned
65
+ * @param {Record<string, string>} touch the session's first_touch ledger
66
+ * @param {string} root
67
+ * @returns {string[]}
68
+ */
69
+ export const foreignLost = (d, touch, root) => {
70
+ if (d.stash) return ["git stash"];
71
+ if (d.unknown) return ["(comando no verificable: ejecutalo como git simple)"];
72
+ const top = spawnSync("git", ["-C", d.cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
73
+ const sameRepo = top.status === 0 && resolve(String(top.stdout).trim()) === resolve(root);
74
+ const own = (f) => sameRepo && Object.hasOwn(touch, f) && touch[f] === "clean";
75
+ if (d.clean) {
76
+ // Ask git what it would remove: -n wins over -f, so the same flags (-ff included) are kept,
77
+ // minus -q (it silences the listing) and every exclude (dropping one only lists MORE).
78
+ // -n FIRST: after "--" it is a pathspec. LC_ALL=C: lines are parsed.
79
+ const [opts, rest] = d.clean;
80
+ const loud = opts.map((a) => (a.startsWith("--") ? a : `-${shortOpts(a).replaceAll("q", "")}`)).filter((a) => a !== "-" && a !== "--quiet" && !a.startsWith("--exclude"));
81
+ const r = spawnSync("git", ["-C", d.cwd, "clean", "-n", ...loud, ...rest], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } });
82
+ if (r.status !== 0) return ["(git clean -n fallo)"];
83
+ return String(r.stdout).split("\n").filter((l) => l.startsWith("Would remove ")).map((l) => relative(root, resolve(d.cwd, l.slice(13)))).filter((f) => !own(f));
84
+ }
85
+ // Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
86
+ // be proven safe; git would refuse to check it out anyway.
87
+ if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) return [`(ruta no resoluble: ${d.paths.join(" ")})`];
88
+ const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
89
+ if (r.status !== 0) return ["(git status fallo)"];
90
+ const files = [];
91
+ const parts = String(r.stdout).split("\0");
92
+ for (let k = 0; k < parts.length; k++) {
93
+ if (parts[k].length < 4) continue;
94
+ files.push(parts[k].slice(3));
95
+ if ("RC".includes(parts[k][0])) k++;
96
+ }
97
+ return files.filter((f) => !own(f));
98
+ };
@@ -0,0 +1,57 @@
1
+ // The method's reminders (KJC-TSK-0917, ADR 0014). An agent reads its rules at
2
+ // the start and loses them when the context is compacted; a hook does not forget.
3
+ // These are reminders, not gates: never a block. Each comes AFTER the action that
4
+ // precedes the one its rule is about (git add before a commit, gh pr create before
5
+ // a merge), from PostToolUse: a PreToolUse reminder would need permissionDecision
6
+ // "allow", which skips the user's permission prompt. Copied byte for byte into
7
+ // .karajan/harness.
8
+ import { headIndex, shellSegments } from "./sentinel-shell.mjs";
9
+
10
+ /** The git or gh subcommand of a simple command, or null: "git commit" -> ["git", "commit"]. */
11
+ const verbOf = (words) => {
12
+ const i = headIndex(words, ["git", "gh"]);
13
+ const tool = (words[i] || "").split("/").at(-1);
14
+ if (tool !== "git" && tool !== "gh") return null;
15
+ let j = i + 1;
16
+ while (j < words.length && words[j].startsWith("-")) j += ["-C", "-c", "-R", "--repo"].includes(words[j]) ? 2 : 1;
17
+ return [tool, words[j] || "", words[j + 1] || ""];
18
+ };
19
+
20
+ const REMINDERS = [
21
+ {
22
+ id: "commit",
23
+ when: (verbs) => verbs.some(([t, a]) => t === "git" && a === "add"),
24
+ say: "kj, antes de commitear: cabecera de 100 caracteres como máximo con el sujeto en minúscula, líneas del cuerpo de 100 como máximo y sin atribución a IA. Valida el mensaje con npx commitlint --edit <fichero> y stagea por nombre (nunca git add -A).",
25
+ },
26
+ {
27
+ id: "gh-account",
28
+ when: (verbs) => verbs.some(([t]) => t === "gh") && !verbs.some(([t, a, b]) => t === "gh" && a === "auth" && b === "switch"),
29
+ say: "kj: gh sin cuenta explícita. La cuenta activa puede haberla cambiado otra sesión; antepón gh auth switch --user <cuenta-del-proyecto> && en el mismo comando.",
30
+ },
31
+ {
32
+ id: "merge",
33
+ when: (verbs) => verbs.some(([t, a, b]) => t === "gh" && a === "pr" && b === "create"),
34
+ say: "kj, PR abierta; antes de mergearla: si la card no está terminada, pártela ahora (lo hecho en su card, el resto en una nueva). Tras el merge, muévela con sus commits.",
35
+ },
36
+ {
37
+ id: "sync-main",
38
+ when: (verbs, segs) => segs.some((w) => w.includes("main")) && verbs.some(([t, a]) => t === "git" && ["checkout", "switch", "pull"].includes(a)),
39
+ say: "kj: tras sincronizar main, crea ya la rama de la siguiente tarea desde origin/main. En main nunca se commitea.",
40
+ },
41
+ ];
42
+
43
+ /**
44
+ * The reminders due after a Bash command ran, minus those already shown within
45
+ * the last `every` actions.
46
+ * @param {string} cmd
47
+ * @param {{seen?: Record<string, number>, step?: number, every?: number}} [opts]
48
+ * seen: action number at which each reminder was last shown; step: this action's number.
49
+ * @returns {{id: string, say: string}[]}
50
+ */
51
+ export const remindersFor = (cmd, { seen = {}, step = 0, every = 25 } = {}) => {
52
+ const segs = shellSegments(cmd);
53
+ const verbs = segs.map(verbOf).filter(Boolean);
54
+ return REMINDERS.filter((r) => r.when(verbs, segs))
55
+ .filter((r) => !Object.hasOwn(seen, r.id) || step - seen[r.id] > every)
56
+ .map(({ id, say }) => ({ id, say }));
57
+ };
@@ -0,0 +1,95 @@
1
+ // The Sentinel's shell reader (KJC-TSK-0915, ADR 0014). A real module with no
2
+ // dependencies: kj harden copies it byte for byte into .karajan/harness as
3
+ // sentinel-shell.mjs, so the hooks stay autonomous and this code is unit-tested.
4
+ // It reads command TEXT; whatever it cannot read with certainty, the guards that
5
+ // use it treat as unknowable and deny.
6
+
7
+ /**
8
+ * Simple commands as word lists, quote-aware: an operator or blank inside
9
+ * quotes belongs to the word ('user;work.txt' is one path).
10
+ * @param {string} cmd
11
+ * @returns {string[][]}
12
+ */
13
+ export const shellSegments = (cmd) => {
14
+ const segs = [[]];
15
+ let word = null;
16
+ let quote = null;
17
+ let escaped = false;
18
+ const end = () => {
19
+ if (word !== null) segs.at(-1).push(word);
20
+ word = null;
21
+ };
22
+ for (const ch of String(cmd)) {
23
+ // A backslash keeps the next char literal (an escaped blank joins the word),
24
+ // except inside single quotes.
25
+ if (escaped) { word += ch; escaped = false; continue; }
26
+ if (ch === "\\" && quote !== "'") { escaped = true; word ??= ""; continue; }
27
+ if (quote) {
28
+ if (ch === quote) quote = null;
29
+ else word += ch;
30
+ continue;
31
+ }
32
+ if (ch === "'" || ch === '"') { quote = ch; word ??= ""; continue; }
33
+ // >| and >& are redirections, not a pipe or a fork.
34
+ if ((ch === "|" || ch === "&") && word?.endsWith(">")) { word += ch; continue; }
35
+ // x>file: end "x" and start the redirection word ">" (the target follows in it).
36
+ if (ch === ">" && word !== null && !/^\d*>?$/.test(word)) { end(); word = ">"; continue; }
37
+ // ( ) { } and backticks also cut: what runs inside $( ), a subshell or a group
38
+ // is a command of its own. A backtick leaves "$" in the word it interrupts, as
39
+ // $( does: that word is no longer readable.
40
+ if (ch === "`") word = (word ?? "") + "$";
41
+ if (";&|(){}\n`".includes(ch)) { end(); segs.push([]); continue; }
42
+ if (ch.trim() === "") { end(); continue; }
43
+ word = (word ?? "") + ch;
44
+ }
45
+ end();
46
+ return segs.filter((s) => s.length);
47
+ };
48
+
49
+ const WRAPPERS = new Set(["command", "exec", "sudo", "doas", "nice", "nohup", "time", "env"]);
50
+
51
+ /**
52
+ * Index of the command word. Skips NAME=value assignments AND wrappers (env,
53
+ * sudo, command...) in any interleaving: env MODE=prod tee -> tee. After a
54
+ * wrapper with options (sudo -u root, env -i FOO=1) the command is the first of
55
+ * `heads`; none found means there is no command to read.
56
+ * @param {string[]} words
57
+ * @param {string[]} heads
58
+ */
59
+ export const headIndex = (words, heads) => {
60
+ let i = 0;
61
+ while (i < words.length && (/^[A-Za-z_]\w*=/.test(words[i]) || WRAPPERS.has(words[i].split("/").at(-1)))) i++;
62
+ if (!words[i]?.startsWith("-")) return i;
63
+ const k = words.slice(i).findIndex((x) => heads.includes(x.split("/").at(-1)));
64
+ return k < 0 ? words.length : i + k;
65
+ };
66
+
67
+ /**
68
+ * KJC-BUG-0243: blank the quoted text that cannot run: single-quoted spans, and
69
+ * double-quoted spans with no $ or backtick. What remains is what the shell can
70
+ * still expand or execute, so operator and substitution checks read only that.
71
+ * @param {string} cmd
72
+ */
73
+ export const stripInertQuotes = (cmd) => {
74
+ let out = "";
75
+ for (let i = 0; i < cmd.length; i++) {
76
+ const q = cmd[i];
77
+ if (q !== "'" && q !== '"') { out += q; continue; }
78
+ let j = i + 1;
79
+ while (j < cmd.length && cmd[j] !== q) j += q === '"' && cmd[j] === "\\" ? 2 : 1;
80
+ if (j >= cmd.length) return out + cmd.slice(i); // unclosed: left as is, for the caller to deny
81
+ const body = cmd.slice(i + 1, j);
82
+ out += q === "'" || !/[$`]/.test(body) ? q + q : q + body + q;
83
+ i = j;
84
+ }
85
+ return out;
86
+ };
87
+
88
+ // Options whose value is prose, never a path (gh, git, kj).
89
+ const TEXT_OPTION = /(^|\s)(--title|--body|--message|-m|--notes|--description|--ac|--criteria|--reason|--decision|--context|--consequences)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
90
+
91
+ /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
92
+ export const stripTextOptionValues = (cmd) => cmd.replace(TEXT_OPTION, (_m, pre, opt, sep, val) => `${pre}${opt}${sep}${val[0]}${val[0]}`);
93
+
94
+ /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
95
+ export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // kj sentinel SessionStart hook (KJC-TSK-0918, ADR 0014), managed by `kj harden`.
3
+ // A compaction takes exactly what the agent needs most: the rules it read at the
4
+ // start. After a compaction or a resume, this gives them back, short, with the
5
+ // state of the session. A new session gets nothing: CLAUDE.md already brings them.
6
+ // Copied byte for byte into .karajan/harness; never fails a session (exit 0).
7
+ import process from "node:process";
8
+ import { CARD, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
9
+
10
+ const RULES = [
11
+ "1. Karajan gobierna y se le obedece: no cambies políticas, configuración de gates ni exclusiones para pasar un gate. Si uno parece injusto, díselo a tu usuario o usa kj report-issue.",
12
+ "2. Card antes de código; rama desde origin/main; nunca se commitea en main.",
13
+ "3. kj rag query antes de tocar código que no has consultado en esta sesión.",
14
+ "4. El test que falla va primero; la suite nunca se deja en rojo.",
15
+ "5. kj review --staged antes de cada commit: el veredicto va atado al diff exacto.",
16
+ "6. PR atómica: unas 150 líneas, 200 como máximo; mídela con kj pr-size y parte antes, no en el gate.",
17
+ "7. Conventional Commits, cabecera de 100 caracteres como máximo y sin atribución a IA.",
18
+ "8. Card sin terminar con PR mergeada: pártela antes de mergear; tras el merge, muévela con sus commits.",
19
+ "9. Dentro del repo se escribe solo con Edit/Write; los escapes KJ_ALLOW_* son de tu usuario, no tuyos.",
20
+ ];
21
+
22
+ let raw = "";
23
+ process.stdin.on("data", (d) => { raw += d; });
24
+ process.stdin.on("end", () => {
25
+ try {
26
+ const { session_id: sid = "default", source } = JSON.parse(raw || "{}");
27
+ if (source !== "compact" && source !== "resume") process.exit(0);
28
+ const branch = branchOf() || "?";
29
+ const card = CARD.exec(branch)?.[0]?.toUpperCase() ?? "ninguna en la rama";
30
+ const pending = pendingMoves(load().sessions?.[sid]).map((p) => `${p.card ?? "?"} (PR #${p.pr})`);
31
+ const state = `Estado: rama ${branch}, card ${card}${pending.length ? `; cards mergeadas sin mover: ${pending.join(", ")}` : ""}.`;
32
+ const context = ["Karajan (reglas que la compactación se lleva):", ...RULES, state].join("\n");
33
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } }));
34
+ } catch { /* never fails a session */ }
35
+ process.exit(0);
36
+ });
@@ -8,7 +8,7 @@
8
8
  * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
9
  */
10
10
 
11
- import { readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { execFileSync } from "node:child_process";
13
13
  import { createHash } from "node:crypto";
14
14
  import { join } from "node:path";
@@ -136,6 +136,7 @@ import process from "node:process";
136
136
  import { relative } from "node:path";
137
137
  import { spawnSync } from "node:child_process";
138
138
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
139
+ import { remindersFor } from "./sentinel-reminders.mjs";
139
140
  const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG", "KJ_ALLOW_NO_VERIFY"];
140
141
  let raw = "";
141
142
  process.stdin.on("data", (d) => { raw += d; });
@@ -273,6 +274,15 @@ process.stdin.on("end", () => {
273
274
  // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
274
275
  // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
275
276
  if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
277
+ // KJC-TSK-0917 (ADR 0014): the method's reminders, after the action that precedes
278
+ // the one each rule is about; at most once every 25 Bash actions each. Never a block.
279
+ const st = load();
280
+ const ss = session(st, sid);
281
+ ss.step = (ss.step || 0) + 1;
282
+ const due = remindersFor(cmdText, { seen: ss.reminded || {}, step: ss.step });
283
+ for (const r of due) (ss.reminded ||= {})[r.id] = ss.step;
284
+ save(st);
285
+ if (due.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: due.map((r) => r.say).join(" ") } }));
276
286
  process.exit(0);
277
287
  }
278
288
  const file = input.file_path || input.notebook_path;
@@ -445,6 +455,10 @@ import { spawnSync } from "node:child_process";
445
455
  import { homedir } from "node:os";
446
456
  import { fileURLToPath } from "node:url";
447
457
  import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
458
+ // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
459
+ import { shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
460
+ import { DISCARD_VERBS, discardOf, foreignLost } from "./sentinel-discard.mjs";
461
+ import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
448
462
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
449
463
  // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
450
464
  // (firebase --account a@b --project p deploy --only hosting:main), asi que la
@@ -463,6 +477,10 @@ process.stdin.on("data", (d) => { raw += d; });
463
477
  process.stdin.on("end", () => {
464
478
  try {
465
479
  const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
480
+ // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
481
+ process.on("exit", (code) => {
482
+ if (code === 2) console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
483
+ });
466
484
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
467
485
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
468
486
  // only the human, editing outside it.
@@ -473,6 +491,28 @@ process.stdin.on("end", () => {
473
491
  console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
474
492
  process.exit(2);
475
493
  }
494
+ // KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
495
+ // like the supervisor: a session does not loosen the rules that govern it.
496
+ if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
497
+ console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
498
+ process.exit(2);
499
+ }
500
+ // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
501
+ // sesion toca un fichero se anota si estaba limpio: solo entonces un
502
+ // descarte posterior pierde nada mas que lo de la sesion. Antes de
503
+ // cualquier escape; git que falla cuenta como sucio (fail-closed).
504
+ if (relT && !relT.startsWith("..") && !relT.startsWith("/")) {
505
+ const st = load();
506
+ const touch = (session(st, sid).first_touch ||= {});
507
+ // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
508
+ if (!Object.hasOwn(touch, relT)) {
509
+ // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
510
+ const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
511
+ const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
512
+ Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
513
+ save(st);
514
+ }
515
+ }
476
516
  }
477
517
  // Any Bash that NAMES the supervisor's files is denied — a write-verb
478
518
  // blocklist is bypassable (cp, dd, one-liners), and reading them is what
@@ -535,6 +575,28 @@ process.stdin.on("end", () => {
535
575
  process.exit(2);
536
576
  }
537
577
  }
578
+ // KJC-BUG-0238 (#1886): un agente no descarta cambios que no hizo. Sin
579
+ // escape y antes de KJ_SENTINEL_OFF: es perdida de datos del usuario, y
580
+ // guardarlo (git stash push) nunca pierde nada.
581
+ if (tool === "Bash") {
582
+ const cmdD = String(input.command || "");
583
+ // A git discard nested in $( ), <( ), backticks, eval, xargs or sh -c cannot be read: fail-closed.
584
+ const nested = ["$(", "<(", ">(", String.fromCharCode(96)].some((n) => cmdD.includes(n)) || /(^|[ ;&|])(eval|xargs|bash|sh|zsh|env)( |$)/.test(cmdD);
585
+ if (nested && /(^|[^a-z])git([^a-z]|$)/.test(cmdD) && DISCARD_VERBS.some((v) => cmdD.includes(v))) {
586
+ console.error("karajan sentinel: un descarte git dentro de $( ), backticks, eval, xargs o sh -c no es verificable — ejecutalo como comando simple." + doc("discard"));
587
+ process.exit(2);
588
+ }
589
+ const touch = load().sessions?.[sid]?.first_touch || {};
590
+ for (const words of shellSegments(cmdD)) {
591
+ const d = discardOf(words, ROOT);
592
+ const lost = d ? foreignLost(d, touch, ROOT) : [];
593
+ if (lost.length === 0) continue;
594
+ console.error(d.stash
595
+ ? "karajan sentinel: git stash drop/clear borra trabajo guardado que puede no ser de esta sesion — pideselo a tu usuario." + doc("discard")
596
+ : "karajan sentinel: ese comando descarta cambios que esta sesion no hizo (" + lost.slice(0, 5).join(", ") + (lost.length > 5 ? ", +" + (lost.length - 5) : "") + ") — pueden ser trabajo sin commitear de tu usuario. Si estorban, guardalos recuperables (git stash push -- <ficheros>) y avisale; si el cambio es tuyo sobre su trabajo, deshazlo con Edit. Sin escape: es perdida de datos." + doc("discard"));
597
+ process.exit(2);
598
+ }
599
+ }
538
600
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
539
601
  // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
540
602
  // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
@@ -747,7 +809,8 @@ process.stdin.on("end", () => {
747
809
  // (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
748
810
  // carril los caza el escaner de abajo.)
749
811
  const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame))\\b[^;|&<>$\`(){}\\n\\r]*$/;
750
- if (!READONLY.test(cmd)) {
812
+ // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
813
+ if (!READONLY.test(stripInertQuotes(cmd))) {
751
814
  // cd/pushd invalida TODO razonamiento textual de rutas posteriores
752
815
  // (carrera de bypasses confirmada en review: destino bare, con $,
753
816
  // relativas post-cd...): en un comando NO-read-only, cambiar de
@@ -838,7 +901,9 @@ process.stdin.on("end", () => {
838
901
  if (q !== null) return true; // comilla sin cerrar: no verificable
839
902
  return flush();
840
903
  };
841
- if (/\\$\\(|\`/.test(cmd) || quotedPathWithSpaces(cmd)) {
904
+ // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
905
+ // of a text option (--title, -m...) is not a path.
906
+ if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
842
907
  if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
843
908
  else {
844
909
  console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
@@ -868,6 +933,21 @@ process.stdin.on("end", () => {
868
933
  }
869
934
  }
870
935
  }
936
+ // KJC-BUG-0237 (#1886): the repo is written through Edit/Write only, the path every gate
937
+ // guards. After the lane guard, whose message is the precise one for another lane.
938
+ if (tool === "Bash") {
939
+ let moved = false; // after cd/pushd/popd, a relative target cannot be placed: fail-closed
940
+ // $( ) and backticks run even inside double quotes: their bodies, read from the raw text, are commands too.
941
+ const raw = String(input.command || "");
942
+ const subs = [...[...raw.matchAll(/[$][(]([^()]*)[)]/g)].map((m) => m[1]), ...raw.split(String.fromCharCode(96)).filter((_, k) => k % 2 === 1)];
943
+ for (const words of [...shellSegments(raw), ...subs.flatMap((s) => shellSegments(s))]) {
944
+ if (["cd", "pushd", "popd"].includes(words[0])) moved = true;
945
+ const inRepo = shellWrites(words).filter((t) => writesRepo(t, ROOT) || (moved && !t.startsWith("/")));
946
+ if (inRepo.length === 0) continue;
947
+ console.error("karajan sentinel: escribir ficheros del repo desde Bash (" + inRepo.slice(0, 3).join(", ") + ") se salta los gates de Edit/Write — usa la tool Edit/Write; para renombrar, git mv; fuera del repo (/tmp, scratchpad) Bash es libre." + doc("bash-write"));
948
+ process.exit(2);
949
+ }
950
+ }
871
951
  // KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
872
952
  // la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
873
953
  // PL-A); los defaults del supervisor viven en el motor y el check
@@ -1143,6 +1223,14 @@ const SCRIPT_BODIES = {
1143
1223
  "posttooluse.mjs": POST_BODY,
1144
1224
  "stop.mjs": STOP_BODY,
1145
1225
  "pretooluse-sentinel.mjs": PRETOOL_BODY,
1226
+ // KJC-TSK-0915 (ADR 0014): real modules, copied byte for byte under the SAME name
1227
+ // (so they can import each other in both places). Being here, the installed
1228
+ // record, the tamper check and the human seal cover them as well.
1229
+ "sentinel-shell.mjs": readFileSync(new URL("./sentinel/sentinel-shell.mjs", import.meta.url), "utf8"),
1230
+ "sentinel-discard.mjs": readFileSync(new URL("./sentinel/sentinel-discard.mjs", import.meta.url), "utf8"),
1231
+ "sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
1232
+ "sentinel-reminders.mjs": readFileSync(new URL("./sentinel/sentinel-reminders.mjs", import.meta.url), "utf8"),
1233
+ "sessionstart.mjs": readFileSync(new URL("./sentinel/sessionstart.mjs", import.meta.url), "utf8"),
1146
1234
  };
1147
1235
 
1148
1236
  /**
@@ -1246,6 +1334,7 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1246
1334
  // first attempt at this fix, and the review was right to reject it.
1247
1335
  const sealed = sealedByPath(root, gitShowFn);
1248
1336
  const ownRecord = readInstalledRecord(dir);
1337
+ const hasRecord = existsSync(join(dir, INSTALLED_RECORD));
1249
1338
  const drift = [];
1250
1339
  const tampered = [];
1251
1340
  const regenerated = [];
@@ -1254,6 +1343,10 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1254
1343
  const hash = text === undefined ? null : sha256(text);
1255
1344
  if (hash && sealed.get(`.karajan/harness/${name}`) === hash) drift.push(name);
1256
1345
  else if (hash && ownRecord[name] === hash) regenerated.push(name);
1346
+ // KJC-TSK-0915: absent from an install kj RECORDED (installed.json exists), never
1347
+ // recorded and never sealed = a guard newer than this install, not a deleted
1348
+ // one. Writing it only adds. A dir with no record is not a kj install at all.
1349
+ else if (!hash && hasRecord && !Object.hasOwn(ownRecord, name) && !sealed.has(`.karajan/harness/${name}`)) regenerated.push(name);
1257
1350
  else tampered.push(name);
1258
1351
  }
1259
1352
  // KJC-BUG-0224 caso 1: lo que kj escribio y nadie sello ni toco se pone al
@@ -1333,7 +1426,13 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1333
1426
  // edit matchers wired, the board-sync recorder never ran in production.
1334
1427
  { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
1335
1428
  { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
1429
+ // KJC-BUG-0236 (#1886): the rag-first ledger records MCP answers here;
1430
+ // unwired, the official tool never satisfied the gate.
1431
+ { event: "PostToolUse", matcher: "mcp__.*__kj_rag_query", script: "posttooluse.mjs" },
1336
1432
  { event: "Stop", script: "stop.mjs" },
1433
+ // KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
1434
+ { event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
1435
+ { event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
1337
1436
  ],
1338
1437
  });
1339
1438
  return { scripts: [lib, post, stop, pre], wired, deferred };
@@ -117,6 +117,16 @@ export async function commitSupervisorRegeneration({
117
117
  `harden --commit es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`,
118
118
  );
119
119
  }
120
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
121
+ // KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
122
+ // un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
123
+ // card equivocada. Se para ANTES de pedir nada al humano y se nombra.
124
+ const foreign = run(["diff", "--cached", "--name-only"]).split("\n").filter((f) => f && !f.startsWith(HOOKS_PREFIX) && f !== PROVENANCE_FILE);
125
+ if (foreign.length > 0) {
126
+ throw new Error(
127
+ `harden --commit: hay cambios en el stage que no son del supervisor (${foreign.slice(0, 5).join(", ")}${foreign.length > 5 ? ", …" : ""}): commitéalos en su rama o sácalos del stage (git restore --staged <ficheros>) antes de sellar`,
128
+ );
129
+ }
120
130
  // Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
121
131
  // prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
122
132
  // Un alimentador ciego no conoce el código; automatizar su lectura exige
@@ -126,7 +136,6 @@ export async function commitSupervisorRegeneration({
126
136
  if (answer !== nonce) {
127
137
  throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
128
138
  }
129
- const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
130
139
  const drift = supervisorDrift({ projectDir, gitFn: run });
131
140
  // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
132
141
  // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
@@ -7,11 +7,22 @@
7
7
  * toca: borrarla es decision del usuario, no efecto de una migracion.
8
8
  */
9
9
  import { existsSync } from "node:fs";
10
+ import { sep } from "node:path";
10
11
 
11
12
  import { openVecStore, insertChunk, getLastIndexedCommit, setLastIndexedCommit } from "./vec-store.js";
12
13
 
13
14
  export const countProjectChunks = (db, slug) => db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ?").get(slug).n;
14
15
 
16
+ /**
17
+ * KJC-BUG-0258: chunks of the project's OWN files (sources under projectDir), as
18
+ * opposed to its plans and briefs. A prefix compare, not LIKE: paths may hold % or _.
19
+ */
20
+ export const countProjectSources = (db, slug, projectDir) => {
21
+ // path.sep: sources are stored as native paths (backslashes on Windows).
22
+ const prefix = projectDir.replace(/[\\/]+$/, "") + sep;
23
+ return db.prepare("SELECT COUNT(*) AS n FROM chunks WHERE project_slug = ? AND substr(source, 1, ?) = ?").get(slug, prefix.length, prefix).n;
24
+ };
25
+
15
26
  /**
16
27
  * KJC-TSK-0882: que decir cuando el indice del proyecto no tiene nada suyo.
17
28
  * Si la base global tiene sus chunks, migrar (segundos); si no, indexar.
@@ -29,9 +40,12 @@ export function emptyIndexRemedy({ slug, legacyPath, dim = 768 }) {
29
40
  }
30
41
 
31
42
  /**
32
- * @returns {{state: "migrated"|"already"|"nothing", migrated: number, reason: string}}
43
+ * KJC-BUG-0255: `keep(source)` is the indexer's criterion today (generated,
44
+ * excluded, .ragignore, sensitive, gone). The global index was filled before
45
+ * those rules, so copying it blind brought vendor/*.min.js along as noise.
46
+ * @returns {{state: "migrated"|"already"|"nothing", migrated: number, skipped?: number, reason: string}}
33
47
  */
34
- export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 }) {
48
+ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768, keep = () => true }) {
35
49
  const nothing = { state: "nothing", migrated: 0, reason: `the global index holds nothing for ${slug}: kj rag index --with-sources` };
36
50
  if (!existsSync(legacyPath)) return nothing;
37
51
 
@@ -49,8 +63,10 @@ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 })
49
63
  const rows = legacy.prepare("SELECT id, source, kind, text, metadata, content_hash FROM chunks WHERE project_slug = ?").all(slug);
50
64
  if (rows.length === 0) return nothing;
51
65
  const vecOf = legacy.prepare("SELECT embedding FROM vec_chunks WHERE rowid = ?");
66
+ let skipped = 0;
52
67
  const copy = target.transaction(() => {
53
68
  for (const r of rows) {
69
+ if (!keep(r.source)) { skipped++; continue; }
54
70
  const raw = vecOf.get(BigInt(r.id))?.embedding;
55
71
  if (!raw) continue; // un chunk sin vector no se puede buscar: no se inventa
56
72
  const embedding = new Float32Array(raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength));
@@ -63,7 +79,8 @@ export function migrateProjectIndex({ slug, legacyPath, targetPath, dim = 768 })
63
79
  const migrated = countProjectChunks(target, slug);
64
80
  const parts = [`${migrated} chunks of ${slug} copied with their embeddings`];
65
81
  if (stamp) parts.push(`indexed at ${stamp.slice(0, 9)}`);
66
- return { state: "migrated", migrated, reason: parts.join(", ") };
82
+ if (skipped) parts.push(`${skipped} left out (the indexer would not take them today: generated, excluded or gone)`);
83
+ return { state: "migrated", migrated, skipped, reason: parts.join(", ") };
67
84
  } finally {
68
85
  legacy.close();
69
86
  }