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 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.0",
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,7 +29,8 @@
29
29
 
30
30
  import { execSync } from 'node:child_process';
31
31
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
32
- import { join } from 'node:path';
32
+ import { join, resolve } from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
33
34
  import { getHuBoardPlansDirs } from './db.js';
34
35
 
35
36
  function runQuiet(cmd, opts = {}) {
@@ -71,10 +72,24 @@ function checkGitRemote(projectDir) {
71
72
  blocking: false
72
73
  };
73
74
  }
74
- // Capture first remote URL for display.
75
- const firstLine = r.out.split('\n')[0] || '';
76
- const remoteUrl = firstLine.split(/\s+/)[1] || '(unknown)';
77
- return { id: 'git_remote', label: 'Repositorio remoto', status: 'ok', detail: remoteUrl, blocking: false };
75
+ // KJC-BUG-0398: origin is the remote that matters, not the first alphabetically; a remote
76
+ // whose URL is a local path that no longer exists is garbage, named with its removal.
77
+ // `git remote -v` is "<name>\t<url> (fetch|push)": a URL may hold spaces, and fetch and push may differ.
78
+ const entries = r.out.split('\n').map((line) => /^(\S+)\t(.+) \((?:fetch|push)\)$/.exec(line)).filter(Boolean).map(([, name, url]) => ({ name, url }));
79
+ const shown = (entries.find((e) => e.name === 'origin') ?? entries[0])?.url ?? '(unknown)';
80
+ // A local remote is an absolute or ./ ../ path (resolved from the project) or a file:// URL.
81
+ const localPath = (url) => {
82
+ if (url.startsWith('file://')) {
83
+ try { return fileURLToPath(url); } catch { return null; } // host-qualified or malformed: not judged
84
+ }
85
+ return /^(\/|\.\.?\/)/.test(url) ? resolve(projectDir, url) : null;
86
+ };
87
+ const dead = [...new Set(entries.filter(({ url }) => localPath(url) && !existsSync(localPath(url))).map((e) => e.name))];
88
+ if (dead.length === 0) return { id: 'git_remote', label: 'Repositorio remoto', status: 'ok', detail: shown, blocking: false };
89
+ return {
90
+ id: 'git_remote', label: 'Repositorio remoto', status: 'warn', detail: shown, blocking: false,
91
+ consequence: `Remotos que apuntan a carpetas que ya no existen: ${dead.join(', ')}. No sirven y confunden; bórralos con: ${dead.map((n) => `git remote remove -- '${n.replaceAll("'", "'\\''")}'`).join(' && ')}`,
92
+ };
78
93
  }
79
94
 
80
95
  function checkGitBranch(projectDir) {
@@ -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
  });
@@ -19,10 +19,13 @@
19
19
 
20
20
  import { execFileSync } from "node:child_process";
21
21
  import { existsSync } from "node:fs";
22
- import { join } from "node:path";
22
+ import { join, resolve } from "node:path";
23
+ import { fileURLToPath } from "node:url";
23
24
 
24
25
  const STRATEGY_MANUAL = "manual";
25
26
 
27
+ // KJC-BUG-0398: a remote's name is free text; quoted, it can never be read as a command.
28
+ const shellQuote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
26
29
  const git = (projectDir, args) => execFileSync("git", ["-C", projectDir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
27
30
  const gitOk = (projectDir, args) => { try { git(projectDir, args); return true; } catch { return false; } };
28
31
 
@@ -77,6 +80,26 @@ export function createRepoStateCheck({ projectDir }) {
77
80
  });
78
81
  }
79
82
 
83
+ // KJC-BUG-0398: a remote whose URL is a local folder that no longer exists is garbage
84
+ // (four /tmp imports lived two months here unnoticed, and the board showed one as the remote).
85
+ // `git remote -v` is "<name>\t<url> (fetch|push)": a URL may hold spaces, and fetch and push may differ.
86
+ // A local remote is an absolute or ./ ../ path (resolved from the project) or a file:// URL.
87
+ const localPath = (url) => {
88
+ if (url.startsWith("file://")) {
89
+ try { return fileURLToPath(url); } catch { return null; } // host-qualified or malformed: not judged
90
+ }
91
+ return /^(\/|\.\.?\/)/.test(url) ? resolve(dir, url) : null;
92
+ };
93
+ const dead = git(dir, ["remote", "-v"]).split("\n").map((l) => /^(\S+)\t(.+) \((?:fetch|push)\)$/.exec(l))
94
+ .filter((m) => m && localPath(m[2]) && !existsSync(localPath(m[2]))).map((m) => m[1]);
95
+ const deadRemotes = [...new Set(dead)];
96
+ if (deadRemotes.length) {
97
+ symptoms.push({
98
+ detail: `remotos que apuntan a carpetas que ya no existen: ${deadRemotes.join(", ")}`,
99
+ fix: deadRemotes.map((n) => `git remote remove -- ${shellQuote(n)}`).join(" && "),
100
+ });
101
+ }
102
+
80
103
  if (symptoms.length === 0) return { ok: true, severity: "info", detail: "el repositorio está en orden" };
81
104
  return {
82
105
  ok: false,
@@ -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(", ")}` };
@@ -7,6 +7,7 @@ import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { parse as parseYaml } from "yaml";
9
9
  import { runCommand } from "../utils/process.js";
10
+ import { assertGhIdentity } from "../hu/gh-identity.js";
10
11
 
11
12
  // Scheduled or advisory kj workflows: they never block a PR, so they are never required.
12
13
  const NOT_REQUIRED = new Set(["kj-mutation.yml", "kj-steward.yml"]);
@@ -34,7 +35,8 @@ const protectionPath = ({ owner, repo, branch }) => `repos/${owner}/${repo}/bran
34
35
 
35
36
  function protectCommand({ checks, ...target }) {
36
37
  const contexts = checks.map((c) => `-f ${shellQuote(`required_status_checks[contexts][]=${c}`)}`).join(" ");
37
- return `gh api -X PUT ${protectionPath(target)} -F 'required_status_checks[strict]=false' ${contexts} -F enforce_admins=false -F required_pull_request_reviews=null -F restrictions=null -F allow_force_pushes=false`;
38
+ // KJC-BUG-0401: enforce_admins=true; an agent working with an admin's token would skip every check otherwise.
39
+ return `gh api -X PUT ${protectionPath(target)} -F 'required_status_checks[strict]=false' ${contexts} -F enforce_admins=true -F required_pull_request_reviews=null -F restrictions=null -F allow_force_pushes=false`;
38
40
  }
39
41
 
40
42
  const addCommand = ({ missing, ...target }) =>
@@ -62,24 +64,37 @@ export async function protectionAdvice({ owner, repo, branch, checks, run = runC
62
64
  if (/404|not protected/i.test(`${res.stderr}${res.stdout}`)) return { status: "unprotected", command: protectCommand({ ...target, checks }) };
63
65
  return { status: "unverified", reason: (res.stderr || res.stdout || "gh failed").trim(), settings: settingsUrl(target), missing: checks };
64
66
  }
65
- const statusChecks = JSON.parse(res.stdout || "{}").required_status_checks;
67
+ const protection = JSON.parse(res.stdout || "{}");
68
+ const statusChecks = protection.required_status_checks;
66
69
  if (!statusChecks) return { status: "no-checks", settings: settingsUrl(target), missing: checks };
67
70
  const existing = new Set(statusChecks.contexts ?? []);
68
71
  const missing = checks.filter((c) => !existing.has(c));
69
- return missing.length === 0 ? { status: "ok" } : { status: "missing", missing, command: addCommand({ ...target, missing }) };
72
+ // KJC-BUG-0401: required checks an admin skips are not required for an agent holding an admin's token.
73
+ const bindAdmins = protection.enforce_admins?.enabled === false ? `gh api -X POST ${protectionPath(target)}/enforce_admins` : null;
74
+ if (missing.length > 0) return { status: "missing", missing, command: [addCommand({ ...target, missing }), bindAdmins].filter(Boolean).join(" && ") };
75
+ if (bindAdmins) return { status: "admins-exempt", command: bindAdmins };
76
+ return { status: "ok" };
70
77
  }
71
78
 
72
79
  // Owner and repo go into a command an admin copies: only the characters GitHub allows in them.
73
80
  const GITHUB_REMOTE = /github\.com[^:/]*[:/]([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
74
81
 
75
82
  /** kj harden's last step: on a GitHub remote, what the base branch needs and who runs it. */
76
- export async function adviseBranchProtection({ projectDir, branch, logger, run = runCommand }) {
83
+ export async function adviseBranchProtection({ projectDir, branch, logger, run = runCommand, identity = assertGhIdentity }) {
77
84
  const checks = requiredChecksFrom(projectDir);
78
85
  if (checks.length === 0) return null;
79
86
  const remote = await run("git", ["-C", projectDir, "remote", "get-url", "origin"]).catch(() => null);
80
87
  const match = remote?.exitCode === 0 ? GITHUB_REMOTE.exec(remote.stdout.trim()) : null;
81
88
  if (!match) return null;
82
- const advice = await protectionAdvice({ owner: match[1], repo: match[2], branch, checks, run });
89
+ // KJC-BUG-0401: read the protection as the account the clone declares; another one gets a 404
90
+ // and kj said "not protected" about a protected branch.
91
+ let advice;
92
+ try {
93
+ identity({ projectDir });
94
+ advice = await protectionAdvice({ owner: match[1], repo: match[2], branch, checks, run });
95
+ } catch (err) {
96
+ advice = { status: "unverified", reason: err.message, settings: settingsUrl({ owner: match[1], repo: match[2] }), missing: checks };
97
+ }
83
98
  if (advice.status === "ok") {
84
99
  logger.info?.(` • branch protection: ${branch} requires the ${checks.length} kj check(s)`);
85
100
  return advice;
@@ -90,6 +105,7 @@ export async function adviseBranchProtection({ projectDir, branch, logger, run =
90
105
  missing: `${branch} does not require ${advice.missing?.length} kj check(s) (${list}). An admin runs (kj never does):`,
91
106
  "no-checks": `${branch} is protected but requires no status checks. An admin enables "Require status checks" and adds ${list} in:`,
92
107
  unverified: `could not read the protection of ${branch} (${advice.reason}). An admin checks that ${list} are required in:`,
108
+ "admins-exempt": `${branch} requires the kj checks, but admins can merge without them, and an agent working with an admin's token would. An admin runs (kj never does):`,
93
109
  }[advice.status];
94
110
  logger.warn?.(` ⚠ branch protection: ${why}`);
95
111
  logger.warn?.(` ${advice.command ?? advice.settings}`);
@@ -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),",