karajan-code 4.65.0 → 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.
@@ -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
+ });