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 +13 -0
- package/package.json +2 -1
- package/scripts/esbuild-sea.config.mjs +5 -1
- package/src/checks/guarantee-level.js +5 -2
- package/src/cli/register-meta.js +13 -5
- package/src/harden/advisory.js +6 -3
- 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 +15 -5
- package/src/policy/supervisor-verify.js +6 -2
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
});
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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")
|
package/src/harden/advisory.js
CHANGED
|
@@ -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: [".
|
|
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
|
-
|
|
18
|
-
//
|
|
19
|
-
|
|
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) =>
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
});
|