karajan-code 4.37.0 → 4.39.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.
@@ -0,0 +1,125 @@
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 { isAbsolute, 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
+ * KJC-BUG-0261: the tracked files an `rm` or `git rm` removes, relative to root.
63
+ * Removing a file is the session touching it, so its state is noted before it
64
+ * goes and restoring it later is not taken for someone else's change.
65
+ * @param {string[]} words
66
+ * @param {string} root
67
+ * @param {string} cwd where relative paths resolve (the session's cwd)
68
+ * @returns {string[]}
69
+ */
70
+ export const removedFiles = (words, root, cwd = process.cwd()) => {
71
+ const i = headIndex(words, ["rm", "git"]);
72
+ const head = words[i]?.split("/").at(-1);
73
+ const isGitRm = head === "git" && words[i + 1] === "rm";
74
+ if (head !== "rm" && !isGitRm) return [];
75
+ const args = words.slice(i + (isGitRm ? 2 : 1));
76
+ const dd = args.indexOf("--");
77
+ const named = dd < 0 ? args.filter((a) => !a.startsWith("-")) : [...args.slice(0, dd).filter((a) => !a.startsWith("-")), ...args.slice(dd + 1)];
78
+ const rels = named.map((p) => relative(root, resolve(cwd, p))).filter((r) => r && !r.startsWith("..") && !isAbsolute(r));
79
+ if (rels.length === 0) return [];
80
+ // ls-files expands a directory (rm -r dir) into the tracked files it holds.
81
+ const r = spawnSync("git", ["-C", root, "ls-files", "-z", "--", ...rels], { encoding: "utf8" });
82
+ return r.status === 0 ? String(r.stdout).split("\0").filter(Boolean) : [];
83
+ };
84
+
85
+ /**
86
+ * Files the discard would lose that the session did not own: dirty now and not
87
+ * clean at first touch. Anything unreadable, or another repo, is foreign.
88
+ * @param {object} d what discardOf returned
89
+ * @param {Record<string, string>} touch the session's first_touch ledger
90
+ * @param {string} root
91
+ * @returns {string[]}
92
+ */
93
+ export const foreignLost = (d, touch, root) => {
94
+ if (d.stash) return ["git stash"];
95
+ if (d.unknown) return ["(comando no verificable: ejecutalo como git simple)"];
96
+ const top = spawnSync("git", ["-C", d.cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
97
+ const sameRepo = top.status === 0 && resolve(String(top.stdout).trim()) === resolve(root);
98
+ const own = (f) => sameRepo && Object.hasOwn(touch, f) && touch[f] === "clean";
99
+ if (d.clean) {
100
+ // Ask git what it would remove: -n wins over -f, so the same flags (-ff included) are kept,
101
+ // minus -q (it silences the listing) and every exclude (dropping one only lists MORE).
102
+ // -n FIRST: after "--" it is a pathspec. LC_ALL=C: lines are parsed.
103
+ const [opts, rest] = d.clean;
104
+ const loud = opts.map((a) => (a.startsWith("--") ? a : `-${shortOpts(a).replaceAll("q", "")}`)).filter((a) => a !== "-" && a !== "--quiet" && !a.startsWith("--exclude"));
105
+ const r = spawnSync("git", ["-C", d.cwd, "clean", "-n", ...loud, ...rest], { encoding: "utf8", env: { ...process.env, LC_ALL: "C" } });
106
+ if (r.status !== 0) return ["(git clean -n fallo)"];
107
+ 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));
108
+ }
109
+ // Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
110
+ // be proven safe; git would refuse to check it out anyway.
111
+ if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) {
112
+ // KJC-BUG-0261: what the session removed with git rm left the index, and it is still its own.
113
+ return d.paths.map((p) => relative(root, resolve(d.cwd, p))).every(own) ? [] : [`(ruta no resoluble: ${d.paths.join(" ")})`];
114
+ }
115
+ const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
116
+ if (r.status !== 0) return ["(git status fallo)"];
117
+ const files = [];
118
+ const parts = String(r.stdout).split("\0");
119
+ for (let k = 0; k < parts.length; k++) {
120
+ if (parts[k].length < 4) continue;
121
+ files.push(parts[k].slice(3));
122
+ if ("RC".includes(parts[k][0])) k++;
123
+ }
124
+ return files.filter((f) => !own(f));
125
+ };
@@ -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,110 @@
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_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position"]);
90
+ const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
91
+
92
+ /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
93
+ export const stripTextOptionValues = (cmd) => cmd.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
94
+
95
+ /**
96
+ * KJC-BUG-0245: the values of the named options, read by the shell reader, so a
97
+ * `;`, `&&` or `|` right after a path ends the word instead of joining it.
98
+ * Covers `--opt value` and `--opt=value`.
99
+ * @param {string} cmd
100
+ * @param {string[]} names
101
+ * @returns {string[]}
102
+ */
103
+ export const optionValues = (cmd, names) => shellSegments(cmd).flatMap((words) => words.flatMap((w, i) => {
104
+ if (names.includes(w) && i + 1 < words.length) return [words[i + 1]];
105
+ const eq = w.indexOf("=");
106
+ return eq > 0 && names.includes(w.slice(0, eq)) ? [w.slice(eq + 1)] : [];
107
+ }));
108
+
109
+ /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
110
+ 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
+ });