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.
- package/README.md +5 -3
- package/package.json +4 -2
- package/packages/hu-board/public/app.js +1 -0
- package/packages/hu-board/public/index.html +2 -0
- package/packages/hu-board/public/styles.css +22 -0
- package/packages/hu-board/public/utils/governance-view.js +149 -0
- package/packages/hu-board/src/routes/governance.js +140 -0
- package/packages/hu-board/src/server.js +2 -0
- package/scripts/install.js +4 -3
- package/scripts/postinstall.js +4 -3
- package/scripts/toml-value.js +18 -0
- package/src/checks/ai-trash.js +1 -1
- package/src/checks/mcp-health.js +1 -1
- package/src/checks/native-build.js +2 -2
- package/src/checks/release-check.js +62 -2
- package/src/claims/cross-check.js +81 -0
- package/src/claims/extract.js +56 -0
- package/src/claims/turn.js +54 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +45 -5
- package/src/commands/claims.js +41 -0
- package/src/commands/init.js +1 -1
- package/src/commands/policy.js +84 -3
- package/src/commands/review-gate.js +4 -2
- package/src/environment/playbook.js +2 -1
- package/src/guards/duplicate-members.js +86 -0
- package/src/harden/sentinel-hooks.js +90 -16
- package/src/policy/exceptions.js +14 -4
- package/src/policy/report.js +99 -0
- package/src/review/card-first.js +4 -1
- package/src/review/one-shot-review.js +10 -4
- package/src/review/parser.js +9 -0
- package/src/review/unparseable-verdict.js +50 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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 {
|
|
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
|
-
|
|
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 ((
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
package/src/policy/exceptions.js
CHANGED
|
@@ -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 {
|
|
41
|
+
return { records: [], discarded: 0 };
|
|
32
42
|
}
|
|
33
|
-
const
|
|
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
|
|
52
|
+
else records.push(rec);
|
|
43
53
|
} catch {
|
|
44
54
|
discarded += 1;
|
|
45
55
|
}
|
|
46
56
|
}
|
|
47
|
-
return {
|
|
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
|
+
}
|
package/src/review/card-first.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
package/src/review/parser.js
CHANGED
|
@@ -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
|
+
}
|