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,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
+ }
@@ -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
+ }
@@ -14,7 +14,10 @@ const LIVE_STATUSES = new Set(["pending", "running", "failed"]);
14
14
  // Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
15
15
  // KJC-TSK-0684 (optional middle segments) — tight enough to skip slugs.
16
16
  // Shared with the method report (MG-D): one pattern, one truth.
17
- export const CARD_REF_RE = /\b[a-z][a-z0-9]{1,9}(?:-[a-z][a-z0-9]{1,9})*-\d{1,6}\b/i;
17
+ // The lookahead keeps a version tail from reading as a card (KJC-BUG-0154):
18
+ // `chore/release-4.22.0` matched "release-4" at the dot's word boundary, and
19
+ // the board-sync gate then demanded moving a card that exists nowhere.
20
+ export const CARD_REF_RE = /\b[a-z][a-z0-9]{1,9}(?:-[a-z][a-z0-9]{1,9})*-\d{1,6}\b(?!\.\d)/i;
18
21
  const DEFAULT_EXEMPT_PREFIXES = ["chore/release-"];
19
22
 
20
23
  // Token-boundary match: BB-002 must not satisfy a branch that actually
@@ -12,9 +12,10 @@ import { createAgent } from "../agents/index.js";
12
12
  import { resolveRole } from "../config/role-resolver.js";
13
13
  import { buildReviewerPrompt } from "../prompts/reviewer.js";
14
14
  import { resolveReviewProfile } from "./profiles.js";
15
- import { parseMaybeJsonString } from "./parser.js";
15
+ import { parseMaybeJsonString, normalizeReviewPayload } from "./parser.js";
16
16
  import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
17
- import { saveVerdict } from "./verdict-store.js";
17
+ import { saveVerdict, diffHash } from "./verdict-store.js";
18
+ import { reportUnparseableVerdict } from "./unparseable-verdict.js";
18
19
  import { detectWorkspace } from "./workspace.js";
19
20
  import { isQuotaExhausted, candidateStatus, pickQuotaFallback, formatCandidateMenu } from "./reviewer-fallback.js";
20
21
 
@@ -103,9 +104,14 @@ export async function runOneShotReview({
103
104
  throw new Error(`reviewer ${activeReviewer} failed: ${result?.error || "no output"}`);
104
105
  }
105
106
 
106
- const parsed = parseMaybeJsonString(result.output);
107
+ // KJC-BUG-0146, second half: parseMaybeJsonString only PARSES — it returns whatever JSON came
108
+ // back, wrapper and all. Yesterday's fix taught normalizeReviewPayload to unwrap {ok, result},
109
+ // but this path never called it, so the bug survived with a green test on the wrong function.
110
+ // A test can only prove the code it actually exercises.
111
+ const parsed = normalizeReviewPayload(parseMaybeJsonString(result.output));
107
112
  if (!parsed || typeof parsed.approved !== "boolean") {
108
- throw new Error(`reviewer ${activeReviewer} returned no parseable verdict`);
113
+ // KJC-BUG-0146: keep the answer. Throwing it away is what left eight occurrences undiagnosed.
114
+ throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
109
115
  }
110
116
 
111
117
  return saveVerdict(projectDir, diff, {
@@ -33,10 +33,19 @@ export function normalizeReviewPayload(payload) {
33
33
  if (isReviewPayload(payload)) return payload;
34
34
  if (Array.isArray(payload)) return findReviewInArray(payload);
35
35
 
36
+ // KJC-BUG-0146 — the verdict often arrives WRAPPED by the CLI that produced it:
37
+ // {"ok":true,"result":{approved,...}}. Only the string form was unwrapped, so a
38
+ // perfectly good approval from codex was thrown away as "no parseable verdict"
39
+ // eight times in three days. The evidence came from the raw answer this bug's
40
+ // first fix started saving.
36
41
  if (typeof payload.result === "string") {
37
42
  const parsedResult = parseMaybeJsonString(payload.result);
38
43
  if (parsedResult?.approved !== undefined) return parsedResult;
39
44
  }
45
+ if (payload.result && typeof payload.result === "object") {
46
+ const inner = normalizeReviewPayload(payload.result);
47
+ if (inner) return inner;
48
+ }
40
49
 
41
50
  return null;
42
51
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * KJC-BUG-0146 — when a reviewer answers something the parser cannot read.
3
+ *
4
+ * Until now that answer was thrown away: the error said "no parseable verdict"
5
+ * and not one byte of what the reviewer actually replied survived. Eight
6
+ * occurrences produced zero evidence, which is why the bug stayed undiagnosed
7
+ * for two days. A failure nobody can inspect is a failure nobody can fix.
8
+ *
9
+ * So the raw answer is written next to the verdicts and an excerpt travels in
10
+ * the error. Nothing else changes: an unreadable answer is still a refusal,
11
+ * never a pass — it COULD be a rejection in the wrong shape, and switching to
12
+ * another reviewer would turn it into an approval.
13
+ */
14
+ import fs from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ const DIR = path.join(".karajan", "reviews");
18
+ const EXCERPT = 400;
19
+
20
+ /** One line about what came back, so the shape is visible without opening anything. */
21
+ export function describeOutput(output) {
22
+ if (output === undefined || output === null) return "no output at all";
23
+ if (typeof output !== "string") return `${typeof output}, not a string`;
24
+ if (output.trim() === "") return "empty output";
25
+ return `${output.length} chars, starts with ${JSON.stringify(output.slice(0, 60))}`;
26
+ }
27
+
28
+ /**
29
+ * Writes the raw answer for inspection and returns the error message to raise.
30
+ * A failure to write is never allowed to hide the original problem.
31
+ * @param {{projectDir?: string, reviewer: string, output: unknown, hash: string, writeFile?: Function}} args
32
+ */
33
+ export async function reportUnparseableVerdict({ projectDir = process.cwd(), reviewer, output, hash, writeFile = fs.writeFile }) {
34
+ const file = path.join(projectDir, DIR, `${hash}.unparseable.txt`);
35
+ const body = typeof output === "string" ? output : JSON.stringify(output ?? null, null, 2);
36
+ let saved = null;
37
+ try {
38
+ await fs.mkdir(path.dirname(file), { recursive: true });
39
+ await writeFile(file, `reviewer: ${reviewer}\n\n${body}\n`, "utf8");
40
+ saved = file;
41
+ } catch {
42
+ // Could not save it — the excerpt below still travels, and the message says so.
43
+ }
44
+ const excerpt = typeof output === "string" && output.trim() ? `\n--- what ${reviewer} answered (first ${EXCERPT} chars) ---\n${output.slice(0, EXCERPT)}\n---` : "";
45
+ return [
46
+ `reviewer ${reviewer} returned no parseable verdict (${describeOutput(output)})`,
47
+ saved ? `full answer saved to ${path.relative(projectDir, saved)}` : "the full answer could NOT be saved to disk",
48
+ "an unreadable answer is a refusal, not a pass: it may be a rejection in the wrong shape (KJC-BUG-0146)",
49
+ ].join("\n") + excerpt;
50
+ }