karajan-code 4.20.1 → 4.22.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.
Files changed (40) hide show
  1. package/README.md +5 -3
  2. package/package.json +4 -2
  3. package/packages/hu-board/public/app.js +1 -0
  4. package/packages/hu-board/public/index.html +2 -0
  5. package/packages/hu-board/public/styles.css +22 -0
  6. package/packages/hu-board/public/utils/governance-view.js +149 -0
  7. package/packages/hu-board/src/routes/governance.js +140 -0
  8. package/packages/hu-board/src/server.js +2 -0
  9. package/scripts/install.js +4 -3
  10. package/scripts/postinstall.js +4 -3
  11. package/scripts/toml-value.js +18 -0
  12. package/src/checks/ai-trash.js +1 -1
  13. package/src/checks/mcp-health.js +1 -1
  14. package/src/checks/native-build.js +2 -2
  15. package/src/checks/release-check.js +62 -2
  16. package/src/claims/cross-check.js +81 -0
  17. package/src/claims/extract.js +56 -0
  18. package/src/claims/turn.js +54 -0
  19. package/src/cli/advanced-commands.js +2 -2
  20. package/src/cli/register-meta.js +70 -5
  21. package/src/commands/claims.js +41 -0
  22. package/src/commands/harden.js +8 -0
  23. package/src/commands/identity.js +60 -0
  24. package/src/commands/init.js +1 -1
  25. package/src/commands/policy.js +84 -3
  26. package/src/commands/review-gate.js +4 -2
  27. package/src/environment/playbook.js +2 -1
  28. package/src/guards/duplicate-members.js +86 -0
  29. package/src/harden/hook-templates.js +40 -1
  30. package/src/harden/sentinel-hooks.js +267 -15
  31. package/src/identity/bootstrap.js +48 -0
  32. package/src/identity/compare.js +38 -0
  33. package/src/identity/detect.js +51 -0
  34. package/src/identity/store.js +66 -0
  35. package/src/policy/exceptions.js +14 -4
  36. package/src/policy/report.js +99 -0
  37. package/src/review/card-first.js +4 -1
  38. package/src/review/one-shot-review.js +10 -4
  39. package/src/review/parser.js +9 -0
  40. package/src/review/unparseable-verdict.js +50 -0
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Duplicate class members (KJC-TSK-0796) — a bug JavaScript refuses to report.
3
+ *
4
+ * Declaring the same member twice in a class is legal: the LAST one wins and the
5
+ * first disappears in silence. No runtime error, no lint warning, no compiler
6
+ * complaint. That is how a `updated()` that loaded the data was lost in GREBLA
7
+ * and a tab stayed empty in production for 17 days.
8
+ *
9
+ * What matters is not that there is a duplicate — it is WHAT WAS LOST: the body
10
+ * that will never run. That is what the finding says.
11
+ *
12
+ * Honest about its limits (the fourth verdict): a file this guard could not read,
13
+ * or one whose member names are computed at runtime, is reported as NOT OBSERVABLE
14
+ * — never as clean.
15
+ */
16
+ import { parse } from "@babel/parser";
17
+
18
+ const plugins = (file) => [
19
+ ...(/\.(ts|tsx|mts|cts)$/.test(file) ? ["typescript"] : []),
20
+ ...(/\.(tsx|jsx)$/.test(file) || !/\.(ts|mts|cts)$/.test(file) ? ["jsx"] : []),
21
+ "decorators",
22
+ ];
23
+ const isClass = (node) => node?.type === "ClassDeclaration" || node?.type === "ClassExpression";
24
+
25
+ /** Walks every node of the tree; enough for a syntax-only guard (no scope analysis). */
26
+ function walk(node, visit) {
27
+ if (!node || typeof node.type !== "string") return;
28
+ visit(node);
29
+ for (const key of Object.keys(node)) {
30
+ const child = node[key];
31
+ if (Array.isArray(child)) child.forEach((c) => walk(c, visit));
32
+ else if (child && typeof child.type === "string") walk(child, visit);
33
+ }
34
+ }
35
+
36
+ const memberName = (member) => {
37
+ if (member.computed) return null; // `[expr]() {}` — the name is not known until it runs
38
+ const key = member.key ?? member.id;
39
+ if (key?.type === "Identifier") return key.name;
40
+ if (key?.type === "PrivateName") return `#${key.id.name}`;
41
+ if (key?.type === "StringLiteral" || key?.type === "NumericLiteral") return String(key.value);
42
+ return null;
43
+ };
44
+
45
+ /**
46
+ * @param {string} source
47
+ * @param {{file?: string}} [where]
48
+ * @returns {{findings: Array<object>, notObservable: {file: string, reason: string}|null}}
49
+ * Each finding: {file, className, member, lostLine, winnerLine, message}.
50
+ */
51
+ export function findDuplicateMembers(source, { file = "<source>" } = {}) {
52
+ let tree;
53
+ try {
54
+ tree = parse(source, { sourceType: "unambiguous", allowReturnOutsideFunction: true, plugins: plugins(file) });
55
+ } catch (err) {
56
+ return { findings: [], notObservable: { file, reason: `could not be parsed (${err.message}) — not read as clean` } };
57
+ }
58
+ const findings = [];
59
+ let computed = 0;
60
+ walk(tree, (node) => {
61
+ if (!isClass(node)) return;
62
+ const className = node.id?.name ?? "(anonymous)";
63
+ const seen = new Map();
64
+ for (const member of node.body.body) {
65
+ // get/set of the same name are legal, and a static member is a different slot from an
66
+ // instance one — treating either as a duplicate would be the false positive that gets a
67
+ // guard switched off (the credibility rule of KJC-PCS-0082).
68
+ // A TypeScript overload signature (TSDeclareMethod) declares the same name on purpose and
69
+ // carries no body: counting it would flag every overloaded method in the codebase.
70
+ if (member.type === "StaticBlock" || member.type === "TSDeclareMethod" || member.type === "TSIndexSignature" || member.kind === "get" || member.kind === "set") continue;
71
+ const name = memberName(member);
72
+ if (name === null) { computed += 1; continue; }
73
+ const key = `${member.static ? "static " : ""}${name}`;
74
+ const line = member.loc.start.line;
75
+ const first = seen.get(key);
76
+ if (first === undefined) { seen.set(key, line); continue; }
77
+ findings.push({
78
+ file, className, member: key, lostLine: first, winnerLine: line,
79
+ message: `${file}:${line} — ${className}: the ${key} of line ${first} never runs, the one of line ${line} replaces it`,
80
+ });
81
+ seen.set(key, line); // a third copy is reported against the second, not against the first
82
+ }
83
+ });
84
+ const notObservable = computed > 0 && findings.length === 0 ? { file, reason: `${computed} member name(s) computed at runtime — this file cannot be fully checked` } : null;
85
+ return { findings, notObservable };
86
+ }
@@ -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; }`);
@@ -29,7 +29,7 @@ const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj h
29
29
  // Single source for every sentinel script: state, branch, classification,
30
30
  // violations, and escape recording.
31
31
  import { readFileSync, writeFileSync } from "node:fs";
32
- import { execSync } from "node:child_process";
32
+ import { execSync, spawnSync } from "node:child_process";
33
33
  import { dirname, join } from "node:path";
34
34
  import { fileURLToPath } from "node:url";
35
35
  const here = dirname(fileURLToPath(import.meta.url));
@@ -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)");
@@ -81,6 +85,18 @@ export const recordEscape = (sid, escape, tool) => {
81
85
  if (!s.escapes.includes(escape)) s.escapes.push(escape);
82
86
  (state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
83
87
  save(state);
88
+ // GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
89
+ // decision log hash-encadenado (kj policy seal), no solo en este estado.
90
+ // Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
91
+ // kj no carga, que es cuando mas se usa el escape (restaurar con git).
92
+ try {
93
+ const args = ["policy", "seal", "--escape", escape];
94
+ if (tool) args.push("--tool", String(tool));
95
+ const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
96
+ if (r.error || r.status !== 0) console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log (kj policy seal " + (r.error ? r.error.message : "exit " + r.status) + ") — revisa kj; el escape queda solo en sentinel-state.json");
97
+ } catch (e) {
98
+ console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message);
99
+ }
84
100
  };
85
101
  `;
86
102
 
@@ -89,13 +105,74 @@ const POST_BODY = `#!/usr/bin/env node
89
105
  // Records deterministic method facts per session; never blocks, never fails
90
106
  // a tool call (PostToolUse, always exit 0).
91
107
  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"];
108
+ import { spawnSync } from "node:child_process";
109
+ import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
110
+ 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
111
  let raw = "";
95
112
  process.stdin.on("data", (d) => { raw += d; });
96
113
  process.stdin.on("end", () => {
97
114
  try {
98
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
115
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, tool_response: response = {} } = JSON.parse(raw);
116
+ // KJC-TSK-0765 board-sync: a merge that gh CONFIRMS leaves its card pending
117
+ // in the tracker (the PreToolUse gate then refuses to advance until it is
118
+ // moved). Recorded here, after the fact, so a failed merge never blocks.
119
+ const text = typeof response === "string" ? response : JSON.stringify(response);
120
+ // Closing states that count as "registered in the tracker" (PG statuses and
121
+ // HU Board moves). The pending entry is cleared by the REAL tool call that
122
+ // moved the card — never by a promise in the agent's prose.
123
+ const CLOSING = ["to validate", "to-validate", "fixed", "verified", "closed", "done", "done&validated", "validated"];
124
+ const clearPending = (cardId) => {
125
+ const state = load();
126
+ const s = session(state, sid);
127
+ s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
128
+ // KJC-TSK-0774: a card closed this turn is what the Stop hook points the
129
+ // user at when the focus comes back (the board is where to look).
130
+ if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
131
+ save(state);
132
+ };
133
+ if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
134
+ // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
135
+ // stringified, the inner quotes are ESCAPED — match both forms (found live:
136
+ // the first real update_card did not clear its pending entry).
137
+ const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
138
+ const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
139
+ if (cid && st && CLOSING.includes(st[1].toLowerCase())) clearPending(cid[1].toUpperCase());
140
+ process.exit(0);
141
+ }
142
+ if (tool === "Bash") {
143
+ const cmdText = String(input.command || "");
144
+ // gh prints "merged pull request #N" only on a TTY: under a tool call the
145
+ // success line is ABSENT (found live on the first dogfood merge). The
146
+ // authoritative signal is the PR state itself: one gh pr view per merge.
147
+ const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
148
+ const said = /merged pull request #(\\d+)/i.exec(text);
149
+ let mergedPr = said ? Number(said[1]) : null;
150
+ // KJC-BUG-0148: the card belongs to the PR's HEAD branch, not to whatever
151
+ // branch the working tree happens to be on (found live: a merge issued
152
+ // from another card's lane pinned the pending move on the wrong card).
153
+ let headRef = null;
154
+ if (mergeCmd || said) {
155
+ const n = said ? said[1] : mergeCmd[2];
156
+ const args = ["pr", "view", ...(n ? [n] : []), "--json", "number,state,headRefName"];
157
+ const r = spawnSync("gh", args, { cwd: ROOT, encoding: "utf8" });
158
+ try {
159
+ const v = JSON.parse(r.status === 0 ? r.stdout : "null");
160
+ if (v && v.state === "MERGED") { mergedPr = Number(v.number); headRef = v.headRefName || null; }
161
+ } catch { /* unknown = not confirmed (or confirmed only by the printed line) */ }
162
+ }
163
+ if (mergedPr !== null) {
164
+ const state = load();
165
+ const s = session(state, sid);
166
+ const ref = CARD.exec(headRef || branchOf() || "");
167
+ (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
168
+ save(state);
169
+ }
170
+ const moved = /kj\\s+hu\\s+move\\s+([A-Za-z0-9-]+)\\s+([a-z&-]+)/i.exec(String(input.command || ""));
171
+ // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
172
+ // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
173
+ if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
174
+ process.exit(0);
175
+ }
99
176
  const file = input.file_path || input.notebook_path;
100
177
  if (!["Write", "Edit", "MultiEdit", "NotebookEdit"].includes(tool) || !file) process.exit(0);
101
178
  const state = load();
@@ -124,7 +201,19 @@ const STOP_BODY = `#!/usr/bin/env node
124
201
  // after 3 unresolved blocks — a sentinel bug never bricks the session — and
125
202
  // the fail-open is recorded in the state. \`--status\` prints, never blocks.
126
203
  import { spawnSync } from "node:child_process";
204
+ import { readFileSync } from "node:fs";
205
+ import { homedir } from "node:os";
206
+ import { join } from "node:path";
127
207
  import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
208
+ // KJC-TSK-0774: when the turn ends green with cards closed, remind the user
209
+ // where to look — the board URL if it runs (pid file alive), else how to start it.
210
+ const boardHint = () => {
211
+ const home = process.env.KARAJAN_HOME || process.env.KJ_HOME || join(homedir(), ".karajan");
212
+ try {
213
+ process.kill(Number(readFileSync(join(home, "hu-board.pid"), "utf8").trim()), 0);
214
+ return "http://localhost:" + (process.env.KJ_BOARD_PORT || "4000") + "/#governance";
215
+ } catch { return "no esta arrancado: kj board start (luego http://localhost:4000/#governance)"; }
216
+ };
128
217
  if (process.argv.includes("--status")) {
129
218
  const st = load();
130
219
  const branch = branchOf();
@@ -168,9 +257,14 @@ process.stdin.on("end", () => {
168
257
  const v = violations(s, branchOf());
169
258
  if (!v.length) {
170
259
  s.blocks = 0;
260
+ const notes = [];
261
+ if ((s.escapes || []).length) notes.push("kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status.");
262
+ if ((s.closed_cards || []).length) {
263
+ notes.push("kj sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
264
+ s.closed_cards = [];
265
+ }
171
266
  save(state);
172
- if ((s.escapes || []).length)
173
- console.log(JSON.stringify({ systemMessage: "kj sentinel: esta sesion uso " + s.escapes.length + " escape(s): " + s.escapes.join(", ") + " — decision registrada; detalle en kj sentinel status." }));
267
+ if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\\n") }));
174
268
  process.exit(0);
175
269
  }
176
270
  s.blocks = (s.blocks || 0) + 1;
@@ -194,10 +288,11 @@ const PRETOOL_BODY = `#!/usr/bin/env node
194
288
  // before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
195
289
  // remediation); read-only tools are never wired here; every honored escape
196
290
  // 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";
291
+ import { dirname, join, relative, resolve } from "node:path";
292
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
199
293
  import { spawnSync } from "node:child_process";
200
- import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape } from "./sentinel-lib.mjs";
294
+ import { homedir } from "node:os";
295
+ import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
201
296
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
202
297
  const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
203
298
  const PUSH = /\\bgit\\s+push\\b/;
@@ -259,12 +354,139 @@ process.stdin.on("end", () => {
259
354
  if (tok === name + "=1") found = true;
260
355
  }
261
356
  if (!found) return false;
262
- for (const c of CMD_TEXT) {
263
- if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT) return false;
264
- if (c === "$" || c === "(" || c === ")") return false;
357
+ // KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
358
+ // mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
359
+ // comillas dobles $ y backtick siguen expandiendo (se rechazan); en
360
+ // comillas simples todo es literal. Y si el escape esta pero se ignora,
361
+ // SE DICE: un escape ignorado en silencio era el bug.
362
+ const bad = escSimple();
363
+ if (bad === null) return true;
364
+ if (!escNoted.has(name)) {
365
+ escNoted.add(name);
366
+ console.error("kj sentinel: " + name + "=1 presente pero IGNORADO — el escape solo vale en un comando simple y este contiene " + JSON.stringify(bad) + " fuera de comillas simples (sin ; | & $ ( ) backtick ni salto de linea: parte el comando o usa -F fichero)");
265
367
  }
266
- return true;
368
+ return false;
267
369
  };
370
+ const escNoted = new Set();
371
+ const escSimple = () => {
372
+ let q = null;
373
+ for (let i = 0; i < CMD_TEXT.length; i += 1) {
374
+ const c = CMD_TEXT[i];
375
+ if (q === "'") { if (c === "'") q = null; continue; }
376
+ if (q === '"') {
377
+ if (c === '"') q = null;
378
+ else if (c === "$" || c === ESC_BT) return c;
379
+ else if (c === "\\\\") i += 1;
380
+ continue;
381
+ }
382
+ if (c === "\\\\") { i += 1; continue; }
383
+ if (c === "'" || c === '"') { q = c; continue; }
384
+ if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
385
+ }
386
+ return q === null ? null : "comilla sin cerrar";
387
+ };
388
+ // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
389
+ // run under an account other than the one THIS CLONE declared (the incident:
390
+ // a gh comment without switch went out as the client account). Deny BEFORE
391
+ // the damage with the literal remedy; never auto-switch; undeclared = closed.
392
+ if (tool === "Bash" && CMD_TEXT.length > 0) {
393
+ const idFile = join(ROOT, ".karajan", "identity.local.yml");
394
+ const idLine = (key) => {
395
+ if (!existsSync(idFile)) return null;
396
+ const line = readFileSync(idFile, "utf8").split(ESC_NL).find((l) => l.startsWith(key + ":"));
397
+ const v = line ? line.slice(key.length + 1).trim() : "";
398
+ return v.length > 0 ? v : null;
399
+ };
400
+ const declaredGh = idLine("gh_user");
401
+ const declaredEmail = idLine("git_email");
402
+ const ghHostsPath = join(process.env.GH_CONFIG_DIR || join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh"), "hosts.yml");
403
+ const activeGh = () => {
404
+ if (!existsSync(ghHostsPath)) return null;
405
+ const lines = readFileSync(ghHostsPath, "utf8").split(ESC_NL);
406
+ const start = lines.findIndex((l) => l.startsWith("github.com:"));
407
+ if (start < 0) return null;
408
+ for (let k = start + 1; k < lines.length; k += 1) {
409
+ if (lines[k].length > 0 && lines[k][0] !== " " && lines[k][0] !== ESC_TAB) break;
410
+ const t = lines[k].trim();
411
+ if (t.startsWith("user:")) { const u = t.slice(5).trim(); return u.length > 0 ? u : null; }
412
+ }
413
+ return null;
414
+ };
415
+ const words = (seg) => seg.trim().split(" ").filter((w) => w.length > 0 && w !== ESC_TAB);
416
+ 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); };
417
+ // Wrapper shells hide the payload in a string (review catch): scan inside.
418
+ const WRAPPERS =["sh", "bash", "zsh", "dash", "eval", "env", "xargs", "sudo", "nohup", "time", "exec"];
419
+ let switchedTo = null;
420
+ const problems = [];
421
+ // $( and backticks are separators too (review catch): their payload is its own segment.
422
+ const SEPS = [";", "&&", "||", "|", "$(", String.fromCharCode(96), "(", ")"];
423
+ const bare = (w) => w.replaceAll(String.fromCharCode(39), "").replaceAll(String.fromCharCode(34), "");
424
+ 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)); };
425
+ const checkSeg = (ws, depth, raw) => {
426
+ if (ws.length === 0) return;
427
+ if (WRAPPERS.includes(ws[0])) { // wrapper args have arity (sudo -u bob): scan from the first gh/git/wrapper token (review catch)
428
+ const j = ws.findIndex((w, i) => i > 0 && ["gh", "git", ...WRAPPERS].includes(bare(w)));
429
+ if (j > 0) scan(ws.slice(j).map(bare).join(" "), depth + 1);
430
+ return;
431
+ }
432
+ if (ws[0] === "gh") {
433
+ if (ws[1] === "auth") {
434
+ if (ws[2] === "switch") { const u = ws.indexOf("--user"); if (u > 0 && ws[u + 1]) switchedTo = ws[u + 1]; }
435
+ return; // session commands ARE the remedy — never blocked
436
+ }
437
+ if (!declaredGh) { problems.push("gh without a declared identity for this clone — run: kj identity set"); return; }
438
+ const eff = switchedTo || activeGh();
439
+ 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 + " && ...");
440
+ return;
441
+ }
442
+ if (ws[0] === "git") {
443
+ // Global options (-C, -c, --git-dir, --work-tree...) are NOT re-implemented: they
444
+ // are forwarded verbatim to git var, so git resolves identity exactly as the real
445
+ // command would (review catches: -C cumulative, --git-dir, --opt=value forms).
446
+ const TWO = ["-c", "-C", "--git-dir", "--work-tree", "--namespace", "--super-prefix", "--exec-path", "--config-env"];
447
+ let k = 1;
448
+ while (k < ws.length && ws[k].startsWith("-")) k += TWO.includes(ws[k]) && ws[k + 1] ? 2 : 1;
449
+ const globals = ws.slice(1, k).map(bare);
450
+ const sub = ws[k];
451
+ if (sub === "push") { // push authenticates with the gh session, not user.email (review catch)
452
+ if (!declaredGh) { problems.push("git push without a declared identity for this clone — run: kj identity set"); return; }
453
+ const eff = switchedTo || activeGh();
454
+ 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 + " && ...");
455
+ return;
456
+ }
457
+ // IDN-B2: anything that authors or rewrites commits carries an identity (review catch).
458
+ if (!["commit", "tag", "merge", "rebase", "cherry-pick", "am", "revert"].includes(sub)) return; // am: the COMMITTER is still this user
459
+ if (!declaredEmail) { problems.push("git " + sub + " without a declared identity for this clone — run: kj identity set"); return; }
460
+ // Let GIT resolve author and committer under the command's own environment
461
+ // (review catch: user.email, -c, GIT_*_EMAIL, EMAIL, GIT_CONFIG_*, includeIf are
462
+ // its business): git var is the single source of truth.
463
+ const assigns = raw.slice(0, raw.length - ws.length).filter((w) => w.indexOf("=") > 0);
464
+ const env = Object.assign({}, process.env);
465
+ for (const w of assigns) env[w.slice(0, w.indexOf("="))] = bare(w.slice(w.indexOf("=") + 1));
466
+ const ident = (v) => {
467
+ const r = spawnSync("git", [...globals, "var", v], { cwd: ROOT, encoding: "utf8", env });
468
+ const s = r.status === 0 ? String(r.stdout) : "";
469
+ const a = s.indexOf("<");
470
+ const b = s.indexOf(">");
471
+ return a >= 0 && b > a ? s.slice(a + 1, b) : null;
472
+ };
473
+ const ai = ws.findIndex((w) => w.startsWith("--author"));
474
+ const authorRaw = ai < 0 ? null : ws[ai].includes("=") ? ws[ai].slice(ws[ai].indexOf("=") + 1) : ws.slice(ai + 1).join(" ");
475
+ const authorMail = authorRaw === null ? null : bare(authorRaw.includes("<") ? authorRaw.slice(authorRaw.indexOf("<") + 1, authorRaw.indexOf(">")) : authorRaw.trim());
476
+ const mails = [authorMail ?? ident("GIT_AUTHOR_IDENT"), ident("GIT_COMMITTER_IDENT")]; // --author overrides the author (review catch); the committer is always git's
477
+ const offender = mails.find((m) => m !== null && m !== declaredEmail) ?? (mails[0] === null ? "none" : null);
478
+ 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 + ")");
479
+ }
480
+ };
481
+ scan(CMD_TEXT, 0);
482
+ if (problems.length > 0) {
483
+ if (escOn("KJ_ALLOW_IDENTITY")) { recordEscape(sid, "KJ_ALLOW_IDENTITY", tool); }
484
+ else {
485
+ 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)");
486
+ process.exit(2);
487
+ }
488
+ }
489
+ }
268
490
  // MONO-0 (KJC-TSK-0737, ADR 0002): cada sesion muta solo SU worktree;
269
491
  // otro carril del MISMO repo se deniega ANTES del dano. Lectura libre.
270
492
  const laneOf = (p) => {
@@ -475,6 +697,23 @@ process.stdin.on("end", () => {
475
697
  }
476
698
  if (tool === "Bash") {
477
699
  const cmd = String(input.command || "");
700
+ // KJC-TSK-0765 board-sync: a merge leaves its card PENDING in the tracker
701
+ // and nothing advances (commit, new PR, another merge; push and the end of
702
+ // the turn go through violations()) until the card is moved. The board is
703
+ // true at all times or the method stops — never a rule in the agent's memory.
704
+ const MERGE = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/;
705
+ 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)
706
+ const mergeM = cmd.match(MERGE);
707
+ const pend = pendingMoves(load().sessions?.[sid]);
708
+ if (pend.length > 0 && (mergeM || ADVANCE.test(cmd))) {
709
+ if (escOn("KJ_ALLOW_BOARD")) { recordEscape(sid, "KJ_ALLOW_BOARD", tool); }
710
+ else {
711
+ 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)");
712
+ process.exit(2);
713
+ }
714
+ }
715
+ // (the pending entry itself is recorded by the PostToolUse hook, only once
716
+ // gh confirms "merged pull request #N" — a failed merge never blocks)
478
717
  if (PUBLISH.test(cmd)) {
479
718
  if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
480
719
  const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
@@ -486,7 +725,15 @@ process.stdin.on("end", () => {
486
725
  process.exit(2);
487
726
  }
488
727
  } else if (PUSH.test(cmd)) {
489
- const v = violations(load().sessions?.[sid], branchOf());
728
+ // KJC-BUG-0147: the board escape covers the push too — a pending move
729
+ // blocks the push like it blocks the commit, and the SAME conscious,
730
+ // recorded escape opens both (multi-PR cards are a legitimate plan).
731
+ const sess = load().sessions?.[sid];
732
+ let v = violations(sess, branchOf());
733
+ if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
734
+ recordEscape(sid, "KJ_ALLOW_BOARD", tool);
735
+ v = violations({ ...sess, pending_moves: [] }, branchOf());
736
+ }
490
737
  if (v.length) {
491
738
  console.error("kj sentinel: git push con el metodo en rojo:\\n" + v.map((x) => "- " + x).join("\\n") + "\\nResuelve antes de empujar. Estado: kj sentinel status");
492
739
  process.exit(2);
@@ -536,6 +783,11 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
536
783
  { event: "PreToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "pretooluse-sentinel.mjs" },
537
784
  { event: "PreToolUse", matcher: "Bash", script: "pretooluse-sentinel.mjs" },
538
785
  { event: "PostToolUse", matcher: "Write|Edit|MultiEdit|NotebookEdit", script: "posttooluse.mjs" },
786
+ // KJC-TSK-0765 board-sync: the merge (Bash) and the tracker move (MCP
787
+ // update_card) are observed AFTER the fact — found live: with only the
788
+ // edit matchers wired, the board-sync recorder never ran in production.
789
+ { event: "PostToolUse", matcher: "Bash", script: "posttooluse.mjs" },
790
+ { event: "PostToolUse", matcher: "mcp__.*__update_card", script: "posttooluse.mjs" },
539
791
  { event: "Stop", script: "stop.mjs" },
540
792
  ],
541
793
  });
@@ -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
+ }