karajan-code 4.21.0 → 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.
@@ -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));
@@ -85,6 +85,18 @@ export const recordEscape = (sid, escape, tool) => {
85
85
  if (!s.escapes.includes(escape)) s.escapes.push(escape);
86
86
  (state.escape_events ||= []).push({ escape, tool, sid, ts: Date.now() });
87
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
+ }
88
100
  };
89
101
  `;
90
102
 
@@ -112,9 +124,11 @@ process.stdin.on("end", () => {
112
124
  const clearPending = (cardId) => {
113
125
  const state = load();
114
126
  const s = session(state, sid);
115
- const before = (s.pending_moves || []).length;
116
127
  s.pending_moves = (s.pending_moves || []).filter((p) => p.card !== null && p.card !== cardId);
117
- if (s.pending_moves.length !== before) save(state);
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);
118
132
  };
119
133
  if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
120
134
  // The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
@@ -133,20 +147,30 @@ process.stdin.on("end", () => {
133
147
  const mergeCmd = /\\bgh\\s+pr\\s+merge(\\s+(\\d+))?/.exec(cmdText);
134
148
  const said = /merged pull request #(\\d+)/i.exec(text);
135
149
  let mergedPr = said ? Number(said[1]) : null;
136
- if (mergedPr === null && mergeCmd) {
137
- const args = ["pr", "view", ...(mergeCmd[2] ? [mergeCmd[2]] : []), "--json", "number,state"];
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"];
138
157
  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 */ }
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) */ }
140
162
  }
141
163
  if (mergedPr !== null) {
142
164
  const state = load();
143
165
  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() });
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" }) });
146
168
  save(state);
147
169
  }
148
170
  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());
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());
150
174
  process.exit(0);
151
175
  }
152
176
  const file = input.file_path || input.notebook_path;
@@ -177,7 +201,19 @@ const STOP_BODY = `#!/usr/bin/env node
177
201
  // after 3 unresolved blocks — a sentinel bug never bricks the session — and
178
202
  // the fail-open is recorded in the state. \`--status\` prints, never blocks.
179
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";
180
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
+ };
181
217
  if (process.argv.includes("--status")) {
182
218
  const st = load();
183
219
  const branch = branchOf();
@@ -221,9 +257,14 @@ process.stdin.on("end", () => {
221
257
  const v = violations(s, branchOf());
222
258
  if (!v.length) {
223
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
+ }
224
266
  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." }));
267
+ if (notes.length) console.log(JSON.stringify({ systemMessage: notes.join("\\n") }));
227
268
  process.exit(0);
228
269
  }
229
270
  s.blocks = (s.blocks || 0) + 1;
@@ -313,11 +354,36 @@ process.stdin.on("end", () => {
313
354
  if (tok === name + "=1") found = true;
314
355
  }
315
356
  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;
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)");
319
367
  }
320
- return true;
368
+ return false;
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";
321
387
  };
322
388
  // IDN-B (KJC-TSK-0763, ADR 0005): identity lock — gh and mutating git never
323
389
  // run under an account other than the one THIS CLONE declared (the incident:
@@ -659,7 +725,15 @@ process.stdin.on("end", () => {
659
725
  process.exit(2);
660
726
  }
661
727
  } else if (PUSH.test(cmd)) {
662
- 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
+ }
663
737
  if (v.length) {
664
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");
665
739
  process.exit(2);
@@ -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
+ }