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.
- package/bin/kj-hook.js +13 -0
- package/package.json +2 -1
- package/packages/hu-board/src/preflight.js +20 -5
- package/scripts/esbuild-sea.config.mjs +5 -1
- package/src/checks/guarantee-level.js +5 -2
- package/src/checks/repo-state.js +24 -1
- package/src/cli/register-meta.js +13 -5
- package/src/harden/advisory.js +6 -3
- package/src/harden/branch-protection.js +21 -5
- package/src/harden/harness-hooks.js +82 -36
- package/src/harden/hook-templates.js +5 -0
- package/src/harden/sentinel/posttooluse.mjs +211 -0
- package/src/harden/sentinel/pretooluse-sentinel.mjs +731 -0
- package/src/harden/sentinel/pretooluse.mjs +19 -0
- package/src/harden/sentinel/sentinel-lib.mjs +114 -0
- package/src/harden/sentinel/sessionstart.mjs +11 -1
- package/src/harden/sentinel/stop.mjs +116 -0
- package/src/harden/sentinel-guards.js +28 -0
- package/src/harden/sentinel-hooks.js +63 -1238
- package/src/harden/sign-changes.js +4 -1
- package/src/harden/supervisor-commit.js +21 -4
- package/src/harden/workflow-templates.js +11 -1
- package/src/hu/team-board-mirror.js +9 -3
- package/src/policy/engine.js +2 -1
- package/src/policy/supervisor-verify.js +7 -3
|
@@ -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
|
+
});
|