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 +1 -1
- package/scripts/esbuild-sea.config.mjs +9 -0
- package/src/cli/register-meta.js +3 -1
- package/src/commands/rag.js +21 -1
- package/src/harden/sentinel/sentinel-bash-write.mjs +106 -0
- package/src/harden/sentinel/sentinel-discard.mjs +98 -0
- package/src/harden/sentinel/sentinel-reminders.mjs +57 -0
- package/src/harden/sentinel/sentinel-shell.mjs +95 -0
- package/src/harden/sentinel/sessionstart.mjs +36 -0
- package/src/harden/sentinel-hooks.js +102 -3
- package/src/harden/supervisor-commit.js +10 -1
- package/src/rag/migrate.js +20 -3
package/package.json
CHANGED
|
@@ -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");
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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 }) => {
|
package/src/commands/rag.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
package/src/rag/migrate.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
}
|