karajan-code 4.65.1 → 4.66.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.
@@ -8,12 +8,11 @@
8
8
  * hooks, which is why the guaranteed level requires Claude as host (ADR).
9
9
  */
10
10
 
11
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, 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";
15
- import { CARD_REF_RE } from "../review/card-first.js";
16
- import { HARNESS_BODIES, mergeClaudeHooks, writeHarnessScript } from "./harness-hooks.js";
15
+ import { HARNESS_BODIES, mergeClaudeHooks, wiringState, writeHarnessScript } from "./harness-hooks.js";
17
16
 
18
17
  /** The harness lives at the PROJECT root — resolve it even from a subdir. */
19
18
  export function resolveSentinelRoot(dir = process.cwd()) {
@@ -26,1182 +25,16 @@ export function resolveSentinelRoot(dir = process.cwd()) {
26
25
  }
27
26
  }
28
27
 
29
- const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
30
- // Single source for every sentinel script: state, branch, classification,
31
- // and violations.
32
- import { readFileSync, writeFileSync } from "node:fs";
33
- import { execSync, spawnSync } from "node:child_process";
34
- import { dirname, join } from "node:path";
35
- import { fileURLToPath } from "node:url";
36
- const here = dirname(fileURLToPath(import.meta.url));
37
- export const STATE = join(here, "sentinel-state.json");
38
- export const ROOT = join(here, "..", "..");
39
- // INF-A (KJC-TSK-0758): infra IS source — terraform/k8s/shell/Docker edits
40
- // engage the method like app code; *_test.* files (terratest) are tests.
41
- export const TESTS = /(^|\\/)(tests?|__tests__|spec)\\/|\\.(test|spec)\\.[a-z]+$|_test\\.[a-z]+$/;
42
- export const CODE = /\\.(m?[jt]sx?|c[jt]s|py|go|rs|java|rb|php|cs|swift|kt|astro|svelte|vue|c|h|cc|cpp|hpp|tf|hcl|ya?ml|sh)$|(^|\\/)(Dockerfile|Makefile|Vagrantfile)$/;
43
- export const CARD = new RegExp(${JSON.stringify(CARD_REF_RE.source)}, "i");
44
- export const BASE_BRANCHES = new Set(["main", "master"]);
45
- export const load = () => { try { return JSON.parse(readFileSync(STATE, "utf8")); } catch { return {}; } };
46
- export const save = (state) => writeFileSync(STATE, JSON.stringify(state, null, 2));
47
- export const doc = (a) => " — doc: https://karajancode.com/docs/guides/sentinel/#" + a;
48
- export const session = (state, sid) => {
49
- state.sessions ||= {};
50
- return (state.sessions[sid] ||= { edited_sources: [], edited_tests: [], escapes: [], errors: [], blocks: 0 });
51
- };
52
- export const branchOf = () => {
53
- try { return execSync("git rev-parse --abbrev-ref HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); }
54
- catch { return null; }
55
- };
56
- const _git = (args, cwd) => {
57
- try { return execSync("git " + args, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
58
- };
59
- let _home;
60
- // MONO-0 (ADR 0002): lane identity = worktree toplevel; repo identity =
61
- // git-common-dir. Same repo but another toplevel than OUR root => foreign
62
- // lane (returns its toplevel); anything else => null.
63
- export const foreignLane = (targetDir) => {
64
- const top = _git("rev-parse --show-toplevel", targetDir);
65
- if (!top) return null;
66
- _home ||= { top: _git("rev-parse --show-toplevel", ROOT), common: _git("rev-parse --path-format=absolute --git-common-dir", ROOT) };
67
- if (!_home.top || top === _home.top) return null;
68
- const common = _git("rev-parse --path-format=absolute --git-common-dir", targetDir);
69
- return common && common === _home.common ? top : null;
70
- };
71
- // KJC-TSK-0765 board-sync: cards merged in this session but not yet moved in
72
- // the tracker. The method does not advance until the board is true.
73
- export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
74
- export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
75
- // KJC-BUG-0185: bootstrap phase = the repo has no commit yet. There is no base
76
- // branch to protect and no coverage to demand over a tree that does not exist,
77
- // and \`git diff HEAD\` cannot even run. The phase is a verifiable FACT, never a
78
- // flag, and it ends at the first commit.
79
- export const bootstrapPhase = () => {
80
- try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
81
- catch { return true; }
82
- };
83
- // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
84
- // open. kj decides with that checkable fact, the same answer for commit and push
85
- // (the Stop gate still holds the turn: the board owes the move); when kj does not
86
- // answer, every pending move blocks.
87
- export const boardGate = (sid, pend) => {
88
- if (!pend.length) return { blocking: [], carried: [] };
89
- const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
90
- if (!bg.error && (bg.status === 0 || bg.status === 2)) {
91
- try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [], reason: d.reason || "" }; } catch { /* unreadable: as always */ }
92
- }
93
- return { blocking: pend, carried: [] };
94
- };
95
- // KJC-BUG-0265: on the base branch what counts is work still there (the session's
96
- // files dirty, or local commits the remote does not have), not the ledger: after a
97
- // merged PR and a sync of main the ledger stays full while the base holds nothing.
98
- // Anything git cannot answer (no upstream, odd path) counts as work: fail closed.
99
- const baseHoldsWork = (files) => {
100
- if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
101
- try {
102
- if (execSync("git status --porcelain -- " + files.join(" "), { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim()) return true;
103
- return execSync("git rev-list @{u}..HEAD", { cwd: ROOT, stdio: ["ignore", "pipe", "ignore"] }).toString().trim() !== "";
104
- } catch { return true; }
105
- };
106
- export const violations = (s, branch, pending = pendingMoves(s)) => {
107
- const v = pending.map(pendingText);
108
- if (!s || !(s.edited_sources || []).length) return v;
109
- if (bootstrapPhase()) return v;
110
- if (BASE_BRANCHES.has(branch)) { if (baseHoldsWork(s.edited_sources)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion"); }
111
- else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
112
- if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
113
- return v;
114
- };
115
- // KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
116
- // source edits only REMOVED lines owes no test. Unreadable git = conservative
117
- // (the violation stands): a gate must never stand down on a shrug.
118
- const sessionAddsCode = (s) => {
119
- const files = s.edited_sources || [];
120
- // _git builds a shell string: any path outside a strict charset stays out of
121
- // it — and the gate stays ARMED for it (conservative, and injection-proof).
122
- if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
123
- const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
124
- if (!stat) return true;
125
- return stat.split(String.fromCharCode(10)).some((l) => {
126
- const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
127
- return a === "-" || Number(a) > 0;
128
- });
129
- };
130
- `;
131
-
132
- const POST_BODY = `#!/usr/bin/env node
133
- // kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
134
- // Records deterministic method facts per session; never blocks, never fails
135
- // a tool call (PostToolUse, always exit 0).
136
- import console from "node:console";
137
- import process from "node:process";
138
- import { realpathSync } from "node:fs";
139
- import { basename, dirname, join, relative } from "node:path";
140
- import { spawnSync } from "node:child_process";
141
- import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
142
- import { remindersFor } from "./sentinel-reminders.mjs";
143
- let raw = "";
144
- process.stdin.on("data", (d) => { raw += d; });
145
- process.stdin.on("end", () => {
146
- try {
147
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, tool_response: response = {} } = JSON.parse(raw);
148
- // KJC-TSK-0765 board-sync: a merge that gh CONFIRMS leaves its card pending
149
- // in the tracker (the PreToolUse gate then refuses to advance until it is
150
- // moved). Recorded here, after the fact, so a failed merge never blocks.
151
- const text = typeof response === "string" ? response : JSON.stringify(response);
152
- // Closing states that count as "registered in the tracker" (PG statuses and
153
- // HU Board moves). The pending entry is cleared by the REAL tool call that
154
- // moved the card — never by a promise in the agent's prose.
155
- const CLOSING = ["to validate", "to-validate", "fixed", "verified", "closed", "done", "done&validated", "validated"];
156
- const clearPending = (cardId) => {
157
- const state = load();
158
- const s = session(state, sid);
159
- s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
160
- // KJC-TSK-0774: a card closed this turn is what the Stop hook points the
161
- // user at when the focus comes back (the board is where to look).
162
- if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
163
- save(state);
164
- };
165
- // KJC-TSK-0847 (ADR 0010, RAG-A): the ledger of what the RAG answered in
166
- // this session — every kj_rag_query (MCP) and kj rag query (CLI) with the
167
- // repo-relative sources it returned. Neither the CLI nor the MCP know the
168
- // host's session id: the harness records, they do not. Hits outside the
169
- // repo (another indexed project) are not ours and are dropped.
170
- // KJC-BUG-0190: an index with nothing in it (a brand new project) answers
171
- // every question with silence, so the gate would demand what no query can
172
- // deliver. kj says so explicitly, in both its human and json forms.
173
- const EMPTY_INDEX = /No chunks indexed yet|"empty"\\s*:\\s*true/;
174
- // KJC-BUG-0293 (#1996, #1995): the index names its sources through whatever
175
- // spelling of the project directory indexed them (a symlinked workspace, an
176
- // alias). Compared as TEXT against the harness root, every hit fell outside
177
- // the tree and was dropped in silence: rag_hits stayed empty and only new
178
- // files passed. Both sides are compared by REAL path, and what still falls
179
- // outside is written down for the gate to name.
180
- // A path whose file is gone (or not yet there) resolves through its deepest
181
- // existing ancestor, so an alias of the directory still lands in the tree.
182
- const realOf = (p) => {
183
- try { return realpathSync(p); } catch { /* resolve the parent instead */ }
184
- const d = dirname(p);
185
- return !d || d === p ? p : join(realOf(d), basename(p));
186
- };
187
- const ROOT_REAL = realOf(ROOT);
188
- const recordRag = (query, sources, answer = "") => {
189
- const state = load();
190
- const s = session(state, sid);
191
- s.at = Date.now();
192
- if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
193
- const hits = [];
194
- const dropped = [];
195
- for (const src of sources) {
196
- const rel = relative(ROOT_REAL, realOf(String(src))).split(String.fromCharCode(92)).join("/");
197
- if (!rel || rel === ".." || rel.startsWith("../") || rel.startsWith("/")) { dropped.push(String(src)); continue; }
198
- if (!hits.includes(rel)) hits.push(rel);
199
- }
200
- (s.rag_queries ||= []).push({ ts: Date.now(), text: String(query || "").slice(0, 200), hits });
201
- const all = (s.rag_hits ||= []);
202
- for (const h of hits) if (!all.includes(h)) all.push(h);
203
- if (hits.length === 0 && dropped.length > 0) s.rag_dropped = { root: ROOT_REAL, count: dropped.length, sample: dropped.slice(0, 3) };
204
- else if (hits.length > 0) delete s.rag_dropped; // an answer inside the tree ends the diagnosis
205
- save(state);
206
- };
207
- // Sources are read STRUCTURALLY, never through the stringified response:
208
- // a JSON document (the MCP {content:[{text}]} parts, the CLI --json array)
209
- // is parsed and its hits[].source / [].source collected.
210
- const sourcesOf = (doc) => {
211
- const list = Array.isArray(doc) ? doc : Array.isArray(doc?.hits) ? doc.hits : [];
212
- return list.map((h) => h?.source).filter((x) => typeof x === "string");
213
- };
214
- const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
215
- if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
216
- // KJC-BUG-0293: the host may hand the answer as {content:[parts]}, as the
217
- // bare array of parts, or as the text itself; all three are the same answer.
218
- const parts = Array.isArray(response?.content) ? response.content : Array.isArray(response) ? response : [{ text: typeof response === "string" ? response : "" }];
219
- let found = parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || ""))));
220
- // KJC-BUG-0365 (#2208): an answer cut short (whole code chunks) is no
221
- // longer valid JSON, but every "source" it carries still names a hit.
222
- if (found.length === 0) found = [...text.matchAll(/\\\\?"source\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/g)].map((m) => m[1]);
223
- recordRag(input.text, found, parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
224
- process.exit(0);
225
- }
226
- if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
227
- // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
228
- // stringified, the inner quotes are ESCAPED — match both forms (found live:
229
- // the first real update_card did not clear its pending entry).
230
- const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
231
- const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
232
- if (cid && st && CLOSING.includes(st[1].toLowerCase())) {
233
- clearPending(cid[1].toUpperCase());
234
- // KJC-BUG-0157: a pending whose card-id NEVER existed (branch named
235
- // before the tracker confirmed the create) could not be cleared by any
236
- // verified route. The pending's TRUE identity is its PR: a CONFIRMED
237
- // closing update whose request names that PR clears it too — still a
238
- // real tracker call seen by this hook, never a promise in prose.
239
- const prIn = Number(input?.updates?.pipelineStatus?.prCreated?.prNumber);
240
- if (Number.isFinite(prIn) && prIn > 0) {
241
- const state = load();
242
- const s = session(state, sid);
243
- s.pending_moves = (s.pending_moves || []).filter((p) => Number(p.pr) !== prIn);
244
- save(state);
245
- }
246
- }
247
- process.exit(0);
248
- }
249
- if (tool === "Bash") {
250
- const cmdText = String(input.command || "");
251
- // CLI form of the ledger: the human output lists one hit per line as
252
- // "[kind · label · score=…] <path>"; --json prints an array with "source".
253
- const ragCmd = /kj +rag +query/.exec(cmdText);
254
- if (ragCmd) {
255
- const out = typeof response === "string" ? response : String(response.stdout || "") + String(response.stderr || "");
256
- const fromLines = out.split(String.fromCharCode(10))
257
- .filter((l) => l.includes("score=") && l.includes("] "))
258
- .map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
259
- .filter(Boolean);
260
- recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))), out);
261
- process.exit(0);
262
- }
263
- // gh prints "merged pull request #N" only on a TTY: under a tool call the
264
- // success line is ABSENT (found live on the first dogfood merge). The
265
- // authoritative signal is the PR state itself: one gh pr view per merge.
266
- const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
267
- const said = /merged pull request #(\\d+)/i.exec(text);
268
- let mergedPr = said ? Number(said[1]) : null;
269
- // KJC-BUG-0148: the card belongs to the PR's HEAD branch, not to whatever
270
- // branch the working tree happens to be on (found live: a merge issued
271
- // from another card's lane pinned the pending move on the wrong card).
272
- let headRef = null;
273
- if (mergeCmd || said) {
274
- const n = said ? said[1] : mergeCmd[2];
275
- const args = ["pr", "view", ...(n ? [n] : []), "--json", "number,state,headRefName"];
276
- const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
277
- try {
278
- const v = JSON.parse(r.status === 0 ? r.stdout : "null");
279
- if (v && v.state === "MERGED") { mergedPr = Number(v.number); headRef = v.headRefName || null; }
280
- } catch { /* unknown = not confirmed (or confirmed only by the printed line) */ }
281
- }
282
- if (mergedPr !== null) {
283
- const state = load();
284
- const s = session(state, sid);
285
- const ref = CARD.exec(headRef || branchOf() || "");
286
- // KJC-BUG-0230: una EPICA (…-PCS-…) no tiene estado que mover: el PG
287
- // la deja en To Do por mas status que reciba y kj hu move no la
288
- // conoce. Registrarla como pendiente era un bloqueo sin cauce. Se dice
289
- // en el momento, para que no parezca que el board dejo de mirar.
290
- const epic = ref && /-PCS-/i.test(ref[0]);
291
- if (epic) {
292
- console.error("karajan sentinel: board-sync — la PR #" + mergedPr + " pertenece a la epica " + ref[0].toUpperCase() + ", que no tiene estado que mover: no queda pendiente (su cierre va en la descripcion de la epica)");
293
- } else {
294
- (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
295
- }
296
- save(state);
297
- }
298
- const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
299
- // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
300
- // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
301
- if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
302
- // KJC-TSK-0917 (ADR 0014): the method's reminders, after the action that precedes
303
- // the one each rule is about; at most once every 25 Bash actions each. Never a block.
304
- const st = load();
305
- const ss = session(st, sid);
306
- ss.step = (ss.step || 0) + 1;
307
- const due = remindersFor(cmdText, { seen: ss.reminded || {}, step: ss.step });
308
- for (const r of due) (ss.reminded ||= {})[r.id] = ss.step;
309
- save(st);
310
- if (due.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: due.map((r) => r.say).join(" ") } }));
311
- process.exit(0);
312
- }
313
- const file = input.file_path || input.notebook_path;
314
- if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
315
- const state = load();
316
- const s = session(state, sid);
317
- s.at = Date.now();
318
- const rel = relative(ROOT, file).replaceAll("\\\\", "/");
319
- // KJC-BUG-0279 (#1981): a file outside the repository is not one of its sources.
320
- const inside = rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/");
321
- const bucket = !inside ? null : TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
322
- if (bucket && !bucket.includes(rel)) bucket.push(rel);
323
- // KJC-TSK-0910: the branch size while it is written, with the CI budget
324
- // (kj pr-size), said once per threshold crossed. Context, never a block.
325
- let sizeNote = null;
326
- if (bucket) {
327
- const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
328
- let size = null;
329
- try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
330
- const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
331
- if (crossed && (s.size_warned || 0) < crossed) {
332
- s.size_warned = crossed;
333
- sizeNote = "karajan sentinel: la rama ya suma " + size.added + " lineas contables (" + size.testAdded + " de tests); " + (crossed >= 200 ? "pasa el limite de 200 del CI: parte antes de seguir" : "pasa de 150: planea la particion ahora, no en el stage");
334
- }
335
- }
336
- const ids = Object.keys(state.sessions);
337
- if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
338
- save(state);
339
- if (sizeNote) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: sizeNote } }));
340
- } catch { /* fail open — the sentinel never breaks a tool call */ }
341
- process.exit(0);
342
- });
343
- `;
344
-
345
- const STOP_BODY = `#!/usr/bin/env node
346
- // kj sentinel stop gate (KJC-TSK-0713) — managed by \`kj harden\`. Exit 2
347
- // BLOCKS ending the turn while method violations are open (stderr lists each
348
- // one with its remediation). Fails OPEN on corrupt state, git errors, or
349
- // after 3 unresolved blocks — a sentinel bug never bricks the session — and
350
- // the fail-open is recorded in the state. \`--status\` prints, never blocks.
351
- import console from "node:console";
352
- import process from "node:process";
353
- import { spawnSync } from "node:child_process";
354
- import { readFileSync } from "node:fs";
355
- import { homedir } from "node:os";
356
- import { join } from "node:path";
357
- import { doc, load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
358
- // KJC-TSK-0774: when the turn ends green with cards closed, remind the user
359
- // where to look — the board URL if it runs (pid file alive), else how to start it.
360
- const boardHint = () => {
361
- const home = process.env.KARAJAN_HOME || process.env.KJ_HOME || join(homedir(), ".karajan");
362
- try {
363
- process.kill(Number(readFileSync(join(home, "hu-board.pid"), "utf8").trim()), 0);
364
- return "http://localhost:" + (process.env.KJ_BOARD_PORT || "4000") + "/#governance";
365
- } catch { return "no esta arrancado: kj board start (luego http://localhost:4000/#governance)"; }
366
- };
367
- if (process.argv.includes("--status")) {
368
- const st = load();
369
- const branch = branchOf();
370
- const sessions = Object.entries(st.sessions || {});
371
- if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
372
- for (const [sid, s] of sessions) {
373
- console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : "") + " rag=" + (s.rag_queries || []).length + " queries/" + (s.rag_hits || []).length + " sources");
374
- for (const x of violations(s, branch)) console.log(" ROJO: " + x);
375
- }
376
- process.exit(0);
377
- }
378
- let raw = "";
379
- process.stdin.on("data", (d) => { raw += d; });
380
- process.stdin.on("end", () => {
381
- try {
382
- if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
383
- const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
384
- const state = load();
385
- // Tamper check runs regardless of session state: shell indirection leaves
386
- // no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
387
- // root of trust is the INSTALLED kj package (outside this project tree) —
388
- // nothing under .karajan can vouch for itself. No kj on PATH → fail open.
389
- const ver = spawnSync("kj", ["sentinel", "verify", "--json"], { cwd: ROOT, encoding: "utf8" });
390
- let tampered = [];
391
- if (!ver.error && ver.status !== null && ver.status !== 0) {
392
- try { tampered = JSON.parse(ver.stdout).mismatched || ["unknown"]; } catch { tampered = ["unknown"]; }
393
- }
394
- if (tampered.length) {
395
- state.tamper_blocks = (state.tamper_blocks || 0) + 1;
396
- save(state);
397
- if (state.tamper_blocks > 3) {
398
- console.error("karajan sentinel: fail-open tras 3 bloqueos por manipulacion sin restaurar — corre kj harden y avisa a tu usuario" + doc("supervisor"));
399
- process.exit(0);
400
- }
401
- console.error("karajan sentinel: scripts del supervisor modificados fuera de kj harden (" + tampered.join(", ") + ") — restaura con kj harden antes de terminar; si lo cambiaste tu (humano), reinstalar lo deja en verde." + doc("supervisor"));
402
- process.exit(2);
403
- }
404
- state.tamper_blocks = 0;
405
- // CLM-B (KJC-TSK-0802, claims-with-evidence ADR): the hard data of the turn's
406
- // final message, crossed against that turn's outputs. It runs even without a
407
- // tracked session — a turn with no edits still ends with a final message. The
408
- // hook carries no policy: kj reads method_gates.claims and answers 0 (off,
409
- // clean, or warn) or 2 (a datum DENIED by its own source, in block mode). A
410
- // missing kj or an errored spawn fails open.
411
- let claimsNote = "";
412
- let claimsBlock = null;
413
- if (transcript) {
414
- const c = spawnSync("kj", ["claims", "gate", "--transcript", transcript], { cwd: ROOT, encoding: "utf8" });
415
- if (!c.error && c.status === 2) claimsBlock = "claims: un dato del mensaje final esta DESMENTIDO por las salidas de este turno — verificalo o marcalo como no comprobado. Detalle: kj claims check --transcript " + transcript;
416
- else if (!c.error && c.status === 0 && (c.stderr || "").trim()) claimsNote = "kj claims: " + c.stderr.trim().split(String.fromCharCode(10))[0] + " — datos sin respaldo en este turno (informa, no bloquea).";
417
- }
418
- const s = state.sessions?.[sid];
419
- if (!s) {
420
- if (claimsBlock) { console.error("karajan sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock + doc("claims")); process.exit(2); }
421
- if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
422
- process.exit(0);
423
- }
424
- // KJC-TSK-0868: si el anfitrion ha escrito codigo teniendo otro coder
425
- // declarado, eso no se bloquea, se REGISTRA: el usuario eligio quien
426
- // escribe y merece enterarse por el decision log, no por casualidad. Una
427
- // vez por sesion, y si kj no responde se reintenta en el turno siguiente.
428
- if (!s.panel_sealed && (s.edited_sources || []).length > 0) {
429
- const p = spawnSync("kj", ["sentinel", "panel-check", "--session", sid], { cwd: ROOT, encoding: "utf8" });
430
- if (!p.error && p.status === 0) {
431
- s.panel_sealed = true;
432
- save(state);
433
- if ((p.stdout || "").trim()) console.error(p.stdout.trim());
434
- }
435
- }
436
- const v = violations(s, branchOf());
437
- if (claimsBlock) v.push(claimsBlock);
438
- if (!v.length) {
439
- s.blocks = 0;
440
- const notes = [];
441
- if (claimsNote) notes.push(claimsNote);
442
- if ((s.closed_cards || []).length) {
443
- notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
444
- s.closed_cards = [];
445
- }
446
- save(state);
447
- if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\\n") }));
448
- process.exit(0);
449
- }
450
- s.blocks = (s.blocks || 0) + 1;
451
- if (s.blocks > 3) {
452
- (s.errors ||= []).push("fail-open: 3 bloqueos consecutivos sin resolver — el sentinel se aparta para no colgar la sesion");
453
- save(state);
454
- console.error("karajan sentinel: fail-open tras 3 bloqueos sin resolver — revisa kj sentinel status con tu usuario" + doc("stop-gate"));
455
- process.exit(0);
456
- }
457
- save(state);
458
- console.error("karajan sentinel: el turno NO puede terminar con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve las violaciones (o pide a tu usuario el escape) y termina de nuevo. Estado: kj sentinel status" + doc("stop-gate"));
459
- process.exit(2);
460
- } catch { /* fail open */ }
461
- process.exit(0);
462
- });
463
- `;
464
-
465
- const PRETOOL_BODY = `#!/usr/bin/env node
466
- // kj sentinel pretooluse gate (KJC-TSK-0714) — managed by \`kj harden\`.
467
- // Consults the session method state BEFORE the tool runs: the rule fires
468
- // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
469
- // remediation); read-only tools are never wired here; every honored escape
470
- // is recorded as an auditable event. Fails OPEN on anything unexpected.
471
- import console from "node:console";
472
- import process from "node:process";
473
- import { dirname, join, relative, resolve } from "node:path";
474
- import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
475
- import { spawnSync } from "node:child_process";
476
- import { homedir } from "node:os";
477
- import { fileURLToPath } from "node:url";
478
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
479
- // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
480
- import { isGhIssueCreate, isPublishCommand, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues, stripTextOnlyCommandValues } from "./sentinel-shell.mjs";
481
- import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
482
- import { sandboxCovers, shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
483
- const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
484
- const inOrder = (want, got) => { let i = 0; for (const w of got) if (w === want[i] && ++i === want.length) return true; return false; };
485
- // KJC-BUG-0333: a publish is read per simple command by the shell reader, so
486
- // words inside a quoted argument of another command are not a publish.
487
- const isPublish = isPublishCommand;
488
- const PROTECTED = /\\.claude\\/settings\\.json\\b|\\.karajan\\/(hooks|harness)\\//;
489
- let raw = "";
490
- process.stdin.on("data", (d) => { raw += d; });
491
- process.stdin.on("end", () => {
492
- try {
493
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
494
- // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
495
- // KJC-TSK-0968 (HUM-E, ADR 0018): and leaves a record next to the state, with the
496
- // gate's own words: kj handoff takes the why from here, never from the agent.
497
- const said = [];
498
- const errOrig = console.error.bind(console);
499
- console.error = (m) => { said.push(String(m)); errOrig(m); };
500
- process.on("exit", (code) => {
501
- if (code !== 2) return;
502
- errOrig("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.");
503
- // Only what a handoff needs: the exact Bash command, or the path an edit aimed at; never file contents.
504
- const what = tool === "Bash" ? { command: input.command } : { file_path: input.file_path || input.notebook_path || null };
505
- try { appendFileSync(join(dirname(STATE), "denials.jsonl"), JSON.stringify({ at: new Date().toISOString(), sid, tool, input: what, why: said.join("\\n") }) + "\\n"); } catch { /* the deny stands without its record */ }
506
- });
507
- // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
508
- // sesion toca un fichero se anota si estaba limpio: solo entonces un
509
- // descarte posterior pierde nada mas que lo de la sesion. Antes de
510
- // cualquier escape; git que falla cuenta como sucio (fail-closed).
511
- const noteFirstTouch = (relT) => {
512
- const st = load();
513
- const touch = (session(st, sid).first_touch ||= {});
514
- // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
515
- if (Object.hasOwn(touch, relT)) return;
516
- // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
517
- const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
518
- const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
519
- Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
520
- save(st);
521
- };
522
- // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
523
- // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
524
- // only the human, editing outside it.
525
- if (EDIT_TOOLS.includes(tool)) {
526
- const target = input.file_path || input.notebook_path;
527
- const relT = target ? relative(ROOT, String(target)).replaceAll("\\\\", "/") : "";
528
- if (relT && PROTECTED.test(relT)) {
529
- console.error("karajan sentinel: ese fichero es parte del supervisor (" + relT + ") — solo el humano desmonta el sentinel, editalo fuera de la sesion." + doc("supervisor"));
530
- process.exit(2);
531
- }
532
- // KJC-TSK-0920 (SNT-E): policies, gate settings and exclusions are the human's,
533
- // like the supervisor: a session does not loosen the rules that govern it.
534
- // .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
535
- // .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
536
- // .karajan/security-level.json (KJC-TSK-1054, ADR 0020): only kj security writes it, after the human layers.
537
- if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".karajan/security-level.json", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
538
- 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"));
539
- process.exit(2);
540
- }
541
- if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
542
- }
543
- // KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
544
- if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
545
- // Any Bash that NAMES the supervisor's files is denied — a write-verb
546
- // blocklist is bypassable (cp, dd, one-liners), and reading them is what
547
- // the Read/Grep tools are for. Shell indirection (variables, globs,
548
- // substitution) cannot be resolved from command text: that vector is
549
- // caught by the Stop gate's checksum verification, which blocks the turn.
550
- // KJC-TSK-1114: where the host sandbox denies writing them, the sandbox guards them instead.
551
- const SUPERVISOR_PATHS = [".claude/settings.json", ".karajan/hooks", ".karajan/harness"];
552
- if (tool === "Bash" && PROTECTED.test(String(input.command || "").replaceAll("\\\\", "/")) && !sandboxCovers(ROOT, SUPERVISOR_PATHS)) {
553
- console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
554
- process.exit(2);
555
- }
556
- // KJC-BUG-0164: todo gh que PUBLICA texto (pr/issue/release, create/edit/
557
- // comment/review) se escanea contra el patron de ATRIBUCION a IA antes de
558
- // salir — comando inline Y ficheros --body-file/--notes-file. Regla
559
- // determinista del proyecto: prohibido, sin escape. Un fichero ilegible
560
- // tampoco publica (fail closed).
561
- {
562
- // Regex SIN backslashes a proposito (clases de caracteres, emoji
563
- // literal): asi la plantilla y el fichero generado son identicos y no
564
- // hay doble-escape que leer mal.
565
- const ghCmd = String(input.command || "");
566
- if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
567
- const attrib = /co-authored-by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated with [[]?claude|🤖/is;
568
- // KJC-BUG-0340: a body file's PATH is not published text (a scratch dir
569
- // under .../claude-1000/... read as an attribution next to a title saying
570
- // "generated by kj"); its content is. Each piece is judged on its own, so a
571
- // match never spans from the command into a file.
572
- const pieces = [];
573
- let cmdText = ghCmd;
574
- // KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
575
- for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
576
- cmdText = cmdText.split(bodyPath).join(" ");
577
- try { pieces.push(readFileSync(bodyPath, "utf8")); } catch {
578
- console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
579
- process.exit(2);
580
- }
581
- }
582
- pieces.push(cmdText);
583
- if (pieces.some((p) => attrib.test(p))) {
584
- console.error("karajan sentinel: atribucion a IA detectada en contenido a PUBLICAR (PR/issue/release) — prohibida por regla determinista del proyecto: limpia el texto." + doc("attribution"));
585
- process.exit(2);
586
- }
587
- }
588
- }
589
- // KJC-BUG-0252 (#1899): una issue para karajan-code sale por kj report-issue
590
- // (sanitiza y busca duplicados), nunca por gh issue create a mano.
591
- {
592
- const ghIssue = String(input.command || "");
593
- // KJC-BUG-0306: the subcommand itself (gh, its flags, issue create), not three words
594
- // anywhere: "gh pr create --title 'kj report-issue ...'" is not an issue.
595
- if (tool === "Bash" && isGhIssueCreate(ghIssue)) {
596
- // The target: --repo/-R, an inline or inherited GH_REPO, else the
597
- // checkout's own origin, which is what gh takes (review catches).
598
- const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
599
- let target = optionValues(ghIssue, ["--repo", "-R"]).at(-1) || (inlineRepo ? inlineRepo[2] : "") || String(process.env.GH_REPO || "");
600
- if (!target) {
601
- const origin = spawnSync("git", ["config", "--get", "remote.origin.url"], { cwd: ROOT, encoding: "utf8" });
602
- target = String(origin.stdout || "").trim();
603
- }
604
- if (/karajan-code([^a-z0-9]|$)/i.test(target)) {
605
- console.error("karajan sentinel: las issues para karajan-code salen por kj report-issue (sanitiza y busca issues parecidas), no por gh issue create." + doc("report-issue"));
606
- process.exit(2);
607
- }
608
- }
609
- }
610
- // KJC-TSK-0822 v1.1: la clave del movil (capa 5) no se toca desde la sesion.
611
- // KJC-TSK-1084 (ADR 0015): los comandos de acto humano (kj harden --commit,
612
- // kj identity enroll-phone) ya no se leen aqui: la propia orden rechaza un
613
- // proceso que desciende de un agente (src/harden/human-act.js). Queda el
614
- // FICHERO de la clave, que solo esto protege hasta que el sandbox lo vete.
615
- {
616
- // KJC-BUG-0354: un titulo o un mensaje de commit que solo lo MENCIONA no es escribirlo.
617
- const namesPhoneFile = stripTextOnlyCommandValues(String(input.command || "")).includes("supervisor-phone.json");
618
- const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
619
- if ((tool === "Bash" && namesPhoneFile) || writesEnroll) {
620
- console.error("karajan sentinel: la clave del movil (capa 5) es del humano — supervisor-phone.json no se toca desde la sesion; enrolar es kj identity enroll-phone desde su terminal." + doc("supervisor"));
621
- process.exit(2);
622
- }
623
- }
624
- // KJC-BUG-0238 (#1886): un agente no descarta cambios que no hizo. Sin
625
- // escape y antes de KJ_SENTINEL_OFF: es perdida de datos del usuario, y
626
- // guardarlo (git stash push) nunca pierde nada.
627
- if (tool === "Bash") {
628
- const cmdD = String(input.command || "");
629
- // A git discard nested in $( ), <( ), backticks, eval, xargs or sh -c cannot be read: fail-closed.
630
- const nested = ["$(", "<(", ">(", String.fromCharCode(96)].some((n) => cmdD.includes(n)) || /(^|[ ;&|])(eval|xargs|bash|sh|zsh|env)( |$)/.test(cmdD);
631
- if (nested && /(^|[^a-z])git([^a-z]|$)/.test(cmdD) && DISCARD_VERBS.some((v) => cmdD.includes(v))) {
632
- console.error("karajan sentinel: un descarte git dentro de $( ), backticks, eval, xargs o sh -c no es verificable — ejecutalo como comando simple." + doc("discard"));
633
- process.exit(2);
634
- }
635
- const touch = load().sessions?.[sid]?.first_touch || {};
636
- for (const words of shellSegments(cmdD)) {
637
- const d = discardOf(words, ROOT);
638
- const lost = d ? foreignLost(d, touch, ROOT) : [];
639
- if (lost.length === 0) continue;
640
- console.error(d.stash
641
- ? "karajan sentinel: git stash drop/clear borra trabajo guardado que puede no ser de esta sesion — pideselo a tu usuario." + doc("discard")
642
- : "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"));
643
- process.exit(2);
644
- }
645
- }
646
- if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
647
- // ADR 0015 (KJC-TSK-0927): no gate has an escape any more, so the command
648
- // text is read only by the identity lock below.
649
- const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
650
- const ESC_TAB = String.fromCharCode(9);
651
- const ESC_NL = String.fromCharCode(10);
652
- // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
653
- // run under an account other than the one THIS CLONE declared (the incident:
654
- // a gh comment without switch went out as the client account). Deny BEFORE
655
- // the damage with the literal remedy; never auto-switch; undeclared = closed.
656
- if (tool === "Bash" && CMD_TEXT.length > 0) {
657
- const idFile = join(ROOT, ".karajan", "identity.local.yml");
658
- const idLine = (key) => {
659
- if (!existsSync(idFile)) return null;
660
- const line = readFileSync(idFile, "utf8").split(ESC_NL).find((l) => l.startsWith(key + ":"));
661
- const v = line ? line.slice(key.length + 1).trim() : "";
662
- return v.length > 0 ? v : null;
663
- };
664
- const declaredGh = idLine("gh_user");
665
- const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
666
- const activeGh = () => {
667
- if (!existsSync(ghHostsPath)) return null;
668
- const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
669
- const start = lines.findIndex((l) => l.startsWith("github.com:"));
670
- if (start < 0) return null;
671
- for (let k = start + 1; k < lines.length; k += 1) {
672
- if (lines[k].length > 0 && lines[k][0] !== " " && lines[k][0] !== ESC_TAB) break;
673
- const t = lines[k].trim();
674
- if (t.startsWith("user:")) { const u = t.slice(5).trim(); return u.length > 0 ? u : null; }
675
- }
676
- return null;
677
- };
678
- const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
679
- const headOf = (ws) => { let k = 0; while (k < ws.length && ws[k].indexOf("=") > 0 && ws[k].indexOf("=") < ws[k].length) k += 1; return ws.slice(k); };
680
- // Wrapper shells hide the payload in a string (review catch): scan inside.
681
- const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
682
- let switchedTo = null;
683
- const problems = [];
684
- // $( and backticks are separators too (review catch): their payload is its own segment.
685
- const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
686
- const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
687
- const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth); };
688
- const checkSeg = (ws, depth) => {
689
- if (ws.length === 0) return;
690
- if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/wrapper token (review catch)
691
- const j = ws.findIndex((w, i) => i > 0 && ["gh", ...WRAPPERS].includes(bare(w)));
692
- if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
693
- return;
694
- }
695
- if (ws[0] === "gh") {
696
- if (ws[1] === "auth") {
697
- if (ws[2] === "switch") { const u = ws.indexOf("--user"); if (u > 0 && ws[u + 1]) switchedTo = ws[u + 1]; }
698
- return; // session commands ARE the remedy — never blocked
699
- }
700
- if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
701
- const eff = switchedTo || activeGh();
702
- if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
703
- }
704
- // KJC-TSK-1085 (ADR 0015): git commit/tag/rebase/push are no longer read here:
705
- // pre-commit checks the author, pre-push every pushed commit and the gh session.
706
- };
707
- scan(CMD_TEXT, 0);
708
- if (problems.length > 0) {
709
- console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(sin escape, ADR 0015: pon la cuenta en el comando)" + doc("identity"));
710
- process.exit(2);
711
- }
712
- }
713
- // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
714
- // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
715
- const laneOf = (p) => {
716
- // Sube al primer ancestro EXISTENTE (un subdir aun no creado escapaba).
717
- let d = String(p);
718
- if (!(existsSync(d) && statSync(d).isDirectory())) d = dirname(d);
719
- while (d && !existsSync(d)) {
720
- const up = dirname(d);
721
- if (up === d) return null;
722
- d = up;
723
- }
724
- // Canonicaliza (reviewer catch: un symlink local hacia otro carril
725
- // esquivaba la clasificacion por ruta literal).
726
- try { d = realpathSync(d); } catch { /* se clasifica tal cual */ }
727
- return d ? foreignLane(d) : null;
728
- };
729
- const laneDeny = (what, lane) => {
730
- console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
731
- return true;
732
- };
733
- if (EDIT_TOOLS.includes(tool)) {
734
- const target = input.file_path || input.notebook_path;
735
- const lane = target ? laneOf(target) : null;
736
- if (lane && laneDeny(String(target), lane)) process.exit(2);
737
- }
738
- if (tool === "Bash") {
739
- const cmd = String(input.command || "");
740
- // KJC-BUG-0166/0167: gestionar worktrees crudos (add/remove/prune) es
741
- // estructura del repo, jamas trabajo de una sesion. 0166: una sesion
742
- // intento EN BUCLE retirar el ARBOL PRINCIPAL con remove+CROSS_LANE.
743
- // 0167: un coder descarrilado ANADIO un worktree .verify-main rebelde y
744
- // de ahi se invento un bootstrap. Deny sin escape en los tres verbos;
745
- // el cauce sancionado es kj worktree start|done (corre dentro de kj).
746
- if (/(^|[^a-z])git[^a-z]+worktree[^a-z]+(add|remove|prune)/.test(cmd)) {
747
- console.error("karajan sentinel: git worktree add/remove/prune no se ejecuta desde una sesion — estructura del repo, sin escape; usa kj worktree start|done." + doc("cross-lane"));
748
- process.exit(2);
749
- }
750
- // KJC-TSK-1114: where the host sandbox denies writing the lanes, it confines Bash itself.
751
- if (!sandboxCovers(ROOT, [".kj/worktrees"])) {
752
- // Deny-unless-known-read-only: un allowlist de verbos mutadores era
753
- // esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
754
- // encadenar ni redirigir) puede nombrar otro carril; leer carriles es
755
- // trabajo de las tools Read/Grep, como con los ficheros del supervisor.
756
- // Solo LITERALES: expansión/sustitución en un comando "de lectura"
757
- // puede ejecutar cualquier cosa (reviewer catch: grep foo $(rm ...)).
758
- // (find fuera: -delete/-exec mutan — reviewer catch; sus tokens de
759
- // carril los caza el escaner de abajo.)
760
- // KJC-BUG-0338: kj rag query only reads, and the rag-first gate asks for it
761
- // with a quoted question that names the file.
762
- const READONLY = /^[ \\t]*(grep|rg|cat|head|tail|less|ls|wc|diff|stat|file|du|tree|git (log|show|diff|status|blame)|kj rag query)\\b[^;|&<>$\`(){}\\n\\r]*$/;
763
- // KJC-BUG-0286 (#1982): a command whose EVERY segment only reads mutates
764
- // nothing, however it is chained (grep | grep, git show | grep, cd && git
765
- // status). A cd with a literal target is one such segment: the paths after
766
- // it need no verifying when nothing after it writes. One mutating segment
767
- // and the whole command is judged below, as before.
768
- const CD_LITERAL = /^[ \\t]*(cd|pushd)([ \\t]+[^ \\t;|&<>$\`(){}\\n\\r]*)?[ \\t]*$/;
769
- // KJC-BUG-0365: 2>&1 and 2>/dev/null only fold stderr, they write nothing.
770
- const QUIET = /[ \\t]2>(?:&1|\\/dev\\/null)(?=[ \\t]|$)/g;
771
- const readsOnly = (s) => s.split(/&&|\\|\\||[;|]/).every((seg) => READONLY.test(seg.replace(QUIET, "")) || CD_LITERAL.test(seg.replace(QUIET, "")));
772
- // KJC-BUG-0243: operators inside inert quotes (grep -e "a|b") do not chain anything.
773
- if (!readsOnly(stripInertQuotes(cmd))) {
774
- // cd/pushd invalida TODO razonamiento textual de rutas posteriores
775
- // (carrera de bypasses confirmada en review: destino bare, con $,
776
- // relativas post-cd...): en un comando NO-read-only, cambiar de
777
- // directorio se deniega conservadoramente — el remedio es no
778
- // necesitarlo (git -C, npm --prefix, rutas absolutas).
779
- if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
780
- console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd." + doc("cross-lane"));
781
- process.exit(2);
782
- }
783
- // Absolute AND dot-relative tokens (../lane escapaba al primer scan —
784
- // reviewer catch); relative se resuelve contra el cwd de la sesion.
785
- // EVERY token (un tope seria un bypass); dedup acota el coste git.
786
- // Fallo de solomon (seguridad no arbitrable) + carrera de bypasses:
787
- // la expansion ($, backtick) solo se tolera en segmentos que son
788
- // asignaciones puras o runners del toolchain propio (npm/pnpm/yarn/
789
- // vitest/jest/kj/gh/echo/printf, git sin -C/--git-dir/--work-tree) —
790
- // sus argumentos son datos. En herramientas genericas de fichero o
791
- // interpretes el objetivo queda oculto al texto => deny. Los paths
792
- // LITERALES (tambien dentro de sustituciones) los escanea el bucle
793
- // de tokens de abajo; el residuo (expansion anidada en segmentos
794
- // runner) queda documentado como en la regla de ficheros PROTECTED.
795
- if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
796
- console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL." + doc("cross-lane"));
797
- process.exit(2);
798
- }
799
- // Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
800
- // en cualquier posicion, y una ruta entrecomillada con espacios es
801
- // invisible al tokenizador: ambas => deny conservador (endpoint
802
- // pedido por el reviewer; friccion asumida, el remedio es literal).
803
- // KJC-BUG-0140: pares de comillas REALES, no regex sobre el texto —
804
- // la comilla de CIERRE de un string + un redirect literal + la de
805
- // apertura del siguiente creaban la ilusion de ruta con espacios y
806
- // denegaban el flujo estandar commit -m "..." > log && echo "ok".
807
- // Se recorre alternando estado (doctrina del parseCommand del motor):
808
- // solo el CONTENIDO realmente entrecomillado con slash y espacio
809
- // deniega — incluidas comillas a mitad de token (scp host:"/a b/x")
810
- // y la comilla sin cerrar (no verificable).
811
- // ...y por TOKEN completo, no por span: spans adyacentes concatenan
812
- // ("dir with"" spaces/file" es UNA ruta) — catch de codex.
813
- const quotedPathWithSpaces = (s) => {
814
- // Un backslash pegado a comilla o blanco (comilla escapada,
815
- // espacio escapado) altera donde CIERRA el string o esconde
816
- // espacios: opaco => deny (doctrina del motor) — catch de codex.
817
- // Caracteres construidos con fromCharCode: CERO escaping en este
818
- // template — fuente y artefacto identicos, nada que malinterpretar
819
- // (el doble-escaping confundio cuatro reviews; solomon desestimo
820
- // la primera y las demas se volvieron ilegibles igual).
821
- // El punto escapado de un sed y similares siguen libres.
822
- const BS = String.fromCharCode(92);
823
- const TAB = String.fromCharCode(9);
824
- const NL = String.fromCharCode(10);
825
- for (let i = s.indexOf(BS); i !== -1; i = s.indexOf(BS, i + 1)) {
826
- const n = s[i + 1] || "";
827
- if (n === '"' || n === "'" || n === " " || n === TAB) return true;
828
- }
829
- let q = null;
830
- let tok = "";
831
- let hadQuote = false;
832
- const flush = () => {
833
- const bad = hadQuote && tok.includes("/") && tok.includes(" ");
834
- tok = "";
835
- hadQuote = false;
836
- return bad;
837
- };
838
- for (const ch of s) {
839
- if (q) {
840
- if (ch === q) q = null;
841
- else tok += ch;
842
- continue;
843
- }
844
- if (ch === '"' || ch === "'") {
845
- q = ch;
846
- hadQuote = true;
847
- continue;
848
- }
849
- if (ch === " " || ch === TAB || ch === NL || ch === ";" || ch === "|" || ch === "&" || ch === "<" || ch === ">" || ch === "(" || ch === ")") {
850
- if (flush()) return true;
851
- continue;
852
- }
853
- tok += ch;
854
- }
855
- if (q !== null) return true; // comilla sin cerrar: no verificable
856
- return flush();
857
- };
858
- // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
859
- // of a text option (--title, -m...) is not a path.
860
- if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
861
- 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. Si es texto (titulo, descripcion, error) con comillas de codigo o dolar, ponlo entre comillas SIMPLES: entre dobles la shell lo ejecuta." + doc("cross-lane"));
862
- process.exit(2);
863
- }
864
- const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
865
- const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
866
- if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
867
- console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain." + doc("cross-lane"));
868
- process.exit(2);
869
- }
870
- // ...incluidas relativas A PELO (los carriles kj viven en
871
- // .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
872
- // y foreignLane da null — solo deniega lo que CAE en otro carril.
873
- const seen = new Set();
874
- for (const m of cmd.matchAll(/(?:^|[\\s"'=;(><])((?:\\.\\.?\\/)[^\\s"'\`;|&)]*|\\/[^\\s"'\`;|&)]+|~\\/[^\\s"'\`;|&)]+|[A-Za-z0-9_.@-]+\\/[^\\s"'\`;|&)]+)/g)) {
875
- const t = m[1].startsWith("~/") ? (process.env.HOME || "~") + m[1].slice(1) : m[1];
876
- const abs = resolve(t);
877
- if (seen.has(abs)) continue;
878
- seen.add(abs);
879
- const lane = laneOf(abs);
880
- if (lane && laneDeny(m[1], lane)) process.exit(2);
881
- }
882
- }
883
- }
884
- }
885
- // KJC-BUG-0237 (#1886): the repo is written through Edit/Write only, the path every gate
886
- // guards. After the lane guard, whose message is the precise one for another lane.
887
- // KJC-TSK-1114: gates run at commit now; what only this guard kept, the governance
888
- // files, the host sandbox denies where it covers them.
889
- if (tool === "Bash" && !sandboxCovers(ROOT, ["kj.config.yml", ".ragignore"])) {
890
- let moved = false; // after cd/pushd/popd, a relative target cannot be placed: fail-closed
891
- // $( ) and backticks run even inside double quotes: their bodies, read from the raw text, are commands too.
892
- const raw = String(input.command || "");
893
- const subs = [...[...raw.matchAll(/[$][(]([^()]*)[)]/g)].map((m) => m[1]), ...raw.split(String.fromCharCode(96)).filter((_, k) => k % 2 === 1)];
894
- for (const words of [...shellSegments(raw), ...subs.flatMap((s) => shellSegments(s))]) {
895
- if (["cd", "pushd", "popd"].includes(words[0])) moved = true;
896
- const inRepo = shellWrites(words).filter((t) => writesRepo(t, ROOT) || (moved && !t.startsWith("/")));
897
- if (inRepo.length === 0) continue;
898
- 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"));
899
- process.exit(2);
900
- }
901
- }
902
- // KJC-TSK-0734 (PL-B): con .karajan/policy.yml presente, la evaluacion
903
- // la hace el MOTOR via kj policy eval --strict (exit 2 = deny, contrato
904
- // PL-A); los defaults del supervisor viven en el motor y el check
905
- // PROTECTED inline de arriba queda como red fail-closed si kj falta.
906
- // Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
907
- // gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
908
- // deny duro (el remedio es restaurar kj); cualquier otro fallo de
909
- // evaluacion = deny sin escape (ADR 0015): un typo de YAML lo corrige el
910
- // usuario fuera de la sesion.
911
- if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
912
- // PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
913
- // kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
914
- const actorRole = process.env.KJ_POLICY_ROLE || "coder";
915
- const pres = spawnSync("kj", ["policy", "eval", "--strict", "--role", actorRole, "--tool", tool, "--input", JSON.stringify(input)], { cwd: ROOT, encoding: "utf8" });
916
- if (pres.error || pres.status === null) {
917
- console.error("karajan sentinel: .karajan/policy.yml declara enforcement pero kj no es ejecutable — la policy no se puede evaluar; restaura kj en el PATH (npm i -g @karajan-family/code, o el nombre legacy karajan-code) o retira la policy conscientemente." + doc("policy"));
918
- process.exit(2);
919
- }
920
- if (pres.status === 2) {
921
- let v = null;
922
- try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
923
- const secure = !!(v && v.class === "security");
924
- // ADR 0015 (KJC-TSK-0932): no escape; a rule that misfires is fixed by PR.
925
- console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (sin escape: si la regla falla, tu usuario corrige .karajan/policy.yml por PR)") + doc("policy"));
926
- process.exit(2);
927
- } else if (pres.status !== 0) {
928
- // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
929
- // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
930
- // la sesion sin Edit, sin Write y sin Bash, o sea sin las herramientas
931
- // para arreglar el fichero, y el remedio que sugeria (kj policy check)
932
- // tampoco arrancaba. Se avisa fuerte y se deja pasar: el resto de este
933
- // guard sigue vigente, que es lo que protege el metodo.
934
- const broken = /SyntaxError|ReferenceError|Cannot find module|ERR_MODULE_NOT_FOUND|ERR_REQUIRE_ESM/.exec(String(pres.stderr || ""));
935
- // Abrir el gate entero seria un bypass a la carta (romper un modulo y
936
- // seguir sin policy), catch de la review. Se permite EXACTAMENTE una
937
- // cosa: editar el fichero que rompe kj. Lo demas sigue denegado.
938
- const at = broken ? (String(pres.stderr || "").split("\\n").map((l) => l.trim()).find((l) => /^[^ ]+\\.(m?js|cjs|ts):\\d+$/.test(l)) || "") : "";
939
- // Node localiza los errores de un modulo ESM como file:///ruta.js:124,
940
- // y esa forma no es una ruta de fichero (catch de la review).
941
- const asPath = (p) => (p.startsWith("file:") ? fileURLToPath(p) : p);
942
- const brokenFile = at ? asPath(at.slice(0, at.lastIndexOf(":"))) : "";
943
- if (broken) {
944
- const target = input.file_path || input.notebook_path || "";
945
- const repairing = EDIT_TOOLS.includes(tool) && brokenFile && target && resolve(String(target)) === resolve(brokenFile);
946
- if (repairing) console.error("karajan sentinel: kj no arranca (" + broken[0] + " en " + at + ") — la policy no se puede evaluar, asi que solo se permite arreglar ESE fichero. Arreglalo y vuelve." + doc("policy"));
947
- else {
948
- console.error("karajan sentinel: kj no arranca (" + broken[0] + (at ? " en " + at : "") + "), asi que la policy no se puede evaluar y NADA MAS pasa: edita " + (brokenFile || "el fichero que rompe kj") + " para arreglarlo. No es un deny de policy, es kj roto." + doc("policy"));
949
- process.exit(2);
950
- }
951
- } else {
952
- console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check; .karajan/policy.yml lo corrige tu usuario fuera de la sesion." + doc("policy"));
953
- process.exit(2);
954
- }
955
- }
956
- }
957
- if (EDIT_TOOLS.includes(tool)) {
958
- // STW-C (KJC-TSK-0791): the sweep's verdict has consequence where work
959
- // STARTS. Inform ALWAYS, once per session and impossible to miss; BLOCK
960
- // almost never — only security and persistent red main, and only when
961
- // the project adopted it (method_gates.steward: block). Unknown is never
962
- // treated as broken: nothing is asserted that is not known. No report =
963
- // the project does not use the Steward yet — silence, adoption is explicit.
964
- try {
965
- const rep = JSON.parse(readFileSync(join(ROOT, ".karajan", "steward", "report.json"), "utf8"));
966
- const bad = (rep.invariants || []).filter((r) => r.verdict === "broken");
967
- const stale = (rep.invariants || []).filter((r) => r.verdict === "unknown");
968
- if (bad.length || stale.length) {
969
- const hard = bad.filter((r) => r.id === "vulnerable-deps" || r.id === "main-ci");
970
- let blockOn = false;
971
- try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
972
- if (hard.length && blockOn) {
973
- console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(sin escape, ADR 0015: si el trabajo sigue, tu usuario cambia method_gates.steward a inform)" + doc("steward"));
974
- process.exit(2);
975
- }
976
- const st = load();
977
- const s = session(st, sid);
978
- if (!s.steward_informed) {
979
- s.steward_informed = true;
980
- save(st);
981
- const lines = [...bad.map((r) => "ROTO " + r.id + ": " + (r.remedy || r.evidence || "")), ...stale.map((r) => "desconocido " + r.id + " (no se afirma lo que no se sabe) — refresca: " + (r.renew || "kj steward sweep"))];
982
- console.log(JSON.stringify({ systemMessage: "kj steward (barrido " + rep.sweptAt + "): " + lines.join(" | ") + " — detalle: .karajan/steward/report.md" }));
983
- }
984
- }
985
- } catch { /* no report or unreadable — the Steward is not adopted here */ }
986
- const file = input.file_path || input.notebook_path;
987
- const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
988
- // KJC-BUG-0279 (#1981): outside the repository there is no source to govern.
989
- if (rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/") && CODE.test(rel) && !TESTS.test(rel)) {
990
- const branch = branchOf();
991
- const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
992
- // ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
993
- if (why) {
994
- console.error(why === "base"
995
- ? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion." + doc("card-first")
996
- : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion." + doc("card-first"));
997
- process.exit(2);
998
- }
999
- // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
1000
- // zone before the session touches it. Covered = the session ledger
1001
- // (RAG-A) holds this file or a sibling of its directory; a NEW file only
1002
- // needs the session to have consulted at all. Same file set as card-first.
1003
- const rs = load().sessions?.[sid] || {};
1004
- const hits = rs.rag_hits || [];
1005
- const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
1006
- // KJC-BUG-0210: nuevo no es "no existe en disco". En cuanto la sesion
1007
- // lo crea, la siguiente edicion exigia una respuesta del RAG sobre un
1008
- // fichero que el indice NO puede tener. El hecho comprobable: un
1009
- // fichero que git no conoce no ha podido indexarse.
1010
- // Solo se pregunta a git en el camino lento, cuando el gate iba a
1011
- // denegar: exit 1 = git responde que no lo conoce; cualquier otro
1012
- // resultado se comporta como hasta ahora y bloquea.
1013
- const fresh = () => !existsSync(String(file)) || spawnSync("git", ["-C", ROOT, "ls-files", "--error-unmatch", "--", rel], { encoding: "utf8" }).status === 1;
1014
- // A path outside this tree is another lane's business (the lane guard
1015
- // above rules on it) and the ledger only holds in-repo paths.
1016
- // KJC-BUG-0190: with nothing indexed, no query can cover anything —
1017
- // the gate asks for kj rag index instead of denying the impossible.
1018
- // KJC-BUG-0308: a barrel only re-exports its folder, so the RAG answers with
1019
- // what it re-exports and never with the barrel: a hit in its own subtree covers it.
1020
- const BARRELS = ["index.js", "index.ts", "index.mjs", "index.cjs", "index.jsx", "index.tsx", "__init__.py", "mod.rs"];
1021
- const barrelCovered = dirOf(rel) !== "" && BARRELS.includes(rel.slice(rel.lastIndexOf("/") + 1)) && hits.some((h) => h.startsWith(dirOf(rel) + "/"));
1022
- const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || barrelCovered || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
1023
- // ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
1024
- // index, not indexable) are decided by the gate itself.
1025
- if (!covered) {
1026
- {
1027
- // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
1028
- // sin adapter NO puede estar en el indice, asi que exigir una
1029
- // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
1030
- // dejar el escape puesto para siempre. La exencion es SOLO
1031
- // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
1032
- // y sigue bloqueando, porque si no el gate desapareceria en
1033
- // cualquier proyecto sin indexar. Nada legible = se bloquea.
1034
- const cov = spawnSync("kj", ["rag", "covers", rel, "--json"], { cwd: ROOT, encoding: "utf8" });
1035
- let blind = null;
1036
- let absent = null;
1037
- try {
1038
- const c = JSON.parse(String(cov.stdout || ""));
1039
- if (c.state === "not-indexable") blind = c.reason;
1040
- // KJC-TSK-0891: si el indice no tiene el fichero, consultar no puede
1041
- // devolverlo; el remedio es indexarlo, y se nombra.
1042
- if (c.state === "stale" || c.state === "index-empty") absent = c.reason;
1043
- } catch { blind = null; }
1044
- if (blind) {
1045
- console.error("karajan sentinel: rag-first — " + blind + " — no se te exige consultar lo que el indice no puede tener; el gate lo anota y sigue" + doc("rag-first"));
1046
- } else if (absent) {
1047
- console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
1048
- process.exit(2);
1049
- } else {
1050
- // KJC-BUG-0293 (#1996): hits that fell outside the tree are named, so the
1051
- // person learns the index was built from another path instead of querying again.
1052
- const dropped = rs.rag_dropped;
1053
- const outside = dropped?.count
1054
- ? " La ultima consulta devolvio " + dropped.count + " hit(s) fuera de este arbol (" + dropped.root + "): " + (dropped.sample || []).join(", ") + " — el indice se construyo desde otra ruta; reindexa desde esta: kj rag index --with-sources."
1055
- : "";
1056
- console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>." + outside + doc("rag-first"));
1057
- process.exit(2);
1058
- }
1059
- }
1060
- }
1061
- }
1062
- }
1063
- if (tool === "Bash") {
1064
- const cmd = String(input.command || "");
1065
- // KJC-TSK-0765 board-sync: a merge leaves its card PENDING in the tracker
1066
- // and nothing advances (commit, new PR, another merge; push and the end of
1067
- // the turn go through violations()) until the card is moved. The board is
1068
- // true at all times or the method stops — never a rule in the agent's memory.
1069
- // ENF-B (KJC-TSK-0854): el review es obligatorio, no una sugerencia. Una
1070
- // bandera no puede apagar el gate de commit, y mover core.hooksPath lo
1071
- // apaga entero. El --no-verify que kj harden --commit usa por dentro no
1072
- // pasa por aqui: no es una tool call. Escape humano, sellado como el resto.
1073
- // KJC-BUG-0286 (#1982): judged on the git commit SEGMENT alone, read by the
1074
- // shell reader: a "commit" inside a grep pattern piped after git show, or
1075
- // the -n of a grep in the same pipeline, is not a skipped hook.
1076
- const commitSegments = shellSegments(cmd).map((ws) => ws.map((w) => w.slice(w.lastIndexOf("/") + 1))).filter((ws) => inOrder(["git", "commit"], ws));
1077
- // git acepta abreviaturas no ambiguas de las opciones largas, asi que
1078
- // --no-ver salta el hook igual que --no-verify (catch de la review).
1079
- const shortWithN = (w) => w.length > 1 && w[0] === "-" && w[1] !== "-" && w.includes("n");
1080
- const skipsVerify = (w) => shortWithN(w) || (w.length >= 5 && "--no-verify".startsWith(w));
1081
- // Mover core.hooksPath apaga TODOS los hooks. git tiene varias puertas
1082
- // para lo mismo (config, -c, --config-env, GIT_CONFIG_KEY_n) y no
1083
- // distingue mayusculas en la llave, asi que lo que se vigila es la LLAVE
1084
- // en cualquier posicion (dos catches de la review). Leerla no hace nada.
1085
- // Nombrar la llave basta para denegar: exceptuar las lecturas invitaba a
1086
- // colar una detras del cambio (git config core.hooksPath /x; cat algo),
1087
- // catch de la review. Leerla tiene remedio barato, perderla no.
1088
- // Se busca la llave DENTRO del token y no solo al principio: las formas
1089
- // de inyeccion la empotran (GIT_CONFIG_KEY_0=core.hooksPath,
1090
- // --config-env=core.hooksPath=VAR) y una propiedad de seguridad no puede
1091
- // depender de como parta las palabras el separador (catch de la review).
1092
- const movesHooks = cmd.toLowerCase().includes("core.hookspath");
1093
- if (commitSegments.some((ws) => ws.some(skipsVerify)) || movesHooks) {
1094
- // ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
1095
- // if it ever must happen, is the user's act in their own terminal.
1096
- console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
1097
- process.exit(2);
1098
- }
1099
- // KJC-BUG-0296 (#1989): the staged set is reviewed by name. A blanket
1100
- // git add -A / git add . / git add -u put a Claude scheduler lock into
1101
- // main; and the agent's runtime files (.claude/*.lock, .kj/, the local
1102
- // identity, the Sentinel state, the harness) are never staged at all.
1103
- // (No backticks in this template: they would end the string.)
1104
- const RUNTIME = /^(\\.\\/)?(\\.claude\\/[^/]*\\.lock|\\.kj\\/|\\.karajan\\/identity\\.local\\.yml|\\.karajan\\/harness\\/sentinel-state\\.json)/;
1105
- for (const ws of shellSegments(cmd)) {
1106
- const gi = ws.findIndex((x) => x.slice(x.lastIndexOf("/") + 1) === "git");
1107
- if (gi < 0) continue;
1108
- // git's own options come before the verb: -C <dir>, -c k=v, --git-dir=…
1109
- let vi = gi + 1;
1110
- while (vi < ws.length && ws[vi].startsWith("-")) vi += ws[vi] === "-C" || ws[vi] === "-c" ? 2 : 1;
1111
- if (ws[vi] !== "add") continue;
1112
- const args = ws.slice(vi + 1);
1113
- // The pathspec: after "--" if present, else every non-option word. "." is
1114
- // the whole tree whichever way it is written; -A/-u with no pathspec too.
1115
- const dash = args.indexOf("--");
1116
- const spec = dash >= 0 ? args.slice(dash + 1) : args.filter((a) => !a.startsWith("-"));
1117
- const whole = spec.some((a) => a === "." || a === "./");
1118
- const blanket = whole || (args.some((a) => a === "-A" || a === "--all" || a === "-u" || a === "--update") && spec.length === 0);
1119
- if (blanket) {
1120
- console.error("karajan sentinel: git add -A / . / -u stagea lo que no has revisado — stagea por nombre (git add -- <ruta>...) y mira git diff --cached --stat antes de commitear." + doc("commit-gate"));
1121
- process.exit(2);
1122
- }
1123
- // Directories too: .kj is runtime whole; .claude and .karajan hold the
1124
- // contract, so only a forced add (-f, past the ignore) reaches their locks.
1125
- const force = args.some((a) => a === "-f" || a === "--force");
1126
- const runtime = spec.find((a) => RUNTIME.test(a) || /^(\\.\\/)?\\.kj\\/?$/.test(a) || (force && /^(\\.\\/)?(\\.claude|\\.karajan)\\/?$/.test(a)));
1127
- if (runtime) {
1128
- console.error("karajan sentinel: " + runtime + " es runtime del agente (locks de .claude, .kj/, identidad local, estado del Sentinel) y no se commitea — fuera del stage." + doc("commit-gate"));
1129
- process.exit(2);
1130
- }
1131
- }
1132
- const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
1133
- // KJC-TSK-1089: git push is not read any more; pending moves still stop the turn (stop gate).
1134
- const ADVANCE = /\\bgit\\s+commit\\b|\\bgh\\s+pr\\s+create\\b/;
1135
- const mergeM = cmd.match(MERGE);
1136
- const pend = pendingMoves(load().sessions?.[sid]);
1137
- if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1138
- // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1139
- // por su primera mitad (boardGate, en la lib).
1140
- const { blocking, carried, reason } = boardGate(sid, pend);
1141
- for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1142
- // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1143
- // are already decided above (KJC-BUG-0230, 0198).
1144
- // KJC-BUG-0363: when kj could not check, its reason travels with the block.
1145
- if (blocking.length > 0) {
1146
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + (reason ? "\\n(" + reason + ")" : "") + doc("board-sync"));
1147
- process.exit(2);
1148
- }
1149
- }
1150
- // (the pending entry itself is recorded by the PostToolUse hook, only once
1151
- // gh confirms "merged pull request #N" — a failed merge never blocks)
1152
- // CLM-C (KJC-TSK-0803): a PR body outlives the turn — an invented figure
1153
- // there misleads every future reader. When the command writes one from a
1154
- // file, kj crosses ITS data against this turn's outputs; the hook carries
1155
- // no policy (kj reads method_gates.claims; off = this check does not exist).
1156
- // KJC-BUG-0245: the path is read by the shell reader, so "pr.md;" is "pr.md".
1157
- const body = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? optionValues(cmd, ["--body-file"])[0] : undefined;
1158
- if (body && transcript) {
1159
- const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
1160
- if (!g.error && g.status === 2) {
1161
- console.error("karajan sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body + doc("claims"));
1162
- process.exit(2);
1163
- }
1164
- }
1165
- if (isPublish(cmd)) {
1166
- // ADR 0015 (KJC-TSK-0932): no escape — KJC-BUG-0204 and 0259 keep the
1167
- // check from blocking its own fix.
1168
- // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
1169
- // declara remedied_by no bloquea el comando que lo repara (desplegar
1170
- // la landing era justo lo que el check pedia); publicar no se exime.
1171
- const res = spawnSync("kj", ["release", "check", "--json", "--for-command", cmd], { cwd: ROOT, encoding: "utf8" });
1172
- if (res.error || res.status === null) process.exit(0);
1173
- let parsed = null;
1174
- try { parsed = JSON.parse(res.stdout); } catch { /* raw output */ }
1175
- const lifted = (parsed && parsed.lifted) || [];
1176
- if (res.status === 0 && lifted.length > 0) {
1177
- // En voz alta: una exencion que nadie ve no se distingue de un gate que no mira.
1178
- console.error("karajan sentinel: release check en rojo SOLO por lo que este comando repara (" + lifted.join(", ") + ") — pasa, y el resto sigue en verde" + doc("release"));
1179
- process.exit(0);
1180
- }
1181
- if (res.status !== 0) {
1182
- let items = "";
1183
- try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
1184
- console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(sin escape: se repara lo que el check nombra)" + doc("release"));
1185
- process.exit(2);
1186
- }
1187
- }
1188
- // KJC-TSK-1089 (ADR 0015): git push is no longer read here. The base branch is
1189
- // protected (required checks), the card and the tests are judged at commit
1190
- // (kj review), and pending card moves keep the turn from ending (stop gate).
1191
- }
1192
- } catch { /* fail open */ }
1193
- process.exit(0);
1194
- });
1195
- `;
1196
-
28
+ // KJC-TSK-0915 (ADR 0014), KJC-BUG-0402 (ADR 0030): every guard is a real module,
29
+ // copied byte for byte under the SAME name (so they can import each other in both
30
+ // places). Being here, the installed record, the tamper check and the human seal
31
+ // cover them; being files, they can also run from the installed package.
32
+ // Each read stays a literal: the single-file build inlines exactly that form.
1197
33
  const SCRIPT_BODIES = {
1198
- "sentinel-lib.mjs": LIB_BODY,
1199
- "posttooluse.mjs": POST_BODY,
1200
- "stop.mjs": STOP_BODY,
1201
- "pretooluse-sentinel.mjs": PRETOOL_BODY,
1202
- // KJC-TSK-0915 (ADR 0014): real modules, copied byte for byte under the SAME name
1203
- // (so they can import each other in both places). Being here, the installed
1204
- // record, the tamper check and the human seal cover them as well.
34
+ "sentinel-lib.mjs": readFileSync(new URL("./sentinel/sentinel-lib.mjs", import.meta.url), "utf8"),
35
+ "posttooluse.mjs": readFileSync(new URL("./sentinel/posttooluse.mjs", import.meta.url), "utf8"),
36
+ "stop.mjs": readFileSync(new URL("./sentinel/stop.mjs", import.meta.url), "utf8"),
37
+ "pretooluse-sentinel.mjs": readFileSync(new URL("./sentinel/pretooluse-sentinel.mjs", import.meta.url), "utf8"),
1205
38
  "sentinel-shell.mjs": readFileSync(new URL("./sentinel/sentinel-shell.mjs", import.meta.url), "utf8"),
1206
39
  "sentinel-discard.mjs": readFileSync(new URL("./sentinel/sentinel-discard.mjs", import.meta.url), "utf8"),
1207
40
  "sentinel-bash-write.mjs": readFileSync(new URL("./sentinel/sentinel-bash-write.mjs", import.meta.url), "utf8"),
@@ -1293,6 +126,14 @@ function writeInstalledRecord(dir) {
1293
126
  */
1294
127
  export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, gitShowFn = null } = {}) {
1295
128
  const root = projectDir || resolveSentinelRoot();
129
+ // KJC-TSK-1206 (ADR 0030): wired to the package runner, the guard that runs is
130
+ // the installed kj's, so the wiring is what can be tampered with and is checked.
131
+ // The governing guards; the tool gate (Write over an existing file) is a convenience.
132
+ const wiring = wiringState({ projectDir: root, entries: SENTINEL_WIRING });
133
+ if (wiring.missing.length > 0) {
134
+ return { ok: false, mismatched: wiring.missing, wiring: true, reason: "hooks de .claude/settings.json que ya no llaman a su guardia — restaura con kj harden" };
135
+ }
136
+ if (wiring.mode === "wiring") return { ok: true, mismatched: [], wiring: true };
1296
137
  const dir = join(root, ".karajan", "harness");
1297
138
  const installed = new Map();
1298
139
  const mismatched = [];
@@ -1353,17 +194,14 @@ export function verifySentinelScripts({ projectDir, readFileFn = readFileSync, g
1353
194
  }
1354
195
 
1355
196
  /**
1356
- * Write the sentinel scripts (shared lib + state writer + gates) and wire them.
197
+ * Wire the Sentinel's hooks to the guards of the installed kj (ADR 0030).
1357
198
  *
1358
- * KJC-BUG-0193: a guard holding exactly what a human sealed is LEFT ALONE. This
1359
- * is the command a session reaches for when it has edited the templates, and
1360
- * rewriting the guards here un-sealed the ones actually running, so the very act
1361
- * of unblocking destroyed the seal that made unblocking unnecessary. Advancing
1362
- * them is a human act: `kj harden --commit` moves guards and seal together.
1363
- * Anything nobody sealed is rewritten as before, because restoring a tampered
1364
- * guard is still the right move.
199
+ * KJC-TSK-1208: no guard is copied into the project any more; what runs is the
200
+ * package's. Once the hooks call it, the copies an older kj wrote are dead code
201
+ * and are retired; the session state and the denial log stay. The next seal
202
+ * records them as deleted. Not wired (unreadable settings), nothing is removed.
1365
203
  */
1366
- export function installSentinelHooks({ projectDir = process.cwd(), logger = console, human = false, gitShowFn = null } = {}) {
204
+ export function installSentinelHooks({ projectDir = process.cwd(), logger = console } = {}) {
1367
205
  // KJC-BUG-0223: instalado desde un subdirectorio, esto sembraba un segundo
1368
206
  // `.karajan/harness` ahi, y el gate (que mira otro sitio) pasaba a verlo. El
1369
207
  // ritual quedaba "arreglado" y el arbol se llenaba de arneses huerfanos que
@@ -1371,56 +209,43 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
1371
209
  const root = resolveSentinelRoot(projectDir);
1372
210
  if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
1373
211
  projectDir = root;
1374
- const sealed = human ? new Map() : sealedByPath(projectDir, gitShowFn);
1375
- const deferred = [];
1376
- const keepsItsSeal = (name) => {
1377
- const sealedHash = sealed.get(`.karajan/harness/${name}`);
1378
- if (!sealedHash) return false;
1379
- try {
1380
- return sealedHash === createHash("sha256").update(readFileSync(join(projectDir, ".karajan", "harness", name), "utf8")).digest("hex");
1381
- } catch {
1382
- return false; // no hay contenido que un sello pueda cubrir
1383
- }
1384
- };
1385
- const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) => {
1386
- if (keepsItsSeal(name)) {
1387
- const abs = join(projectDir, ".karajan", "harness", name);
1388
- // KJC-BUG-0325: only a sealed guard the current template would change is
1389
- // waiting for the human; one identical to it has nothing to advance.
1390
- if (readFileSync(abs, "utf8") !== body) deferred.push(name);
1391
- return abs;
1392
- }
1393
- return writeHarnessScript(projectDir, name, body);
1394
- });
1395
- writeInstalledRecord(join(projectDir, ".karajan", "harness"));
1396
- if (deferred.length > 0) {
1397
- logger?.info?.(`kj harden: ${deferred.join(", ")} se queda como está — es lo que un humano selló, y avanzarlo es suyo: kj harden --commit`);
1398
- }
1399
- const { wired } = mergeClaudeHooks({
1400
- projectDir,
1401
- logger,
1402
- entries: [
1403
- { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
1404
- { event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
1405
- // KJC-TSK-0946 (ADR 0016): no matcher. The rules of the MD files are checked
1406
- // before EVERY tool call, MCP ones included.
1407
- { event: "PreToolUse", script: "pretooluse-rules.mjs" },
1408
- { event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
1409
- // KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
1410
- // update_card) are observed AFTER the fact — found live: with only the
1411
- // edit matchers wired, the board-sync recorder never ran in production.
1412
- { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
1413
- { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
1414
- // KJC-BUG-0236 (#1886): the rag-first ledger records MCP answers here;
1415
- // unwired, the official tool never satisfied the gate.
1416
- { event: "PostToolUse", matcher: "mcp__.*__kj_rag_query", script: "posttooluse.mjs" },
1417
- { event: "Stop", script: "stop.mjs" },
1418
- // KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
1419
- { event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
1420
- { event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
1421
- // KJC-TSK-0949: a new session hears of MD rules with no gate, and nothing else.
1422
- { event: "SessionStart", matcher: "startup", script: "sessionstart.mjs" },
1423
- ],
212
+ mkdirSync(join(projectDir, ".karajan", "harness"), { recursive: true });
213
+ const { wired } = mergeClaudeHooks({ projectDir, logger, entries: SENTINEL_WIRING });
214
+ const retired = wired ? retireGuardCopies(projectDir, [...Object.keys(SCRIPT_BODIES), INSTALLED_RECORD]) : [];
215
+ if (retired.length > 0) logger?.info?.(`kj harden: los guardias corren desde el kj instalado; retiradas las copias del proyecto (${retired.join(", ")})`);
216
+ return { wired, retired };
217
+ }
218
+
219
+ /** KJC-TSK-1208: remove the named files from .karajan/harness, returning those that were there. */
220
+ export function retireGuardCopies(projectDir, names) {
221
+ const dir = join(projectDir, ".karajan", "harness");
222
+ return names.filter((name) => {
223
+ if (!existsSync(join(dir, name))) return false;
224
+ rmSync(join(dir, name));
225
+ return true;
1424
226
  });
1425
- return { scripts: [lib, post, stop, pre], wired, deferred };
1426
227
  }
228
+
229
+ /** The Sentinel's place in the host settings: which guard each event runs. */
230
+ export const SENTINEL_WIRING = [
231
+ { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
232
+ { event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
233
+ // KJC-TSK-0946 (ADR 0016): no matcher. The rules of the MD files are checked
234
+ // before EVERY tool call, MCP ones included.
235
+ { event: "PreToolUse", script: "pretooluse-rules.mjs" },
236
+ { event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
237
+ // KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
238
+ // update_card) are observed AFTER the fact — found live: with only the
239
+ // edit matchers wired, the board-sync recorder never ran in production.
240
+ { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
241
+ { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
242
+ // KJC-BUG-0236 (#1886): the rag-first ledger records MCP answers here;
243
+ // unwired, the official tool never satisfied the gate.
244
+ { event: "PostToolUse", matcher: "mcp__.*__kj_rag_query", script: "posttooluse.mjs" },
245
+ { event: "Stop", script: "stop.mjs" },
246
+ // KJC-TSK-0918 (ADR 0014): the critical rules back after a compaction or a resume.
247
+ { event: "SessionStart", matcher: "compact", script: "sessionstart.mjs" },
248
+ { event: "SessionStart", matcher: "resume", script: "sessionstart.mjs" },
249
+ // KJC-TSK-0949: a new session hears of MD rules with no gate, and nothing else.
250
+ { event: "SessionStart", matcher: "startup", script: "sessionstart.mjs" },
251
+ ];