karajan-code 4.38.0 → 4.40.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.
Files changed (53) hide show
  1. package/package.json +1 -1
  2. package/packages/ai-trash/src/destructive-parser.js +34 -5
  3. package/packages/ai-trash/src/hook.js +28 -6
  4. package/src/audit/ai-slop-findings.js +4 -2
  5. package/src/audit/circular-deps.js +8 -7
  6. package/src/audit/webperf-input.js +3 -1
  7. package/src/checks/method.js +1 -1
  8. package/src/checks/release-check.js +3 -1
  9. package/src/cli/advanced-commands.js +1 -1
  10. package/src/cli/register-meta.js +112 -1
  11. package/src/cli/register-pipeline.js +2 -1
  12. package/src/commands/init.js +6 -2
  13. package/src/commands/review-gate.js +21 -22
  14. package/src/commands/rules-approve.js +59 -0
  15. package/src/commands/rules-compile.js +116 -0
  16. package/src/commands/rules-decide.js +51 -0
  17. package/src/commands/rules-review.js +44 -0
  18. package/src/commands/rules.js +169 -0
  19. package/src/config/loader.js +23 -1
  20. package/src/environment/adr.js +5 -2
  21. package/src/harden/config-templates.js +3 -0
  22. package/src/harden/harness-hooks.js +7 -85
  23. package/src/harden/hook-templates.js +6 -5
  24. package/src/harden/human-act.js +70 -0
  25. package/src/harden/phone-sign.js +26 -0
  26. package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
  27. package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
  28. package/src/harden/sentinel/sentinel-discard.mjs +33 -4
  29. package/src/harden/sentinel/sentinel-rules.mjs +86 -0
  30. package/src/harden/sentinel/sentinel-shell.mjs +34 -2
  31. package/src/harden/sentinel/sessionstart.mjs +18 -9
  32. package/src/harden/sentinel-hooks.js +111 -194
  33. package/src/harden/supervisor-commit.js +13 -56
  34. package/src/mcp/handlers/run-handler.js +5 -0
  35. package/src/mcp/sovereignty-guard.js +16 -15
  36. package/src/orchestrator/preflight-checks.js +4 -3
  37. package/src/policy/supervisor-verify.js +20 -6
  38. package/src/privacy/scan.js +7 -0
  39. package/src/review/card-first.js +3 -5
  40. package/src/review/gate-gitignore.js +8 -0
  41. package/src/review/policy-gate.js +4 -6
  42. package/src/review/sonar-pregate.js +3 -3
  43. package/src/review/tests-with-code.js +3 -6
  44. package/src/rules/approval-view.js +49 -0
  45. package/src/rules/compiled.js +108 -0
  46. package/src/rules/coverage.js +23 -0
  47. package/src/rules/evaluate.js +60 -0
  48. package/src/rules/inventory.js +118 -0
  49. package/src/sonar/api.js +21 -0
  50. package/src/sonar/config-resolver.js +19 -1
  51. package/src/sonar/scanner.js +22 -3
  52. package/src/utils/run-log.js +74 -2
  53. package/src/utils/stack-detect.js +12 -0
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The layers that make a command a HUMAN act (ADR 0009), out of the supervisor's
3
+ * seal so that other human-only commands use the very same ones (KJC-TSK-0951).
4
+ *
5
+ * 1-2. the environment and the terminal: an agent session declares itself;
6
+ * 3. the process ancestry: a kj launched by an agent DESCENDS from it, and
7
+ * that is in /proc whoever fakes the pty or cleans the environment;
8
+ * 4. a random nonce typed back, read from the real controlling terminal.
9
+ */
10
+ import { randomBytes } from "node:crypto";
11
+ import { closeSync, openSync, readFileSync, readSync } from "node:fs";
12
+
13
+ const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
14
+
15
+ export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
16
+ const read = readProc || ((p) => {
17
+ const stat = readFileSync(`/proc/${p}/stat`, "utf8");
18
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
19
+ let cmd = "";
20
+ try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
21
+ return { ppid, cmd };
22
+ });
23
+ let cur = pid;
24
+ for (let i = 0; i < maxDepth && cur > 1; i += 1) {
25
+ let info;
26
+ try { info = read(cur); } catch { return { agent: false, unknown: true }; }
27
+ if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
28
+ if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
29
+ cur = info.ppid;
30
+ }
31
+ return { agent: false };
32
+ }
33
+
34
+ /** Layers 1 to 3: throws when `what` runs inside an agent session. */
35
+ export function refuseAgentSession(what, { env = process.env, tty = process.stdout.isTTY, ancestry = {} } = {}) {
36
+ if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
37
+ throw new Error(`${what} es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)`);
38
+ }
39
+ const anc = agentAncestry(ancestry);
40
+ if (anc.agent) {
41
+ throw new Error(`${what} es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`);
42
+ }
43
+ }
44
+
45
+ // The answer comes from the process's REAL tty (not stdin, which an attacker
46
+ // feeds through a pipe): /dev/tty only exists with a controlling terminal.
47
+ function readTty(what, nonce) {
48
+ process.stdout.write(`${what}: teclea "${nonce}" para confirmar que eres humano: `);
49
+ try {
50
+ const buf = Buffer.alloc(64);
51
+ const fd = openSync("/dev/tty", "r");
52
+ const n = readSync(fd, buf, 0, 64);
53
+ closeSync(fd);
54
+ return buf.toString("utf8", 0, n).trim();
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Layer 4: a random nonce typed back. A blind feeder does not know the code;
62
+ * automating its reading takes an expect driver, which is premeditation.
63
+ * @param {string} what
64
+ * @param {(nonce: string) => string|null} [confirm] test seam
65
+ */
66
+ export function confirmHuman(what, confirm = null) {
67
+ const nonce = randomBytes(3).toString("hex");
68
+ const answer = confirm ? confirm(nonce) : readTty(what, nonce);
69
+ if (answer !== nonce) throw new Error(`${what}: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
70
+ }
@@ -132,6 +132,32 @@ export function verifyPhoneSignature({ payload, signature, publicKey }) {
132
132
  return verify(null, Buffer.from(payload, "utf8"), key, Buffer.from(signature, "base64"));
133
133
  }
134
134
 
135
+ /**
136
+ * KJC-TSK-0964 (HUM-A, ADR 0018): the seal's signature, checked where the
137
+ * machine that made it has no say. With signers in the versioned roster, a
138
+ * provenance must be signed by one of them over the very files it declares.
139
+ * With no roster nothing is required: the guarantee is local only.
140
+ * @returns {{required: boolean, ok: boolean, reason?: string}}
141
+ */
142
+ export function provenanceSignature({ projectDir, provenance }) {
143
+ const roster = readSigners({ projectDir });
144
+ if (roster.length === 0) return { required: false, ok: true };
145
+ const fail = (reason) => ({ required: true, ok: false, reason });
146
+ const sig = provenance?.signature;
147
+ if (typeof sig?.signature !== "string" || typeof sig?.signer !== "string") return fail("el padrón tiene firmantes y la procedencia no lleva firma");
148
+ if (!roster.includes(sig.signer)) return fail("la clave que firma la procedencia no está en el padrón");
149
+ // The files as the phone signed them: {file, sha256}, a deletion with no hash.
150
+ // A provenance is read from the project: a malformed one is rejected, it does not crash.
151
+ const entries = Array.isArray(provenance.files) ? provenance.files : [];
152
+ if (entries.some((entry) => typeof entry?.file !== "string")) return fail("la procedencia declara ficheros mal formados");
153
+ const files = entries.map(({ file, sha256 }) => ({ file, sha256: sha256 ?? "" }));
154
+ let good = false;
155
+ try {
156
+ good = verifyPhoneSignature({ payload: canonicalPayload({ ...sig, files }), signature: sig.signature, publicKey: sig.signer });
157
+ } catch { /* a malformed key or signature is a signature that does not verify */ }
158
+ return good ? { required: true, ok: true } : fail("la firma no corresponde a los ficheros que la procedencia declara");
159
+ }
160
+
135
161
  /**
136
162
  * Publica la petición en Firestore, enseña QR + URL, pollea el doc y verifica
137
163
  * la firma. Sin fallbacks silenciosos: red caída o HTTP no-ok ⇒ throw. Firma
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ // kj sentinel rules hook (KJC-TSK-0946, MDR-C2, ADR 0016), managed by `kj harden`.
3
+ // PreToolUse with NO matcher: every tool call, MCP ones included, passes the rules
4
+ // of the MD files (compiled into .karajan/rules.yml) before it runs. Exit 2 blocks
5
+ // the call and stderr says which rule. The decision is the gate's (sentinel-rules.mjs).
6
+ // Copied byte for byte into .karajan/harness.
7
+ import console from "node:console";
8
+ import process from "node:process";
9
+ import { spawnSync } from "node:child_process";
10
+ import { ROOT, doc } from "./sentinel-lib.mjs";
11
+ import { rulesGate } from "./sentinel-rules.mjs";
12
+
13
+ let raw = "";
14
+ process.stdin.on("data", (d) => { raw += d; });
15
+ process.stdin.on("end", () => {
16
+ let call;
17
+ try { call = JSON.parse(raw); } catch { process.exit(0); } // not a tool call: nothing to judge
18
+ // The user's switch for the whole Sentinel, set in the host's environment.
19
+ if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
20
+ const verdict = rulesGate({ root: ROOT, tool: call?.tool_name, input: call?.tool_input, run: spawnSync });
21
+ if (!verdict.deny) process.exit(0);
22
+ console.error("karajan sentinel: " + verdict.message + doc("rules"));
23
+ console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la regla para pasarlo; si te parece injusta, díselo a tu usuario o usa kj report-issue.");
24
+ process.exit(2);
25
+ });
@@ -5,7 +5,7 @@
5
5
  import { existsSync, realpathSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join, relative, resolve } from "node:path";
8
- import { headIndex, shellSegments } from "./sentinel-shell.mjs";
8
+ import { headIndex, shellSegments, withoutRedirections } from "./sentinel-shell.mjs";
9
9
 
10
10
  const WRITES = ["tee", "touch", "truncate", "cp", "mv", "install", "ln", "sed", "perl", "dd", "sh", "bash", "zsh", "dash", "eval"];
11
11
  const RUNNERS = ["xargs", "find", "node", "python3", "python", "ruby", "php", "deno", "bun"];
@@ -56,11 +56,7 @@ export const shellWrites = (words) => {
56
56
  // xargs / find -exec run a writer on targets that arrive at run time.
57
57
  if (["xargs", "find"].includes(head) && words.slice(i + 1).some((w) => WRITES.includes(w.split("/").at(-1)))) out.push(`$(${head})`);
58
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
- }
59
+ const rest = withoutRedirections(words.slice(i + 1));
64
60
  // Operands: words that are not options, and EVERY word after "--" (touch -- -file).
65
61
  const cut = rest.includes("--") ? rest.indexOf("--") : rest.length;
66
62
  const plain = [...rest.slice(0, cut).filter((w) => !w.startsWith("-")), ...rest.slice(cut + 1)];
@@ -2,9 +2,9 @@
2
2
  // ADR 0014). An agent does not discard changes it did not make. Copied byte for
3
3
  // byte into .karajan/harness; `root` is the project root, injected.
4
4
  import { spawnSync } from "node:child_process";
5
- import { relative, resolve } from "node:path";
5
+ import { isAbsolute, relative, resolve } from "node:path";
6
6
  import process from "node:process";
7
- import { headIndex, shortOpts } from "./sentinel-shell.mjs";
7
+ import { headIndex, shortOpts, withoutRedirections } from "./sentinel-shell.mjs";
8
8
 
9
9
  export const DISCARD_VERBS = ["checkout", "restore", "reset", "stash", "switch", "clean"];
10
10
 
@@ -16,7 +16,9 @@ export const DISCARD_VERBS = ["checkout", "restore", "reset", "stash", "switch",
16
16
  * @param {string[]} words
17
17
  * @param {string} root
18
18
  */
19
- export const discardOf = (words, root) => {
19
+ export const discardOf = (command, root) => {
20
+ // KJC-BUG-0268: `git checkout main 2>&1` names a branch, not a branch and a path.
21
+ const words = withoutRedirections(command);
20
22
  let i = headIndex(words, ["git"]); // /usr/bin/git is git
21
23
  // A $variable in command position beside a discard verb ($g checkout) cannot be read.
22
24
  if (words[i]?.startsWith("$") && words.some((w) => DISCARD_VERBS.includes(w))) return { unknown: true };
@@ -58,6 +60,30 @@ export const discardOf = (words, root) => {
58
60
  return pos.length > 1 ? { cwd, paths: pos.slice(1) } : null;
59
61
  };
60
62
 
63
+ /**
64
+ * KJC-BUG-0261: the tracked files an `rm` or `git rm` removes, relative to root.
65
+ * Removing a file is the session touching it, so its state is noted before it
66
+ * goes and restoring it later is not taken for someone else's change.
67
+ * @param {string[]} words
68
+ * @param {string} root
69
+ * @param {string} cwd where relative paths resolve (the session's cwd)
70
+ * @returns {string[]}
71
+ */
72
+ export const removedFiles = (words, root, cwd = process.cwd()) => {
73
+ const i = headIndex(words, ["rm", "git"]);
74
+ const head = words[i]?.split("/").at(-1);
75
+ const isGitRm = head === "git" && words[i + 1] === "rm";
76
+ if (head !== "rm" && !isGitRm) return [];
77
+ const args = words.slice(i + (isGitRm ? 2 : 1));
78
+ const dd = args.indexOf("--");
79
+ const named = dd < 0 ? args.filter((a) => !a.startsWith("-")) : [...args.slice(0, dd).filter((a) => !a.startsWith("-")), ...args.slice(dd + 1)];
80
+ const rels = named.map((p) => relative(root, resolve(cwd, p))).filter((r) => r && !r.startsWith("..") && !isAbsolute(r));
81
+ if (rels.length === 0) return [];
82
+ // ls-files expands a directory (rm -r dir) into the tracked files it holds.
83
+ const r = spawnSync("git", ["-C", root, "ls-files", "-z", "--", ...rels], { encoding: "utf8" });
84
+ return r.status === 0 ? String(r.stdout).split("\0").filter(Boolean) : [];
85
+ };
86
+
61
87
  /**
62
88
  * Files the discard would lose that the session did not own: dirty now and not
63
89
  * clean at first touch. Anything unreadable, or another repo, is foreign.
@@ -84,7 +110,10 @@ export const foreignLost = (d, touch, root) => {
84
110
  }
85
111
  // Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
86
112
  // 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(" ")})`];
113
+ if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) {
114
+ // KJC-BUG-0261: what the session removed with git rm left the index, and it is still its own.
115
+ return d.paths.map((p) => relative(root, resolve(d.cwd, p))).every(own) ? [] : [`(ruta no resoluble: ${d.paths.join(" ")})`];
116
+ }
88
117
  const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
89
118
  if (r.status !== 0) return ["(git status fallo)"];
90
119
  const files = [];
@@ -0,0 +1,86 @@
1
+ // The Sentinel's rules gate (KJC-TSK-0939, MDR-C, ADR 0016). A real module with
2
+ // no dependencies: kj harden copies it byte for byte into .karajan/harness as
3
+ // sentinel-rules.mjs, and this code is unit-tested.
4
+ //
5
+ // The rules written in the MD files, compiled into .karajan/rules.yml, are asked
6
+ // to kj before EVERY tool call. kj decides (exit 0 allow, 2 deny); whatever
7
+ // cannot be evaluated is denied, because rules silently off would be every call
8
+ // allowed. With no rules.yml the gate does not exist and costs nothing.
9
+ import { existsSync } from "node:fs";
10
+ import { join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ const RULES_FILE = join(".karajan", "rules.yml");
14
+ // KJC-TSK-0954 (ADR 0017): the rules from the user's private sources, out of git.
15
+ // Either file is rules: a project with only local ones is gated just the same.
16
+ const LOCAL_RULES_FILE = join(".karajan", "rules.local.yml");
17
+ const hasRules = (root, exists) => exists(join(root, RULES_FILE)) || exists(join(root, LOCAL_RULES_FILE));
18
+ const EDIT_TOOLS = new Set(["Write", "Edit", "MultiEdit", "NotebookEdit"]);
19
+ const KJ_DOES_NOT_START = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/;
20
+
21
+ /**
22
+ * KJC-BUG-0207: a kj that does not start (a linked tree with a syntax error) is
23
+ * not a verdict. Returns the file its stack names, "" when it names none, or
24
+ * null when kj did start.
25
+ * @param {string} stderr
26
+ */
27
+ export const brokenKjFile = (stderr) => {
28
+ const text = String(stderr || "");
29
+ if (!KJ_DOES_NOT_START.test(text)) return null;
30
+ // The location line node prints: a path or file: URL (blanks allowed), then :line.
31
+ const at = text.split("\n").map((line) => line.trim()).find((line) => /^(file:|\/|[A-Za-z]:\\).*\.(m?js|cjs|ts):\d+$/.test(line)) || "";
32
+ const file = at.slice(0, at.lastIndexOf(":"));
33
+ return file.startsWith("file:") ? fileURLToPath(file) : file;
34
+ };
35
+
36
+ const lastJson = (stdout) => {
37
+ try { return JSON.parse(String(stdout || "").trim().split("\n").pop()); } catch { return null; }
38
+ };
39
+ const deny = (message) => ({ deny: true, message });
40
+
41
+ /**
42
+ * KJC-TSK-0949 (MDR-E2): an MD changed and what was compiled no longer covers
43
+ * it. One line for the session's start, or "" when every rule is decided, there
44
+ * is no rules.yml, or kj cannot tell (a notice never fails a session).
45
+ * @param {{root: string, run: Function, exists?: Function}} opts
46
+ * @returns {string}
47
+ */
48
+ export function rulesDriftNotice({ root, run, exists = existsSync }) {
49
+ if (!hasRules(root, exists)) return "";
50
+ const res = run("kj", ["rules", "coverage", "--json"], { cwd: root, encoding: "utf8" });
51
+ const counts = res.error || res.status !== 0 ? null : lastJson(res.stdout)?.counts;
52
+ const none = Number(counts?.none) || 0;
53
+ const stale = Number(counts?.stale) || 0;
54
+ if (none + stale === 0) return "";
55
+ return none + " sin gate y " + stale + " desfasada(s) entre las reglas de tus MD (" + RULES_FILE + "). Ejecuta kj rules compile, "
56
+ + "escribe la propuesta y pide a tu usuario que la apruebe (kj rules approve); kj rules coverage las nombra.";
57
+ }
58
+
59
+ /**
60
+ * @param {{root: string, tool: string, input?: object, run: Function, exists?: Function}} call
61
+ * `run` is spawnSync (injected so the gate is testable without a process).
62
+ * @returns {{deny: boolean, message?: string}}
63
+ */
64
+ export function rulesGate({ root, tool, input, run, exists = existsSync }) {
65
+ if (!hasRules(root, exists)) return { deny: false };
66
+ // The input goes on stdin: a large Write does not fit in an argument.
67
+ const res = run("kj", ["rules", "eval", "--tool", String(tool), "--input", "-"], { cwd: root, encoding: "utf8", input: JSON.stringify(input ?? {}) });
68
+ if (res.error || res.status === null) {
69
+ return deny(RULES_FILE + " declara reglas pero kj no es ejecutable, así que no se pueden evaluar: restaura kj en el PATH.");
70
+ }
71
+ if (res.status === 0) return { deny: false };
72
+ const out = lastJson(res.stdout);
73
+ if (res.status === 2) {
74
+ const source = out?.source ? " (" + out.source + ")" : "";
75
+ const what = String(out?.message ?? "la acción rompe una regla de los MD").replace(/[.\s]+$/, "");
76
+ return deny("regla " + (out?.rule_id ?? "sin identificar") + source + ": " + what + ". Sin escape (ADR 0016): si la regla está mal compilada, la corrige tu usuario.");
77
+ }
78
+ const broken = brokenKjFile(res.stderr);
79
+ if (broken !== null) {
80
+ const target = input?.file_path || input?.notebook_path || "";
81
+ if (EDIT_TOOLS.has(tool) && broken && target && resolve(String(target)) === resolve(broken)) return { deny: false };
82
+ return deny("kj no arranca" + (broken ? " (" + broken + ")" : "") + ", así que las reglas no se pueden evaluar y nada más pasa: arregla ese fichero.");
83
+ }
84
+ const why = Array.isArray(out?.errors) ? out.errors.join("; ") : "exit " + res.status;
85
+ return deny(RULES_FILE + " no se puede evaluar (" + why + "): lo corrige tu usuario, fuera de la sesión.");
86
+ }
@@ -86,10 +86,42 @@ export const stripInertQuotes = (cmd) => {
86
86
  };
87
87
 
88
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;
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;
90
91
 
91
92
  /** 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
+ 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
+ /**
110
+ * KJC-BUG-0268: the words of a simple command without its redirections (2>&1,
111
+ * >file, < file, >> log...) and the operand a detached operator takes. A
112
+ * redirection is where output goes, never an argument of the command.
113
+ * @param {string[]} words
114
+ */
115
+ export const withoutRedirections = (words) => {
116
+ const rest = [];
117
+ let operand = false; // the previous word was a bare operator: this one is its target
118
+ for (const word of words) {
119
+ if (operand) operand = false;
120
+ else if (!/^\d*[<>]/.test(word)) rest.push(word);
121
+ else operand = /^\d*(<{1,3}|>>?|>[|&])$/.test(word);
122
+ }
123
+ return rest;
124
+ };
93
125
 
94
126
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
95
127
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
@@ -2,10 +2,12 @@
2
2
  // kj sentinel SessionStart hook (KJC-TSK-0918, ADR 0014), managed by `kj harden`.
3
3
  // A compaction takes exactly what the agent needs most: the rules it read at the
4
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.
5
+ // state of the session. A new session gets no rules: CLAUDE.md already brings them.
6
6
  // Copied byte for byte into .karajan/harness; never fails a session (exit 0).
7
7
  import process from "node:process";
8
- import { CARD, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
8
+ import { spawnSync } from "node:child_process";
9
+ import { CARD, ROOT, branchOf, load, pendingMoves } from "./sentinel-lib.mjs";
10
+ import { rulesDriftNotice } from "./sentinel-rules.mjs";
9
11
 
10
12
  const RULES = [
11
13
  "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.",
@@ -24,13 +26,20 @@ process.stdin.on("data", (d) => { raw += d; });
24
26
  process.stdin.on("end", () => {
25
27
  try {
26
28
  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 } }));
29
+ // KJC-TSK-0949: an MD changed and its compiled rules fell behind. Said at
30
+ // every start, a new session included: that one gets this line and nothing else.
31
+ const drift = rulesDriftNotice({ root: ROOT, run: spawnSync });
32
+ const lines = [];
33
+ if (source === "compact" || source === "resume") {
34
+ const branch = branchOf() || "?";
35
+ const card = CARD.exec(branch)?.[0]?.toUpperCase() ?? "ninguna en la rama";
36
+ const pending = pendingMoves(load().sessions?.[sid]).map((p) => `${p.card ?? "?"} (PR #${p.pr})`);
37
+ const unmoved = pending.length ? "; cards mergeadas sin mover: " + pending.join(", ") : "";
38
+ lines.push("Karajan (reglas que la compactación se lleva):", ...RULES, `Estado: rama ${branch}, card ${card}${unmoved}.`);
39
+ }
40
+ if (drift) lines.push(`Karajan: ${drift}`);
41
+ if (lines.length === 0) process.exit(0);
42
+ process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: lines.join("\n") } }));
34
43
  } catch { /* never fails a session */ }
35
44
  process.exit(0);
36
45
  });