karajan-code 4.27.0 → 4.28.1

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.1",
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,16 @@ process.stdin.on("end", () => {
578
616
  }
579
617
  if (tool === "Bash") {
580
618
  const cmd = String(input.command || "");
619
+ // KJC-BUG-0166/0167: gestionar worktrees crudos (add/remove/prune) es
620
+ // estructura del repo, jamas trabajo de una sesion. 0166: una sesion
621
+ // intento EN BUCLE retirar el ARBOL PRINCIPAL con remove+CROSS_LANE.
622
+ // 0167: un coder descarrilado ANADIO un worktree .verify-main rebelde y
623
+ // de ahi se invento un bootstrap. Deny sin escape en los tres verbos;
624
+ // el cauce sancionado es kj worktree start|done (corre dentro de kj).
625
+ if (/(^|[^a-z])git[^a-z]+worktree[^a-z]+(add|remove|prune)/.test(cmd)) {
626
+ console.error("karajan sentinel: git worktree add/remove/prune no se ejecuta desde una sesion — estructura del repo, sin escape; usa kj worktree start|done." + doc("cross-lane"));
627
+ process.exit(2);
628
+ }
581
629
  // Deny-unless-known-read-only: un allowlist de verbos mutadores era
582
630
  // esquivable (dd, interpretes). Solo un comando SIMPLE de lectura (sin
583
631
  // 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
 
@@ -246,6 +246,10 @@ export async function buildCoderPromptLayout({ task, reviewerFeedback = null, so
246
246
  section(langInstruction, STABLE),
247
247
  section("Implement directly in the repository.", STABLE),
248
248
  section("Keep changes minimal and production-ready.", STABLE),
249
+ // KJC-BUG-0167: a headless coder finished the real subtask, then derailed
250
+ // into inventing a "set up project structure" bootstrap (generic js+go
251
+ // .gitignore, a rogue verify worktree). Anchor the scope hard.
252
+ section("SCOPE: this is an EXISTING project. Implement ONLY the current HU against the code already here. NEVER scaffold, bootstrap or initialize project structure, never add a generic multi-stack .gitignore, and never create git worktrees (`git worktree add`) — your workspace is already prepared. If a step ever reads like 'set up the project', treat it as a misread of an existing repo: re-read the HU and make the real change.", STABLE),
249
253
  section("Follow SOLID principles. Write small, focused functions (< 30 lines).", STABLE),
250
254
  section("Make atomic commits: 1 logical change = 1 commit. Keep PRs small and reviewable.", STABLE),
251
255
  section("Security: validate all input, parameterize queries, never expose secrets. Use the auth pattern that matches the stack (e.g. httpOnly cookies for web sessions).", STABLE),
@@ -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
  /**