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.
package/bin/kj-hook.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * kj-hook <guard>: runs one Sentinel guard from the installed kj package
4
+ * (KJC-TSK-1203, ADR 0030). The project's hooks name the guard; its code lives
5
+ * here, outside the project tree, so editing the project cannot change it.
6
+ * Only the guard is loaded, never the CLI: a hook pays node, not kj's start.
7
+ * A name outside the closed list blocks (exit 2): a wiring that names no known
8
+ * guard cannot be trusted to guard anything.
9
+ */
10
+ import process from "node:process";
11
+ import { runGuard } from "../src/harden/sentinel-guards.js";
12
+
13
+ await runGuard(process.argv[2]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.65.1",
3
+ "version": "4.66.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -67,6 +67,7 @@
67
67
  ],
68
68
  "bin": {
69
69
  "kj": "bin/kj.js",
70
+ "kj-hook": "bin/kj-hook.js",
70
71
  "kj-tail": "bin/kj-tail",
71
72
  "kj-trash": "packages/ai-trash/bin/kj-trash.js",
72
73
  "karajan-mcp": "bin/karajan-mcp.js",
@@ -29,9 +29,13 @@ const seaTransformPlugin = {
29
29
  // KJC-TSK-0915 (ADR 0014): the Sentinel's guard modules are read as TEXT at
30
30
  // load time, to be copied into a project's harness. A single-file bundle has
31
31
  // no sibling files, so the text is inlined here, before import.meta.url goes.
32
+ // KJC-BUG-0402: a guard's own import.meta.url is text here; spelled with an
33
+ // escaped dot it stays the same string at run time and out of the rewrite below.
32
34
  const SENTINEL_READ = /readFileSync\(new URL\("(\.\/sentinel\/[\w-]+\.mjs)", import\.meta\.url\), "utf8"\)/g;
33
35
  if (SENTINEL_READ.test(contents)) {
34
- contents = contents.replace(SENTINEL_READ, (_m, rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")));
36
+ const escapedDot = `${String.fromCodePoint(92)}u002e`;
37
+ const asText = (rel) => JSON.stringify(readFileSync(path.resolve(path.dirname(args.path), rel), "utf8")).replaceAll("import.meta.url", `import.meta${escapedDot}url`);
38
+ contents = contents.replace(SENTINEL_READ, (_m, rel) => asText(rel));
35
39
  modified = true;
36
40
  }
37
41
 
@@ -9,6 +9,8 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
  import { detectHostAgent } from "../utils/agent-detect.js";
12
+ import { wiringState } from "../harden/harness-hooks.js";
13
+ import { SENTINEL_WIRING } from "../harden/sentinel-hooks.js";
12
14
 
13
15
  /** Pure: computes the three tiers from observable facts. */
14
16
  export function guaranteeLevel({ host, harnessWired, policyDeclared, policyWorkflow }) {
@@ -28,10 +30,11 @@ export function guaranteeLevel({ host, harnessWired, policyDeclared, policyWorkf
28
30
 
29
31
  /** Facts from disk + env; injectable for tests. */
30
32
  export function collectGuaranteeFacts({ projectDir = process.cwd(), deps = {} } = {}) {
31
- const { detectHost = detectHostAgent, exists = existsSync } = deps;
33
+ // KJC-TSK-1208 (ADR 0030): the Sentinel is its wiring now, not files in the project.
34
+ const { detectHost = detectHostAgent, exists = existsSync, sentinelWired = () => wiringState({ projectDir, entries: SENTINEL_WIRING }).missing.length === 0 } = deps;
32
35
  return guaranteeLevel({
33
36
  host: detectHost(),
34
- harnessWired: exists(join(projectDir, ".karajan", "harness", "pretooluse-sentinel.mjs")),
37
+ harnessWired: sentinelWired(),
35
38
  policyDeclared: exists(join(projectDir, ".karajan", "policy.yml")),
36
39
  policyWorkflow: exists(join(projectDir, ".github", "workflows", "kj-policy.yml")),
37
40
  });
@@ -42,8 +42,8 @@ import { policyAddCommand } from "../policy/add.js";
42
42
  import { withConfig } from "./_shared.js";
43
43
  import { existsSync } from "node:fs";
44
44
  import { join } from "node:path";
45
- import { spawnSync } from "node:child_process";
46
45
  import { verifySentinelScripts, resolveSentinelRoot } from "../harden/sentinel-hooks.js";
46
+ import { runGuard } from "../harden/sentinel-guards.js";
47
47
  import { boardGate } from "../review/board-pending.js";
48
48
  import { panelDeviation } from "../environment/panel.js";
49
49
  import { detectHostAgent } from "../utils/agent-detect.js";
@@ -697,14 +697,15 @@ export function registerMeta(program, { pkgVersion }) {
697
697
  const sentinel = program.command("sentinel").description("Deterministic method supervisor wired into the harness hooks (Claude Code)");
698
698
  sentinel.command("status")
699
699
  .description("Print the per-session method facts (sources/tests edited, escapes used) and any open violation the Stop gate would block on")
700
- .action(() => {
701
- const script = join(resolveSentinelRoot(), ".karajan", "harness", "stop.mjs");
702
- if (!existsSync(script)) {
700
+ .action(async () => {
701
+ if (!existsSync(join(resolveSentinelRoot(), ".karajan", "harness"))) {
703
702
  console.log("sentinel: not installed in this project — run `kj harden` (standard profile or higher)");
704
703
  process.exitCode = 1;
705
704
  return;
706
705
  }
707
- process.exitCode = spawnSync("node", [script, "--status"], { stdio: "inherit" }).status ?? 0;
706
+ // KJC-TSK-1208 (ADR 0030): the Stop guard of this kj, in its report mode.
707
+ process.argv.push("--status");
708
+ await runGuard("stop");
708
709
  });
709
710
  sentinel.command("verify")
710
711
  .description("Verify the harness scripts match this kj install — the root of trust is the installed package, not the project tree; exit 1 lists what was modified")
@@ -720,6 +721,13 @@ export function registerMeta(program, { pkgVersion }) {
720
721
  else console.log(res.ok ? "sentinel verify: scripts intactos" : `sentinel verify: modificados fuera de kj harden: ${res.mismatched.join(", ")} — restaura con kj harden`);
721
722
  process.exitCode = res.ok ? 0 : 1;
722
723
  });
724
+ // KJC-TSK-1204 (ADR 0030): the single-file binary has no kj-hook, so its
725
+ // wiring runs the guards through here, from the same closed list.
726
+ sentinel.command("hook <guard>")
727
+ .description("Run one Sentinel guard from this kj install, reading the hook's input from stdin (the wiring of a single-file kj; npm installs use kj-hook)")
728
+ .action(async (guard) => {
729
+ await runGuard(guard);
730
+ });
723
731
  // KJC-TSK-0868: el anfitrión escribiendo con otro coder declarado no se
724
732
  // bloquea, se registra. El Stop gate pregunta una vez por sesión.
725
733
  sentinel.command("panel-check")
@@ -13,6 +13,8 @@ import { join } from "node:path";
13
13
 
14
14
  import { findManagedBlock } from "../utils/managed-markers.js";
15
15
  import { findAlternative } from "./alternatives.js";
16
+ import { wiringState } from "./harness-hooks.js";
17
+ import { SENTINEL_WIRING } from "./sentinel-hooks.js";
16
18
  import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
17
19
  import { detectStackRoots } from "./stack-roots.js";
18
20
 
@@ -169,14 +171,15 @@ export function classifyArtifact(searchDir, artifact, altDirs = [searchDir]) {
169
171
  // KJC-BUG-0335 (#2094): the method's own pieces, not only the configs. Without
170
172
  // them the report said "0 missing" while the review gate demanded the Sentinel.
171
173
  const METHOD_PIECES = [
174
+ // KJC-TSK-1208 (ADR 0030): the Sentinel is its wiring to the installed kj, not files.
172
175
  { id: "sentinel", what: "the Sentinel (it records the session, the RAG queries the review gate needs among them)",
173
- files: [".karajan/harness/pretooluse-sentinel.mjs", ".karajan/harness/posttooluse.mjs", ".karajan/harness/stop.mjs"] },
176
+ files: [".claude/settings.json"], missingOf: (projectDir) => wiringState({ projectDir, entries: SENTINEL_WIRING }).missing },
174
177
  { id: "git-hooks", what: "the git hooks", files: [".karajan/hooks/pre-commit", ".karajan/hooks/commit-msg", ".karajan/hooks/pre-push"] },
175
178
  ];
176
179
 
177
- function methodPiece(projectDir, { id, what, files }) {
180
+ function methodPiece(projectDir, { id, what, files, missingOf }) {
178
181
  const head = { dir: ".", id, file: files[0], currentVersion: null, managedVersion: null, upToDate: null, improvements: [], mergeable: false };
179
- const missing = files.filter((f) => !existsSync(join(projectDir, f)));
182
+ const missing = missingOf ? missingOf(projectDir) : files.filter((f) => !existsSync(join(projectDir, f)));
180
183
  return missing.length === 0
181
184
  ? { ...head, status: "KJ_MANAGED", foundAt: files[0], recommendation: "keep", rationale: `${what}: installed` }
182
185
  : { ...head, status: "MISSING", foundAt: null, missing, recommendation: "install", rationale: `kj harden (without --report) installs ${what}; missing: ${missing.join(", ")}` };
@@ -10,30 +10,14 @@
10
10
  * supports tool hooks.
11
11
  */
12
12
 
13
- import { execFileSync } from "node:child_process";
14
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { execFileSync, spawnSync } from "node:child_process";
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
+ import { isSea } from "node:sea";
16
17
 
17
- const SCRIPT_BODY = `#!/usr/bin/env node
18
- // kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
19
- // tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
20
- // so a gate bug never bricks the session.
21
- import console from "node:console";
22
- import process from "node:process";
23
- import { existsSync } from "node:fs";
24
- let raw = "";
25
- process.stdin.on("data", (d) => { raw += d; });
26
- process.stdin.on("end", () => {
27
- try {
28
- const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
29
- if (tool === "Write" && input.file_path && existsSync(input.file_path)) {
30
- console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes.");
31
- process.exit(2);
32
- }
33
- } catch { /* fail open */ }
34
- process.exit(0);
35
- });
36
- `;
18
+ // KJC-TSK-1204 (ADR 0030): the tool gate is a module of the package, like the
19
+ // Sentinel's guards, so kj-hook can run it from there.
20
+ const SCRIPT_BODY = readFileSync(new URL("./sentinel/pretooluse.mjs", import.meta.url), "utf8");
37
21
 
38
22
  /** Write a managed hook script under .karajan/harness/ and return its path. */
39
23
  /**
@@ -50,6 +34,62 @@ export function writeHarnessScript(projectDir, name, body) {
50
34
  return abs;
51
35
  }
52
36
 
37
+ /**
38
+ * KJC-TSK-1204 (ADR 0030): the hook runs the guard from the installed kj, never
39
+ * a copy in the project. npm installs have kj-hook; the single-file kj, or an
40
+ * install whose kj-hook is not on the PATH yet (a linked kj predating it), runs
41
+ * it through `kj sentinel hook`, which exists wherever kj harden ran. Without
42
+ * the runner, a hook before a tool blocks (no kj, no governance: the
43
+ * maintainer's decision) and any other one warns.
44
+ */
45
+ export const kjHookOnPath = () => spawnSync("sh", ["-c", "command -v kj-hook"], { stdio: "ignore" }).status === 0;
46
+
47
+ export function guardCommand(guard, { blocking = false, viaKj = isSea() || !kjHookOnPath() } = {}) {
48
+ const [bin, run] = viaKj ? ["kj", `kj sentinel hook ${guard}`] : ["kj-hook", `kj-hook ${guard}`];
49
+ const missing = `echo "karajan: ${bin} no está en el PATH — instala kj (npm i -g @karajan-family/code); sin él el Sentinel no gobierna esta sesión" >&2`;
50
+ return `if command -v ${bin} >/dev/null 2>&1; then ${run}; else ${missing}${blocking ? "; exit 2" : ""}; fi`;
51
+ }
52
+
53
+ /** The wiring kj harden wrote before ADR 0030: node running the project's copy. */
54
+ const withoutLegacy = (list, script) => {
55
+ const legacy = `node ${join(".karajan", "harness", script)}`;
56
+ return list.flatMap((e) => {
57
+ if (!Array.isArray(e?.hooks) || !e.hooks.some((h) => h?.command === legacy)) return [e];
58
+ const hooks = e.hooks.filter((h) => h?.command !== legacy);
59
+ return hooks.length ? [{ ...e, hooks }] : [];
60
+ });
61
+ };
62
+
63
+ /**
64
+ * KJC-TSK-1206 (ADR 0030): the wiring is what decides which guard runs, so it is
65
+ * what gets verified. Every expected entry must call its guard, by the package
66
+ * runner or by the old `node .karajan/harness/<script>`; `missing` names each
67
+ * one that does not, and an unreadable settings file is missing everything.
68
+ * "files" = some entry still runs the project's copies, so those copies are live
69
+ * and verified as before; "wiring" = every entry runs the installed kj.
70
+ */
71
+ export function wiringState({ projectDir, entries }) {
72
+ let hooks;
73
+ try {
74
+ hooks = JSON.parse(readFileSync(join(projectDir, ".claude", "settings.json"), "utf8"))?.hooks;
75
+ } catch {
76
+ hooks = null;
77
+ }
78
+ if (!hooks || typeof hooks !== "object") return { mode: "wiring", missing: [".claude/settings.json (sin hooks legibles)"] };
79
+ const missing = [];
80
+ let legacy = false;
81
+ for (const { event, matcher, script } of entries) {
82
+ const guard = script.replace(/\.mjs$/, "");
83
+ const runner = [true, false].map((viaKj) => guardCommand(guard, { blocking: event === "PreToolUse", viaKj }));
84
+ const old = `node ${join(".karajan", "harness", script)}`;
85
+ const here = (Array.isArray(hooks[event]) ? hooks[event] : []).filter((e) => e?.matcher === matcher).flatMap((e) => (Array.isArray(e?.hooks) ? e.hooks : []));
86
+ if (here.some((h) => runner.includes(h?.command))) continue;
87
+ if (here.some((h) => h?.command === old)) legacy = true;
88
+ else missing.push(`${event} → ${guard}`);
89
+ }
90
+ return { mode: legacy ? "files" : "wiring", missing: [...new Set(missing)] };
91
+ }
92
+
53
93
  /**
54
94
  * Merge hook entries into .claude/settings.json. Preserve, never clobber:
55
95
  * invalid JSON or a hook event with an unexpected (non-array) shape is the
@@ -76,13 +116,15 @@ export function mergeClaudeHooks({ projectDir, logger = console, entries }) {
76
116
  return { wired: false };
77
117
  }
78
118
  }
119
+ const viaKj = isSea() || !kjHookOnPath();
79
120
  for (const { event, matcher, script } of entries) {
80
- const list = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
121
+ const list = withoutLegacy(Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [], script);
122
+ const command = guardCommand(script.replace(/\.mjs$/, ""), { blocking: event === "PreToolUse", viaKj });
81
123
  const present = list.some(
82
- (e) => JSON.stringify(e).includes(script) && (matcher === undefined || e?.matcher === matcher),
124
+ (e) => (e?.hooks || []).some((h) => h?.command === command) && (matcher === undefined || e?.matcher === matcher),
83
125
  );
84
126
  if (!present) {
85
- const cmd = { type: "command", command: `node ${join(".karajan", "harness", script)}` };
127
+ const cmd = { type: "command", command };
86
128
  list.push(matcher === undefined ? { hooks: [cmd] } : { matcher, hooks: [cmd] });
87
129
  }
88
130
  settings.hooks[event] = list;
@@ -105,7 +147,17 @@ function resolveProjectRoot(dir) {
105
147
  }
106
148
  }
107
149
 
108
- /** Write the script and merge the PreToolUse entries into .claude/settings.json. */
150
+ /** The tool gate's place in the host settings. */
151
+ export const HARNESS_WIRING = [
152
+ { event: "PreToolUse", matcher: "Write", script: "pretooluse.mjs" },
153
+ { event: "PreToolUse", matcher: "Bash", script: "pretooluse.mjs" },
154
+ ];
155
+
156
+ /**
157
+ * Merge the tool gate's PreToolUse entries into .claude/settings.json. KJC-TSK-1208
158
+ * (ADR 0030): the gate runs from the installed kj, so the copy an older kj wrote
159
+ * into the project is retired once the hooks call the package's.
160
+ */
109
161
  export function installHarnessHooks({ projectDir = process.cwd(), logger = console } = {}) {
110
162
  // KJC-BUG-0223: el harness es del proyecto y vive en su raiz. Instalado desde
111
163
  // un subdirectorio sembraba ahi un segundo `.karajan/harness` que no gobierna
@@ -113,14 +165,8 @@ export function installHarnessHooks({ projectDir = process.cwd(), logger = conso
113
165
  const root = resolveProjectRoot(projectDir);
114
166
  if (root !== projectDir) logger?.info?.(`kj harden: el harness es del proyecto, se instala en su raíz (${root})`);
115
167
  projectDir = root;
116
- const scriptAbs = writeHarnessScript(projectDir, "pretooluse.mjs", SCRIPT_BODY);
117
- const { wired } = mergeClaudeHooks({
118
- projectDir,
119
- logger,
120
- entries: [
121
- { event: "PreToolUse", matcher: "Write", script: "pretooluse.mjs" },
122
- { event: "PreToolUse", matcher: "Bash", script: "pretooluse.mjs" },
123
- ],
124
- });
125
- return { script: scriptAbs, wired };
168
+ const { wired } = mergeClaudeHooks({ projectDir, logger, entries: HARNESS_WIRING });
169
+ const copy = join(projectDir, ".karajan", "harness", "pretooluse.mjs");
170
+ if (wired && existsSync(copy)) rmSync(copy);
171
+ return { wired };
126
172
  }
@@ -159,6 +159,11 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
159
159
  "if git diff --cached --name-only --diff-filter=d | grep -qE '^(\\.claude/[^/]*\\.lock|\\.claude/scheduled_tasks\\.lock|\\.kj/|\\.karajan/identity\\.local\\.yml|\\.karajan/harness/sentinel-state\\.json)'; then",
160
160
  " echo 'kj harden: agent runtime files are staged (.claude/*.lock, .kj/, identity.local.yml, sentinel-state.json) — unstage them: git restore --staged <path>'; exit 1",
161
161
  "fi",
162
+ "# KJC-TSK-1205 (ADR 0030): a tampered Sentinel is caught before each commit,",
163
+ "# not only when the turn ends. Only where it is installed; no kj, no check.",
164
+ "if [ -d .karajan/harness ] && command -v kj >/dev/null 2>&1; then",
165
+ " kj sentinel verify >/dev/null 2>&1 || { echo 'kj harden: the Sentinel is tampered with (kj sentinel verify says how) — restore it with kj harden'; exit 1; }",
166
+ "fi",
162
167
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
163
168
  "# a staged diff only enters with a recorded cross-AI approved verdict.",
164
169
  "# KJC-BUG-0165: the gate is active once the marker is COMMITTED (HEAD),",
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env node
2
+ // kj sentinel state writer (KJC-TSK-0713) — managed by `kj harden`.
3
+ // Records deterministic method facts per session; never blocks, never fails
4
+ // a tool call (PostToolUse, always exit 0).
5
+ import console from "node:console";
6
+ import process from "node:process";
7
+ import { realpathSync } from "node:fs";
8
+ import { basename, dirname, join, relative } from "node:path";
9
+ import { spawnSync } from "node:child_process";
10
+ import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
11
+ import { remindersFor } from "./sentinel-reminders.mjs";
12
+ let raw = "";
13
+ process.stdin.on("data", (d) => { raw += d; });
14
+ process.stdin.on("end", () => {
15
+ try {
16
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, tool_response: response = {} } = JSON.parse(raw);
17
+ // KJC-TSK-0765 board-sync: a merge that gh CONFIRMS leaves its card pending
18
+ // in the tracker (the PreToolUse gate then refuses to advance until it is
19
+ // moved). Recorded here, after the fact, so a failed merge never blocks.
20
+ const text = typeof response === "string" ? response : JSON.stringify(response);
21
+ // Closing states that count as "registered in the tracker" (PG statuses and
22
+ // HU Board moves). The pending entry is cleared by the REAL tool call that
23
+ // moved the card — never by a promise in the agent's prose.
24
+ const CLOSING = ["to validate", "to-validate", "fixed", "verified", "closed", "done", "done&validated", "validated"];
25
+ const clearPending = (cardId) => {
26
+ const state = load();
27
+ const s = session(state, sid);
28
+ s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
29
+ // KJC-TSK-0774: a card closed this turn is what the Stop hook points the
30
+ // user at when the focus comes back (the board is where to look).
31
+ if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
32
+ save(state);
33
+ };
34
+ // KJC-TSK-0847 (ADR 0010, RAG-A): the ledger of what the RAG answered in
35
+ // this session — every kj_rag_query (MCP) and kj rag query (CLI) with the
36
+ // repo-relative sources it returned. Neither the CLI nor the MCP know the
37
+ // host's session id: the harness records, they do not. Hits outside the
38
+ // repo (another indexed project) are not ours and are dropped.
39
+ // KJC-BUG-0190: an index with nothing in it (a brand new project) answers
40
+ // every question with silence, so the gate would demand what no query can
41
+ // deliver. kj says so explicitly, in both its human and json forms.
42
+ const EMPTY_INDEX = /No chunks indexed yet|"empty"\s*:\s*true/;
43
+ // KJC-BUG-0293 (#1996, #1995): the index names its sources through whatever
44
+ // spelling of the project directory indexed them (a symlinked workspace, an
45
+ // alias). Compared as TEXT against the harness root, every hit fell outside
46
+ // the tree and was dropped in silence: rag_hits stayed empty and only new
47
+ // files passed. Both sides are compared by REAL path, and what still falls
48
+ // outside is written down for the gate to name.
49
+ // A path whose file is gone (or not yet there) resolves through its deepest
50
+ // existing ancestor, so an alias of the directory still lands in the tree.
51
+ const realOf = (p) => {
52
+ try { return realpathSync(p); } catch { /* resolve the parent instead */ }
53
+ const d = dirname(p);
54
+ return !d || d === p ? p : join(realOf(d), basename(p));
55
+ };
56
+ const ROOT_REAL = realOf(ROOT);
57
+ const recordRag = (query, sources, answer = "") => {
58
+ const state = load();
59
+ const s = session(state, sid);
60
+ s.at = Date.now();
61
+ if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
62
+ const hits = [];
63
+ const dropped = [];
64
+ for (const src of sources) {
65
+ const rel = relative(ROOT_REAL, realOf(String(src))).split(String.fromCharCode(92)).join("/");
66
+ if (!rel || rel === ".." || rel.startsWith("../") || rel.startsWith("/")) { dropped.push(String(src)); continue; }
67
+ if (!hits.includes(rel)) hits.push(rel);
68
+ }
69
+ (s.rag_queries ||= []).push({ ts: Date.now(), text: String(query || "").slice(0, 200), hits });
70
+ const all = (s.rag_hits ||= []);
71
+ for (const h of hits) if (!all.includes(h)) all.push(h);
72
+ if (hits.length === 0 && dropped.length > 0) s.rag_dropped = { root: ROOT_REAL, count: dropped.length, sample: dropped.slice(0, 3) };
73
+ else if (hits.length > 0) delete s.rag_dropped; // an answer inside the tree ends the diagnosis
74
+ save(state);
75
+ };
76
+ // Sources are read STRUCTURALLY, never through the stringified response:
77
+ // a JSON document (the MCP {content:[{text}]} parts, the CLI --json array)
78
+ // is parsed and its hits[].source / [].source collected.
79
+ const sourcesOf = (doc) => {
80
+ const list = Array.isArray(doc) ? doc : Array.isArray(doc?.hits) ? doc.hits : [];
81
+ return list.map((h) => h?.source).filter((x) => typeof x === "string");
82
+ };
83
+ const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
84
+ if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
85
+ // KJC-BUG-0293: the host may hand the answer as {content:[parts]}, as the
86
+ // bare array of parts, or as the text itself; all three are the same answer.
87
+ const parts = Array.isArray(response?.content) ? response.content : Array.isArray(response) ? response : [{ text: typeof response === "string" ? response : "" }];
88
+ let found = parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || ""))));
89
+ // KJC-BUG-0365 (#2208): an answer cut short (whole code chunks) is no
90
+ // longer valid JSON, but every "source" it carries still names a hit.
91
+ if (found.length === 0) found = [...text.matchAll(/\\?"source\\?"\s*:\s*\\?"([^"\\]+)\\?"/g)].map((m) => m[1]);
92
+ recordRag(input.text, found, parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
93
+ process.exit(0);
94
+ }
95
+ if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
96
+ // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
97
+ // stringified, the inner quotes are ESCAPED — match both forms (found live:
98
+ // the first real update_card did not clear its pending entry).
99
+ const cid = /\\?"cardId\\?"\s*:\s*\\?"([A-Za-z0-9-]+)\\?"/.exec(text);
100
+ const st = /\\?"status\\?"\s*:\s*\\?"([^"\\]+)\\?"/.exec(text);
101
+ if (cid && st && CLOSING.includes(st[1].toLowerCase())) {
102
+ clearPending(cid[1].toUpperCase());
103
+ // KJC-BUG-0157: a pending whose card-id NEVER existed (branch named
104
+ // before the tracker confirmed the create) could not be cleared by any
105
+ // verified route. The pending's TRUE identity is its PR: a CONFIRMED
106
+ // closing update whose request names that PR clears it too — still a
107
+ // real tracker call seen by this hook, never a promise in prose.
108
+ const prIn = Number(input?.updates?.pipelineStatus?.prCreated?.prNumber);
109
+ if (Number.isFinite(prIn) && prIn > 0) {
110
+ const state = load();
111
+ const s = session(state, sid);
112
+ s.pending_moves = (s.pending_moves || []).filter((p) => Number(p.pr) !== prIn);
113
+ save(state);
114
+ }
115
+ }
116
+ process.exit(0);
117
+ }
118
+ if (tool === "Bash") {
119
+ const cmdText = String(input.command || "");
120
+ // CLI form of the ledger: the human output lists one hit per line as
121
+ // "[kind · label · score=…] <path>"; --json prints an array with "source".
122
+ const ragCmd = /kj +rag +query/.exec(cmdText);
123
+ if (ragCmd) {
124
+ const out = typeof response === "string" ? response : String(response.stdout || "") + String(response.stderr || "");
125
+ const fromLines = out.split(String.fromCharCode(10))
126
+ .filter((l) => l.includes("score=") && l.includes("] "))
127
+ .map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
128
+ .filter(Boolean);
129
+ recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))), out);
130
+ process.exit(0);
131
+ }
132
+ // gh prints "merged pull request #N" only on a TTY: under a tool call the
133
+ // success line is ABSENT (found live on the first dogfood merge). The
134
+ // authoritative signal is the PR state itself: one gh pr view per merge.
135
+ const mergeCmd = /\bgh\s+pr\s+merge(\s+(\d+))?/.exec(cmdText);
136
+ const said = /merged pull request #(\d+)/i.exec(text);
137
+ let mergedPr = said ? Number(said[1]) : null;
138
+ // KJC-BUG-0148: the card belongs to the PR's HEAD branch, not to whatever
139
+ // branch the working tree happens to be on (found live: a merge issued
140
+ // from another card's lane pinned the pending move on the wrong card).
141
+ let headRef = null;
142
+ if (mergeCmd || said) {
143
+ const n = said ? said[1] : mergeCmd[2];
144
+ const args = ["pr", "view", ...(n ? [n] : []), "--json", "number,state,headRefName"];
145
+ const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
146
+ try {
147
+ const v = JSON.parse(r.status === 0 ? r.stdout : "null");
148
+ if (v && v.state === "MERGED") { mergedPr = Number(v.number); headRef = v.headRefName || null; }
149
+ } catch { /* unknown = not confirmed (or confirmed only by the printed line) */ }
150
+ }
151
+ if (mergedPr !== null) {
152
+ const state = load();
153
+ const s = session(state, sid);
154
+ const ref = CARD.exec(headRef || branchOf() || "");
155
+ // KJC-BUG-0230: una EPICA (…-PCS-…) no tiene estado que mover: el PG
156
+ // la deja en To Do por mas status que reciba y kj hu move no la
157
+ // conoce. Registrarla como pendiente era un bloqueo sin cauce. Se dice
158
+ // en el momento, para que no parezca que el board dejo de mirar.
159
+ const epic = ref && /-PCS-/i.test(ref[0]);
160
+ if (epic) {
161
+ 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)");
162
+ } else {
163
+ (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
164
+ }
165
+ save(state);
166
+ }
167
+ const moved = /kj\s+hu\s+move\s+([A-Za-z0-9-]+)\s+([a-z&-]+)/i.exec(String(input.command || ""));
168
+ // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
169
+ // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
170
+ if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
171
+ // KJC-TSK-0917 (ADR 0014): the method's reminders, after the action that precedes
172
+ // the one each rule is about; at most once every 25 Bash actions each. Never a block.
173
+ const st = load();
174
+ const ss = session(st, sid);
175
+ ss.step = (ss.step || 0) + 1;
176
+ const due = remindersFor(cmdText, { seen: ss.reminded || {}, step: ss.step });
177
+ for (const r of due) (ss.reminded ||= {})[r.id] = ss.step;
178
+ save(st);
179
+ if (due.length) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: due.map((r) => r.say).join(" ") } }));
180
+ process.exit(0);
181
+ }
182
+ const file = input.file_path || input.notebook_path;
183
+ if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
184
+ const state = load();
185
+ const s = session(state, sid);
186
+ s.at = Date.now();
187
+ const rel = relative(ROOT, file).replaceAll("\\", "/");
188
+ // KJC-BUG-0279 (#1981): a file outside the repository is not one of its sources.
189
+ const inside = rel && rel !== ".." && !rel.startsWith("../") && !rel.startsWith("/");
190
+ const bucket = !inside ? null : TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
191
+ if (bucket && !bucket.includes(rel)) bucket.push(rel);
192
+ // KJC-TSK-0910: the branch size while it is written, with the CI budget
193
+ // (kj pr-size), said once per threshold crossed. Context, never a block.
194
+ let sizeNote = null;
195
+ if (bucket) {
196
+ const ps = spawnSync("kj", ["pr-size", "--json"], { cwd: ROOT, encoding: "utf8", timeout: 5000 });
197
+ let size = null;
198
+ try { size = JSON.parse(String(ps.stdout || "").trim().split(String.fromCharCode(10)).pop()); } catch { size = null; }
199
+ const crossed = size ? [200, 150].find((t) => size.added > t) : undefined;
200
+ if (crossed && (s.size_warned || 0) < crossed) {
201
+ s.size_warned = crossed;
202
+ 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");
203
+ }
204
+ }
205
+ const ids = Object.keys(state.sessions);
206
+ if (ids.length > 5) delete state.sessions[ids.sort((a, b) => (state.sessions[a].at || 0) - (state.sessions[b].at || 0))[0]];
207
+ save(state);
208
+ if (sizeNote) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: "PostToolUse", additionalContext: sizeNote } }));
209
+ } catch { /* fail open — the sentinel never breaks a tool call */ }
210
+ process.exit(0);
211
+ });