karajan-code 4.27.0 → 4.28.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.27.0",
3
+ "version": "4.28.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",
@@ -116,6 +116,7 @@
116
116
  "karajan-rag": "^1.2.0",
117
117
  "knip": "^6.15.0",
118
118
  "madge": "^8.0.0",
119
+ "qrcode-terminal": "0.12.0",
119
120
  "sqlite-vec": "^0.1.9",
120
121
  "valibot": "^1.4.1",
121
122
  "web-tree-sitter": "^0.26.9",
@@ -148,6 +148,15 @@ export function registerMeta(program, { pkgVersion }) {
148
148
  process.exitCode = await identityCommand({ action: "set", config, flags });
149
149
  });
150
150
  });
151
+ identityCmd
152
+ .command("enroll-phone")
153
+ .description("Enroll the phone's PUBLIC signing key (base64 of the raw 32-byte ed25519 key) for the supervisor seal")
154
+ .argument("<publicKeyBase64>", "raw ed25519 public key, base64 (shown by the phone app)")
155
+ .action(async (publicKeyBase64, flags) => {
156
+ await withConfig(pkgVersion, "identity-enroll-phone", flags, async ({ config }) => {
157
+ process.exitCode = await identityCommand({ action: "enroll-phone", config, flags: { ...flags, publicKeyBase64 } });
158
+ });
159
+ });
151
160
 
152
161
  // MGL-A (KJC-TSK-0808): the muggle launcher — one command, at most one
153
162
  // question, and the person lands inside a governed conversation.
@@ -207,7 +207,7 @@ export async function hardenCommand({
207
207
  // commitSupervisorRegeneration y rechaza sesiones de agente.
208
208
  if (commitSupervisor && !dryRun) {
209
209
  try {
210
- out.supervisorCommit = commitSupervisorRegeneration({
210
+ out.supervisorCommit = await commitSupervisorRegeneration({
211
211
  projectDir,
212
212
  kjVersion: kjVersion ?? "unknown",
213
213
  generation: result.generation ?? { profile },
@@ -8,6 +8,7 @@
8
8
  * no session and no flags → exit 1.
9
9
  */
10
10
 
11
+ import { enrollPhone } from "../harden/phone-sign.js";
11
12
  import { readIdentity, writeIdentity } from "../identity/store.js";
12
13
  import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
13
14
  import { compareIdentity } from "../identity/compare.js";
@@ -23,6 +24,18 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
23
24
  git_email: effectiveGitEmail(projectDir, detectOpts.gitFn ? { gitFn: detectOpts.gitFn } : {}),
24
25
  };
25
26
 
27
+ // KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
28
+ if (action === "enroll-phone") {
29
+ try {
30
+ enrollPhone(flags.publicKeyBase64, { home: deps.home });
31
+ } catch (err) {
32
+ log(`kj identity enroll-phone: ${err.message}`);
33
+ return 1;
34
+ }
35
+ log("Móvil enrolado: la clave pública quedó en ~/.karajan/supervisor-phone.json. A partir de ahora, sellar el supervisor pedirá la firma de tu móvil.");
36
+ return 0;
37
+ }
38
+
26
39
  if (action === "set") {
27
40
  const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
28
41
  if (!candidate.gh_user || !candidate.git_email) {
@@ -109,6 +109,14 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
109
109
  if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
110
110
  if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
111
111
  lines.push(
112
+ "# KJC-BUG-0164: attribution is surface EVERYWHERE — the staged diff's",
113
+ "# ADDED lines (changelog, docs, code comments) are scanned too, with",
114
+ "# the same precise pattern (tool MENTIONS stay legal; attribution not).",
115
+ "# The guard's own definition files are excluded by literal path — they",
116
+ "# CONTAIN the pattern; excluding a path that does not exist is harmless.",
117
+ `if git diff --cached -- . ':(exclude).github/workflows/kj-no-ai-attribution.yml' ':(exclude)src/harden/hook-templates.js' ':(exclude)src/harden/workflow-templates.js' ':(exclude)src/harden/sentinel-hooks.js' ':(exclude)scripts/ai-attribution-guard.yml' ':(exclude)tests/harden/attribution-guard.test.js' ':(exclude)tests/harden/sentinel-hooks.test.js' | grep '^+' | grep -qiE '${AI_ATTRIBUTION}|generated with \\[?claude'; then`,
118
+ " echo 'kj harden: AI attribution is not allowed in committed content'; exit 1",
119
+ "fi",
112
120
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
113
121
  "# a staged diff only enters with a recorded cross-AI approved verdict.",
114
122
  "if [ -f .karajan/review-gate ]; then",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * KJC-TSK-0822 (PRP-0023) — capa 5 del sello del supervisor: firma asimétrica
3
+ * desde el móvil. La clave privada vive SOLO en el teléfono; aquí se enrola su
4
+ * clave PÚBLICA (~/.karajan/supervisor-phone.json) y se verifica ed25519
5
+ * contra ella — el transporte (Firestore, PR siguiente) nunca es la verdad.
6
+ */
7
+ import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ import qrcode from "qrcode-terminal";
13
+
14
+ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
15
+ // La config del relé (apiKey de cliente Firestore — pública por diseño, la
16
+ // misma que la página firmante lleva incrustada) NO viaja en el tarball: el
17
+ // escáner de privacidad del pack deniega claves google con razón general, y
18
+ // servirla desde la landing la hace además rotable sin release.
19
+ const RELAY_CONFIG_URL = "https://karajancode.com/sign/relay.json";
20
+ let relayCache = null;
21
+ async function relayConfig(fetchFn) {
22
+ if (relayCache) return relayCache;
23
+ const res = await fetchFn(RELAY_CONFIG_URL);
24
+ if (!res.ok) throw new Error(`phone-sign: no pude cargar la config del relé (${res.status}) — revisa la red`);
25
+ const cfg = await res.json();
26
+ if (!cfg?.apiKey || !cfg?.projectId || !cfg?.collection) throw new Error("phone-sign: config del relé incompleta");
27
+ relayCache = {
28
+ apiKey: cfg.apiKey,
29
+ url: `https://firestore.googleapis.com/v1/projects/${cfg.projectId}/databases/(default)/documents/${cfg.collection}`,
30
+ };
31
+ return relayCache;
32
+ }
33
+ const SIGN_PAGE = "https://karajancode.com/sign";
34
+ const POLL_INTERVAL_MS = 2000;
35
+ const TTL_MS = 120000;
36
+
37
+ const phoneKeyPath = (home) => join(home ?? homedir(), ".karajan", "supervisor-phone.json");
38
+
39
+ export const isPhoneEnrolled = ({ home } = {}) => existsSync(phoneKeyPath(home));
40
+
41
+ export const readEnrolledKey = ({ home } = {}) =>
42
+ JSON.parse(readFileSync(phoneKeyPath(home), "utf8")).publicKey;
43
+
44
+ /** Valida y persiste la clave pública del móvil (base64 del raw de 32 bytes). */
45
+ export function enrollPhone(publicKeyBase64, { home } = {}) {
46
+ const raw = Buffer.from(publicKeyBase64 ?? "", "base64");
47
+ if (raw.length !== 32 || raw.toString("base64") !== publicKeyBase64) {
48
+ throw new Error("la clave debe ser base64 canónico de EXACTAMENTE 32 bytes (ed25519 raw)");
49
+ }
50
+ mkdirSync(join(phoneKeyPath(home), ".."), { recursive: true });
51
+ const record = { publicKey: publicKeyBase64, enrolledAt: new Date().toISOString() };
52
+ writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
53
+ }
54
+
55
+ /** Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios. */
56
+ export function canonicalPayload({ cid, nonce, project, files }) {
57
+ const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
58
+ return `kj-supervisor-sign:v1:${cid}:${nonce}:${project}:${filesHash}`;
59
+ }
60
+
61
+ /** Verifica la firma (base64) del payload contra la clave pública raw (base64). */
62
+ export function verifyPhoneSignature({ payload, signature, publicKey }) {
63
+ const der = Buffer.concat([SPKI_ED25519_PREFIX, Buffer.from(publicKey, "base64")]);
64
+ const key = createPublicKey({ key: der, format: "der", type: "spki" });
65
+ return verify(null, Buffer.from(payload, "utf8"), key, Buffer.from(signature, "base64"));
66
+ }
67
+
68
+ /**
69
+ * Publica la petición en Firestore, enseña QR + URL, pollea el doc y verifica
70
+ * la firma. Sin fallbacks silenciosos: red caída o HTTP no-ok ⇒ throw. Firma
71
+ * mala / clave ajena / TTL vencido ⇒ {ok:false, reason}.
72
+ * @returns {Promise<{ok: boolean, reason?: string}>}
73
+ */
74
+ export async function requestPhoneSignature({ project, files, kjVersion, logger = console, deps = {} }) {
75
+ const fetchFn = deps.fetch ?? fetch;
76
+ const now = deps.now ?? Date.now;
77
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
78
+ const drawQr = deps.qr ?? ((url) => qrcode.generate(url, { small: true }));
79
+ const cid = randomBytes(16).toString("hex");
80
+ const nonce = randomBytes(16).toString("hex");
81
+ const body = {
82
+ fields: {
83
+ nonce: { stringValue: nonce },
84
+ project: { stringValue: project },
85
+ kj_version: { stringValue: kjVersion },
86
+ state: { stringValue: "pending" },
87
+ createdAt: { timestampValue: new Date().toISOString() },
88
+ files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
89
+ },
90
+ };
91
+ const relay = await relayConfig(fetchFn);
92
+ const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
93
+ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
94
+ });
95
+ if (!created.ok) {
96
+ throw new Error(`phone-sign: no se pudo publicar la petición de firma (HTTP ${created.status}) — sin red no hay sello, no se degrada`);
97
+ }
98
+ const signUrl = `${SIGN_PAGE}?c=${cid}`;
99
+ drawQr(signUrl);
100
+ logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
101
+ const deadline = now() + TTL_MS;
102
+ while (now() < deadline) {
103
+ const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
104
+ if (!res.ok) throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`);
105
+ const fields = (await res.json()).fields ?? {};
106
+ if (fields.state?.stringValue === "signed") {
107
+ const enrolled = readEnrolledKey({ home: deps.home });
108
+ if (fields.publicKey?.stringValue !== enrolled) {
109
+ return { ok: false, reason: "la publicKey del doc no coincide con la enrolada — el doc es transporte, la verdad es la enrolada" };
110
+ }
111
+ const payload = canonicalPayload({ cid, nonce, project, files });
112
+ const good = verifyPhoneSignature({ payload, signature: fields.signature?.stringValue ?? "", publicKey: enrolled });
113
+ return good ? { ok: true } : { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
114
+ }
115
+ logger.info?.("phone-sign: esperando la firma del móvil…");
116
+ await sleep(POLL_INTERVAL_MS);
117
+ }
118
+ return { ok: false, reason: "caducado" };
119
+ }
@@ -371,6 +371,44 @@ 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
+ // KJC-BUG-0164: todo gh que PUBLICA texto (pr/issue/release, create/edit/
375
+ // comment/review) se escanea contra el patron de ATRIBUCION a IA antes de
376
+ // salir — comando inline Y ficheros --body-file/--notes-file. Regla
377
+ // determinista del proyecto: prohibido, sin escape. Un fichero ilegible
378
+ // tampoco publica (fail closed).
379
+ {
380
+ // Regex SIN backslashes a proposito (clases de caracteres, emoji
381
+ // literal): asi la plantilla y el fichero generado son identicos y no
382
+ // hay doble-escape que leer mal.
383
+ const ghCmd = String(input.command || "");
384
+ 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)) {
385
+ 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;
386
+ let corpus = ghCmd;
387
+ for (const m of ghCmd.matchAll(/--(?:body-file|notes-file|comment-file)[= ]+("([^"]+)"|'([^']+)'|([^ ]+))/g)) {
388
+ const bodyPath = m[2] || m[3] || m[4];
389
+ try { corpus += "\\n" + readFileSync(bodyPath, "utf8"); } catch {
390
+ console.error("karajan sentinel: no puedo leer " + bodyPath + " para el escaneo de atribucion — sin escaneo no se publica." + doc("attribution"));
391
+ process.exit(2);
392
+ }
393
+ }
394
+ if (attrib.test(corpus)) {
395
+ console.error("karajan sentinel: atribucion a IA detectada en contenido a PUBLICAR (PR/issue/release) — prohibida por regla determinista del proyecto: limpia el texto." + doc("attribution"));
396
+ process.exit(2);
397
+ }
398
+ }
399
+ }
400
+ // KJC-TSK-0822 v1.1: enrolar (o pisar) la clave del movil es acto humano
401
+ // — si un agente pudiera enrolar una clave SUYA, contestaria la capa 5 el
402
+ // solo. Ni el comando ni el fichero de enrolamiento desde la sesion.
403
+ {
404
+ const enrollCmd = String(input.command || "");
405
+ const namesEnroll = (enrollCmd.includes("identity") && enrollCmd.includes("enroll-phone")) || enrollCmd.includes("supervisor-phone.json");
406
+ const writesEnroll = EDIT_TOOLS.includes(tool) && String(input.file_path || "").includes("supervisor-phone.json");
407
+ if ((tool === "Bash" && namesEnroll) || writesEnroll) {
408
+ console.error("karajan sentinel: el enrolamiento del movil (capa 5) es un acto humano — pideselo a tu usuario; supervisor-phone.json no se toca desde la sesion." + doc("supervisor"));
409
+ process.exit(2);
410
+ }
411
+ }
374
412
  // ADR 0009 (KJC-BUG-0161): kj harden --commit es un ACTO HUMANO — la
375
413
  // sesion ni lo intenta. Sin escape (superficie de supervisor). includes()
376
414
  // a proposito: cero ambiguedad de escapes en plantilla, y fail-closed
@@ -578,6 +616,15 @@ process.stdin.on("end", () => {
578
616
  }
579
617
  if (tool === "Bash") {
580
618
  const cmd = String(input.command || "");
619
+ // KJC-BUG-0166: una sesion intento EN BUCLE retirar el ARBOL PRINCIPAL
620
+ // con git worktree remove y KJ_ALLOW_CROSS_LANE puesto. Retirar un
621
+ // worktree es destruccion de la estructura del repo — jamas trabajo de
622
+ // una sesion, y el escape de cruce NO puede ampararlo. Deny sin escape
623
+ // (el cauce sancionado es kj worktree done, que corre dentro de kj).
624
+ if (/(^|[^a-z])git[^a-z]+worktree[^a-z]+(remove|prune)/.test(cmd)) {
625
+ console.error("karajan sentinel: git worktree remove/prune no se ejecuta desde una sesion — destruccion de estructura del repo, sin escape; usa kj worktree done." + doc("cross-lane"));
626
+ process.exit(2);
627
+ }
581
628
  // Deny-unless-known-read-only: un allowlist de verbos mutadores era
582
629
  // esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
583
630
  // encadenar ni redirigir) puede nombrar otro carril; leer carriles es
@@ -7,11 +7,12 @@
7
7
  */
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { createHash } from "node:crypto";
10
- import { closeSync, existsSync, openSync, readFileSync, readSync, writeFileSync } from "node:fs";
11
- import { join } from "node:path";
10
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, writeFileSync } from "node:fs";
11
+ import { basename, join } from "node:path";
12
12
 
13
13
  import { recordGateDecision } from "../policy/decisions.js";
14
14
  import { readIdentity } from "../identity/store.js";
15
+ import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
15
16
 
16
17
  export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
17
18
  const HOOKS_PREFIX = ".karajan/hooks/";
@@ -68,9 +69,9 @@ export function supervisorDrift({ projectDir, gitFn }) {
68
69
  }
69
70
 
70
71
  /**
71
- * @returns {{committed: boolean, reason?: string, files?: object[]}}
72
+ * @returns {Promise<{committed: boolean, reason?: string, files?: object[]}>}
72
73
  */
73
- export function commitSupervisorRegeneration({
74
+ export async function commitSupervisorRegeneration({
74
75
  projectDir,
75
76
  kjVersion,
76
77
  generation,
@@ -102,16 +103,37 @@ export function commitSupervisorRegeneration({
102
103
  throw new Error(`harden --commit: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
103
104
  }
104
105
  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");
106
+ const drift = supervisorDrift({ projectDir, gitFn: run });
107
+ // La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
108
+ // en el primer estreno real: un sello parcial pisaba al anterior y dejaba
109
+ // ficheros sin respaldo). Borrados del drift ⇒ deleted (catch de codex).
110
+ const current = readdirSync(join(projectDir, HOOKS_PREFIX)).map((f) => HOOKS_PREFIX + f).sort();
111
+ const hashed = [
112
+ ...current.map((file) => ({ file, sha256: sha256(join(projectDir, file)) })),
113
+ ...drift.filter((f) => !existsSync(join(projectDir, f))).map((file) => ({ file, deleted: true })),
114
+ ];
115
+ let previous = null;
116
+ try { previous = JSON.parse(readFileSync(join(projectDir, PROVENANCE_FILE), "utf8")); } catch { /* primer sello */ }
117
+ const covered = JSON.stringify(previous?.files ?? null) === JSON.stringify(hashed);
118
+ if (drift.length === 0 && covered) {
119
+ logger.info?.("harden --commit: sin drift y provenance completa — nada que versionar");
108
120
  return { committed: false, reason: "sin drift" };
109
121
  }
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
- });
122
+ // Capa 5 (KJC-TSK-0822 / PRP-0023): con móvil enrolado la firma asimétrica
123
+ // es OBLIGATORIA — jamás se degrada a solo-nonce. Firma los MISMOS
124
+ // files/hashes de la provenance (normalizados al par {file, sha256}).
125
+ const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
126
+ if (phone.enrolled({})) {
127
+ const signed = await phone.request({
128
+ project: basename(projectDir),
129
+ files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
130
+ kjVersion,
131
+ logger,
132
+ });
133
+ if (!signed.ok) {
134
+ throw new Error(`capa 5: firma del móvil rechazada (${signed.reason}) — con móvil enrolado el sello exige su firma (PRP-0023)`);
135
+ }
136
+ }
115
137
  const who = readIdentity(projectDir);
116
138
  const provenance = {
117
139
  kj_version: kjVersion,
@@ -142,6 +164,6 @@ export function commitSupervisorRegeneration({
142
164
  PROVENANCE_FILE,
143
165
  ".karajan/hooks",
144
166
  ]);
145
- logger.info?.(`harden --commit: ${files.length} fichero(s) de supervisor versionados con procedencia sellada`);
167
+ logger.info?.(`harden --commit: ${hashed.length} fichero(s) de supervisor versionados con procedencia sellada`);
146
168
  return { committed: true, files: hashed };
147
169
  }
@@ -36,9 +36,14 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
36
36
  ` - uses: ${PINNED_ACTIONS.checkout}`,
37
37
  " with:",
38
38
  " fetch-depth: 0",
39
- " - name: Scan commit messages for AI attribution",
39
+ " - name: Scan commit messages and PR body for AI attribution",
40
40
  " env:",
41
41
  " BASE_REF: ${{ github.base_ref }}",
42
+ // KJC-BUG-0164: el cuerpo de la PR tambien es superficie — 15 PRs entraron
43
+ // con pie de atribucion porque solo se escaneaban commits. Via env, jamas
44
+ // interpolado en el script (el cuerpo es texto no confiable).
45
+ " PR_BODY: ${{ github.event.pull_request.body }}",
46
+ " PR_TITLE: ${{ github.event.pull_request.title }}",
42
47
  " run: |",
43
48
  ' if ! msgs=$(git log --format=%B "origin/${BASE_REF}...HEAD"); then',
44
49
  ' echo "::error::cannot resolve origin/${BASE_REF}...HEAD — the scan did not run"; exit 1',
@@ -48,6 +53,9 @@ export const NO_AI_ATTRIBUTION_WORKFLOW = [
48
53
  ' if printf "%s" "$msgs" | grep -qiE "$pat"; then',
49
54
  ' echo "::error::AI attribution detected in commit messages"; exit 1',
50
55
  " fi",
56
+ ' if printf "%s\\n%s" "$PR_BODY" "$PR_TITLE" | grep -qiE "$pat|generated with \\[?claude|🤖"; then',
57
+ ' echo "::error::AI attribution detected in the PR body or title"; exit 1',
58
+ " fi",
51
59
  ' echo "AI attribution scan: clean"',
52
60
  ].join("\n");
53
61
 
@@ -25,7 +25,11 @@ export const CONTRACT_BLOCK = [
25
25
  "!.karajan/hooks/commit-msg",
26
26
  "!.karajan/hooks/pre-push",
27
27
  "!.karajan/hooks/post-merge",
28
+ "!.karajan/hooks/prepare-commit-msg",
28
29
  "!.karajan/adrs/",
30
+ // ADR 0009 (KJC-BUG-0161): la procedencia del supervisor VIAJA con el
31
+ // repo — es lo que CI verifica. Cazado en el primer harden --commit real.
32
+ "!.karajan/supervisor-provenance.json",
29
33
  ];
30
34
 
31
35
  /**