karajan-code 4.26.0 → 4.27.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.26.0",
3
+ "version": "4.27.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",
@@ -783,10 +783,13 @@ export function registerMeta(program, { pkgVersion }) {
783
783
  .option("--interactive", "Adopt the kj standard piece by piece (default keeps yours)")
784
784
  .option("--dry-run", "Show what would change without writing")
785
785
  .option("--json", "Emit machine-readable JSON")
786
+ .option("--commit", "Versiona la regeneración del supervisor con procedencia sellada (acto humano, ADR 0009)")
786
787
  .action(async (flags) => {
787
788
  await withConfig(pkgVersion, "harden", flags, async ({ logger }) => {
788
789
  await hardenCommand({
789
790
  profile: flags.profile,
791
+ commitSupervisor: flags.commit === true,
792
+ kjVersion: pkgVersion,
790
793
  config: flags.config !== false,
791
794
  ci: flags.ci !== false,
792
795
  guidelines: flags.guidelines !== false,
@@ -14,6 +14,7 @@ import { resolveTestOnPush } from "../harden/test-on-push.js";
14
14
  import { compareHarden, formatAdvisoryReport } from "../harden/advisory.js";
15
15
  import { interactiveHarden } from "../harden/interactive.js";
16
16
  import { createWizard, isTTY } from "../utils/wizard.js";
17
+ import { commitSupervisorRegeneration } from "../harden/supervisor-commit.js";
17
18
  import { installConfigsForRoots } from "../harden/config-engine.js";
18
19
  import { installGuidelines } from "../harden/guidelines-engine.js";
19
20
  import { commandsForLanguage } from "../harden/hook-commands.js";
@@ -88,6 +89,8 @@ export async function hardenCommand({
88
89
  interactive = false,
89
90
  only = [],
90
91
  exclude = [],
92
+ commitSupervisor = false,
93
+ kjVersion = null,
91
94
  logger = console,
92
95
  } = {}) {
93
96
  const repoCfg = readHardenConfig(projectDir);
@@ -199,5 +202,24 @@ export async function hardenCommand({
199
202
  out.identity = id.declared ? id.identity : null;
200
203
  if (id.pending) out.identityPending = id.pending;
201
204
  }
205
+ // KJC-BUG-0161 / ADR 0009 (opción A): --commit versiona la regeneración
206
+ // del supervisor con procedencia sellada. Acto humano — el guard vive en
207
+ // commitSupervisorRegeneration y rechaza sesiones de agente.
208
+ if (commitSupervisor && !dryRun) {
209
+ try {
210
+ out.supervisorCommit = commitSupervisorRegeneration({
211
+ projectDir,
212
+ kjVersion: kjVersion ?? "unknown",
213
+ generation: result.generation ?? { profile },
214
+ logger,
215
+ });
216
+ } catch (err) {
217
+ // Fallar ALTO (catch de codex): un --commit que no commitea no puede
218
+ // devolver ok — quien lo pidió debe verlo, no descubrirlo después.
219
+ logger.error?.(`kj harden: ${err.message}`);
220
+ out.supervisorCommit = { committed: false, reason: err.message };
221
+ out.ok = false;
222
+ }
223
+ }
202
224
  return out;
203
225
  }
@@ -12,6 +12,7 @@ import { createHash } from "node:crypto";
12
12
  import { readFileSync, writeFileSync } from "node:fs";
13
13
  import { join } from "node:path";
14
14
  import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
15
+ import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
15
16
  import { loadExceptionRecords, loadGlobalExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
16
17
  import { buildPolicyReport } from "../policy/report.js";
17
18
  import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
@@ -231,7 +232,14 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
231
232
  // --strict (PL-C): con --strict, una violación enforcement=deny devuelve
232
233
  // exit 2 nombrando la regla — el contrato merge-blocking del tier C.
233
234
  const facts = await stagedFacts(projectDir, deps.gitFn, flags.range || null);
234
- const violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
235
+ let violations = checkStagedDiff(policy, { role: flags.role || "coder", ...facts });
236
+ // ADR 0009 (KJC-BUG-0161): en CI la misma exención estructural que en la
237
+ // review — supervisor respaldado por provenance sellada + render canónico.
238
+ const sup = liftSealedSupervisorViolations({ projectDir, violations });
239
+ if (sup.lifted > 0) {
240
+ logger.info?.(`✓ policy: ${sup.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009)`);
241
+ violations = sup.violations;
242
+ }
235
243
  const hard = flags.strict ? violations.filter((v) => v.enforcement === "deny") : [];
236
244
  if (flags.json) {
237
245
  logger.info?.(JSON.stringify({ mode: flags.strict ? "strict" : "warn", violations }));
@@ -15,6 +15,7 @@ import { ensureGateTrackable } from "../review/gate-gitignore.js";
15
15
  import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
16
16
  import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
17
17
  import { checkCardFirst } from "../review/card-first.js";
18
+ import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
18
19
  import { checkTestsWithCode } from "../review/tests-with-code.js";
19
20
  import { loadPrivacyList, scanText } from "../privacy/scan.js";
20
21
  import { checkStagedDiff, loadPolicy } from "../policy/engine.js";
@@ -262,6 +263,17 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
262
263
  recordException: (entry) => recordPolicyException({ projectDir, entry }),
263
264
  standingExceptions: std.standing,
264
265
  });
266
+ // ADR 0009 (KJC-BUG-0161): la exención ESTRUCTURAL del supervisor — un
267
+ // diff de .karajan/hooks respaldado por la provenance sellada Y el render
268
+ // canónico no es una edición: es la regeneración de kj harden --commit.
269
+ {
270
+ const res = liftSealedSupervisorViolations({ projectDir, violations: gate.denials });
271
+ if (res.lifted > 0) {
272
+ console.log(`✓ policy: ${res.lifted} fichero(s) de supervisor verificados contra la provenance sellada (ADR 0009) — regeneración, no edición`);
273
+ gate.denials = res.violations;
274
+ gate.ok = gate.denials.length === 0 && !gate.invalid;
275
+ }
276
+ }
265
277
  const at = (v) => (v.file ? ` (${v.file})` : "");
266
278
  for (const w of gate.warns) console.log(`⚠ policy [${w.rule_id}] ${w.reason}${at(w)}`);
267
279
  for (const e of gate.exempted) console.log(formatExempted(e));
@@ -22,6 +22,24 @@ export function isGitRepo(projectDir) {
22
22
  return existsSync(join(projectDir, ".git"));
23
23
  }
24
24
 
25
+ /**
26
+ * KJC-BUG-0161 / ADR 0009: el contenido CANÓNICO de un hook para una
27
+ * generación dada — exactamente lo que installHooks escribiría partiendo
28
+ * de cero. La verificación estructural del supervisor recomputa esto y
29
+ * compara por hash: un fichero con una sola coma manual ya no coincide.
30
+ */
31
+ export function renderCanonicalHook(hook, generation = {}) {
32
+ const { cmds = {}, baseBranch = null, globalHooksDir = null } = generation;
33
+ const { content } = upsertManagedBlock({
34
+ source: HOOK_PREAMBLE,
35
+ blockId: `hook:${hook}`,
36
+ version: BLOCK_VERSION,
37
+ body: hookBody(hook, cmds, { globalHooksDir, baseBranch }),
38
+ style: "hash",
39
+ });
40
+ return content;
41
+ }
42
+
25
43
  /**
26
44
  * Install (or refresh) the profile's hooks under `.karajan/hooks/` and point
27
45
  * `core.hooksPath` there. Idempotent. `dryRun` computes actions without
@@ -76,5 +94,7 @@ export async function installHooks({
76
94
  await runCommand("git", ["config", "core.hooksPath", HOOKS_DIR], { cwd: projectDir });
77
95
  }
78
96
 
79
- return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results };
97
+ // KJC-BUG-0161: los parámetros con los que SE GENERÓ — lo que la
98
+ // provenance necesita registrar para que CI pueda recomputar y comparar.
99
+ return { profile, hooksPath: HOOKS_DIR, dryRun, hooks: results, generation: { profile, cmds, baseBranch, globalHooksDir } };
80
100
  }
@@ -8,6 +8,8 @@
8
8
  * is imposed on non-JS repos. See docs/specs/quality-harness.md §5.
9
9
  */
10
10
 
11
+ import { homedir } from "node:os";
12
+
11
13
  const CONVENTIONAL_TYPES = "feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert";
12
14
  const AI_ATTRIBUTION = "co-authored-by:.*(claude|gpt|copilot|gemini)|(generated|written) (by|with) .*(claude|gpt|copilot)";
13
15
 
@@ -27,10 +29,18 @@ export const PROFILE_HOOKS = {
27
29
  // guarded, so machines without it are unaffected.
28
30
  function chainToGlobal(hook, globalHooksDir) {
29
31
  if (!globalHooksDir) return [];
32
+ // KJC-BUG-0161 (ADR 0009): a committed hook must not bake a machine's
33
+ // absolute home path — resolve through $HOME at runtime so the same
34
+ // generated content is valid on every machine and its provenance stays
35
+ // verifiable (and no local filesystem layout leaks into a public repo).
36
+ const home = homedir();
37
+ const dir = globalHooksDir === home || globalHooksDir.startsWith(`${home}/`)
38
+ ? `$HOME${globalHooksDir.slice(home.length)}`
39
+ : globalHooksDir;
30
40
  return [
31
41
  "# Chain the machine's previous global hook (kj harden keeps it active).",
32
- `if [ -x "${globalHooksDir}/${hook}" ]; then`,
33
- ` "${globalHooksDir}/${hook}" "$@"; rc=$?`,
42
+ `if [ -x "${dir}/${hook}" ]; then`,
43
+ ` "${dir}/${hook}" "$@"; rc=$?`,
34
44
  ' [ "$rc" -eq 0 ] || exit "$rc"',
35
45
  "fi",
36
46
  ];
@@ -371,6 +371,20 @@ process.stdin.on("end", () => {
371
371
  console.error("karajan sentinel: los ficheros del supervisor no se tocan desde Bash — consultalos con la tool Read/Grep; solo el humano los modifica, fuera de la sesion." + doc("supervisor"));
372
372
  process.exit(2);
373
373
  }
374
+ // ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
375
+ // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
376
+ // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
377
+ // (mencionarlo en un argumento tambien bloquea: preferible a un hueco).
378
+ // ANTES de KJ_SENTINEL_OFF a proposito, como el guard PROTECTED de
379
+ // arriba: un env var lo pone tambien un agente — el supervisor no se
380
+ // apaga con interruptores al alcance del supervisado.
381
+ {
382
+ const kjCmd = String(input.command || "");
383
+ if (tool === "Bash" && kjCmd.includes("harden") && kjCmd.includes("--commit")) {
384
+ console.error("karajan sentinel: kj harden --commit es un acto humano (ADR 0009) — pideselo a tu usuario; ninguna sesion de agente lo ejecuta." + doc("supervisor"));
385
+ process.exit(2);
386
+ }
387
+ }
374
388
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
375
389
  // KJC-BUG-0142: cada deny anuncia su escape como prefijo del comando
376
390
  // (KJ_ALLOW_X=1 cmd), pero el hook corre con el env del HOST — el
@@ -0,0 +1,147 @@
1
+ /**
2
+ * KJC-BUG-0161 / ADR 0009 (opción A) — `kj harden --commit`, el cauce
3
+ * sancionado del supervisor: ACTO HUMANO que versiona la regeneración con
4
+ * provenance trackeada (versión, parámetros, sha256), sello en el acta y
5
+ * commit quirúrgico. El --no-verify es a conciencia: lo que salta en local
6
+ * lo re-verifica CI contra la provenance (pieza 3).
7
+ */
8
+ import { execFileSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import { closeSync, existsSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+
13
+ import { recordGateDecision } from "../policy/decisions.js";
14
+ import { readIdentity } from "../identity/store.js";
15
+
16
+ export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
17
+ const HOOKS_PREFIX = ".karajan/hooks/";
18
+
19
+ const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
20
+
21
+ // Capa 3 del acto humano (test adversarial del 6-sep): un pty falso engaña a
22
+ // isTTY y `env -u` borra CLAUDECODE, pero el kj que lanza un agente DESCIENDE
23
+ // de su proceso — y eso está en /proc lo falsifique quien lo falsifique.
24
+ const AGENT_PROC = /claude|codex|copilot|gemini|opencode|\bagy\b/i;
25
+ export function agentAncestry({ pid = process.pid, readProc = null, maxDepth = 40 } = {}) {
26
+ const read = readProc || ((p) => {
27
+ const stat = readFileSync(`/proc/${p}/stat`, "utf8");
28
+ const ppid = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[1]);
29
+ let cmd = "";
30
+ try { cmd = readFileSync(`/proc/${p}/cmdline`).toString("utf8").replaceAll("\0", " "); } catch { /* gone */ }
31
+ return { ppid, cmd };
32
+ });
33
+ let cur = pid;
34
+ for (let i = 0; i < maxDepth && cur > 1; i += 1) {
35
+ let info;
36
+ try { info = read(cur); } catch { return { agent: false, unknown: true }; }
37
+ if (info?.cmd && AGENT_PROC.test(info.cmd)) return { agent: true, match: info.cmd.slice(0, 80) };
38
+ if (!Number.isFinite(info?.ppid) || info.ppid === cur) break;
39
+ cur = info.ppid;
40
+ }
41
+ return { agent: false };
42
+ }
43
+
44
+ // Lee la respuesta del nonce de la TTY REAL del proceso (no de stdin, que un
45
+ // atacante alimenta por pipe): /dev/tty solo existe con terminal de control.
46
+ function defaultConfirm(nonce) {
47
+ process.stdout.write(`harden --commit: teclea "${nonce}" para confirmar que eres humano: `);
48
+ try {
49
+ const buf = Buffer.alloc(64);
50
+ const fd = openSync("/dev/tty", "r");
51
+ const n = readSync(fd, buf, 0, 64);
52
+ closeSync(fd);
53
+ return buf.toString("utf8", 0, n).trim();
54
+ } catch {
55
+ return null;
56
+ }
57
+ }
58
+
59
+ /** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
60
+ export function supervisorDrift({ projectDir, gitFn }) {
61
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
62
+ // Un rename sale como "R old -> new" (catch de codex): ambas rutas son
63
+ // drift — la vieja se versiona como borrado y la nueva con su hash.
64
+ return run(["status", "--porcelain", "--", ".karajan/hooks"])
65
+ .split("\n")
66
+ .flatMap((l) => l.slice(3).trim().split(" -> "))
67
+ .filter((f) => f.startsWith(HOOKS_PREFIX));
68
+ }
69
+
70
+ /**
71
+ * @returns {{committed: boolean, reason?: string, files?: object[]}}
72
+ */
73
+ export function commitSupervisorRegeneration({
74
+ projectDir,
75
+ kjVersion,
76
+ generation,
77
+ logger = console,
78
+ gitFn = null,
79
+ env = process.env,
80
+ tty = process.stdout.isTTY,
81
+ deps = {},
82
+ }) {
83
+ // El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
84
+ if (env.CLAUDECODE || env.KJ_NON_INTERACTIVE === "1" || !tty) {
85
+ throw new Error(
86
+ "harden --commit es un acto humano: córrelo desde TU terminal, fuera de una sesión de agente (ADR 0009)",
87
+ );
88
+ }
89
+ const anc = agentAncestry(deps.ancestry ?? {});
90
+ if (anc.agent) {
91
+ throw new Error(
92
+ `harden --commit es un acto humano y este proceso desciende de un agente (${anc.match}) — ni con pty falso ni con el entorno limpio (ADR 0009)`,
93
+ );
94
+ }
95
+ // Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
96
+ // prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
97
+ // Un alimentador ciego no conoce el código; automatizar su lectura exige
98
+ // un driver expect — premeditación inequívoca, no un descuido.
99
+ const nonce = createHash("sha256").update(String(Math.random())).digest("hex").slice(0, 6);
100
+ const answer = (deps.confirm ?? defaultConfirm)(nonce);
101
+ if (answer !== nonce) {
102
+ throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
103
+ }
104
+ const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
105
+ const files = supervisorDrift({ projectDir, gitFn: run });
106
+ if (files.length === 0) {
107
+ logger.info?.("harden --commit: sin drift en el supervisor — nada que versionar");
108
+ return { committed: false, reason: "sin drift" };
109
+ }
110
+ // Borrados/renombrados también son drift (catch de codex): ausente ⇒ deleted.
111
+ const hashed = files.map((file) => {
112
+ const abs = join(projectDir, file);
113
+ return existsSync(abs) ? { file, sha256: sha256(abs) } : { file, deleted: true };
114
+ });
115
+ const who = readIdentity(projectDir);
116
+ const provenance = {
117
+ kj_version: kjVersion,
118
+ generated_at: new Date().toISOString(),
119
+ generation,
120
+ who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
121
+ files: hashed,
122
+ };
123
+ writeFileSync(join(projectDir, PROVENANCE_FILE), `${JSON.stringify(provenance, null, 2)}\n`, "utf8");
124
+ recordGateDecision(projectDir, {
125
+ decision: "exempt",
126
+ chokepoint: "supervisor",
127
+ kind: "supervisor-regeneration",
128
+ kj_version: kjVersion,
129
+ files: hashed,
130
+ who: provenance.who,
131
+ });
132
+ // Por DIRECTORIO, no por fichero: un rename ya staged deja la ruta vieja
133
+ // sin existir y `git add -- <vieja>` falla; `-A` sobre el dir del
134
+ // supervisor versiona altas, cambios, borrados y renombrados por igual.
135
+ run(["add", "-A", "--", PROVENANCE_FILE, ".karajan/hooks"]);
136
+ run([
137
+ "commit",
138
+ "--no-verify",
139
+ "-m",
140
+ `chore(harden): supervisor regenerated and sealed by kj harden v${kjVersion}`,
141
+ "--",
142
+ PROVENANCE_FILE,
143
+ ".karajan/hooks",
144
+ ]);
145
+ logger.info?.(`harden --commit: ${files.length} fichero(s) de supervisor versionados con procedencia sellada`);
146
+ return { committed: true, files: hashed };
147
+ }
@@ -25,7 +25,9 @@ import {
25
25
  export const SUPERVISOR_DEFAULTS = [
26
26
  {
27
27
  id: "defaults.supervisor.write",
28
- pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\//,
28
+ // ADR 0009: la provenance del supervisor es superficie de supervisor —
29
+ // un agente no la escribe; solo kj harden --commit (humano) la produce.
30
+ pattern: /\.claude\/settings\.json\b|\.karajan\/(hooks|harness)\/|\.karajan\/supervisor-provenance\.json\b/,
29
31
  message: "nombra ficheros del supervisor — solo el humano los modifica, fuera de la sesión",
30
32
  },
31
33
  ];
@@ -0,0 +1,79 @@
1
+ /**
2
+ * KJC-BUG-0161 / ADR 0009, pieza 3 — exención ESTRUCTURAL del supervisor:
3
+ * un fichero de .karajan/hooks solo deja de ser violación si su contenido
4
+ * coincide (sha256) con la provenance sellada Y con el render canónico
5
+ * recomputado. No confía: recomputa. Una coma manual sigue denegada.
6
+ */
7
+ import { createHash } from "node:crypto";
8
+ import { lstatSync, readFileSync } from "node:fs";
9
+ import { resolve, sep } from "node:path";
10
+
11
+ import { renderCanonicalHook } from "../harden/harden-engine.js";
12
+ import { PROVENANCE_FILE } from "../harden/supervisor-commit.js";
13
+
14
+ const HOOKS_PREFIX = ".karajan/hooks/";
15
+ // globalHooksDir es el único texto libre que se interpola en el render:
16
+ // vocabulario cerrado de ruta o la provenance no verifica NADA.
17
+ const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
18
+
19
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
20
+
21
+ /**
22
+ * @returns {{files: Set<string>, reason?: string}} rutas repo-relativas de
23
+ * supervisor cuyo estado actual está respaldado por la provenance sellada.
24
+ */
25
+ export function verifiedSupervisorFiles({ projectDir }) {
26
+ let prov;
27
+ try {
28
+ prov = JSON.parse(readFileSync(resolve(projectDir, PROVENANCE_FILE), "utf8"));
29
+ } catch {
30
+ return { files: new Set(), reason: "sin provenance" };
31
+ }
32
+ const gen = prov?.generation ?? {};
33
+ if (gen.globalHooksDir != null && !SAFE_DIR.test(gen.globalHooksDir)) {
34
+ return { files: new Set(), reason: "globalHooksDir no verificable en la provenance" };
35
+ }
36
+ const ok = new Set();
37
+ const entries = Array.isArray(prov?.files) ? prov.files : [];
38
+ const hooksRoot = resolve(projectDir, HOOKS_PREFIX);
39
+ for (const entry of entries) {
40
+ if (typeof entry?.file !== "string" || !entry.file.startsWith(HOOKS_PREFIX)) continue;
41
+ // Contención por resolución, no por prefijo textual (catch de codex).
42
+ const abs = resolve(projectDir, entry.file);
43
+ if (!abs.startsWith(hooksRoot + sep)) continue;
44
+ let st = null;
45
+ try { st = lstatSync(abs); } catch { /* no hay objeto en la ruta */ }
46
+ if (entry.deleted === true) {
47
+ if (st === null) ok.add(entry.file);
48
+ continue;
49
+ }
50
+ if (typeof entry.sha256 !== "string" || !st?.isFile()) continue;
51
+ if (sha256(readFileSync(abs)) !== entry.sha256) continue;
52
+ let canonical;
53
+ try {
54
+ canonical = renderCanonicalHook(entry.file.slice(HOOKS_PREFIX.length), gen);
55
+ } catch {
56
+ continue;
57
+ }
58
+ if (sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
59
+ }
60
+ // `complete` = CADA entrada verificó: solo entonces la provenance se
61
+ // describe con honestidad y su propio diff puede alzarse.
62
+ const complete = entries.length > 0 && entries.every((e) => typeof e?.file === "string" && ok.has(e.file));
63
+ return { files: ok, complete };
64
+ }
65
+
66
+ /** Filtra violaciones de supervisor respaldadas — {violations, lifted}. */
67
+ export function liftSealedSupervisorViolations({ projectDir, violations }) {
68
+ const RULE = "defaults.supervisor.write";
69
+ if (!violations.some((v) => v.rule_id === RULE && v.file)) return { violations, lifted: 0 };
70
+ const sealed = verifiedSupervisorFiles({ projectDir });
71
+ // La violación sobre la PROPIA provenance solo se alza si la provenance
72
+ // entera verificó — a medias no describe la verdad y sigue denegada.
73
+ const liftable = (v) =>
74
+ v.rule_id === RULE
75
+ && v.file
76
+ && (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
77
+ const kept = violations.filter((v) => !liftable(v));
78
+ return { violations: kept, lifted: violations.length - kept.length };
79
+ }