karajan-code 4.20.1 → 4.21.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.20.1",
3
+ "version": "4.21.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",
@@ -31,7 +31,7 @@ export const ADVANCED_GROUPS = [
31
31
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
32
32
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
33
33
  { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy"] },
34
- { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel"] },
34
+ { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
35
35
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
36
36
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
37
37
  ];
@@ -4,6 +4,7 @@ import { researcherCommand } from "../commands/researcher.js";
4
4
  import { architectCommand } from "../commands/architect.js";
5
5
  import { onboardCommand } from "../commands/onboard.js";
6
6
  import { startCommand } from "../commands/start.js";
7
+ import { identityCommand } from "../commands/identity.js";
7
8
  import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
8
9
  import { qmdQueryCommand } from "../commands/qmd.js";
9
10
  import { ragMcpCommand } from "../commands/rag-mcp.js";
@@ -120,6 +121,30 @@ export function registerMeta(program, { pkgVersion }) {
120
121
  });
121
122
  });
122
123
 
124
+ // IDN-A (KJC-TSK-0762, epic KJC-PCS-0079): identity lock per clone.
125
+ const identityCmd = program
126
+ .command("identity")
127
+ .description("Identity lock: which gh account and git email this clone is worked with (.karajan/identity.local.yml, never tracked)");
128
+ identityCmd
129
+ .command("show", { isDefault: true })
130
+ .description("Declared identity vs the active one (exit 2 on mismatch or when undeclared)")
131
+ .action(async (flags) => {
132
+ await withConfig(pkgVersion, "identity-show", flags, async ({ config }) => {
133
+ process.exitCode = await identityCommand({ action: "show", config, flags });
134
+ });
135
+ });
136
+ identityCmd
137
+ .command("set")
138
+ .description("Declare this clone's identity from the active gh session and git config (or --gh/--email)")
139
+ .option("--gh <user>", "gh account to bind")
140
+ .option("--email <email>", "git email to bind")
141
+ .option("--yes", "Do not ask for confirmation")
142
+ .action(async (flags) => {
143
+ await withConfig(pkgVersion, "identity-set", flags, async ({ config }) => {
144
+ process.exitCode = await identityCommand({ action: "set", config, flags });
145
+ });
146
+ });
147
+
123
148
  program
124
149
  .command("start")
125
150
  .description("Single entry point: assess the project and recommend the next step")
@@ -23,6 +23,7 @@ import { installSentinelHooks } from "../harden/sentinel-hooks.js";
23
23
  import { detectStackRoots } from "../harden/stack-roots.js";
24
24
  import { installWorkflows } from "../harden/workflow-engine.js";
25
25
  import { detectTestFramework } from "../utils/project-detect.js";
26
+ import { ensureIdentity } from "../identity/bootstrap.js";
26
27
 
27
28
  const JS_TEST_CMD = {
28
29
  vitest: "npx vitest run",
@@ -191,5 +192,12 @@ export async function hardenCommand({
191
192
  for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
192
193
  for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
193
194
  if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
195
+ // IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
196
+ // only with a human confirming; headless runs get the pending command.
197
+ if (!dryRun) {
198
+ const id = await ensureIdentity({ projectDir, logger });
199
+ out.identity = id.declared ? id.identity : null;
200
+ if (id.pending) out.identityPending = id.pending;
201
+ }
194
202
  return out;
195
203
  }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `kj identity show|set` (IDN-A, KJC-TSK-0762, epic KJC-PCS-0079).
3
+ *
4
+ * show — declared identity vs what is ACTIVE now; exit 2 on mismatch or
5
+ * when nothing is declared (scriptable: the gate and hooks reuse it).
6
+ * set — capture the effective gh account + git email (or --gh/--email),
7
+ * confirm on a TTY, write .karajan/identity.local.yml. Never invents:
8
+ * no session and no flags → exit 1.
9
+ */
10
+
11
+ import { readIdentity, writeIdentity } from "../identity/store.js";
12
+ import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
13
+ import { compareIdentity } from "../identity/compare.js";
14
+ import { createWizard, isTTY } from "../utils/wizard.js";
15
+
16
+ export async function identityCommand({ action = "show", config, flags = {}, deps = {} }) {
17
+ const projectDir = config?.projectDir || process.cwd();
18
+ const log = deps.log || console.log;
19
+ const tty = deps.isTTY || isTTY;
20
+ const detectOpts = { hostsPath: deps.hostsPath, gitFn: deps.gitFn };
21
+ const effective = {
22
+ gh_user: activeGhUser(detectOpts.hostsPath ? { hostsPath: detectOpts.hostsPath } : {}),
23
+ git_email: effectiveGitEmail(projectDir, detectOpts.gitFn ? { gitFn: detectOpts.gitFn } : {}),
24
+ };
25
+
26
+ if (action === "set") {
27
+ const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
28
+ if (!candidate.gh_user || !candidate.git_email) {
29
+ log(`kj identity: cannot declare — gh account: ${candidate.gh_user ?? "no session"}, git email: ${candidate.git_email ?? "none"}. Log in / set git config, or pass --gh <user> --email <email>.`);
30
+ return 1;
31
+ }
32
+ if (tty() && !flags.yes) {
33
+ const wizard = (deps.makeWizard || createWizard)();
34
+ try {
35
+ const ok = await wizard.confirm(`Bind this clone to gh ${candidate.gh_user} / git ${candidate.git_email}?`, true);
36
+ if (!ok) { log("kj identity: not declared."); return 1; }
37
+ } finally { wizard.close(); }
38
+ } else if (!flags.gh || !flags.email) {
39
+ // Proven live on day one: the active gh session had been flipped by
40
+ // ANOTHER session, and --yes bound the clone to the wrong account.
41
+ // Binding without confirmation is allowed, but never silent.
42
+ log("kj identity: WARNING — binding the ACTIVE session as-is without confirmation (another session may have switched it); prefer --gh/--email explicitly and verify with `kj identity show`.");
43
+ }
44
+ writeIdentity(projectDir, candidate);
45
+ log(`kj identity: declared gh ${candidate.gh_user} / git ${candidate.git_email} → .karajan/identity.local.yml (ignored by git).`);
46
+ return 0;
47
+ }
48
+
49
+ const declared = readIdentity(projectDir);
50
+ const result = compareIdentity(declared, effective);
51
+ if (!declared) {
52
+ log("kj identity: no identity declared for this clone — run `kj identity set`.");
53
+ return 2;
54
+ }
55
+ const mark = (field) => (result.mismatches.some((m) => m.field === field) ? "DISTINTA" : "ACTIVA");
56
+ log(`gh_user ${declared.gh_user} [${mark("gh_user")}: ${effective.gh_user ?? "no session"}]`);
57
+ log(`git_email ${declared.git_email} [${mark("git_email")}: ${effective.git_email ?? "none"}]`);
58
+ for (const m of result.mismatches) log(` → ${m.remedy}`);
59
+ return result.ok ? 0 : 2;
60
+ }
@@ -52,11 +52,49 @@ function baseBranchGuard(baseBranch) {
52
52
  ];
53
53
  }
54
54
 
55
+ // IDN-C (KJC-TSK-0764, ADR 0005): identity lock at tier B — the declared clone
56
+ // identity (.karajan/identity.local.yml) governs authorship on commit and the
57
+ // gh session on push, on ANY host. Undeclared = advisory only (old clones keep
58
+ // working); the fail-closed default is the Sentinel's (IDN-B).
59
+ function identityGuard(hook) {
60
+ const head = [
61
+ "# Identity lock (IDN-C, ADR 0005) — this clone's declared identity governs.",
62
+ 'if [ "$KJ_ALLOW_IDENTITY" != "1" ] && [ -f .karajan/identity.local.yml ]; then',
63
+ ];
64
+ const tail = [
65
+ "elif [ ! -f .karajan/identity.local.yml ]; then",
66
+ " echo 'kj harden: no identity declared for this clone — run kj identity set (advisory)'",
67
+ "fi",
68
+ ];
69
+ if (hook === "pre-commit") {
70
+ return [
71
+ ...head,
72
+ " kj_declared_email=$(sed -n 's/^git_email:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
73
+ " kj_author=$(git var GIT_AUTHOR_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
74
+ " kj_committer=$(git var GIT_COMMITTER_IDENT | sed 's/.*<\\(.*\\)>.*/\\1/')",
75
+ ' if [ -n "$kj_declared_email" ] && { [ "$kj_author" != "$kj_declared_email" ] || [ "$kj_committer" != "$kj_declared_email" ]; }; then',
76
+ ' 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',
77
+ " fi",
78
+ ...tail,
79
+ ];
80
+ }
81
+ return [
82
+ ...head,
83
+ " kj_declared_gh=$(sed -n 's/^gh_user:[[:space:]]*//p' .karajan/identity.local.yml | head -n1)",
84
+ ' kj_hosts="${GH_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/gh}/hosts.yml"',
85
+ " 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)",
86
+ ' if [ -n "$kj_declared_gh" ] && [ "$kj_active_gh" != "$kj_declared_gh" ]; then',
87
+ ' 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',
88
+ " fi",
89
+ ...tail,
90
+ ];
91
+ }
92
+
55
93
  export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch = null } = {}) {
56
94
  const chain = chainToGlobal(hook, globalHooksDir);
57
95
  switch (hook) {
58
96
  case "pre-commit": {
59
- const lines = [...baseBranchGuard(baseBranch), "# Lint + format the working tree with the project's native tools."];
97
+ const lines = [...baseBranchGuard(baseBranch), ...identityGuard("pre-commit"), "# Lint + format the working tree with the project's native tools."];
60
98
  if (cmds.lint) lines.push(`${cmds.lint} || { echo 'kj harden: lint failed'; exit 1; }`);
61
99
  if (cmds.format) lines.push(`${cmds.format} || { echo 'kj harden: format check failed'; exit 1; }`);
62
100
  if (!cmds.lint && !cmds.format) lines.push("# (no lint/format command detected for this stack)");
@@ -93,6 +131,7 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
93
131
  " echo 'kj harden: git user.name/user.email not set — refusing to push'; exit 1",
94
132
  "fi",
95
133
  'echo "kj harden: pushing as $(git config user.name) <$(git config user.email)>"',
134
+ ...identityGuard("pre-push"),
96
135
  "# Run the test suite before pushing.",
97
136
  ];
98
137
  if (cmds.test) lines.push(`${cmds.test} || { echo 'kj harden: tests failed'; exit 1; }`);
@@ -66,8 +66,12 @@ export const foreignLane = (targetDir) => {
66
66
  const common = _git("rev-parse --path-format=absolute --git-common-dir", targetDir);
67
67
  return common && common === _home.common ? top : null;
68
68
  };
69
+ // KJC-TSK-0765 board-sync: cards merged in this session but not yet moved in
70
+ // the tracker. The method does not advance until the board is true.
71
+ export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
72
+ export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
69
73
  export const violations = (s, branch) => {
70
- const v = [];
74
+ const v = pendingMoves(s).map(pendingText);
71
75
  if (!s || !(s.edited_sources || []).length) return v;
72
76
  if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
73
77
  else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
@@ -89,13 +93,62 @@ const POST_BODY = `#!/usr/bin/env node
89
93
  // Records deterministic method facts per session; never blocks, never fails
90
94
  // a tool call (PostToolUse, always exit 0).
91
95
  import { relative } from "node:path";
92
- import { CODE, TESTS, ROOT, load, save, session } from "./sentinel-lib.mjs";
93
- const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY"];
96
+ import { spawnSync } from "node:child_process";
97
+ import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
98
+ 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"];
94
99
  let raw = "";
95
100
  process.stdin.on("data", (d) => { raw += d; });
96
101
  process.stdin.on("end", () => {
97
102
  try {
98
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
103
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, tool_response: response = {} } = JSON.parse(raw);
104
+ // KJC-TSK-0765 board-sync: a merge that gh CONFIRMS leaves its card pending
105
+ // in the tracker (the PreToolUse gate then refuses to advance until it is
106
+ // moved). Recorded here, after the fact, so a failed merge never blocks.
107
+ const text = typeof response === "string" ? response : JSON.stringify(response);
108
+ // Closing states that count as "registered in the tracker" (PG statuses and
109
+ // HU Board moves). The pending entry is cleared by the REAL tool call that
110
+ // moved the card — never by a promise in the agent's prose.
111
+ const CLOSING = ["to validate", "to-validate", "fixed", "verified", "closed", "done", "done&validated", "validated"];
112
+ const clearPending = (cardId) => {
113
+ const state = load();
114
+ const s = session(state, sid);
115
+ const before = (s.pending_moves || []).length;
116
+ s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
117
+ if (s.pending_moves.length !== before) save(state);
118
+ };
119
+ if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
120
+ // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
121
+ // stringified, the inner quotes are ESCAPED — match both forms (found live:
122
+ // the first real update_card did not clear its pending entry).
123
+ const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
124
+ const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
125
+ if (cid && st && CLOSING.includes(st[1].toLowerCase())) clearPending(cid[1].toUpperCase());
126
+ process.exit(0);
127
+ }
128
+ if (tool === "Bash") {
129
+ const cmdText = String(input.command || "");
130
+ // gh prints "merged pull request #N" only on a TTY: under a tool call the
131
+ // success line is ABSENT (found live on the first dogfood merge). The
132
+ // authoritative signal is the PR state itself: one gh pr view per merge.
133
+ const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
134
+ const said = /merged pull request #(\\d+)/i.exec(text);
135
+ let mergedPr = said ? Number(said[1]) : null;
136
+ if (mergedPr === null && mergeCmd) {
137
+ const args = ["pr", "view", ...(mergeCmd[2] ? [mergeCmd[2]] : []), "--json", "number,state"];
138
+ const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
139
+ try { const v = JSON.parse(r.status === 0 ? r.stdout : "null"); if (v && v.state === "MERGED") mergedPr = Number(v.number); } catch { /* unknown = not confirmed */ }
140
+ }
141
+ if (mergedPr !== null) {
142
+ const state = load();
143
+ const s = session(state, sid);
144
+ const ref = CARD.exec(branchOf() || "");
145
+ (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now() });
146
+ save(state);
147
+ }
148
+ const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
149
+ if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail/i.test(text)) clearPending(moved[1].toUpperCase());
150
+ process.exit(0);
151
+ }
99
152
  const file = input.file_path || input.notebook_path;
100
153
  if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
101
154
  const state = load();
@@ -194,10 +247,11 @@ const PRETOOL_BODY = `#!/usr/bin/env node
194
247
  // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
195
248
  // remediation); read-only tools are never wired here; every honored escape
196
249
  // is recorded as an auditable event. Fails OPEN on anything unexpected.
197
- import { dirname, relative, resolve } from "node:path";
198
- import { existsSync, realpathSync, statSync } from "node:fs";
250
+ import { dirname, join, relative, resolve } from "node:path";
251
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
199
252
  import { spawnSync } from "node:child_process";
200
- import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape } from "./sentinel-lib.mjs";
253
+ import { homedir } from "node:os";
254
+ import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
201
255
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
202
256
  const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
203
257
  const PUSH = /\\bgit\\s+push\\b/;
@@ -265,6 +319,108 @@ process.stdin.on("end", () => {
265
319
  }
266
320
  return true;
267
321
  };
322
+ // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
323
+ // run under an account other than the one THIS CLONE declared (the incident:
324
+ // a gh comment without switch went out as the client account). Deny BEFORE
325
+ // the damage with the literal remedy; never auto-switch; undeclared = closed.
326
+ if (tool === "Bash" && CMD_TEXT.length > 0) {
327
+ const idFile = join(ROOT, ".karajan", "identity.local.yml");
328
+ const idLine = (key) => {
329
+ if (!existsSync(idFile)) return null;
330
+ const line = readFileSync(idFile, "utf8").split(ESC_NL).find((l) => l.startsWith(key + ":"));
331
+ const v = line ? line.slice(key.length + 1).trim() : "";
332
+ return v.length > 0 ? v : null;
333
+ };
334
+ const declaredGh = idLine("gh_user");
335
+ const declaredEmail = idLine("git_email");
336
+ const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
337
+ const activeGh = () => {
338
+ if (!existsSync(ghHostsPath)) return null;
339
+ const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
340
+ const start = lines.findIndex((l) => l.startsWith("github.com:"));
341
+ if (start < 0) return null;
342
+ for (let k = start + 1; k < lines.length; k += 1) {
343
+ if (lines[k].length > 0 && lines[k][0] !== " " && lines[k][0] !== ESC_TAB) break;
344
+ const t = lines[k].trim();
345
+ if (t.startsWith("user:")) { const u = t.slice(5).trim(); return u.length > 0 ? u : null; }
346
+ }
347
+ return null;
348
+ };
349
+ const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
350
+ const headOf = (ws) => { let k = 0; while (k < ws.length && ws[k].indexOf("=") > 0 && ws[k].indexOf("=") < ws[k].length) k += 1; return ws.slice(k); };
351
+ // Wrapper shells hide the payload in a string (review catch): scan inside.
352
+ const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
353
+ let switchedTo = null;
354
+ const problems = [];
355
+ // $( and backticks are separators too (review catch): their payload is its own segment.
356
+ const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
357
+ const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
358
+ const scan = (text, depth) => { if (depth > 3) return; for (const seg of SEPS.reduce((acc, s) => acc.flatMap((l) => l.split(s)), text.split(ESC_NL))) checkSeg(headOf(words(seg)), depth, words(seg)); };
359
+ const checkSeg = (ws, depth, raw) => {
360
+ if (ws.length === 0) return;
361
+ if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/git/wrapper token (review catch)
362
+ const j = ws.findIndex((w, i) => i > 0 && ["gh", "git", ...WRAPPERS].includes(bare(w)));
363
+ if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
364
+ return;
365
+ }
366
+ if (ws[0] === "gh") {
367
+ if (ws[1] === "auth") {
368
+ if (ws[2] === "switch") { const u = ws.indexOf("--user"); if (u > 0 && ws[u + 1]) switchedTo = ws[u + 1]; }
369
+ return; // session commands ARE the remedy — never blocked
370
+ }
371
+ if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
372
+ const eff = switchedTo || activeGh();
373
+ if (eff !== declaredGh) problems.push("gh as '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix the command: gh auth switch --user " + declaredGh + " && ...");
374
+ return;
375
+ }
376
+ if (ws[0] === "git") {
377
+ // Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
378
+ // are forwarded verbatim to git var, so git resolves identity exactly as the real
379
+ // command would (review catches: -C cumulative, --git-dir, --opt=value forms).
380
+ const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
381
+ let k = 1;
382
+ while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
383
+ const globals = ws.slice(1, k).map(bare);
384
+ const sub = ws[k];
385
+ if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
386
+ if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
387
+ const eff = switchedTo || activeGh();
388
+ if (eff !== declaredGh) problems.push("git push with gh session '" + (eff || "no session") + "' but this clone is declared as '" + declaredGh + "' — prefix: gh auth switch --user " + declaredGh + " && ...");
389
+ return;
390
+ }
391
+ // IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
392
+ if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
393
+ if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
394
+ // Let GIT resolve author and committer under the command's own environment
395
+ // (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
396
+ // its business): git var is the single source of truth.
397
+ const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
398
+ const env = Object.assign({}, process.env);
399
+ for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
400
+ const ident = (v) => {
401
+ const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
402
+ const s = r.status === 0 ? String(r.stdout) : "";
403
+ const a = s.indexOf("<");
404
+ const b = s.indexOf(">");
405
+ return a >= 0 && b > a ? s.slice(a + 1, b) : null;
406
+ };
407
+ const ai = ws.findIndex((w) => w.startsWith("--author"));
408
+ const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
409
+ const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
410
+ const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
411
+ const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
412
+ if (offender !== null) problems.push("git " + sub + " as '" + offender + "' but this clone is declared as '" + declaredEmail + "' — use: git -c user.email=" + declaredEmail + " " + sub + " ... (or git config user.email " + declaredEmail + ")");
413
+ }
414
+ };
415
+ scan(CMD_TEXT, 0);
416
+ if (problems.length > 0) {
417
+ if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
418
+ else {
419
+ console.error("kj sentinel: identity lock (ADR 0005) —" + problems.map((p) => ESC_NL + "- " + p).join("") + ESC_NL + "(KJ_ALLOW_IDENTITY=1 = excepcion consciente, queda registrada)");
420
+ process.exit(2);
421
+ }
422
+ }
423
+ }
268
424
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
269
425
  // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
270
426
  const laneOf = (p) => {
@@ -475,6 +631,23 @@ process.stdin.on("end", () => {
475
631
  }
476
632
  if (tool === "Bash") {
477
633
  const cmd = String(input.command || "");
634
+ // KJC-TSK-0765 board-sync: a merge leaves its card PENDING in the tracker
635
+ // and nothing advances (commit, new PR, another merge; push and the end of
636
+ // the turn go through violations()) until the card is moved. The board is
637
+ // true at all times or the method stops — never a rule in the agent's memory.
638
+ const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
639
+ 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)
640
+ const mergeM = cmd.match(MERGE);
641
+ const pend = pendingMoves(load().sessions?.[sid]);
642
+ if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
643
+ if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
644
+ else {
645
+ console.error("kj sentinel: board-sync — el metodo no avanza con cards mergeadas sin mover:\\n" + pend.map((p) => "- " + pendingText(p)).join("\\n") + "\\n(KJ_ALLOW_BOARD=1 = excepcion consciente, queda registrada)");
646
+ process.exit(2);
647
+ }
648
+ }
649
+ // (the pending entry itself is recorded by the PostToolUse hook, only once
650
+ // gh confirms "merged pull request #N" — a failed merge never blocks)
478
651
  if (PUBLISH.test(cmd)) {
479
652
  if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
480
653
  const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
@@ -536,6 +709,11 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
536
709
  { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
537
710
  { event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
538
711
  { event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
712
+ // KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
713
+ // update_card) are observed AFTER the fact — found live: with only the
714
+ // edit matchers wired, the board-sync recorder never ran in production.
715
+ { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
716
+ { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
539
717
  { event: "Stop", script: "stop.mjs" },
540
718
  ],
541
719
  });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Identity lock — bootstrap (IDN-A, KJC-TSK-0762). Runs at the end of
3
+ * `kj harden` (and therefore `kj env install`): if this clone has no declared
4
+ * identity, capture it — but ONLY with a human confirming on a TTY. Headless
5
+ * runs never bind blindly (the first real `set --yes` bound a clone to an
6
+ * account another session had switched to); they report the pending step
7
+ * with the exact command instead. Fail-loud, never a guess.
8
+ */
9
+
10
+ import { readIdentity, writeIdentity } from "./store.js";
11
+ import { activeGhUser, effectiveGitEmail } from "./detect.js";
12
+ import { createWizard, isTTY } from "../utils/wizard.js";
13
+
14
+ /**
15
+ * @param {{projectDir: string, logger?: object, deps?: object}} args
16
+ * @returns {Promise<{declared: boolean, identity: object|null, pending: string|null}>}
17
+ */
18
+ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
19
+ const existing = readIdentity(projectDir);
20
+ if (existing) return { declared: true, identity: existing, pending: null };
21
+
22
+ const gh = activeGhUser(deps.hostsPath ? { hostsPath: deps.hostsPath } : {});
23
+ const email = effectiveGitEmail(projectDir, deps.gitFn ? { gitFn: deps.gitFn } : {});
24
+ const hint = `kj identity set --gh ${gh ?? "<user>"} --email ${email ?? "<email>"}`;
25
+ const tty = (deps.isTTY || isTTY)();
26
+
27
+ if (!tty || !gh || !email) {
28
+ const why = !gh || !email ? "no active gh session or git email" : "non-interactive run";
29
+ const pending = `identity not declared for this clone (${why}) — run: ${hint}`;
30
+ logger?.warn?.(`kj identity: ${pending}`);
31
+ return { declared: false, identity: null, pending };
32
+ }
33
+
34
+ const wizard = (deps.makeWizard || createWizard)();
35
+ try {
36
+ const ok = await wizard.confirm(`Bind this clone to gh ${gh} / git ${email}? (other sessions may switch gh accounts — verify)`, true);
37
+ if (!ok) {
38
+ const pending = `identity not declared (declined) — run: ${hint}`;
39
+ logger?.warn?.(`kj identity: ${pending}`);
40
+ return { declared: false, identity: null, pending };
41
+ }
42
+ } finally {
43
+ wizard.close();
44
+ }
45
+ const identity = writeIdentity(projectDir, { gh_user: gh, git_email: email });
46
+ logger?.info?.(`kj identity: declared gh ${gh} / git ${email} → .karajan/identity.local.yml (ignored by git)`);
47
+ return { declared: true, identity, pending: null };
48
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Identity lock — compare (IDN-A, KJC-TSK-0762). Pure: declared vs effective
3
+ * → {ok, mismatches}. Every mismatch carries the LITERAL remedy, because the
4
+ * gate (IDN-B) and the hooks (IDN-C) print it instead of auto-switching —
5
+ * changing the user's keyring is the user's act, never the agent's.
6
+ */
7
+
8
+ /**
9
+ * @param {{gh_user: string, git_email: string}|null} declared
10
+ * @param {{gh_user: string|null, git_email: string|null}} effective
11
+ * @returns {{ok: boolean, mismatches: Array<{field: string, declared: string|null, effective: string|null, remedy: string}>}}
12
+ */
13
+ export function compareIdentity(declared, effective) {
14
+ if (!declared) {
15
+ return {
16
+ ok: false,
17
+ mismatches: [{ field: "identity", declared: null, effective: null, remedy: "kj identity set — this clone has no declared identity yet" }],
18
+ };
19
+ }
20
+ const mismatches = [];
21
+ if (effective.gh_user !== declared.gh_user) {
22
+ mismatches.push({
23
+ field: "gh_user",
24
+ declared: declared.gh_user,
25
+ effective: effective.gh_user ?? null,
26
+ remedy: effective.gh_user ? `gh auth switch --user ${declared.gh_user}` : `gh auth login (as ${declared.gh_user}) or gh auth switch --user ${declared.gh_user}`,
27
+ });
28
+ }
29
+ if (effective.git_email !== declared.git_email) {
30
+ mismatches.push({
31
+ field: "git_email",
32
+ declared: declared.git_email,
33
+ effective: effective.git_email ?? null,
34
+ remedy: `git config user.email ${declared.git_email} (or -c user.email=${declared.git_email} on the command)`,
35
+ });
36
+ }
37
+ return { ok: mismatches.length === 0, mismatches };
38
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Identity lock — detect (IDN-A, KJC-TSK-0762). What is EFFECTIVE right now,
3
+ * with no network and no spawned `gh`: the active gh account is read from
4
+ * gh's own hosts.yml (the file `gh auth switch` mutates), the git email from
5
+ * `git config user.email`. Both return null when unknown — never a guess.
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { execFileSync } from "node:child_process";
10
+ import { join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import yaml from "js-yaml";
13
+
14
+ export function defaultHostsPath() {
15
+ const base = process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh");
16
+ return join(base, "hosts.yml");
17
+ }
18
+
19
+ /**
20
+ * @param {{hostsPath?: string, host?: string}} [opts]
21
+ * @returns {string|null} active gh login for the host, null without a session
22
+ */
23
+ export function activeGhUser({ hostsPath = defaultHostsPath(), host = "github.com" } = {}) {
24
+ if (!existsSync(hostsPath)) return null;
25
+ try {
26
+ const parsed = yaml.load(readFileSync(hostsPath, "utf8"));
27
+ const user = parsed?.[host]?.user;
28
+ return typeof user === "string" && user.length > 0 ? user : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ const defaultGitFn = (projectDir) =>
35
+ execFileSync("git", ["config", "--get", "user.email"], { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
36
+
37
+ /**
38
+ * @param {string} projectDir
39
+ * @param {{gitFn?: (projectDir: string) => string}} [opts]
40
+ * @returns {string|null}
41
+ */
42
+ export function effectiveGitEmail(projectDir, { gitFn = defaultGitFn } = {}) {
43
+ try {
44
+ // --get yields the single effective value; keep the last line anyway so a
45
+ // multi-line answer from an odd config can never leak a newline into it.
46
+ const out = String(gitFn(projectDir) || "").trim().split("\n").at(-1).trim();
47
+ return out.length > 0 ? out : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Identity lock — store (IDN-A, KJC-TSK-0762, epic KJC-PCS-0079).
3
+ *
4
+ * Each CLONE declares the identity it is worked with: the gh account and the
5
+ * git email. It is per developer, not per project, so it lives in
6
+ * `.karajan/identity.local.yml`, never tracked (this module keeps it in the
7
+ * .gitignore). Born from the 2026-08-20 incident: one `gh` call without an
8
+ * explicit account switch posted as a client account on a public repo.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import yaml from "js-yaml";
14
+
15
+ export const IDENTITY_FILE = ".karajan/identity.local.yml";
16
+
17
+ /** @param {string} projectDir */
18
+ export function identityPath(projectDir) {
19
+ return join(projectDir, ".karajan", "identity.local.yml");
20
+ }
21
+
22
+ /**
23
+ * @param {string} projectDir
24
+ * @returns {{gh_user: string, git_email: string, declared_at: string}|null}
25
+ */
26
+ export function readIdentity(projectDir) {
27
+ const p = identityPath(projectDir);
28
+ if (!existsSync(p)) return null;
29
+ let parsed;
30
+ try {
31
+ parsed = yaml.load(readFileSync(p, "utf8"));
32
+ } catch {
33
+ return null; // corrupt or half-written file = no identity (review catch)
34
+ }
35
+ if (!parsed || typeof parsed !== "object" || !parsed.gh_user || !parsed.git_email) return null;
36
+ return { gh_user: String(parsed.gh_user), git_email: String(parsed.git_email), declared_at: String(parsed.declared_at || "") };
37
+ }
38
+
39
+ /**
40
+ * Write the declaration and make sure it never gets tracked. Fails loud on
41
+ * an incomplete identity — a half-declared lock is no lock.
42
+ * @param {string} projectDir
43
+ * @param {{gh_user: string, git_email: string}} identity
44
+ */
45
+ export function writeIdentity(projectDir, identity) {
46
+ for (const field of ["gh_user", "git_email"]) {
47
+ if (!identity?.[field] || typeof identity[field] !== "string") {
48
+ throw new Error(`identity: "${field}" is required (got ${JSON.stringify(identity?.[field])})`);
49
+ }
50
+ }
51
+ // Ignore FIRST, write SECOND (review catch): if the ignore step fails, no
52
+ // unignored identity file is ever left on disk for git to pick up.
53
+ ensureIgnored(projectDir);
54
+ mkdirSync(join(projectDir, ".karajan"), { recursive: true });
55
+ const record = { gh_user: identity.gh_user, git_email: identity.git_email, declared_at: new Date().toISOString() };
56
+ writeFileSync(identityPath(projectDir), yaml.dump(record, { lineWidth: 120 }), "utf8");
57
+ return record;
58
+ }
59
+
60
+ function ensureIgnored(projectDir) {
61
+ const ignorePath = join(projectDir, ".gitignore");
62
+ const current = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
63
+ if (current.split("\n").some((line) => line.trim() === IDENTITY_FILE)) return;
64
+ const sep = current.length === 0 || current.endsWith("\n") ? "" : "\n";
65
+ writeFileSync(ignorePath, `${current}${sep}${IDENTITY_FILE}\n`, "utf8");
66
+ }