karajan-code 4.38.0 → 4.39.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.38.0",
3
+ "version": "4.39.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",
@@ -35,14 +35,43 @@ function classifyMv(tokens) {
35
35
  return { destructive: true, kind: "mv-overwrite", paths: positional.slice(-1), reason: "mv may overwrite destination" };
36
36
  }
37
37
 
38
+ // KJC-BUG-0240 (#1886): checkout and restore drop uncommitted work file by file,
39
+ // and a bundle keeps commits only, so the named files are kept as files and a
40
+ // discard of the whole tree marks it (worktree) for the hook to keep every dirty file.
41
+ function classifyDiscard(tokens, sub) {
42
+ const args = tokens.slice(2);
43
+ const has = (...flags) => flags.some((f) => args.includes(f));
44
+ const SAFE = { destructive: false, kind: "git-safe", paths: [] };
45
+ if (sub === "restore" && has("--staged", "-S") && !has("--worktree", "-W")) return { ...SAFE, reason: "restore --staged only unstages" };
46
+ // -B resets an existing branch: its commits need the bundle.
47
+ if (sub === "checkout" && has("-B")) return { destructive: true, kind: "git-checkout-reset-branch", bundle: true, paths: [], reason: "git checkout -B resets an existing branch" };
48
+ if (sub === "checkout" && has("-b", "--orphan")) return { ...SAFE, reason: "checkout -b creates a branch" };
49
+ const dd = args.indexOf("--");
50
+ const named = [];
51
+ let sourceValue = false;
52
+ args.forEach((a, i) => {
53
+ if (sourceValue) sourceValue = false;
54
+ else if (a === "-s" || a === "--source") sourceValue = true;
55
+ else if ((dd >= 0 && i > dd) || !a.startsWith("-")) named.push(a);
56
+ });
57
+ if (!named.length && !has("-f", "--force")) return { ...SAFE, reason: `${sub} with nothing to discard` };
58
+ const whole = has("-f", "--force") || named.includes(".") || named.includes(":/");
59
+ return { destructive: true, kind: `git-${sub}-discard`, paths: named.filter((p) => p !== "." && p !== ":/"), ...(whole ? { worktree: "tracked" } : {}), reason: `git ${sub} discards local edits` };
60
+ }
61
+
38
62
  function classifyGit(tokens) {
39
63
  const sub = tokens[1];
40
64
  const has = (...flags) => flags.some((f) => tokens.includes(f));
41
- if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", paths: [], reason: "git reset --hard drops working tree" };
42
- if (sub === "clean" && has("-f", "-fd", "-xdf")) return { destructive: true, kind: "git-clean", paths: [], reason: "git clean -f removes untracked files" };
43
- if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", paths: [], reason: "git branch -D drops unmerged branch" };
44
- if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", paths: [], reason: "git push --force rewrites remote history" };
45
- if (sub === "checkout" && has("--", ".")) return { destructive: true, kind: "git-checkout-discard", paths: [], reason: "git checkout -- discards local edits" };
65
+ if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", bundle: true, worktree: "tracked", paths: [], reason: "git reset --hard drops working tree" };
66
+ if (sub === "clean" && tokens.some((t) => t === "--force" || /^-[a-zA-Z]*f/.test(t))) {
67
+ // -x / -X also remove ignored files.
68
+ const ignored = tokens.some((t) => /^-[a-zA-Z]*[xX]/.test(t));
69
+ return { destructive: true, kind: "git-clean", bundle: true, worktree: ignored ? "untracked+ignored" : "untracked", paths: [], reason: "git clean -f removes untracked files" };
70
+ }
71
+ if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", bundle: true, paths: [], reason: "git branch -D drops unmerged branch" };
72
+ if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", bundle: true, paths: [], reason: "git push --force rewrites remote history" };
73
+ if (sub === "checkout" || sub === "restore") return classifyDiscard(tokens, sub);
74
+ if (sub === "switch" && has("-f", "--force", "--discard-changes")) return { destructive: true, kind: "git-switch-discard", worktree: "tracked", paths: [], reason: "git switch --discard-changes drops local edits" };
46
75
  return { destructive: false, kind: "git-safe", paths: [], reason: "git op without destructive flags" };
47
76
  }
48
77
 
@@ -4,6 +4,7 @@
4
4
  // throws, deny the op so Claude never destroys without a recovery copy.
5
5
 
6
6
  import { promises as fs } from "node:fs";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { isAbsolute, resolve } from "node:path";
8
9
  import process from "node:process";
9
10
  import { classifyCommand } from "./destructive-parser.js";
@@ -47,6 +48,29 @@ async function snapshotExistingPaths(root, paths, cwd, command) {
47
48
  return snapshots;
48
49
  }
49
50
 
51
+ // KJC-BUG-0240 (#1886): the dirty files a whole-tree discard would drop, kept as
52
+ // files (a bundle keeps commits only). `which`: "tracked" changes, "untracked"
53
+ // files, or "untracked+ignored" (git clean -x/-X removes ignored files too).
54
+ async function snapshotDirtyFiles(root, cwd, command, which) {
55
+ if (!cwd) return [];
56
+ const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
57
+ if (top.status !== 0) return [];
58
+ const withIgnored = which === "untracked+ignored";
59
+ // traditional + all: every file inside an ignored directory is listed one by one.
60
+ const st = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all", ...(withIgnored ? ["--ignored=traditional"] : [])], { encoding: "utf8" });
61
+ if (st.status !== 0) throw new Error(`git status failed: ${st.stderr.trim()}`);
62
+ const wanted = (rec) => (which === "tracked" ? !rec.startsWith("??") && !rec.startsWith("!!") : rec.startsWith("??") || (withIgnored && rec.startsWith("!!")));
63
+ const files = [];
64
+ let renameSource = false; // a rename or copy record is followed by its source path
65
+ for (const rec of st.stdout.split("\0")) {
66
+ if (renameSource) { renameSource = false; continue; }
67
+ if (rec.length < 4) continue;
68
+ if (wanted(rec)) files.push(rec.slice(3));
69
+ renameSource = "RC".includes(rec[0]);
70
+ }
71
+ return snapshotExistingPaths(root, files, top.stdout.trim(), command);
72
+ }
73
+
50
74
  async function snapshotGitRepo(root, cwd, command, kind) {
51
75
  if (!cwd) return { snapshots: [], skipped: "no cwd" };
52
76
  try {
@@ -86,11 +110,9 @@ export async function handleHookPayload(payload, { root, stdin }) {
86
110
  await assertOwnedByCurrentUser(root);
87
111
  let snapshots = [];
88
112
  let skipped = null;
89
- if (verdict.kind.startsWith("git-")) {
90
- ({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
91
- } else {
92
- snapshots = await snapshotExistingPaths(root, verdict.paths, data.cwd, command);
93
- }
113
+ if (verdict.bundle) ({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
114
+ snapshots.push(...(await snapshotExistingPaths(root, verdict.paths, data.cwd, command)));
115
+ if (verdict.worktree) snapshots.push(...(await snapshotDirtyFiles(root, data.cwd, command, verdict.worktree)));
94
116
  if (snapshots.length) {
95
117
  const m = await loadManifest(root);
96
118
  for (const s of snapshots) m.entries.push(s);
@@ -106,7 +128,7 @@ export async function handleHookPayload(payload, { root, stdin }) {
106
128
  if (snapshots.length) {
107
129
  return reply(
108
130
  "allow",
109
- `ai-trash: snapshotted ${snapshots.length} ${verdict.kind.startsWith("git-") ? "git bundle" : "path(s)"} before ${verdict.kind}`
131
+ `ai-trash: snapshotted ${snapshots.length} ${snapshots.some((s) => s.type === "git-bundle") ? "item(s), git bundle included," : "path(s)"} before ${verdict.kind}`
110
132
  );
111
133
  }
112
134
  return reply(
@@ -36,7 +36,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
36
36
  const blocks = blocksRes.exitCode === 0
37
37
  ? blocksRes.stdout.split(/^@.*$/m).map((b) => b.split("\n").map((l) => l.trim()).filter(Boolean)).filter((b) => b.length > 0)
38
38
  : [];
39
- const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files, env: {} }).mode !== "pass").length;
39
+ const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files }).mode !== "pass").length;
40
40
 
41
41
  const verdicts = recentVerdicts(projectDir, sample);
42
42
  const stamped = verdicts.filter((v) => v.workspace);
@@ -34,7 +34,9 @@ async function policyRangeCheck(projectDir) {
34
34
  // protects the supervisor. `kj review` and `kj policy check` already lift
35
35
  // what the sealed provenance backs; without this the release check blocked
36
36
  // every version cut after a human `kj harden --commit`, by its own seal.
37
- const sup = liftSealedSupervisorViolations({ projectDir, violations: raw });
37
+ // KJC-BUG-0259: the history is judged on what is versioned (hooks, provenance);
38
+ // the machine-local guards were verified when the seal was reviewed.
39
+ const sup = liftSealedSupervisorViolations({ projectDir, violations: raw, trackedOnly: true });
38
40
  const violations = sup.violations;
39
41
  const hard = violations.filter((v) => v.enforcement === "deny");
40
42
  const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
@@ -201,7 +201,8 @@ export function registerPipeline(program, { pkgVersion }) {
201
201
  program
202
202
  .command("solomon")
203
203
  .description("Ask a third AI to arbitrate a rejected review verdict (brain ≠ reviewer ≠ solomon)")
204
- .requiredOption("--position <text>", "Why the brain disagrees with the reviewer")
204
+ .option("--position <text>", "Why the brain disagrees with the reviewer")
205
+ .option("--position-file <path>", "Read the position from a file (no quoting to get right)")
205
206
  .option("--range <range>", "Arbitrate a git range instead of the staged diff")
206
207
  .action(async (flags) => {
207
208
  await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
@@ -42,7 +42,6 @@ async function enforceCardFirst({ config, projectDir }) {
42
42
  // KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
43
43
  // branch-ref alone must never read as a tracker-verified pass.
44
44
  if (card.note) console.log(`⚠ card-first: ${card.note}`);
45
- if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
46
45
  if (!card.ok) {
47
46
  console.log(`✗ card-first gate: ${card.reason}`);
48
47
  process.exitCode = 1;
@@ -139,6 +138,14 @@ function printVerdict(record) {
139
138
  */
140
139
  export async function solomonCommand({ config, logger = null, flags = {} }) {
141
140
  const projectDir = config?.projectDir || process.cwd();
141
+ // KJC-BUG-0260: a position is prose with blanks and slashes; from a file it
142
+ // needs no quoting the lane guard has to tell apart from a path.
143
+ const position = flags.positionFile ? readFileSync(flags.positionFile, "utf8").trim() : flags.position;
144
+ if (!position) {
145
+ console.log("✗ kj solomon: give the brain's position with --position <text> or --position-file <path>");
146
+ process.exitCode = 1;
147
+ return { ruling: "reject", reasoning: "no position given" };
148
+ }
142
149
  const diff = await rawDiff(flags.range);
143
150
  // KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
144
151
  // arbitrable — solomon se niega antes de gastar un token. Y una policy
@@ -162,7 +169,7 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
162
169
  process.exitCode = 1;
163
170
  return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
164
171
  }
165
- const res = await runSolomonArbitration({ diff, position: flags.position, config, logger, projectDir });
172
+ const res = await runSolomonArbitration({ diff, position, config, logger, projectDir });
166
173
  if (res.ruling === "approve") {
167
174
  console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
168
175
  } else {
@@ -256,10 +263,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
256
263
  // partitioning is a decision, and it must never split code from its tests.
257
264
  const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
258
265
  const sizePolicy = config?.method_gates?.pr_size || "warn";
259
- if (process.env.KJ_ALLOW_LARGE_PR === "1") {
260
- console.log(`⚠ pr-size exempt: ${added} lines added — KJ_ALLOW_LARGE_PR=1 (explicit escape hatch)`);
261
- } else if (sizePolicy === "block") {
262
- const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
266
+ // ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
267
+ if (sizePolicy === "block") {
268
+ const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
263
269
  console.log(`✗ pr-size gate: ${reason}`);
264
270
  process.exitCode = 1;
265
271
  return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
@@ -269,7 +275,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
269
275
  }
270
276
  }
271
277
  // KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
272
- // Denylist data rejects (KJ_ALLOW_PII=1 is the named escape); generic PII
278
+ // Denylist data rejects with no escape (ADR 0015: false positives go in the allow list); generic PII
273
279
  // warns (privacy.generic: "block" hardens). Added lines only — deletions
274
280
  // don't publish. In the SEA binary the privacy module is stubbed and
275
281
  // throws: the gate degrades with a note instead of crashing.
@@ -288,25 +294,19 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
288
294
  for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
289
295
  const hardened = config?.privacy?.generic === "block" && warns.length > 0;
290
296
  if (blocks.length > 0 || hardened) {
291
- if (process.env.KJ_ALLOW_PII === "1") {
292
- console.log(`⚠ privacy exempt: ${blocks.length} denylist hit(s) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
293
- } else {
294
- for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
295
- const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (KJ_ALLOW_PII=1 to override consciously)`;
296
- console.log(`✗ privacy gate: ${reason}`);
297
- process.exitCode = 1;
298
- return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
299
- }
297
+ for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
298
+ const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (a confirmed false positive goes in the allow list of ~/.karajan/privacy.yml)`;
299
+ console.log(`✗ privacy gate: ${reason}`);
300
+ process.exitCode = 1;
301
+ return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
300
302
  }
301
303
  } catch (err) {
302
304
  // Known degradation: the SEA stub throws its install-from-npm message.
303
305
  // Anything else fails CLOSED — a silent skip would defeat the guarantee.
304
306
  if (/standalone binary/i.test(err?.message || "")) {
305
307
  console.log("⚠ privacy gate unavailable in this build — skipping");
306
- } else if (process.env.KJ_ALLOW_PII === "1") {
307
- console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
308
308
  } else {
309
- const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked (KJ_ALLOW_PII=1 to override consciously)`;
309
+ const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked`;
310
310
  console.log(`✗ ${reason}`);
311
311
  process.exitCode = 1;
312
312
  return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
@@ -330,9 +330,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
330
330
 
331
331
  // KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
332
332
  // --staged Y en --check — el pre-commit hereda los dientes sin regenerar
333
- // hooks. enforcement=warn avisa; deny cierra salvo excepción probatoria
334
- // (KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, registrada con identidad y hash
335
- // del diff); class=security cierra sin escape y sin arbitraje.
333
+ // hooks. enforcement=warn avisa; deny cierra sin excepción por diff (ADR
334
+ // 0015); class=security cierra además sin arbitraje.
336
335
  // KJC-BUG-0208: el mismo modulo que decide el presupuesto decide el metrico
337
336
  // del invariante. Sumar aqui el numstat entero daba dos cifras para la misma
338
337
  // regla en dos lineas seguidas del mismo comando.
@@ -3,8 +3,9 @@
3
3
  * Field case 2026-08-02: text rules were not enough — the environment must
4
4
  * impose them AT TOOL TIME. `kj harden` writes a PreToolUse script and wires
5
5
  * it into the project's `.claude/settings.json` (merged, never clobbered):
6
- * Write over an existing file blocks ("use Edit", KJ_ALLOW_WRITE=1 escapes);
7
- * Bash that reserializes whole JSON files blocks (KJ_ALLOW_REWRITE=1).
6
+ * Write over an existing file blocks ("use Edit"), with no escape (ADR 0015).
7
+ * Writes through Bash are the Sentinel's bash-write guard (KJC-BUG-0237), which
8
+ * replaced the JSON-reserialization guard that lived here.
8
9
  * Claude-only v1 — the abstraction arrives with the second host that
9
10
  * supports tool hooks.
10
11
  */
@@ -19,94 +20,15 @@ const SCRIPT_BODY = `#!/usr/bin/env node
19
20
  // so a gate bug never bricks the session.
20
21
  import console from "node:console";
21
22
  import process from "node:process";
22
- import { existsSync, realpathSync } from "node:fs";
23
- import { dirname, join, relative, resolve } from "node:path";
24
- import { fileURLToPath } from "node:url";
25
- // El guard vive en <repo>/.karajan/harness/, asi que el arbol es dos arriba.
26
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
23
+ import { existsSync } from "node:fs";
27
24
  let raw = "";
28
25
  process.stdin.on("data", (d) => { raw += d; });
29
26
  process.stdin.on("end", () => {
30
27
  try {
31
28
  const { tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
32
- if (tool === "Write" && process.env.KJ_ALLOW_WRITE !== "1") {
33
- if (input.file_path && existsSync(input.file_path)) {
34
- console.error("kj tool gate: Write over an EXISTING file destroys unseen changes — use Edit for targeted changes (KJ_ALLOW_WRITE=1 to override consciously).");
35
- process.exit(2);
36
- }
37
- }
38
- if (tool === "Bash") {
39
- const cmd = String(input.command || "");
40
- // KJC-BUG-0227: el escape tambien viaja en el PROPIO comando, como en el
41
- // resto de guardias. Mirando solo process.env el agente no tenia salida:
42
- // un export dentro del comando jamas llega a este proceso.
43
- // Solo cuentan las asignaciones DEL PRINCIPIO, que son las que el shell
44
- // aplica al comando. Buscarla en cualquier parte del texto dejaba pasar
45
- // un "echo KJ_ALLOW_REWRITE=1; python3 ..." (catch de la review).
46
- const toks = cmd.trim().split(/\\s+/);
47
- let i = 0;
48
- let named = false;
49
- while (i < toks.length && /^[A-Z][A-Z0-9_]*=/.test(toks[i])) {
50
- if (toks[i] === "KJ_ALLOW_REWRITE=1") named = true;
51
- i += 1;
52
- }
53
- // Una asignacion delante solo alcanza a SU comando, asi que el escape del
54
- // comando vale unicamente en un comando SIMPLE: en "VAR=1 true && python"
55
- // o "VAR=1 && python" el python nunca la recibe (catch de la review). Lo
56
- // que va entre comillas no encadena nada, asi que no cuenta.
57
- // Tampoco es simple si hay sustitucion: el proceso de dentro de $( ) no
58
- // hereda la asignacion (catch de la review). Mismo criterio que el resto
59
- // de guardias del Sentinel.
60
- // Dentro de comillas SIMPLES todo es literal; dentro de dobles, un ; no
61
- // encadena pero $( ) si ejecuta. Por eso se miran dos cosas distintas.
62
- const noSingle = cmd.replaceAll(/'[^']*'/g, "");
63
- const substitutes = noSingle.includes("$(") || noSingle.includes("\\u0060");
64
- const bare = noSingle.replaceAll(/"[^"]*"/g, "");
65
- const simple = !substitutes && !/[;&|]/.test(bare) && !bare.includes("\\n");
66
- const cmdEscape = named && simple && i < toks.length;
67
- const escaped = process.env.KJ_ALLOW_REWRITE === "1" || cmdEscape;
68
- // Un escape ignorado EN SILENCIO era el bug: si esta puesto y no vale, se dice.
69
- if (named && !cmdEscape) console.error("kj tool gate: KJ_ALLOW_REWRITE=1 presente pero IGNORADO — solo vale delante del comando y en uno simple (sin ; | & fuera de comillas).");
70
- const writes = /open\\s*\\([^)]*["'][wa]["']|>\\s*\\S+\\.json\\b/.test(cmd);
71
- // KJC-BUG-0227: y solo se vigila lo que esta DENTRO del arbol. Un fichero
72
- // temporal del scratchpad no es asunto de este guard, y bloquearlo
73
- // ensenaba a rodearlo. Sin ruta reconocible se vigila: el lado seguro.
74
- const targets = cmd.match(/[\\w./-]+\\.json\\b/g) || [];
75
- // Solo se exime lo que se puede establecer CON CERTEZA: una ruta absoluta
76
- // fuera del arbol. Una relativa depende del cwd del shell, que este hook
77
- // no conoce — "cd packages && ... open(\\"../package.json\\")" escribe
78
- // dentro del repo (catch de la review), asi que se sigue vigilando.
79
- // Un enlace puede apuntar dentro: /tmp/link.json escribiria en el repo
80
- // (catch de la review). Se resuelve el padre real antes de clasificar, y
81
- // si no se puede resolver, se vigila.
82
- // Fuera del arbol es SALIR de el, no que el nombre empiece por dos puntos:
83
- // <repo>/..config.json esta dentro (catch de la review).
84
- const escapesRoot = (rel) => rel === ".." || rel.startsWith("../");
85
- const outsideForSure = (t) => {
86
- if (!t.startsWith("/")) return false;
87
- const abs = resolve(t);
88
- try {
89
- const root = realpathSync(ROOT);
90
- // El propio fichero puede SER el enlace (/tmp/link.json -> repo/cfg.json),
91
- // asi que se resuelve entero cuando existe; si aun no existe, manda su
92
- // directorio real.
93
- if (existsSync(abs)) return escapesRoot(relative(root, realpathSync(abs)));
94
- const parts = abs.split("/");
95
- const name = parts.pop();
96
- return escapesRoot(relative(root, realpathSync(parts.join("/") || "/") + "/" + name));
97
- } catch {
98
- return false;
99
- }
100
- };
101
- // Con expansion del shell no se sabe a donde apunta nada: "$PWD/cfg.json"
102
- // deja el trozo "/cfg.json", que parece absoluto y ajeno sin serlo (catch
103
- // de la review). Ante la duda se vigila.
104
- const expands = /[$~]/.test(cmd) || cmd.includes("\\u0060");
105
- const mine = expands || targets.length === 0 || !targets.every(outsideForSure);
106
- if (!escaped && mine && /json\\.dumps?\\s*\\(/.test(cmd) && writes) {
107
- console.error("kj tool gate: reserializing a whole JSON file makes the diff unreviewable — make targeted edits instead (KJ_ALLOW_REWRITE=1 to override consciously).");
108
- process.exit(2);
109
- }
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);
110
32
  }
111
33
  } catch { /* fail open */ }
112
34
  process.exit(0);
@@ -62,10 +62,11 @@ function baseBranchGuard(baseBranch) {
62
62
  return [
63
63
  "# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
64
64
  "# the bootstrap commit is exempt, there is no commit to branch from yet.",
65
- 'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then',
65
+ "# No escape (ADR 0015): an env prefix on git commit was one the agent could raise.",
66
+ "if git rev-parse --verify HEAD >/dev/null 2>&1; then",
66
67
  ' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
67
68
  ` if [ "$current_branch" = "${baseBranch}" ]; then`,
68
- ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
69
+ ` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR'; exit 1`,
69
70
  " fi",
70
71
  "fi",
71
72
  ];
@@ -78,7 +79,7 @@ function baseBranchGuard(baseBranch) {
78
79
  function identityGuard(hook) {
79
80
  const head = [
80
81
  "# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
81
- 'if [ "$KJ_ALLOW_IDENTITY" != "1" ] && [ -f .karajan/identity.local.yml ]; then',
82
+ "if [ -f .karajan/identity.local.yml ]; then",
82
83
  ];
83
84
  const tail = [
84
85
  "elif [ ! -f .karajan/identity.local.yml ]; then",
@@ -92,7 +93,7 @@ function identityGuard(hook) {
92
93
  " kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
93
94
  " kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
94
95
  ' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
95
- ' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show; KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
96
+ ' echo "kj harden: identity lock — committing as $kj_author / $kj_committer but this clone is declared as $kj_declared_email (kj identity show)"; exit 1',
96
97
  " fi",
97
98
  ...tail,
98
99
  ];
@@ -103,7 +104,7 @@ function identityGuard(hook) {
103
104
  ' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
104
105
  " kj_active_gh=$(awk '/^github.com:/{f=1;next} f&&/^[^[:space:]]/{f=0} f&&/^[[:space:]]*user:/{sub(/^[[:space:]]*user:[[:space:]]*/,\"\");print;exit}' \"$kj_hosts\" 2>/dev/null)",
105
106
  ' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
106
- ' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh (KJ_ALLOW_IDENTITY=1 to override)"; exit 1',
107
+ ' echo "kj harden: identity lock — gh session is ${kj_active_gh:-none} but this clone is declared as $kj_declared_gh — run: gh auth switch --user $kj_declared_gh"; exit 1',
107
108
  " fi",
108
109
  ...tail,
109
110
  ];
@@ -2,7 +2,7 @@
2
2
  // ADR 0014). An agent does not discard changes it did not make. Copied byte for
3
3
  // byte into .karajan/harness; `root` is the project root, injected.
4
4
  import { spawnSync } from "node:child_process";
5
- import { relative, resolve } from "node:path";
5
+ import { isAbsolute, relative, resolve } from "node:path";
6
6
  import process from "node:process";
7
7
  import { headIndex, shortOpts } from "./sentinel-shell.mjs";
8
8
 
@@ -58,6 +58,30 @@ export const discardOf = (words, root) => {
58
58
  return pos.length > 1 ? { cwd, paths: pos.slice(1) } : null;
59
59
  };
60
60
 
61
+ /**
62
+ * KJC-BUG-0261: the tracked files an `rm` or `git rm` removes, relative to root.
63
+ * Removing a file is the session touching it, so its state is noted before it
64
+ * goes and restoring it later is not taken for someone else's change.
65
+ * @param {string[]} words
66
+ * @param {string} root
67
+ * @param {string} cwd where relative paths resolve (the session's cwd)
68
+ * @returns {string[]}
69
+ */
70
+ export const removedFiles = (words, root, cwd = process.cwd()) => {
71
+ const i = headIndex(words, ["rm", "git"]);
72
+ const head = words[i]?.split("/").at(-1);
73
+ const isGitRm = head === "git" && words[i + 1] === "rm";
74
+ if (head !== "rm" && !isGitRm) return [];
75
+ const args = words.slice(i + (isGitRm ? 2 : 1));
76
+ const dd = args.indexOf("--");
77
+ const named = dd < 0 ? args.filter((a) => !a.startsWith("-")) : [...args.slice(0, dd).filter((a) => !a.startsWith("-")), ...args.slice(dd + 1)];
78
+ const rels = named.map((p) => relative(root, resolve(cwd, p))).filter((r) => r && !r.startsWith("..") && !isAbsolute(r));
79
+ if (rels.length === 0) return [];
80
+ // ls-files expands a directory (rm -r dir) into the tracked files it holds.
81
+ const r = spawnSync("git", ["-C", root, "ls-files", "-z", "--", ...rels], { encoding: "utf8" });
82
+ return r.status === 0 ? String(r.stdout).split("\0").filter(Boolean) : [];
83
+ };
84
+
61
85
  /**
62
86
  * Files the discard would lose that the session did not own: dirty now and not
63
87
  * clean at first touch. Anything unreadable, or another repo, is foreign.
@@ -84,7 +108,10 @@ export const foreignLost = (d, touch, root) => {
84
108
  }
85
109
  // Fail-closed: a path git does not know (misparsed, $VAR, substitution) cannot
86
110
  // be proven safe; git would refuse to check it out anyway.
87
- if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) return [`(ruta no resoluble: ${d.paths.join(" ")})`];
111
+ if (spawnSync("git", ["-C", d.cwd, "ls-files", "--error-unmatch", "--", ...d.paths]).status !== 0) {
112
+ // KJC-BUG-0261: what the session removed with git rm left the index, and it is still its own.
113
+ return d.paths.map((p) => relative(root, resolve(d.cwd, p))).every(own) ? [] : [`(ruta no resoluble: ${d.paths.join(" ")})`];
114
+ }
88
115
  const r = spawnSync("git", ["-C", d.cwd, "status", "--porcelain", "-z", "--untracked-files=no", "--", ...d.paths], { encoding: "utf8" });
89
116
  if (r.status !== 0) return ["(git status fallo)"];
90
117
  const files = [];
@@ -86,10 +86,25 @@ export const stripInertQuotes = (cmd) => {
86
86
  };
87
87
 
88
88
  // Options whose value is prose, never a path (gh, git, kj).
89
- const TEXT_OPTION = /(^|\s)(--title|--body|--message|-m|--notes|--description|--ac|--criteria|--reason|--decision|--context|--consequences)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
89
+ const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position"]);
90
+ const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
90
91
 
91
92
  /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
92
- export const stripTextOptionValues = (cmd) => cmd.replace(TEXT_OPTION, (_m, pre, opt, sep, val) => `${pre}${opt}${sep}${val[0]}${val[0]}`);
93
+ export const stripTextOptionValues = (cmd) => cmd.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
94
+
95
+ /**
96
+ * KJC-BUG-0245: the values of the named options, read by the shell reader, so a
97
+ * `;`, `&&` or `|` right after a path ends the word instead of joining it.
98
+ * Covers `--opt value` and `--opt=value`.
99
+ * @param {string} cmd
100
+ * @param {string[]} names
101
+ * @returns {string[]}
102
+ */
103
+ export const optionValues = (cmd, names) => shellSegments(cmd).flatMap((words) => words.flatMap((w, i) => {
104
+ if (names.includes(w) && i + 1 < words.length) return [words[i + 1]];
105
+ const eq = w.indexOf("=");
106
+ return eq > 0 && names.includes(w.slice(0, eq)) ? [w.slice(eq + 1)] : [];
107
+ }));
93
108
 
94
109
  /** Short flags of a cluster stop at "e": the rest is -e's value (-fen = -f -e n). */
95
110
  export const shortOpts = (a) => (/^-[a-zA-Z]/.test(a) ? a.slice(1).split("e")[0] : "");
@@ -28,8 +28,7 @@ export function resolveSentinelRoot(dir = process.cwd()) {
28
28
 
29
29
  const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
30
30
  // Single source for every sentinel script: state, branch, classification,
31
- // violations, and escape recording.
32
- import console from "node:console";
31
+ // and violations.
33
32
  import { readFileSync, writeFileSync } from "node:fs";
34
33
  import { execSync, spawnSync } from "node:child_process";
35
34
  import { dirname, join } from "node:path";
@@ -81,8 +80,20 @@ export const bootstrapPhase = () => {
81
80
  try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
82
81
  catch { return true; }
83
82
  };
84
- export const violations = (s, branch) => {
85
- const v = pendingMoves(s).map(pendingText);
83
+ // KJC-BUG-0198/0262: a card split across PRs is carried while another PR of it is
84
+ // open. kj decides with that checkable fact, the same answer for commit and push
85
+ // (the Stop gate still holds the turn: the board owes the move); when kj does not
86
+ // answer, every pending move blocks.
87
+ export const boardGate = (sid, pend) => {
88
+ if (!pend.length) return { blocking: [], carried: [] };
89
+ const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
90
+ if (!bg.error && (bg.status === 0 || bg.status === 2)) {
91
+ try { const d = JSON.parse(bg.stdout); return { blocking: d.blocking || [], carried: d.carried || [] }; } catch { /* unreadable: as always */ }
92
+ }
93
+ return { blocking: pend, carried: [] };
94
+ };
95
+ export const violations = (s, branch, pending = pendingMoves(s)) => {
96
+ const v = pending.map(pendingText);
86
97
  if (!s || !(s.edited_sources || []).length) return v;
87
98
  if (bootstrapPhase()) return v;
88
99
  if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
@@ -105,26 +116,6 @@ const sessionAddsCode = (s) => {
105
116
  return a === "-" || Number(a) > 0;
106
117
  });
107
118
  };
108
- export const recordEscape = (sid, escape, tool) => {
109
- const state = load();
110
- const s = session(state, sid);
111
- s.at = Date.now();
112
- if (!s.escapes.includes(escape)) s.escapes.push(escape);
113
- (state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
114
- save(state);
115
- // GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
116
- // decision log hash-encadenado (kj policy seal), no solo en este estado.
117
- // Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
118
- // kj no carga, que es cuando mas se usa el escape (restaurar con git).
119
- try {
120
- const args = ["policy", "seal", "--escape", escape];
121
- if (tool) args.push("--tool", String(tool));
122
- const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
123
- if (r.error || r.status !== 0) console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json" + doc("escapes"));
124
- } catch (e) {
125
- console.error("karajan sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message + doc("escapes"));
126
- }
127
- };
128
119
  `;
129
120
 
130
121
  const POST_BODY = `#!/usr/bin/env node
@@ -137,7 +128,6 @@ import { relative } from "node:path";
137
128
  import { spawnSync } from "node:child_process";
138
129
  import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
139
130
  import { remindersFor } from "./sentinel-reminders.mjs";
140
- const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG", "KJ_ALLOW_NO_VERIFY"];
141
131
  let raw = "";
142
132
  process.stdin.on("data", (d) => { raw += d; });
143
133
  process.stdin.on("end", () => {
@@ -293,11 +283,6 @@ process.stdin.on("end", () => {
293
283
  const rel = relative(ROOT, file).replaceAll("\\\\", "/");
294
284
  const bucket = TESTS.test(rel) ? s.edited_tests : CODE.test(rel) ? s.edited_sources : null;
295
285
  if (bucket && !bucket.includes(rel)) bucket.push(rel);
296
- for (const e of ESCAPES)
297
- if (process.env[e] === "1" && !s.escapes.includes(e)) {
298
- s.escapes.push(e);
299
- (state.escape_events ||= []).push({ escape: e, tool, sid, ts: Date.now() });
300
- }
301
286
  // KJC-TSK-0910: the branch size while it is written, with the CI budget
302
287
  // (kj pr-size), said once per threshold crossed. Context, never a block.
303
288
  let sizeNote = null;
@@ -417,7 +402,6 @@ process.stdin.on("end", () => {
417
402
  s.blocks = 0;
418
403
  const notes = [];
419
404
  if (claimsNote) notes.push(claimsNote);
420
- if ((s.escapes || []).length) notes.push("karajan sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
421
405
  if ((s.closed_cards || []).length) {
422
406
  notes.push("karajan sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
423
407
  s.closed_cards = [];
@@ -454,10 +438,10 @@ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
454
438
  import { spawnSync } from "node:child_process";
455
439
  import { homedir } from "node:os";
456
440
  import { fileURLToPath } from "node:url";
457
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
441
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
458
442
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
459
- import { shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
460
- import { DISCARD_VERBS, discardOf, foreignLost } from "./sentinel-discard.mjs";
443
+ import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
444
+ import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
461
445
  import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
462
446
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
463
447
  // KJC-BUG-0204: el comando real lleva flags EN MEDIO del verbo
@@ -481,6 +465,21 @@ process.stdin.on("end", () => {
481
465
  process.on("exit", (code) => {
482
466
  if (code === 2) console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
483
467
  });
468
+ // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
469
+ // sesion toca un fichero se anota si estaba limpio: solo entonces un
470
+ // descarte posterior pierde nada mas que lo de la sesion. Antes de
471
+ // cualquier escape; git que falla cuenta como sucio (fail-closed).
472
+ const noteFirstTouch = (relT) => {
473
+ const st = load();
474
+ const touch = (session(st, sid).first_touch ||= {});
475
+ // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
476
+ if (Object.hasOwn(touch, relT)) return;
477
+ // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
478
+ const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
479
+ const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
480
+ Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
481
+ save(st);
482
+ };
484
483
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
485
484
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
486
485
  // only the human, editing outside it.
@@ -497,23 +496,10 @@ process.stdin.on("end", () => {
497
496
  console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
498
497
  process.exit(2);
499
498
  }
500
- // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
501
- // sesion toca un fichero se anota si estaba limpio: solo entonces un
502
- // descarte posterior pierde nada mas que lo de la sesion. Antes de
503
- // cualquier escape; git que falla cuenta como sucio (fail-closed).
504
- if (relT && !relT.startsWith("..") && !relT.startsWith("/")) {
505
- const st = load();
506
- const touch = (session(st, sid).first_touch ||= {});
507
- // hasOwn + defineProperty: a file named __proto__ or toString is a path, not a prototype key.
508
- if (!Object.hasOwn(touch, relT)) {
509
- // --ignored: a user's ignored .env is theirs too (git clean -x deletes it).
510
- const gs = spawnSync("git", ["-C", ROOT, "status", "--porcelain", "--ignored", "--", relT], { encoding: "utf8" });
511
- const value = gs.status === 0 && String(gs.stdout).trim() === "" ? "clean" : "dirty";
512
- Object.defineProperty(touch, relT, { value, enumerable: true, writable: true, configurable: true });
513
- save(st);
514
- }
515
- }
499
+ if (relT && !relT.startsWith("..") && !relT.startsWith("/")) noteFirstTouch(relT);
516
500
  }
501
+ // KJC-BUG-0261: rm / git rm is the session touching those files, noted before they go.
502
+ if (tool === "Bash") for (const words of shellSegments(String(input.command || ""))) for (const f of removedFiles(words, ROOT)) noteFirstTouch(f);
517
503
  // Any Bash that NAMES the supervisor's files is denied — a write-verb
518
504
  // blocklist is bypassable (cp, dd, one-liners), and reading them is what
519
505
  // the Read/Grep tools are for. Shell indirection (variables, globs,
@@ -536,8 +522,8 @@ process.stdin.on("end", () => {
536
522
  if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghCmd) && /(^|[^a-z])(pr|issue|release)([^a-z]|$)/.test(ghCmd) && /(^|[^a-z])(create|edit|comment|review)([^a-z]|$)/.test(ghCmd)) {
537
523
  const attrib = /co-authored-by:.{0,120}(claude|gpt|copilot|gemini)|(generated|written|created) (by|with).{0,120}(claude|gpt|copilot|gemini|codex)|generated with [[]?claude|🤖/is;
538
524
  let corpus = ghCmd;
539
- for (const m of ghCmd.matchAll(/--(?:body-file|notes-file|comment-file)[= ]+("([^"]+)"|'([^']+)'|([^ ]+))/g)) {
540
- const bodyPath = m[2] || m[3] || m[4];
525
+ // KJC-BUG-0245: the shell reader ends the path at ; && | (a regex took "pr.md;").
526
+ for (const bodyPath of optionValues(ghCmd, ["--body-file", "--notes-file", "--comment-file"])) {
541
527
  try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
542
528
  console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
543
529
  process.exit(2);
@@ -598,68 +584,11 @@ process.stdin.on("end", () => {
598
584
  }
599
585
  }
600
586
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
601
- // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
602
- // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
603
- // prefijo jamas llegaba a process.env y el escape era inalcanzable
604
- // (deadlock real del gate de release: la landing solo se pone verde
605
- // tras el deploy y el deploy estaba bloqueado por el mismo gate). Un
606
- // escape es una excepcion CONSCIENTE y auditada, no un boundary de
607
- // seguridad (security sigue sin escape y la autoprotecion corre antes),
608
- // asi que el TEXTO del comando es fuente valida: solo asignaciones
609
- // LITERALES al inicio (tokens VAR=VAL antes del primer verbo) y solo
610
- // sobre un comando SIMPLE — con ;|& $() backtick o salto de linea el
611
- // prefijo shell no alcanzaria a los comandos posteriores (catch de
612
- // codex: "X=1 true && npm publish" escaparia sin serlo), asi que el
613
- // escape por texto se rechaza y queda la via env de siempre.
587
+ // ADR 0015 (KJC-TSK-0927): no gate has an escape any more, so the command
588
+ // text is read only by the identity lock below.
614
589
  const CMD_TEXT = tool === "Bash" ? String(input.command || "") : "";
615
590
  const ESC_TAB = String.fromCharCode(9);
616
591
  const ESC_NL = String.fromCharCode(10);
617
- const ESC_BT = String.fromCharCode(96);
618
- const escOn = (name) => {
619
- if (process.env[name] === "1") return true;
620
- let found = false;
621
- let i = 0;
622
- while (i < CMD_TEXT.length) {
623
- while (i < CMD_TEXT.length && (CMD_TEXT[i] === " " || CMD_TEXT[i] === ESC_TAB)) i += 1;
624
- const start = i;
625
- while (i < CMD_TEXT.length && CMD_TEXT[i] !== " " && CMD_TEXT[i] !== ESC_TAB && CMD_TEXT[i] !== ESC_NL) i += 1;
626
- const tok = CMD_TEXT.slice(start, i);
627
- const eq = tok.indexOf("=");
628
- if (eq <= 0 || !/^[A-Z][A-Z0-9_]*$/.test(tok.slice(0, eq))) break;
629
- if (tok === name + "=1") found = true;
630
- }
631
- if (!found) return false;
632
- // KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
633
- // mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
634
- // comillas dobles $ y backtick siguen expandiendo (se rechazan); en
635
- // comillas simples todo es literal. Y si el escape esta pero se ignora,
636
- // SE DICE: un escape ignorado en silencio era el bug.
637
- const bad = escSimple();
638
- if (bad === null) return true;
639
- if (!escNoted.has(name)) {
640
- escNoted.add(name);
641
- console.error("karajan sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)" + doc("escapes"));
642
- }
643
- return false;
644
- };
645
- const escNoted = new Set();
646
- const escSimple = () => {
647
- let q = null;
648
- for (let i = 0; i < CMD_TEXT.length; i += 1) {
649
- const c = CMD_TEXT[i];
650
- if (q === "'") { if (c === "'") q = null; continue; }
651
- if (q === '"') {
652
- if (c === '"') q = null;
653
- else if (c === "$" || c === ESC_BT) return c;
654
- else if (c === "\\\\") i += 1;
655
- continue;
656
- }
657
- if (c === "\\\\") { i += 1; continue; }
658
- if (c === "'" || c === '"') { q = c; continue; }
659
- if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
660
- }
661
- return q === null ? null : "comilla sin cerrar";
662
- };
663
592
  // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
664
593
  // run under an account other than the one THIS CLONE declared (the incident:
665
594
  // a gh comment without switch went out as the client account). Deny BEFORE
@@ -755,11 +684,8 @@ process.stdin.on("end", () => {
755
684
  };
756
685
  scan(CMD_TEXT, 0);
757
686
  if (problems.length > 0) {
758
- if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
759
- else {
760
- console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)" + doc("identity"));
761
- process.exit(2);
762
- }
687
+ console.error("karajan sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(sin escape, ADR 0015: pon la cuenta en el comando)" + doc("identity"));
688
+ process.exit(2);
763
689
  }
764
690
  }
765
691
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
@@ -779,8 +705,7 @@ process.stdin.on("end", () => {
779
705
  return d ? foreignLane(d) : null;
780
706
  };
781
707
  const laneDeny = (what, lane) => {
782
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); return false; }
783
- console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Cruce deliberado: KJ_ALLOW_CROSS_LANE=1, queda registrado." + doc("cross-lane"));
708
+ console.error("karajan sentinel: " + what + " vive en otro carril (" + lane + ") de este repo — cada sesion muta solo SU worktree (MONO-0). Sin escape (ADR 0015): un cruce deliberado lo hace tu usuario en su terminal." + doc("cross-lane"));
784
709
  return true;
785
710
  };
786
711
  if (EDIT_TOOLS.includes(tool)) {
@@ -817,11 +742,8 @@ process.stdin.on("end", () => {
817
742
  // directorio se deniega conservadoramente — el remedio es no
818
743
  // necesitarlo (git -C, npm --prefix, rutas absolutas).
819
744
  if (/(^|[;&|(\\s])(cd|pushd)([ \\t]|$)/.test(cmd)) {
820
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
821
- else {
822
- console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
823
- process.exit(2);
824
- }
745
+ console.error("karajan sentinel: cd/pushd en un comando mutador — las rutas posteriores no son verificables por el guard de carriles (MONO-0); usa git -C / npm --prefix / rutas ABSOLUTAS sin cd." + doc("cross-lane"));
746
+ process.exit(2);
825
747
  }
826
748
  // Absolute AND dot-relative tokens (../lane escapaba al primer scan —
827
749
  // reviewer catch); relative se resuelve contra el cwd de la sesion.
@@ -836,11 +758,8 @@ process.stdin.on("end", () => {
836
758
  // de tokens de abajo; el residuo (expansion anidada en segmentos
837
759
  // runner) queda documentado como en la regla de ficheros PROTECTED.
838
760
  if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
839
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
840
- else {
841
- console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
842
- process.exit(2);
843
- }
761
+ console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL." + doc("cross-lane"));
762
+ process.exit(2);
844
763
  }
845
764
  // Sustitucion de comandos ($(...) o backticks) EJECUTA su contenido
846
765
  // en cualquier posicion, y una ruta entrecomillada con espacios es
@@ -904,20 +823,14 @@ process.stdin.on("end", () => {
904
823
  // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
905
824
  // of a text option (--title, -m...) is not a path.
906
825
  if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
907
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
908
- else {
909
- console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
910
- process.exit(2);
911
- }
826
+ console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion." + doc("cross-lane"));
827
+ process.exit(2);
912
828
  }
913
829
  const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
914
830
  const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
915
831
  if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
916
- if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
917
- else {
918
- console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
919
- process.exit(2);
920
- }
832
+ console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain." + doc("cross-lane"));
833
+ process.exit(2);
921
834
  }
922
835
  // ...incluidas relativas A PELO (los carriles kj viven en
923
836
  // .kj/worktrees/). Un falso token (s/a/b/) resuelve al arbol propio
@@ -955,8 +868,8 @@ process.stdin.on("end", () => {
955
868
  // Fail CLOSED por defecto (catches de codex; doctrina KJC-BUG-0095: un
956
869
  // gate no se cae en silencio): solo exit 0 permite. kj inejecutable =
957
870
  // deny duro (el remedio es restaurar kj); cualquier otro fallo de
958
- // evaluacion = deny con escape humano registrable — la sesion no queda
959
- // presa de un typo de YAML, pero abrirla es decision del usuario.
871
+ // evaluacion = deny sin escape (ADR 0015): un typo de YAML lo corrige el
872
+ // usuario fuera de la sesion.
960
873
  if ((EDIT_TOOLS.includes(tool) || tool === "Bash") && existsSync(resolve(ROOT, ".karajan", "policy.yml"))) {
961
874
  // PL-C: el rol que ACTÚA (KJ_POLICY_ROLE, sembrado por los runners de
962
875
  // kj run en los subprocesos) — el anfitrión-brain evalúa como coder.
@@ -970,11 +883,9 @@ process.stdin.on("end", () => {
970
883
  let v = null;
971
884
  try { v = JSON.parse(String(pres.stdout || "").trim().split("\\n").pop()); } catch { /* mensaje generico */ }
972
885
  const secure = !!(v && v.class === "security");
973
- if (!secure && escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
974
- else {
975
- console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada; el commit exigira ademas KJ_POLICY_REASON)") + doc("policy"));
976
- process.exit(2);
977
- }
886
+ // ADR 0015 (KJC-TSK-0932): no escape; a rule that misfires is fixed by PR.
887
+ console.error("karajan sentinel: policy deny [" + ((v && v.rule_id) || "policy") + "] " + ((v && v.reason) || "la tool call viola la policy del proyecto") + (secure ? " [security — sin escape ni arbitraje]" : " (sin escape: si la regla falla, tu usuario corrige .karajan/policy.yml por PR)") + doc("policy"));
888
+ process.exit(2);
978
889
  } else if (pres.status !== 0) {
979
890
  // KJC-BUG-0207: un kj que NO ARRANCA no es una violacion de policy. Con
980
891
  // kj linkado al arbol, un error de sintaxis transitorio en src/ dejaba
@@ -999,9 +910,8 @@ process.stdin.on("end", () => {
999
910
  console.error("karajan sentinel: kj no arranca (" + broken[0] + (at ? " en " + at : "") + "), asi que la policy no se puede evaluar y NADA MAS pasa: edita " + (brokenFile || "el fichero que rompe kj") + " para arreglarlo. No es un deny de policy, es kj roto." + doc("policy"));
1000
911
  process.exit(2);
1001
912
  }
1002
- } else if (escOn("KJ_ALLOW_POLICY")) { recordEscape(sid, "KJ_ALLOW_POLICY", tool); }
1003
- else {
1004
- console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check y corrige .karajan/policy.yml fuera de la sesion (o KJ_ALLOW_POLICY=1 = excepcion consciente, queda registrada)." + doc("policy"));
913
+ } else {
914
+ console.error("karajan sentinel: kj policy eval fallo (exit " + pres.status + ") — la policy declarada no se pudo evaluar, deny por defecto; diagnostica con kj policy check; .karajan/policy.yml lo corrige tu usuario fuera de la sesion." + doc("policy"));
1005
915
  process.exit(2);
1006
916
  }
1007
917
  }
@@ -1022,11 +932,8 @@ process.stdin.on("end", () => {
1022
932
  let blockOn = false;
1023
933
  try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
1024
934
  if (hard.length && blockOn) {
1025
- if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
1026
- else {
1027
- console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)" + doc("steward"));
1028
- process.exit(2);
1029
- }
935
+ console.error("karajan sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(sin escape, ADR 0015: si el trabajo sigue, tu usuario cambia method_gates.steward a inform)" + doc("steward"));
936
+ process.exit(2);
1030
937
  }
1031
938
  const st = load();
1032
939
  const s = session(st, sid);
@@ -1043,11 +950,11 @@ process.stdin.on("end", () => {
1043
950
  if (rel && CODE.test(rel) && !TESTS.test(rel)) {
1044
951
  const branch = branchOf();
1045
952
  const why = !branch ? null : BASE_BRANCHES.has(branch) ? "base" : !CARD.test(branch) ? "nocard" : null;
953
+ // ADR 0015 (KJC-TSK-0925): no escape. Without a card there is no work.
1046
954
  if (why) {
1047
- if (escOn("KJ_ALLOW_NO_CARD")) { recordEscape(sid, "KJ_ALLOW_NO_CARD", tool); process.exit(0); }
1048
955
  console.error(why === "base"
1049
- ? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first")
1050
- : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
956
+ ? "karajan sentinel: no se editan fuentes en la rama base '" + branch + "' — crea la card (kj hu add) y la rama: git checkout -b feat/<CARD-ID>-descripcion." + doc("card-first")
957
+ : "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion." + doc("card-first"));
1051
958
  process.exit(2);
1052
959
  }
1053
960
  // KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
@@ -1070,15 +977,14 @@ process.stdin.on("end", () => {
1070
977
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
1071
978
  // the gate asks for kj rag index instead of denying the impossible.
1072
979
  const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
980
+ // ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
981
+ // index, not indexable) are decided by the gate itself.
1073
982
  if (!covered) {
1074
- if (escOn("KJ_ALLOW_NO_RAG")) {
1075
- // Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
1076
- if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
1077
- } else {
983
+ {
1078
984
  // KJC-BUG-0216 (issue #1807): un .astro, un .php o cualquier fichero
1079
985
  // sin adapter NO puede estar en el indice, asi que exigir una
1080
986
  // respuesta del RAG sobre el es pedir lo imposible, y empujaba a
1081
- // dejar KJ_ALLOW_NO_RAG puesta para siempre. La exencion es SOLO
987
+ // dejar el escape puesto para siempre. La exencion es SOLO
1082
988
  // esa: un indice vacio o por detras si tiene arreglo (kj rag index)
1083
989
  // y sigue bloqueando, porque si no el gate desapareceria en
1084
990
  // cualquier proyecto sin indexar. Nada legible = se bloquea.
@@ -1098,7 +1004,7 @@ process.stdin.on("end", () => {
1098
1004
  console.error("karajan sentinel: rag-first — el indice aun no tiene " + rel + " (" + absent + "); indexalo y consulta despues: kj_rag_query / kj rag query <que hace " + rel + ">" + doc("rag-first"));
1099
1005
  process.exit(2);
1100
1006
  } else {
1101
- console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
1007
+ console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>." + doc("rag-first"));
1102
1008
  process.exit(2);
1103
1009
  }
1104
1010
  }
@@ -1133,11 +1039,10 @@ process.stdin.on("end", () => {
1133
1039
  // depender de como parta las palabras el separador (catch de la review).
1134
1040
  const movesHooks = cmd.toLowerCase().includes("core.hookspath");
1135
1041
  if ((inOrder(["git", "commit"], words) && words.some(skipsVerify)) || movesHooks) {
1136
- if (escOn("KJ_ALLOW_NO_VERIFY")) { recordEscape(sid, "KJ_ALLOW_NO_VERIFY", tool); }
1137
- else {
1138
- console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta, es una decision de tu usuario. (KJ_ALLOW_NO_VERIFY=1 = excepcion consciente, queda registrada)" + doc("escapes"));
1139
- process.exit(2);
1140
- }
1042
+ // ADR 0015 (KJC-TSK-0926): no escape. A broken hook is fixed; skipping it,
1043
+ // if it ever must happen, is the user's act in their own terminal.
1044
+ console.error("karajan sentinel: el gate de commit no se apaga con una bandera — el review cruzado y la policy corren en el hook, y saltarselos deja el diff sin veredicto. Si el hook esta roto, arreglalo; si de verdad hace falta saltarlo, lo hace tu usuario en su terminal." + doc("escapes"));
1045
+ process.exit(2);
1141
1046
  }
1142
1047
  const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
1143
1048
  const ADVANCE = /\\bgit\\s+commit\\b|\\bgit\\s+push\\b|\\bgh\\s+pr\\s+create\\b/; // push also falls under violations() — explicit here too (review catch)
@@ -1145,20 +1050,14 @@ process.stdin.on("end", () => {
1145
1050
  const pend = pendingMoves(load().sessions?.[sid]);
1146
1051
  if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
1147
1052
  // KJC-BUG-0198: una card partida en varias PRs no puede quedar bloqueada
1148
- // por su primera mitad. kj decide con un hecho comprobable (otra PR
1149
- // abierta de la card); si kj no responde, se bloquea como siempre.
1150
- const bg = spawnSync("kj", ["sentinel", "board-gate", "--session", sid, "--json"], { cwd: ROOT, encoding: "utf8" });
1151
- let blocking = pend, carried = [];
1152
- if (!bg.error && (bg.status === 0 || bg.status === 2)) {
1153
- try { const d = JSON.parse(bg.stdout); blocking = d.blocking || []; carried = d.carried || []; } catch { /* sin respuesta legible: como siempre */ }
1154
- }
1053
+ // por su primera mitad (boardGate, en la lib).
1054
+ const { blocking, carried } = boardGate(sid, pend);
1155
1055
  for (const c of carried) console.error("karajan sentinel: board-sync arrastra " + c.card + " — " + c.why + ", se movera al cerrarla");
1056
+ // ADR 0015 (KJC-TSK-0925): no escape; an epic and a card split across PRs
1057
+ // are already decided above (KJC-BUG-0230, 0198).
1156
1058
  if (blocking.length > 0) {
1157
- if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
1158
- else {
1159
- console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)" + doc("board-sync"));
1160
- process.exit(2);
1161
- }
1059
+ console.error("karajan sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + blocking.map((p) => "- " + pendingText(p)).join("\\n") + doc("board-sync"));
1060
+ process.exit(2);
1162
1061
  }
1163
1062
  }
1164
1063
  // (the pending entry itself is recorded by the PostToolUse hook, only once
@@ -1167,9 +1066,9 @@ process.stdin.on("end", () => {
1167
1066
  // there misleads every future reader. When the command writes one from a
1168
1067
  // file, kj crosses ITS data against this turn's outputs; the hook carries
1169
1068
  // no policy (kj reads method_gates.claims; off = this check does not exist).
1170
- const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
1171
- if (bodyFile && transcript) {
1172
- const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
1069
+ // KJC-BUG-0245: the path is read by the shell reader, so "pr.md;" is "pr.md".
1070
+ const body = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? optionValues(cmd, ["--body-file"])[0] : undefined;
1071
+ if (body && transcript) {
1173
1072
  const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
1174
1073
  if (!g.error && g.status === 2) {
1175
1074
  console.error("karajan sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body + doc("claims"));
@@ -1177,7 +1076,8 @@ process.stdin.on("end", () => {
1177
1076
  }
1178
1077
  }
1179
1078
  if (isPublish(cmd)) {
1180
- if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
1079
+ // ADR 0015 (KJC-TSK-0932): no escape — KJC-BUG-0204 and 0259 keep the
1080
+ // check from blocking its own fix.
1181
1081
  // KJC-BUG-0204: el check se evalua PARA este comando. Un item que
1182
1082
  // declara remedied_by no bloquea el comando que lo repara (desplegar
1183
1083
  // la landing era justo lo que el check pedia); publicar no se exime.
@@ -1194,19 +1094,15 @@ process.stdin.on("end", () => {
1194
1094
  if (res.status !== 0) {
1195
1095
  let items = "";
1196
1096
  try { items = (parsed.checks || []).filter((c) => !c.ok && c.lifted !== true).map((c) => "\\n- " + c.name + ": " + c.detail).join(""); } catch { /* raw output */ }
1197
- console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(KJ_ALLOW_RELEASE=1 = excepcion consciente, queda registrada)" + doc("release"));
1097
+ console.error("karajan sentinel: release check en ROJO — no se publica ni despliega hasta resolverlo:" + (items || "\\n- corre kj release check para el detalle") + "\\n(sin escape: se repara lo que el check nombra)" + doc("release"));
1198
1098
  process.exit(2);
1199
1099
  }
1200
1100
  } else if (PUSH.test(cmd)) {
1201
- // KJC-BUG-0147: the board escape covers the push too — a pending move
1202
- // blocks the push like it blocks the commit, and the SAME conscious,
1203
- // recorded escape opens both (multi-PR cards are a legitimate plan).
1101
+ // KJC-BUG-0147: a pending move blocks the push like it blocks the commit.
1102
+ // ADR 0015 (KJC-TSK-0925): with no escape.
1103
+ // KJC-BUG-0262: a carried card does not block the push the commit let through.
1204
1104
  const sess = load().sessions?.[sid];
1205
- let v = violations(sess, branchOf());
1206
- if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
1207
- recordEscape(sid, "KJ_ALLOW_BOARD", tool);
1208
- v = violations({ ...sess, pending_moves: [] }, branchOf());
1209
- }
1105
+ const v = violations(sess, branchOf(), boardGate(sid, pendingMoves(sess)).blocking);
1210
1106
  if (v.length) {
1211
1107
  console.error("karajan sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status" + doc("push-gate"));
1212
1108
  process.exit(2);
@@ -24,7 +24,7 @@ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
24
24
  * @returns {{files: Set<string>, reason?: string}} rutas repo-relativas de
25
25
  * supervisor cuyo estado actual está respaldado por la provenance sellada.
26
26
  */
27
- export function verifiedSupervisorFiles({ projectDir }) {
27
+ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
28
28
  let prov;
29
29
  try {
30
30
  prov = JSON.parse(readFileSync(resolve(projectDir, PROVENANCE_FILE), "utf8"));
@@ -37,7 +37,11 @@ export function verifiedSupervisorFiles({ projectDir }) {
37
37
  }
38
38
  const ok = new Set();
39
39
  const unjudgeable = new Set();
40
- const entries = Array.isArray(prov?.files) ? prov.files : [];
40
+ // KJC-BUG-0259 (user's decision, option A): history is judged with the kj of
41
+ // when it was committed. The harness guards are machine-local and never in the
42
+ // repo; the review and each PR's CI verify them when the seal is made. A check
43
+ // over the repo's history (release check) judges only what is versioned.
44
+ const entries = (Array.isArray(prov?.files) ? prov.files : []).filter((e) => !trackedOnly || !String(e?.file).startsWith(HARNESS_PREFIX));
41
45
  // KJC-BUG-0211: desde KJC-BUG-0197 el sello cubre tambien los guardias del
42
46
  // supervisor, y aqui solo se sabian verificar los hooks. Como `complete`
43
47
  // exige que TODAS las entradas verifiquen, el sello no se podia ni commitear.
@@ -93,10 +97,10 @@ export function verifiedSupervisorFiles({ projectDir }) {
93
97
  * `note` dice lo que este sitio NO ha podido juzgar, para que un levantamiento
94
98
  * mas laxo de lo que parece no pase por silencio (KJC-BUG-0212).
95
99
  */
96
- export function liftSealedSupervisorViolations({ projectDir, violations }) {
100
+ export function liftSealedSupervisorViolations({ projectDir, violations, trackedOnly = false }) {
97
101
  const RULE = "defaults.supervisor.write";
98
102
  if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
99
- const sealed = verifiedSupervisorFiles({ projectDir });
103
+ const sealed = verifiedSupervisorFiles({ projectDir, trackedOnly });
100
104
  // La violación sobre la PROPIA provenance solo se alza si la provenance
101
105
  // entera verificó — a medias no describe la verdad y sigue denegada.
102
106
  const liftable = (v) =>
@@ -4,7 +4,8 @@
4
4
  * hu-board → the branch must reference a LIVE card (block by default);
5
5
  * planning-game/external → card-shaped reference required (warn default,
6
6
  * block via method_gates.card_first). Exemptions explicit and visible:
7
- * base branch, configured prefixes (releases), KJ_ALLOW_NO_CARD=1.
7
+ * base branch and configured prefixes (releases). No escape (ADR 0015): a
8
+ * gate an agent can switch off is a suggestion.
8
9
  */
9
10
  import { listPlans, loadPlan } from "../plan/plan-store.js";
10
11
  import { escapeRegExp } from "../utils/escape-regexp.js";
@@ -48,10 +49,7 @@ async function findLiveCardInBranch(projectDir, branch) {
48
49
  return closedHit;
49
50
  }
50
51
 
51
- export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, env = process.env, deps = {} }) {
52
- if (env.KJ_ALLOW_NO_CARD === "1") {
53
- return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_CARD=1 (explicit escape hatch)" };
54
- }
52
+ export async function checkCardFirst({ config = {}, projectDir = process.cwd(), branch, deps = {} }) {
55
53
  const exempt = config.method_gates?.card_first_exempt_branches || DEFAULT_EXEMPT_PREFIXES;
56
54
  if (exempt.some((p) => branch.startsWith(p))) {
57
55
  return { ok: true, mode: "exempt", reason: `branch prefix exempt (${branch.split("/")[0]}/…)` };
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Adaptador del gate de policy en el flujo de review de karajan-code
3
3
  * (GOV-A, KJC-TSK-0745). La decisión warn/deny/inexcepcionable/excepción
4
- * vive en el kernel (@karajan-family/governance); aquí solo el dominio: la
5
- * excepción se pide con KJ_ALLOW_POLICY=1 + KJ_POLICY_REASON, y el
6
- * artefacto de este dominio es el diff staged — por eso el alcance
7
- * registrado dice "este diff exacto".
4
+ * vive en el kernel (@karajan-family/governance); aquí solo el dominio: el
5
+ * artefacto es el diff staged. Sin excepción por diff (ADR 0015,
6
+ * KJC-TSK-0932): una regla que falla se corrige por PR.
8
7
  */
9
8
  import { evaluateGate } from "@karajan-family/governance";
10
9
 
@@ -13,13 +12,12 @@ import { evaluateGate } from "@karajan-family/governance";
13
12
  */
14
13
  export function evaluatePolicyGate({
15
14
  policy, errors = [], role = "coder", files = [], netLinesAdded = null,
16
- diffHashValue = null, env = process.env, recordException = () => {},
15
+ diffHashValue = null, recordException = () => {},
17
16
  standingExceptions = [], now = new Date(),
18
17
  }) {
19
18
  return evaluateGate({
20
19
  policy, errors, role, files, netLinesAdded,
21
20
  artifactHash: diffHashValue,
22
- exemption: { requested: env.KJ_ALLOW_POLICY === "1", justification: env.KJ_POLICY_REASON, hint: 'KJ_POLICY_REASON="por qué"' },
23
21
  recordException: (entry) => recordException({ ...entry, scope: "este diff exacto" }),
24
22
  standingExceptions, now,
25
23
  });
@@ -110,9 +110,9 @@ export async function runSonarPregate({ config, stagedFiles = [], touchedLines =
110
110
  }
111
111
  for (const f of own) indexed.add(`${prefix}${f}`);
112
112
  keys.push(scan.projectKey);
113
- // The scan above ALWAYS runs before issues are read (single-flight): the
114
- // verdict is about the code as it is now, never a stale server analysis
115
- // (KJC-TSK-0795 AC2 — that failure mode has no route here, by design).
113
+ // The scan above ALWAYS runs before issues are read (single-flight), and
114
+ // it returns once the server has processed it (KJC-BUG-0263: before, the
115
+ // upload returned first and the issues read were the previous analysis).
116
116
  const res = await getOpenIssues(config, scan.projectKey);
117
117
  totalProject += res.total ?? (res.issues || []).length;
118
118
  // Issue components are package-relative: made repo-relative here.
@@ -5,7 +5,7 @@
5
5
  * `method_gates.tests_with_code`. Test-FIRST remains the reviewer's
6
6
  * judgment; test-PRESENT belongs to the gate. Patterns come from the
7
7
  * project's own `development.*` config (same ones headless enforces).
8
- * KJ_ALLOW_NO_TESTS=1 is the explicit escape hatch.
8
+ * No escape (ADR 0015): deletions and comment-only cleanups already pass.
9
9
  */
10
10
 
11
11
  const DEFAULT_TEST_PATTERNS = ["/tests/", "/__tests__/", ".test.", ".spec."];
@@ -28,10 +28,7 @@ export function sourceFilesOf(config = {}, files = []) {
28
28
  };
29
29
  }
30
30
 
31
- export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null, env = process.env }) {
32
- if (env.KJ_ALLOW_NO_TESTS === "1") {
33
- return { ok: true, mode: "exempt", reason: "KJ_ALLOW_NO_TESTS=1 (explicit escape hatch)" };
34
- }
31
+ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = null }) {
35
32
  const { sources, hasTests } = sourceFilesOf(config, stagedFiles);
36
33
 
37
34
  if (sources.length === 0 || hasTests) return { ok: true, mode: "pass" };
@@ -51,7 +48,7 @@ export function checkTestsWithCode({ config = {}, stagedFiles = [], numstat = nu
51
48
  }
52
49
 
53
50
  const policy = config.method_gates?.tests_with_code || "warn";
54
- const reason = `source changes without any test change (${sources.slice(0, 5).join(", ")}${sources.length > 5 ? "…" : ""}) — the failing test comes first; add one or KJ_ALLOW_NO_TESTS=1 for a deliberate exception`;
51
+ const reason = `source changes without any test change (${sources.slice(0, 5).join(", ")}${sources.length > 5 ? "…" : ""}) — the failing test comes first: add one`;
55
52
  return policy === "block"
56
53
  ? { ok: false, mode: "block", sources, reason }
57
54
  : { ok: true, mode: "warn", sources, reason };
package/src/sonar/api.js CHANGED
@@ -82,6 +82,27 @@ async function sonarFetch(config, urlPath) {
82
82
  );
83
83
  }
84
84
 
85
+ const CE_FINAL = new Set(["SUCCESS", "FAILED", "CANCELED"]);
86
+ const sleepMs = (ms) => new Promise((done) => setTimeout(done, ms));
87
+
88
+ /**
89
+ * KJC-BUG-0263 — the scanner uploads the analysis and returns; the server
90
+ * processes it in the background, and issues read before that task ends are
91
+ * the PREVIOUS analysis. Polls /api/ce/task until it reaches a final status.
92
+ * @returns {Promise<{ok: boolean, status: string, timedOut?: boolean}>}
93
+ */
94
+ export async function waitForAnalysis(config, taskId, { timeoutMs = 120_000, intervalMs = 1000, sleep = sleepMs } = {}) {
95
+ const deadline = Date.now() + timeoutMs;
96
+ let status = "UNKNOWN";
97
+ while (Date.now() < deadline) {
98
+ const body = await sonarFetch(config, `/api/ce/task?id=${encodeURIComponent(taskId)}`);
99
+ try { status = JSON.parse(body).task?.status || "UNKNOWN"; } catch { status = "UNKNOWN"; }
100
+ if (CE_FINAL.has(status)) return { ok: status === "SUCCESS", status };
101
+ await sleep(intervalMs);
102
+ }
103
+ return { ok: false, status, timedOut: true };
104
+ }
105
+
85
106
  /**
86
107
  * KJC-BUG-0226 (issue #1838) — de donde sale la clave que se CONSULTA. El
87
108
  * scanner sube el analisis con la que el repo declara en su
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import os from "node:os";
5
5
  import { runCommand } from "../utils/process.js";
6
6
  import { sonarUp } from "./manager.js";
7
+ import { waitForAnalysis } from "./api.js";
7
8
  import { resolveSonarProjectKey } from "./project-key.js";
8
9
  import {
9
10
  resolveSonarHost,
@@ -380,14 +381,32 @@ export async function runSonarScan(config, projectKey = null, { verbose = false,
380
381
  }
381
382
 
382
383
  const result = await runCommand(cmd, args, { timeout: scannerTimeout, env, cwd: scanCwd });
384
+ // KJC-BUG-0263: the scan is done when the server has processed it; whoever reads
385
+ // the issues next would otherwise get the previous analysis. Said out loud when
386
+ // the log names no task to wait for.
387
+ let ok = result.exitCode === 0;
388
+ let analysisNote = null;
389
+ if (ok) {
390
+ const taskId = /api\/ce\/task\?id=([\w-]+)/.exec(String(result.stdout || ""))?.[1];
391
+ if (taskId) {
392
+ const done = await waitForAnalysis({ ...config, sonarqube: { ...sonarConfig, host: apiHost } }, taskId);
393
+ if (!done.ok) {
394
+ ok = false;
395
+ analysisNote = `sonar: the server did not finish analysis ${taskId} (${done.status}${done.timedOut ? ", timed out" : ""}) — its issues would be the previous analysis`;
396
+ }
397
+ } else {
398
+ analysisNote = "sonar: the scanner log names no analysis task, so kj could not wait for the server — issues may belong to the previous analysis";
399
+ }
400
+ }
383
401
  return {
384
- ok: result.exitCode === 0,
402
+ ok,
385
403
  projectKey: effectiveProjectKey,
386
404
  cwd: scanCwd,
387
405
  scanner: pick.type,
388
- note,
406
+ note: [note, analysisNote].filter(Boolean).join("; ") || null,
389
407
  stdout: result.stdout,
390
- stderr: result.stderr,
408
+ // A scan that failed on the server's side says so, not with the scanner's own log.
409
+ stderr: !ok && result.exitCode === 0 ? analysisNote : result.stderr,
391
410
  exitCode: result.exitCode
392
411
  };
393
412
  }