karajan-code 4.21.0 → 4.23.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 (54) hide show
  1. package/README.md +6 -3
  2. package/package.json +5 -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/auth.js +29 -1
  8. package/packages/hu-board/src/routes/governance.js +140 -0
  9. package/packages/hu-board/src/server.js +2 -0
  10. package/scripts/install.js +4 -3
  11. package/scripts/postinstall.js +4 -3
  12. package/scripts/toml-value.js +18 -0
  13. package/src/audit/basal-cost.js +4 -0
  14. package/src/audit/dead-exports.js +52 -1
  15. package/src/audit/deterministic-summary.js +18 -3
  16. package/src/audit/member-reachability.js +158 -0
  17. package/src/audit/osv-findings.js +1 -0
  18. package/src/checks/ai-trash.js +1 -1
  19. package/src/checks/mcp-health.js +1 -1
  20. package/src/checks/native-build.js +2 -2
  21. package/src/checks/release-check.js +62 -2
  22. package/src/claims/cross-check.js +86 -0
  23. package/src/claims/extract.js +56 -0
  24. package/src/claims/turn.js +54 -0
  25. package/src/cli/advanced-commands.js +1 -1
  26. package/src/cli/register-meta.js +66 -5
  27. package/src/commands/claims.js +73 -0
  28. package/src/commands/hu.js +3 -1
  29. package/src/commands/init.js +1 -1
  30. package/src/commands/policy.js +84 -3
  31. package/src/commands/privacy.js +6 -2
  32. package/src/commands/resume.js +4 -0
  33. package/src/commands/review-gate.js +34 -9
  34. package/src/commands/steward.js +146 -0
  35. package/src/config/defaults.js +6 -1
  36. package/src/environment/playbook.js +2 -1
  37. package/src/guards/duplicate-members.js +86 -0
  38. package/src/harden/sentinel-hooks.js +188 -22
  39. package/src/harden/workflow-engine.js +8 -2
  40. package/src/harden/workflow-templates.js +38 -1
  41. package/src/policy/exceptions.js +14 -4
  42. package/src/policy/report.js +99 -0
  43. package/src/privacy/scan.js +23 -1
  44. package/src/review/card-first.js +4 -1
  45. package/src/review/one-shot-review.js +10 -4
  46. package/src/review/parser.js +9 -0
  47. package/src/review/sonar-pregate.js +38 -3
  48. package/src/review/tests-with-code.js +12 -1
  49. package/src/review/unparseable-verdict.js +50 -0
  50. package/src/roles/audit-role.js +19 -1
  51. package/src/sonar/scanner.js +34 -8
  52. package/src/steward/invariants.js +190 -0
  53. package/src/steward/phantom-coverage.js +137 -0
  54. package/src/steward/proposed-work.js +68 -0
@@ -52,7 +52,8 @@ const playbookBody = (stateBackend, boardName) => `# Karajan method (v4)
52
52
 
53
53
  You are the orchestrator; Karajan governs. A task is DONE when its
54
54
  done-statement is literally true, the full suite is green, and every commit
55
- carries a cross-AI verdict.
55
+ carries a cross-AI verdict. A card closed? Point your user at the board
56
+ (\`kj board start\` → http://localhost:4000/#governance): that is where to look.
56
57
 
57
58
  Invariants (the git gates enforce these — they are not suggestions):
58
59
 
@@ -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
+ }
@@ -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));
@@ -75,9 +75,24 @@ export const violations = (s, branch) => {
75
75
  if (!s || !(s.edited_sources || []).length) return v;
76
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");
77
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)");
78
- if (!(s.edited_tests || []).length) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
78
+ if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
79
79
  return v;
80
80
  };
81
+ // KJC-TSK-0795 AC1: deleting code adds no behavior to test — a session whose
82
+ // source edits only REMOVED lines owes no test. Unreadable git = conservative
83
+ // (the violation stands): a gate must never stand down on a shrug.
84
+ const sessionAddsCode = (s) => {
85
+ const files = s.edited_sources || [];
86
+ // _git builds a shell string: any path outside a strict charset stays out of
87
+ // it — and the gate stays ARMED for it (conservative, and injection-proof).
88
+ if (files.some((f) => !/^[A-Za-z0-9._/-]+$/.test(f))) return true;
89
+ const stat = _git("diff HEAD --numstat -- " + files.join(" "), ROOT);
90
+ if (!stat) return true;
91
+ return stat.split(String.fromCharCode(10)).some((l) => {
92
+ const a = l.trim().replaceAll(String.fromCharCode(9), " ").split(" ").filter(Boolean)[0];
93
+ return a === "-" || Number(a) > 0;
94
+ });
95
+ };
81
96
  export const recordEscape = (sid, escape, tool) => {
82
97
  const state = load();
83
98
  const s = session(state, sid);
@@ -85,6 +100,18 @@ export const recordEscape = (sid, escape, tool) => {
85
100
  if (!s.escapes.includes(escape)) s.escapes.push(escape);
86
101
  (state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
87
102
  save(state);
103
+ // GOV-F (KJC-TSK-0768): el escape es una excepcion consciente — entra en el
104
+ // decision log hash-encadenado (kj policy seal), no solo en este estado.
105
+ // Best-effort CON aviso: fail-closed aqui encerraria la sesion justo cuando
106
+ // kj no carga, que es cuando mas se usa el escape (restaurar con git).
107
+ try {
108
+ const args = ["policy", "seal", "--escape", escape];
109
+ if (tool) args.push("--tool", String(tool));
110
+ const r = spawnSync("kj", args, { cwd: ROOT, encoding: "utf8" });
111
+ 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");
112
+ } catch (e) {
113
+ console.error("kj sentinel: escape " + escape + " usado pero NO sellado en el decision log: " + e.message);
114
+ }
88
115
  };
89
116
  `;
90
117
 
@@ -112,9 +139,11 @@ process.stdin.on("end", () => {
112
139
  const clearPending = (cardId) => {
113
140
  const state = load();
114
141
  const s = session(state, sid);
115
- const before = (s.pending_moves || []).length;
116
142
  s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
117
- if (s.pending_moves.length !== before) save(state);
143
+ // KJC-TSK-0774: a card closed this turn is what the Stop hook points the
144
+ // user at when the focus comes back (the board is where to look).
145
+ if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
146
+ save(state);
118
147
  };
119
148
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
120
149
  // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
@@ -122,7 +151,21 @@ process.stdin.on("end", () => {
122
151
  // the first real update_card did not clear its pending entry).
123
152
  const cid = /\\\\?"cardId\\\\?"\\s*:\\s*\\\\?"([A-Za-z0-9-]+)\\\\?"/.exec(text);
124
153
  const st = /\\\\?"status\\\\?"\\s*:\\s*\\\\?"([^"\\\\]+)\\\\?"/.exec(text);
125
- if (cid && st && CLOSING.includes(st[1].toLowerCase())) clearPending(cid[1].toUpperCase());
154
+ if (cid && st && CLOSING.includes(st[1].toLowerCase())) {
155
+ clearPending(cid[1].toUpperCase());
156
+ // KJC-BUG-0157: a pending whose card-id NEVER existed (branch named
157
+ // before the tracker confirmed the create) could not be cleared by any
158
+ // verified route. The pending's TRUE identity is its PR: a CONFIRMED
159
+ // closing update whose request names that PR clears it too — still a
160
+ // real tracker call seen by this hook, never a promise in prose.
161
+ const prIn = Number(input?.updates?.pipelineStatus?.prCreated?.prNumber);
162
+ if (Number.isFinite(prIn) && prIn > 0) {
163
+ const state = load();
164
+ const s = session(state, sid);
165
+ s.pending_moves = (s.pending_moves || []).filter((p) => Number(p.pr) !== prIn);
166
+ save(state);
167
+ }
168
+ }
126
169
  process.exit(0);
127
170
  }
128
171
  if (tool === "Bash") {
@@ -133,20 +176,30 @@ process.stdin.on("end", () => {
133
176
  const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
134
177
  const said = /merged pull request #(\\d+)/i.exec(text);
135
178
  let mergedPr = said ? Number(said[1]) : null;
136
- if (mergedPr === null && mergeCmd) {
137
- const args = ["pr", "view", ...(mergeCmd[2] ? [mergeCmd[2]] : []), "--json", "number,state"];
179
+ // KJC-BUG-0148: the card belongs to the PR's HEAD branch, not to whatever
180
+ // branch the working tree happens to be on (found live: a merge issued
181
+ // from another card's lane pinned the pending move on the wrong card).
182
+ let headRef = null;
183
+ if (mergeCmd || said) {
184
+ const n = said ? said[1] : mergeCmd[2];
185
+ const args = ["pr", "view", ...(n ? [n] : []), "--json", "number,state,headRefName"];
138
186
  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 */ }
187
+ try {
188
+ const v = JSON.parse(r.status === 0 ? r.stdout : "null");
189
+ if (v && v.state === "MERGED") { mergedPr = Number(v.number); headRef = v.headRefName || null; }
190
+ } catch { /* unknown = not confirmed (or confirmed only by the printed line) */ }
140
191
  }
141
192
  if (mergedPr !== null) {
142
193
  const state = load();
143
194
  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() });
195
+ const ref = CARD.exec(headRef || branchOf() || "");
196
+ (s.pending_moves ||= []).push({ card: ref ? ref[0].toUpperCase() : null, pr: mergedPr, at: Date.now(), ...(headRef ? {} : { head: "unknown" }) });
146
197
  save(state);
147
198
  }
148
199
  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());
200
+ // "not found" counts as failure (KJC-BUG-0154): a move that moved nothing must not
201
+ // clear a pending — that would discard a LEGITIMATE one without touching the tracker.
202
+ if (moved && CLOSING.includes(moved[2].toLowerCase()) && !/error|fail|not found/i.test(text)) clearPending(moved[1].toUpperCase());
150
203
  process.exit(0);
151
204
  }
152
205
  const file = input.file_path || input.notebook_path;
@@ -177,7 +230,19 @@ const STOP_BODY = `#!/usr/bin/env node
177
230
  // after 3 unresolved blocks — a sentinel bug never bricks the session — and
178
231
  // the fail-open is recorded in the state. \`--status\` prints, never blocks.
179
232
  import { spawnSync } from "node:child_process";
233
+ import { readFileSync } from "node:fs";
234
+ import { homedir } from "node:os";
235
+ import { join } from "node:path";
180
236
  import { load, save, branchOf, violations, ROOT } from "./sentinel-lib.mjs";
237
+ // KJC-TSK-0774: when the turn ends green with cards closed, remind the user
238
+ // where to look — the board URL if it runs (pid file alive), else how to start it.
239
+ const boardHint = () => {
240
+ const home = process.env.KARAJAN_HOME || process.env.KJ_HOME || join(homedir(), ".karajan");
241
+ try {
242
+ process.kill(Number(readFileSync(join(home, "hu-board.pid"), "utf8").trim()), 0);
243
+ return "http://localhost:" + (process.env.KJ_BOARD_PORT || "4000") + "/#governance";
244
+ } catch { return "no esta arrancado: kj board start (luego http://localhost:4000/#governance)"; }
245
+ };
181
246
  if (process.argv.includes("--status")) {
182
247
  const st = load();
183
248
  const branch = branchOf();
@@ -194,7 +259,7 @@ process.stdin.on("data", (d) => { raw += d; });
194
259
  process.stdin.on("end", () => {
195
260
  try {
196
261
  if (process.env.KJ_SENTINEL_OFF === "1") process.exit(0);
197
- const { session_id: sid = "default" } = JSON.parse(raw);
262
+ const { session_id: sid = "default", transcript_path: transcript = null } = JSON.parse(raw);
198
263
  const state = load();
199
264
  // Tamper check runs regardless of session state: shell indirection leaves
200
265
  // no edit-tool record, but it cannot survive \`kj sentinel verify\`, whose
@@ -216,14 +281,38 @@ process.stdin.on("end", () => {
216
281
  process.exit(2);
217
282
  }
218
283
  state.tamper_blocks = 0;
284
+ // CLM-B (KJC-TSK-0802, claims-with-evidence ADR): the hard data of the turn's
285
+ // final message, crossed against that turn's outputs. It runs even without a
286
+ // tracked session — a turn with no edits still ends with a final message. The
287
+ // hook carries no policy: kj reads method_gates.claims and answers 0 (off,
288
+ // clean, or warn) or 2 (a datum DENIED by its own source, in block mode). A
289
+ // missing kj or an errored spawn fails open.
290
+ let claimsNote = "";
291
+ let claimsBlock = null;
292
+ if (transcript) {
293
+ const c = spawnSync("kj", ["claims", "gate", "--transcript", transcript], { cwd: ROOT, encoding: "utf8" });
294
+ if (!c.error && c.status === 2) claimsBlock = "claims: un dato del mensaje final esta DESMENTIDO por las salidas de este turno — verificalo o marcalo como no comprobado. Detalle: kj claims check --transcript " + transcript;
295
+ else if (!c.error && c.status === 0 && (c.stderr || "").trim()) claimsNote = "kj claims: " + c.stderr.trim().split(String.fromCharCode(10))[0] + " — datos sin respaldo en este turno (informa, no bloquea).";
296
+ }
219
297
  const s = state.sessions?.[sid];
220
- if (!s) process.exit(0);
298
+ if (!s) {
299
+ if (claimsBlock) { console.error("kj sentinel: el turno NO puede terminar:" + String.fromCharCode(10) + "- " + claimsBlock); process.exit(2); }
300
+ if (claimsNote) console.log(JSON.stringify({ systemMessage: claimsNote }));
301
+ process.exit(0);
302
+ }
221
303
  const v = violations(s, branchOf());
304
+ if (claimsBlock) v.push(claimsBlock);
222
305
  if (!v.length) {
223
306
  s.blocks = 0;
307
+ const notes = [];
308
+ if (claimsNote) notes.push(claimsNote);
309
+ 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.");
310
+ if ((s.closed_cards || []).length) {
311
+ notes.push("kj sentinel: card(s) cerrada(s) en este turno: " + s.closed_cards.join(", ") + " — el board las muestra: " + boardHint());
312
+ s.closed_cards = [];
313
+ }
224
314
  save(state);
225
- if ((s.escapes || []).length)
226
- 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." }));
315
+ if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\\n") }));
227
316
  process.exit(0);
228
317
  }
229
318
  s.blocks = (s.blocks || 0) + 1;
@@ -251,7 +340,7 @@ import { dirname, join, relative, resolve } from "node:path";
251
340
  import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
252
341
  import { spawnSync } from "node:child_process";
253
342
  import { homedir } from "node:os";
254
- import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
343
+ import { CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, recordEscape, pendingMoves, pendingText } from "./sentinel-lib.mjs";
255
344
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
256
345
  const PUBLISH = /\\bnpm\\s+publish\\b|\\bfirebase\\s+deploy\\b|\\bgh\\s+release\\s+create\\b/;
257
346
  const PUSH = /\\bgit\\s+push\\b/;
@@ -260,7 +349,7 @@ let raw = "";
260
349
  process.stdin.on("data", (d) => { raw += d; });
261
350
  process.stdin.on("end", () => {
262
351
  try {
263
- const { session_id: sid = "default", tool_name: tool, tool_input: input = {} } = JSON.parse(raw);
352
+ const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
264
353
  // Self-protection (KJC-TSK-0715) rules run BEFORE any escape, including
265
354
  // KJ_SENTINEL_OFF: the sentinel is not dismantled from inside a session —
266
355
  // only the human, editing outside it.
@@ -313,11 +402,36 @@ process.stdin.on("end", () => {
313
402
  if (tok === name + "=1") found = true;
314
403
  }
315
404
  if (!found) return false;
316
- for (const c of CMD_TEXT) {
317
- if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT) return false;
318
- if (c === "$" || c === "(" || c === ")") return false;
405
+ // KJC-BUG-0147: lo que va ENTRE COMILLAS no encadena comandos — un
406
+ // mensaje "fix(x): ... (KJC-TSK-1)" es un comando simple. Dentro de
407
+ // comillas dobles $ y backtick siguen expandiendo (se rechazan); en
408
+ // comillas simples todo es literal. Y si el escape esta pero se ignora,
409
+ // SE DICE: un escape ignorado en silencio era el bug.
410
+ const bad = escSimple();
411
+ if (bad === null) return true;
412
+ if (!escNoted.has(name)) {
413
+ escNoted.add(name);
414
+ 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)");
319
415
  }
320
- return true;
416
+ return false;
417
+ };
418
+ const escNoted = new Set();
419
+ const escSimple = () => {
420
+ let q = null;
421
+ for (let i = 0; i < CMD_TEXT.length; i += 1) {
422
+ const c = CMD_TEXT[i];
423
+ if (q === "'") { if (c === "'") q = null; continue; }
424
+ if (q === '"') {
425
+ if (c === '"') q = null;
426
+ else if (c === "$" || c === ESC_BT) return c;
427
+ else if (c === "\\\\") i += 1;
428
+ continue;
429
+ }
430
+ if (c === "\\\\") { i += 1; continue; }
431
+ if (c === "'" || c === '"') { q = c; continue; }
432
+ if (c === ";" || c === "|" || c === "&" || c === ESC_NL || c === ESC_BT || c === "$" || c === "(" || c === ")") return c;
433
+ }
434
+ return q === null ? null : "comilla sin cerrar";
321
435
  };
322
436
  // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
323
437
  // run under an account other than the one THIS CLONE declared (the incident:
@@ -615,6 +729,37 @@ process.stdin.on("end", () => {
615
729
  }
616
730
  }
617
731
  if (EDIT_TOOLS.includes(tool)) {
732
+ // STW-C (KJC-TSK-0791): the sweep's verdict has consequence where work
733
+ // STARTS. Inform ALWAYS, once per session and impossible to miss; BLOCK
734
+ // almost never — only security and persistent red main, and only when
735
+ // the project adopted it (method_gates.steward: block). Unknown is never
736
+ // treated as broken: nothing is asserted that is not known. No report =
737
+ // the project does not use the Steward yet — silence, adoption is explicit.
738
+ try {
739
+ const rep = JSON.parse(readFileSync(join(ROOT, ".karajan", "steward", "report.json"), "utf8"));
740
+ const bad = (rep.invariants || []).filter((r) => r.verdict === "broken");
741
+ const stale = (rep.invariants || []).filter((r) => r.verdict === "unknown");
742
+ if (bad.length || stale.length) {
743
+ const hard = bad.filter((r) => r.id === "vulnerable-deps" || r.id === "main-ci");
744
+ let blockOn = false;
745
+ try { blockOn = /steward:\\s*["']?block/.test(readFileSync(join(ROOT, ".karajan", "kj.config.yml"), "utf8")); } catch { /* no config — no block */ }
746
+ if (hard.length && blockOn) {
747
+ if (escOn("KJ_ALLOW_STEWARD")) { recordEscape(sid, "KJ_ALLOW_STEWARD", tool); }
748
+ else {
749
+ console.error("kj sentinel: steward — el estado del proyecto bloquea empezar (barrido " + rep.sweptAt + "):" + String.fromCharCode(10) + hard.map((r) => "- " + r.id + ": " + (r.evidence || "") + " -> " + (r.remedy || r.renew || "kj steward sweep")).join(String.fromCharCode(10)) + String.fromCharCode(10) + "(KJ_ALLOW_STEWARD=1 = excepcion consciente de esta sesion, queda registrada)");
750
+ process.exit(2);
751
+ }
752
+ }
753
+ const st = load();
754
+ const s = session(st, sid);
755
+ if (!s.steward_informed) {
756
+ s.steward_informed = true;
757
+ save(st);
758
+ const lines = [...bad.map((r) => "ROTO " + r.id + ": " + (r.remedy || r.evidence || "")), ...stale.map((r) => "desconocido " + r.id + " (no se afirma lo que no se sabe) — refresca: " + (r.renew || "kj steward sweep"))];
759
+ console.log(JSON.stringify({ systemMessage: "kj steward (barrido " + rep.sweptAt + "): " + lines.join(" | ") + " — detalle: .karajan/steward/report.md" }));
760
+ }
761
+ }
762
+ } catch { /* no report or unreadable — the Steward is not adopted here */ }
618
763
  const file = input.file_path || input.notebook_path;
619
764
  const rel = file ? relative(ROOT, String(file)).replaceAll("\\\\", "/") : "";
620
765
  if (rel && CODE.test(rel) && !TESTS.test(rel)) {
@@ -648,6 +793,19 @@ process.stdin.on("end", () => {
648
793
  }
649
794
  // (the pending entry itself is recorded by the PostToolUse hook, only once
650
795
  // gh confirms "merged pull request #N" — a failed merge never blocks)
796
+ // CLM-C (KJC-TSK-0803): a PR body outlives the turn — an invented figure
797
+ // there misleads every future reader. When the command writes one from a
798
+ // file, kj crosses ITS data against this turn's outputs; the hook carries
799
+ // no policy (kj reads method_gates.claims; off = this check does not exist).
800
+ const bodyFile = /\\bgh\\s+pr\\s+(?:create|edit)\\b/.test(cmd) ? /--body-file[= ]+("([^"]+)"|'([^']+)'|([^\\s"']+))/.exec(cmd) : null;
801
+ if (bodyFile && transcript) {
802
+ const body = bodyFile[2] || bodyFile[3] || bodyFile[4];
803
+ const g = spawnSync("kj", ["claims", "gate", "--transcript", transcript, "--file", body], { cwd: ROOT, encoding: "utf8" });
804
+ if (!g.error && g.status === 2) {
805
+ console.error("kj sentinel: claims — un dato del cuerpo de la PR esta DESMENTIDO por las salidas de este turno:\\n" + (g.stderr || "").trim() + "\\nVerificalo o marcalo como no comprobado antes de crear la PR. Detalle: kj claims check --transcript " + transcript + " --file " + body);
806
+ process.exit(2);
807
+ }
808
+ }
651
809
  if (PUBLISH.test(cmd)) {
652
810
  if (escOn("KJ_ALLOW_RELEASE")) { recordEscape(sid, "KJ_ALLOW_RELEASE", tool); process.exit(0); }
653
811
  const res = spawnSync("kj", ["release", "check", "--json"], { cwd: ROOT, encoding: "utf8" });
@@ -659,7 +817,15 @@ process.stdin.on("end", () => {
659
817
  process.exit(2);
660
818
  }
661
819
  } else if (PUSH.test(cmd)) {
662
- const v = violations(load().sessions?.[sid], branchOf());
820
+ // KJC-BUG-0147: the board escape covers the push too — a pending move
821
+ // blocks the push like it blocks the commit, and the SAME conscious,
822
+ // recorded escape opens both (multi-PR cards are a legitimate plan).
823
+ const sess = load().sessions?.[sid];
824
+ let v = violations(sess, branchOf());
825
+ if (v.length && pendingMoves(sess).length && escOn("KJ_ALLOW_BOARD")) {
826
+ recordEscape(sid, "KJ_ALLOW_BOARD", tool);
827
+ v = violations({ ...sess, pending_moves: [] }, branchOf());
828
+ }
663
829
  if (v.length) {
664
830
  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");
665
831
  process.exit(2);
@@ -12,8 +12,9 @@ import { join } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { dirname } from "node:path";
14
14
 
15
+ import { parse as parseYaml } from "yaml";
15
16
  import { upsertManagedBlock } from "../utils/managed-markers.js";
16
- import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
17
+ import { extraWorkflowsFor, mutationWorkflowFor, qualityWorkflowFor, policyWorkflowFor, stewardWorkflowFor, WORKFLOWS } from "./workflow-templates.js";
17
18
 
18
19
  // La versión del kj que corre harden — es la que se pinea en el fallback
19
20
  // npx del workflow de policy (jamás @latest en CI). Lectura LAZY: en el
@@ -85,7 +86,12 @@ export function installWorkflows({
85
86
  const policy = existsSync(join(projectDir, ".karajan", "policy.yml"))
86
87
  ? { file: "kj-policy.yml", blockId: "wf-policy", body: policyWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") }
87
88
  : null;
88
- const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : [])];
89
+ // STW-E (KJC-TSK-0793): the scheduled sweep is OPT-IN — the project says
90
+ // steward.action: true in its config; nothing is imposed by default.
91
+ let stewardOn = false;
92
+ try { stewardOn = parseYaml(readFileSync(join(projectDir, ".karajan", "kj.config.yml"), "utf8"))?.steward?.action === true; } catch { /* no config — no action */ }
93
+ const steward = stewardOn ? { file: "kj-steward.yml", blockId: "wf-steward", body: stewardWorkflowFor(pinned, ownPkg()?.name ?? "karajan-code") } : null;
94
+ const all = [...WORKFLOWS, ...(quality ? [quality] : []), ...extras, ...(mut ? [mut] : []), ...(policy ? [policy] : []), ...(steward ? [steward] : [])];
89
95
  const results = [];
90
96
  for (const wf of all) {
91
97
  const target = join(dir, wf.file);
@@ -186,7 +186,10 @@ export const SHRINK_BUDGET_WORKFLOW = [
186
186
  " env:",
187
187
  " BASE_REF: ${{ github.base_ref }}",
188
188
  " run: |",
189
- " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' || true)",
189
+ // KJC-TSK-0795 AC4 — measured in GREBLA: a 414-line warning that was almost
190
+ // all pnpm-lock.yaml. Generated files are weight nobody wrote: the generated
191
+ // gate applies the same exclusions kj's own repo applies.
192
+ " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' || true)",
190
193
  ' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
191
194
  ' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
192
195
  ' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
@@ -272,6 +275,40 @@ export function mutationWorkflowFor(language, pm = "npm") {
272
275
  return { file: "kj-mutation.yml", blockId: "wf-mutation", body };
273
276
  }
274
277
 
278
+ // STW-E (KJC-TSK-0793): the scheduled Steward sweep — OPT-IN via
279
+ // steward.action: true. Decay happens precisely on the days nobody looks, so
280
+ // the watcher cannot live inside a session: the Action runs where the repo
281
+ // lives, publishes the SAME report file every other mode writes (one source
282
+ // of truth), and a broken invariant never stops the report from publishing.
283
+ export function stewardWorkflowFor(kjVersion, pkgName = "karajan-code") {
284
+ return [
285
+ "name: Steward sweep",
286
+ "on:",
287
+ " schedule:",
288
+ ' - cron: "17 6 * * 1"',
289
+ " workflow_dispatch: {}",
290
+ "permissions:",
291
+ " contents: write",
292
+ "jobs:",
293
+ " sweep:",
294
+ " runs-on: ubuntu-latest",
295
+ " steps:",
296
+ ` - uses: ${PINNED_ACTIONS.checkout}`,
297
+ ` - uses: ${PINNED_ACTIONS.setupNode}`,
298
+ " with:",
299
+ ' node-version: "22"',
300
+ ` - run: npm install -g ${pkgName}@${kjVersion}`,
301
+ " - name: Sweep (a broken invariant must not stop the report)",
302
+ " env:",
303
+ " GH_TOKEN: ${{ github.token }}",
304
+ " run: kj steward sweep || true",
305
+ " - name: Publish the report and the proposed cards",
306
+ " run: |",
307
+ " git add .karajan/steward .karajan/plans 2>/dev/null || true",
308
+ ' git diff --cached --quiet || { git -c user.name="github-actions[bot]" -c user.email="41898282+github-actions[bot]@users.noreply.github.com" commit -m "chore(steward): sweep report [skip ci]" && git push; }',
309
+ ].join("\n");
310
+ }
311
+
275
312
  /** Conditional workflows: shrink-budget on strict, pack-smoke when publishable. */
276
313
  export function extraWorkflowsFor({ profile = "standard", publishable = false, pm = "npm" } = {}) {
277
314
  const extras = [];
@@ -24,13 +24,23 @@ function defaultIdentity(projectDir) {
24
24
  * @returns {{standing: object[], discarded: number}}
25
25
  */
26
26
  export function loadStandingExceptions(projectDir) {
27
+ const { records, discarded } = loadExceptionRecords(projectDir);
28
+ return { standing: records.filter((rec) => rec.scopeKind === "permanente"), discarded };
29
+ }
30
+
31
+ /**
32
+ * TODOS los registros del jsonl (permanentes y puntuales) con el mismo parse
33
+ * tolerante — el informe (PL-E) cuenta también las puntuales.
34
+ * @returns {{records: object[], discarded: number}}
35
+ */
36
+ export function loadExceptionRecords(projectDir) {
27
37
  let raw;
28
38
  try {
29
39
  raw = readFileSync(join(projectDir, ".karajan", "policy-exceptions.jsonl"), "utf8");
30
40
  } catch {
31
- return { standing: [], discarded: 0 };
41
+ return { records: [], discarded: 0 };
32
42
  }
33
- const standing = [];
43
+ const records = [];
34
44
  let discarded = 0;
35
45
  for (const line of raw.split("\n")) {
36
46
  if (!line.trim()) continue;
@@ -39,12 +49,12 @@ export function loadStandingExceptions(projectDir) {
39
49
  // JSON válido pero no-objeto (null, número…) es tan corrupto como el
40
50
  // que no parsea: se descarta contando (catch de codex, explícito).
41
51
  if (typeof rec !== "object" || rec === null) discarded += 1;
42
- else if (rec.scopeKind === "permanente") standing.push(rec);
52
+ else records.push(rec);
43
53
  } catch {
44
54
  discarded += 1;
45
55
  }
46
56
  }
47
- return { standing, discarded };
57
+ return { records, discarded };
48
58
  }
49
59
 
50
60
  function defaultAppend(projectDir, line) {
@@ -0,0 +1,99 @@
1
+ /**
2
+ * PL-E (KJC-TSK-0767) — el informe de policy, PURO (líneas del decision log +
3
+ * excepciones + policy + reloj ⇒ objeto determinista; cero I/O, cero LLM).
4
+ * Responde: ¿qué reglas avisan y cuánto (gana dientes con datos)?, ¿qué
5
+ * denegaciones siguen abiertas (la renuncia deja rastro)?, ¿qué concesiones
6
+ * viven, vencen o se renuevan (la renovación es señal)?
7
+ */
8
+ import { verifyDecisionChain } from "@karajan-family/governance";
9
+
10
+ const DAY_MS = 86_400_000;
11
+ const WARN_SIGNAL_MIN = 5;
12
+
13
+ /** enforcement/class de una regla según la policy cargada (null si no se sabe). */
14
+ function ruleMeta(policy, ruleId) {
15
+ if (!policy) return {};
16
+ if (ruleId.startsWith("defaults.")) return { enforcement: "deny", class: "security" };
17
+ const m = /^roles\.([^.]+)\.([^.]+)/.exec(ruleId);
18
+ const spec = m ? policy.roles?.[m[1]]?.[m[2]] : policy.invariants?.find((i) => i.id === ruleId);
19
+ if (!spec) return {};
20
+ return { enforcement: spec.enforcement || "warn", ...(spec.class ? { class: spec.class } : {}) };
21
+ }
22
+
23
+ function parseDecisions(lines) {
24
+ const records = [];
25
+ let discarded = 0;
26
+ for (const line of lines) {
27
+ try {
28
+ const rec = JSON.parse(line);
29
+ if (typeof rec === "object" && rec !== null) records.push(rec);
30
+ else discarded += 1;
31
+ } catch {
32
+ discarded += 1;
33
+ }
34
+ }
35
+ return { records, discarded };
36
+ }
37
+
38
+ // Abiertas = denies posteriores al último allow/exempt: el log acaba en
39
+ // rechazo sin que nada haya pasado después. Heurística declarada — el log
40
+ // no lleva rama ni autor, así que no distingue "arreglado" de "abandonado".
41
+ const lastResolvedIndex = (records) => records.findLastIndex((r) => r.decision === "allow" || r.decision === "exempt");
42
+
43
+ function tallyRules(records, policy, lastResolved) {
44
+ const rules = new Map();
45
+ const row = (id) => {
46
+ if (!rules.has(id)) rules.set(id, { rule_id: id, ...ruleMeta(policy, id), warns: 0, denies: 0, exempts: 0, open: 0 });
47
+ return rules.get(id);
48
+ };
49
+ // Un sello lleva un rule_id por FICHERO violador: se cuentan decisiones
50
+ // por regla, no ficheros — de ahí el Set por entrada.
51
+ const ids = (list) => new Set(list ?? []);
52
+ records.forEach((rec, i) => {
53
+ for (const id of ids(rec.warn_rule_ids)) row(id).warns += 1;
54
+ if (rec.decision === "deny") for (const id of ids(rec.rule_ids)) { row(id).denies += 1; if (i > lastResolved) row(id).open += 1; }
55
+ if (rec.decision === "exempt") for (const id of ids(rec.rule_ids)) row(id).exempts += 1;
56
+ });
57
+ return [...rules.values()].toSorted((a, b) => (b.denies + b.warns) - (a.denies + a.warns));
58
+ }
59
+
60
+ function tallyGrants(exceptionRecords, now, soonDays) {
61
+ const perm = exceptionRecords.filter((e) => e?.scopeKind === "permanente");
62
+ // Una caducidad ilegible (NaN) NO está viva: la excepción falla cerrada.
63
+ const alive = [];
64
+ const expired = [];
65
+ for (const e of perm) (Date.parse(e.expiresAt) > now.getTime() ? alive : expired).push(e);
66
+ const soon = alive.filter((e) => Date.parse(e.expiresAt) - now.getTime() <= soonDays * DAY_MS);
67
+ const counts = Map.groupBy(perm, (e) => e.rule_id);
68
+ const renewals = [...counts].filter(([, v]) => v.length >= 2).map(([rule_id, v]) => ({ rule_id, count: v.length }));
69
+ return { alive, expired, soon, renewals, point: exceptionRecords.length - perm.length };
70
+ }
71
+
72
+ function signalsFor(rules, grants) {
73
+ const out = [];
74
+ for (const g of grants.renewals) out.push(`[${g.rule_id}] concedida ${g.count} veces — una excepción que se renueva ya no es una excepción: es la política pidiendo cambio (PR a .karajan/policy.yml)`);
75
+ for (const r of rules) {
76
+ if (r.warns >= WARN_SIGNAL_MIN && r.denies === 0 && r.enforcement !== "deny") out.push(`[${r.rule_id}] ha avisado ${r.warns} veces y nunca ha bloqueado — decide: promover a enforcement=deny o retirarla; un aviso perpetuo es ruido`);
77
+ if (r.open > 0) out.push(`[${r.rule_id}] ${r.open} denegación(es) abierta(s) — el log acaba en rechazo: ¿arreglo pendiente, excepción por pedir, o renuncia?`);
78
+ }
79
+ for (const g of grants.soon) out.push(`[${g.rule_id}] vence ${g.expiresAt} — al vencer vuelve a bloquear sola; renueva por su cauce o deja que caduque`);
80
+ return out;
81
+ }
82
+
83
+ /**
84
+ * @returns {{chain: object, decisions: object, rules: object[], grants: object, signals: string[]}}
85
+ */
86
+ export function buildPolicyReport({ decisionLines = [], exceptionRecords = [], policy = null, now = new Date(), soonDays = 7 } = {}) {
87
+ const chain = decisionLines.length ? verifyDecisionChain(decisionLines) : { ok: true, length: 0 };
88
+ const { records, discarded } = parseDecisions(decisionLines);
89
+ const count = (d) => records.filter((r) => r.decision === d).length;
90
+ const chokepoints = Object.fromEntries([...Map.groupBy(records, (r) => r.chokepoint ?? "unknown")].map(([k, v]) => [k, v.length]));
91
+ const lastResolved = lastResolvedIndex(records);
92
+ const rules = tallyRules(records, policy, lastResolved);
93
+ const grants = tallyGrants(exceptionRecords, now, soonDays);
94
+ const decisions = {
95
+ total: records.length, discarded, allow: count("allow"), deny: count("deny"), exempt: count("exempt"),
96
+ open: records.filter((r, i) => r.decision === "deny" && i > lastResolved).length, chokepoints,
97
+ };
98
+ return { chain, decisions, rules, grants, signals: signalsFor(rules, grants) };
99
+ }