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.
- 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 +2 -2
- package/src/cli/register-meta.js +70 -5
- package/src/commands/claims.js +41 -0
- package/src/commands/harden.js +8 -0
- package/src/commands/identity.js +60 -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/hook-templates.js +40 -1
- package/src/harden/sentinel-hooks.js +267 -15
- package/src/identity/bootstrap.js +48 -0
- package/src/identity/compare.js +38 -0
- package/src/identity/detect.js +51 -0
- package/src/identity/store.js +66 -0
- 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
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossing what was said against what actually ran (CLM-A, KJC-TSK-0801).
|
|
3
|
+
*
|
|
4
|
+
* The transcript is the register of sources: every command, query and read left
|
|
5
|
+
* its output there, so nothing has to be annotated by hand. A datum that appears
|
|
6
|
+
* in some output is BACKED; one that appears nowhere came out of the model's
|
|
7
|
+
* memory (UNBACKED); one whose own source says otherwise is DENIED — and that
|
|
8
|
+
* is the only verdict that blocks, because it is a proven hallucination and not
|
|
9
|
+
* a suspicion.
|
|
10
|
+
*
|
|
11
|
+
* What cannot be decided is NOT_CHECKABLE, never an accusation: a guard that
|
|
12
|
+
* cries wolf gets switched off (KJC-PCS-0082).
|
|
13
|
+
*/
|
|
14
|
+
import { extractClaims } from "./extract.js";
|
|
15
|
+
|
|
16
|
+
export const BACKED = "backed";
|
|
17
|
+
export const UNBACKED = "unbacked";
|
|
18
|
+
export const DENIED = "denied";
|
|
19
|
+
export const NOT_CHECKABLE = "not_checkable";
|
|
20
|
+
|
|
21
|
+
const norm = (s) => String(s ?? "").toLowerCase();
|
|
22
|
+
const escape = (s) => s.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Numbers must match as WHOLE tokens: searching "4" as a substring finds it inside "24.6 kB"
|
|
26
|
+
* and backs a figure nobody measured. Found while running this over a real message — the
|
|
27
|
+
* synthetic tests had passed. Ids, paths and versions are distinctive enough as substrings.
|
|
28
|
+
*/
|
|
29
|
+
function appearsIn(output, claim) {
|
|
30
|
+
const needle = norm(claim.value);
|
|
31
|
+
if (claim.kind === "path" || claim.kind === "card" || claim.kind === "version") return output.includes(needle);
|
|
32
|
+
return new RegExp(`(?<![\\w.])${escape(needle)}(?![\\w.])`).test(output);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** "no cards", "[]", "0 results" — a source that positively states emptiness. */
|
|
36
|
+
const SAYS_EMPTY = /(^|\W)(\[\]|\bnone\b|\bno results?\b|\bempty\b|\b0 (results?|items?|matches|cards?|files?)\b)/i;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{text: string, outputs: string[], userSaid?: string}} input
|
|
40
|
+
* outputs: tool outputs of the turn, in order. userSaid: what the user wrote (also a source).
|
|
41
|
+
* @returns {{claims: Array<object>, denied: Array<object>, unbacked: Array<object>}}
|
|
42
|
+
*/
|
|
43
|
+
export function crossCheck({ text, outputs = [], userSaid = "" }) {
|
|
44
|
+
const haystack = [...outputs.map(norm), norm(userSaid)];
|
|
45
|
+
const claims = extractClaims(text).map((claim) => ({ ...claim, status: verdictFor(claim, haystack) }));
|
|
46
|
+
return {
|
|
47
|
+
claims,
|
|
48
|
+
denied: claims.filter((c) => c.status === DENIED),
|
|
49
|
+
unbacked: claims.filter((c) => c.status === UNBACKED),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function verdictFor(claim, haystack) {
|
|
54
|
+
if (haystack.some((h) => appearsIn(h, claim))) return BACKED;
|
|
55
|
+
|
|
56
|
+
// A count stated as non-zero while every source that mentions the same noun says empty:
|
|
57
|
+
// that is the "four cards are waiting" case, and it is the one that blocks.
|
|
58
|
+
if (claim.kind === "count" && Number(claim.value) > 0) {
|
|
59
|
+
const noun = nounAfterCount(claim.sentence, claim.value);
|
|
60
|
+
if (noun) {
|
|
61
|
+
const mentions = haystack.filter((h) => h.includes(norm(noun)));
|
|
62
|
+
if (mentions.length && mentions.every((h) => SAYS_EMPTY.test(h))) return DENIED;
|
|
63
|
+
}
|
|
64
|
+
// Small numbers are prose as often as data ("las dos capas", "3 reglas"): not worth accusing.
|
|
65
|
+
if (Number(claim.value) <= 3) return NOT_CHECKABLE;
|
|
66
|
+
}
|
|
67
|
+
return UNBACKED;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function nounAfterCount(sentence, value) {
|
|
71
|
+
const m = new RegExp(`${value}\\s+([a-zá-ú]{4,})`, "i").exec(sentence);
|
|
72
|
+
return m ? m[1] : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One human line per problem; the message is what makes a guard usable. */
|
|
76
|
+
export function formatClaimReport({ denied, unbacked }) {
|
|
77
|
+
const lines = [];
|
|
78
|
+
for (const c of denied) lines.push(`✗ "${c.value}" (${c.kind}) is DENIED by the output that should back it — ${c.sentence.slice(0, 100)}`);
|
|
79
|
+
for (const c of unbacked) lines.push(`? "${c.value}" (${c.kind}) has no backing in this turn — verify it or say it is from memory`);
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hard data in what the AI says (CLM-A, KJC-TSK-0801, ADR "claims with evidence").
|
|
3
|
+
*
|
|
4
|
+
* A model states an invented figure with the same confidence as a measured one,
|
|
5
|
+
* and that figure travels to a PR, a card, another session or the user, where
|
|
6
|
+
* nobody checks it again. This module pulls the CHECKABLE data out of a text —
|
|
7
|
+
* counts, versions, file paths, card ids, commit SHAs — so it can be crossed
|
|
8
|
+
* against what actually ran. Prose is left alone: only data is verifiable.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic and free: no model in the loop. Verifying must be cheaper than
|
|
11
|
+
* inventing, or nobody will verify.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Sentences that ALREADY admit they are unverified are respected, never reported. */
|
|
15
|
+
const HEDGES = /\b(de memoria|sin (comprobar|verificar)|no (lo )?he (comprobado|verificado)|creo que|puede que|quizá|quizás|probablemente|from memory|unverified|not checked|i think|probably)\b/i;
|
|
16
|
+
|
|
17
|
+
// Most specific first: a number already claimed as a PR or a version is not also a bare count.
|
|
18
|
+
// A number with no unit next to it (an OTP, a phone) is deliberately NOT a claim: it is not
|
|
19
|
+
// verifiable from prose, and some of them are secrets that must never travel into a report.
|
|
20
|
+
const PATTERNS = [
|
|
21
|
+
{ kind: "card", re: /\b([A-Z]{3}-(?:TSK|BUG|PCS|SPR|PLA|PRP)-\d{4})\b/g, value: (m) => m[1] },
|
|
22
|
+
{ kind: "path", re: /\b((?:[\w.-]+\/){1,}[\w.-]+\.\w{1,5})\b/g, value: (m) => m[1] },
|
|
23
|
+
{ kind: "version", re: /\bv?(\d+\.\d+\.\d+(?:-[\w.]+)?)\b/g, value: (m) => m[1] },
|
|
24
|
+
{ kind: "pr", re: /(?:^|[\s(])#(\d{2,6})\b/g, value: (m) => m[1] },
|
|
25
|
+
{ kind: "sha", re: /\b([0-9a-f]{7,40})\b/g, value: (m) => m[1] },
|
|
26
|
+
// A number that means something: "1004 ficheros", "8 ocurrencias", "53 tests".
|
|
27
|
+
{ kind: "count", re: /\b(\d[\d.,]*)\s+(?=[a-záéíóúñ]{3,})/gi, value: (m) => m[1].replaceAll(".", "").replaceAll(",", "") },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/** Splits into sentences so a hedge only covers what it is attached to. */
|
|
31
|
+
const sentences = (text) => String(text || "").split(/(?<=[.!?\n])\s+/).filter(Boolean);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @returns {Array<{kind: string, value: string, sentence: string}>} unique claims, in order.
|
|
36
|
+
*/
|
|
37
|
+
export function extractClaims(text) {
|
|
38
|
+
const out = [];
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
for (const sentence of sentences(text)) {
|
|
41
|
+
if (HEDGES.test(sentence)) continue; // saying "I did not check" is the behaviour to encourage
|
|
42
|
+
const claimedHere = new Set();
|
|
43
|
+
for (const { kind, re, value } of PATTERNS) {
|
|
44
|
+
for (const m of sentence.matchAll(re)) {
|
|
45
|
+
const v = value(m);
|
|
46
|
+
if (claimedHere.has(v)) continue; // already claimed as something more specific
|
|
47
|
+
claimedHere.add(v);
|
|
48
|
+
const key = `${kind}:${v}`;
|
|
49
|
+
if (seen.has(key)) continue;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
out.push({ kind, value: v, sentence: sentence.trim() });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading one turn out of the transcript (CLM-B, KJC-TSK-0802).
|
|
3
|
+
*
|
|
4
|
+
* The transcript is the register of sources: what the AI finally said, what the
|
|
5
|
+
* user asked, and every tool output in between. Nothing is annotated by hand —
|
|
6
|
+
* this just reads what the session already wrote.
|
|
7
|
+
*
|
|
8
|
+
* A turn = from the user's last real message to the end. A "user" entry whose
|
|
9
|
+
* content is a tool_result is NOT the user talking: it is the machine answering.
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
|
|
13
|
+
const MAX_OUTPUT = 20_000; // a single huge output must not eat the whole check
|
|
14
|
+
const blocks = (entry) => {
|
|
15
|
+
const c = entry?.message?.content;
|
|
16
|
+
return Array.isArray(c) ? c : typeof c === "string" ? [{ type: "text", text: c }] : [];
|
|
17
|
+
};
|
|
18
|
+
const isToolResult = (entry) => blocks(entry).some((b) => b.type === "tool_result");
|
|
19
|
+
const textOf = (value) =>
|
|
20
|
+
typeof value === "string" ? value : Array.isArray(value) ? value.map((b) => b?.text ?? "").join("\n") : String(value ?? "");
|
|
21
|
+
|
|
22
|
+
/** Parses the JSONL, ignoring lines that are not valid JSON (a partial write must not throw). */
|
|
23
|
+
export function readEntries(path) {
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const line of readFileSync(path, "utf8").split("\n")) {
|
|
26
|
+
if (!line.trim()) continue;
|
|
27
|
+
try { out.push(JSON.parse(line)); } catch { /* a half-written line is not a reason to fail */ }
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} path transcript_path given by the hook
|
|
34
|
+
* @returns {{text: string, outputs: string[], userSaid: string}}
|
|
35
|
+
* text: what the AI says at the end of the turn (its final prose, no thinking).
|
|
36
|
+
*/
|
|
37
|
+
export function readTurn(path) {
|
|
38
|
+
const entries = readEntries(path);
|
|
39
|
+
const startedAt = entries.findLastIndex((e) => e.type === "user" && !isToolResult(e));
|
|
40
|
+
const turn = startedAt >= 0 ? entries.slice(startedAt) : entries;
|
|
41
|
+
|
|
42
|
+
const userSaid = startedAt >= 0 ? textOf(entries[startedAt]?.message?.content) : "";
|
|
43
|
+
const outputs = [];
|
|
44
|
+
for (const entry of turn) {
|
|
45
|
+
for (const b of blocks(entry)) {
|
|
46
|
+
if (b.type === "tool_result") outputs.push(textOf(b.content).slice(0, MAX_OUTPUT));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The final message is the last assistant entry that actually says something
|
|
50
|
+
// (one with tool_use only is the AI working, not the AI reporting).
|
|
51
|
+
const finals = turn.filter((e) => e.type === "assistant" && blocks(e).some((b) => b.type === "text" && b.text?.trim()));
|
|
52
|
+
const text = finals.length ? blocks(finals.at(-1)).filter((b) => b.type === "text").map((b) => b.text).join("\n") : "";
|
|
53
|
+
return { text, outputs, userSaid };
|
|
54
|
+
}
|
|
@@ -30,8 +30,8 @@ export const ADVANCED_GROUPS = [
|
|
|
30
30
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
31
31
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
32
32
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy"] },
|
|
34
|
-
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims"] },
|
|
34
|
+
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
|
|
35
35
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
36
36
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
|
37
37
|
];
|
package/src/cli/register-meta.js
CHANGED
|
@@ -4,6 +4,9 @@ import { researcherCommand } from "../commands/researcher.js";
|
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
6
|
import { startCommand } from "../commands/start.js";
|
|
7
|
+
import { identityCommand } from "../commands/identity.js";
|
|
8
|
+
import { policyCommand } from "../commands/policy.js";
|
|
9
|
+
import { claimsCommand } from "../commands/claims.js";
|
|
7
10
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
8
11
|
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
9
12
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
@@ -120,6 +123,30 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
120
123
|
});
|
|
121
124
|
});
|
|
122
125
|
|
|
126
|
+
// IDN-A (KJC-TSK-0762, epic KJC-PCS-0079): identity lock per clone.
|
|
127
|
+
const identityCmd = program
|
|
128
|
+
.command("identity")
|
|
129
|
+
.description("Identity lock: which gh account and git email this clone is worked with (.karajan/identity.local.yml, never tracked)");
|
|
130
|
+
identityCmd
|
|
131
|
+
.command("show", { isDefault: true })
|
|
132
|
+
.description("Declared identity vs the active one (exit 2 on mismatch or when undeclared)")
|
|
133
|
+
.action(async (flags) => {
|
|
134
|
+
await withConfig(pkgVersion, "identity-show", flags, async ({ config }) => {
|
|
135
|
+
process.exitCode = await identityCommand({ action: "show", config, flags });
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
identityCmd
|
|
139
|
+
.command("set")
|
|
140
|
+
.description("Declare this clone's identity from the active gh session and git config (or --gh/--email)")
|
|
141
|
+
.option("--gh <user>", "gh account to bind")
|
|
142
|
+
.option("--email <email>", "git email to bind")
|
|
143
|
+
.option("--yes", "Do not ask for confirmation")
|
|
144
|
+
.action(async (flags) => {
|
|
145
|
+
await withConfig(pkgVersion, "identity-set", flags, async ({ config }) => {
|
|
146
|
+
process.exitCode = await identityCommand({ action: "set", config, flags });
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
123
150
|
program
|
|
124
151
|
.command("start")
|
|
125
152
|
.description("Single entry point: assess the project and recommend the next step")
|
|
@@ -286,6 +313,14 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
286
313
|
});
|
|
287
314
|
|
|
288
315
|
// KJC-TSK-0733 PL-A — policy as code: motor determinista en modo warn.
|
|
316
|
+
// CLM-B (KJC-TSK-0802): the data the AI states, checked against what actually ran.
|
|
317
|
+
program.command("claims").description("Afirmaciones con fuente: comprueba los datos que la IA afirma en un turno contra las salidas de ese turno (ADR claims-with-evidence)")
|
|
318
|
+
.command("check")
|
|
319
|
+
.description("Cruza el mensaje final del turno con sus salidas — exit 2 solo si un dato está DESMENTIDO por su propia fuente; falla abierto si no puede leer el transcript")
|
|
320
|
+
.requiredOption("--transcript <path>", "Ruta del transcript de la sesión (la que pasa el hook)")
|
|
321
|
+
.option("--json", "Machine-readable")
|
|
322
|
+
.action(async (flags) => { process.exitCode = await claimsCommand({ flags }); });
|
|
323
|
+
|
|
289
324
|
const policyCmd = program.command("policy").description("Policy as code (.karajan/policy.yml, vocabulario cerrado): eval/check deterministas, grant con caducidad, anchor del decision log — deny en commit y CI");
|
|
290
325
|
policyCmd.command("eval")
|
|
291
326
|
.description("Evalúa UNA tool call: imprime {decision, rule_id, reason}; --strict devuelve exit 2 en deny (contrato para adaptadores de hooks)")
|
|
@@ -295,7 +330,6 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
295
330
|
.option("--strict", "Exit 2 si deny")
|
|
296
331
|
.action(async (flags) => {
|
|
297
332
|
await withConfig(pkgVersion, "policy-eval", flags, async ({ config }) => {
|
|
298
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
299
333
|
process.exitCode = await policyCommand({ action: "eval", config, flags });
|
|
300
334
|
});
|
|
301
335
|
});
|
|
@@ -306,7 +340,6 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
306
340
|
.option("--reason <text>", "Justificación escrita en el momento")
|
|
307
341
|
.action(async (flags) => {
|
|
308
342
|
await withConfig(pkgVersion, "policy-grant", flags, async ({ config }) => {
|
|
309
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
310
343
|
process.exitCode = await policyCommand({ action: "grant", config, flags });
|
|
311
344
|
});
|
|
312
345
|
});
|
|
@@ -323,10 +356,27 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
323
356
|
.description("Verifica la cadena del decision log y sella su head-hash en .karajan/policy-anchor.json (trackeado) — anclaje temporal en la historia de git, GOV-C2")
|
|
324
357
|
.action(async (flags) => {
|
|
325
358
|
await withConfig(pkgVersion, "policy-anchor", flags, async ({ config }) => {
|
|
326
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
327
359
|
process.exitCode = await policyCommand({ action: "anchor", config, flags });
|
|
328
360
|
});
|
|
329
361
|
});
|
|
362
|
+
policyCmd.command("seal")
|
|
363
|
+
.description("Sella en el decision log un escape KJ_ALLOW_* usado en tool-time (exempt, chokepoint=tool, identidad declarada del clon) — lo invoca el Sentinel, GOV-F")
|
|
364
|
+
.requiredOption("--escape <name>", "Escape usado (p.ej. KJ_ALLOW_BOARD)")
|
|
365
|
+
.option("--tool <tool>", "Tool que lo usó (Bash, Edit…)")
|
|
366
|
+
.action(async (flags) => {
|
|
367
|
+
await withConfig(pkgVersion, "policy-seal", flags, async ({ config }) => {
|
|
368
|
+
process.exitCode = await policyCommand({ action: "seal", config, flags });
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
policyCmd.command("report")
|
|
372
|
+
.description("Informe determinista del decision log y las concesiones: avisos/denegaciones/exenciones por regla, denegaciones abiertas, concesiones vivas/vencidas/renovadas y señales — cadena rota = exit 1 (PL-E)")
|
|
373
|
+
.option("--soon <days>", "Días para considerar una concesión 'próxima a vencer'", "7")
|
|
374
|
+
.option("--json", "Machine-readable")
|
|
375
|
+
.action(async (flags) => {
|
|
376
|
+
await withConfig(pkgVersion, "policy-report", flags, async ({ config }) => {
|
|
377
|
+
process.exitCode = await policyCommand({ action: "report", config, flags });
|
|
378
|
+
});
|
|
379
|
+
});
|
|
330
380
|
policyCmd.command("check")
|
|
331
381
|
.description("Comprueba el diff (staged, o base...head con --range) contra la policy — warn por defecto; --strict devuelve exit 2 si hay violación enforcement=deny (tier C, merge-blocking)")
|
|
332
382
|
.option("--role <role>", "Rol del agente", "coder")
|
|
@@ -335,7 +385,6 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
335
385
|
.option("--json", "Machine-readable")
|
|
336
386
|
.action(async (flags) => {
|
|
337
387
|
await withConfig(pkgVersion, "policy-check", flags, async ({ config }) => {
|
|
338
|
-
const { policyCommand } = await import("../commands/policy.js");
|
|
339
388
|
process.exitCode = await policyCommand({ action: "check", config, flags });
|
|
340
389
|
});
|
|
341
390
|
});
|
|
@@ -574,9 +623,10 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
574
623
|
|
|
575
624
|
program
|
|
576
625
|
.command("board [action]")
|
|
577
|
-
.description("Manage HU Board (start|stop|status|open|cleanup)")
|
|
626
|
+
.description("Manage HU Board (start|stop|status|open|cleanup) — SIN acción arranca un servidor persistente en segundo plano (equivale a `start`)")
|
|
578
627
|
.option("--port <number>", "Port (default: 4000)", "4000")
|
|
579
628
|
.option("--bind <host>", "Bind host (default: 127.0.0.1; use 0.0.0.0 to expose on LAN — token auth auto-enforced)")
|
|
629
|
+
.option("--force", "Arranca aunque hu_board.enabled sea false en kj.config.yml")
|
|
580
630
|
.action(async (action = "start", opts) => {
|
|
581
631
|
await withConfig(pkgVersion, "board", opts, async ({ config, logger }) => {
|
|
582
632
|
// KJC-TSK-0684 (issue #1287): an external board is the source of
|
|
@@ -587,6 +637,21 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
587
637
|
console.log(`⚠ this project's board lives in ${name} (state_backend: external) — kj does not run a parallel HU Board here.`);
|
|
588
638
|
return;
|
|
589
639
|
}
|
|
640
|
+
// KJC-BUG-0152 (issue #1427): `kj board` with no action starts a persistent server.
|
|
641
|
+
// Doing that while hu_board.enabled is false contradicts kj doctor, which reports the
|
|
642
|
+
// board as skipped — two commands saying opposite things about the same config. The
|
|
643
|
+
// system works or fails loudly; it never does the opposite of what the config says.
|
|
644
|
+
// Only `start` is gated (the bare command defaults to it): stop, status, cleanup and open
|
|
645
|
+
// never bring a server up, and blocking them would take away the way to tidy up.
|
|
646
|
+
if (config?.hu_board?.enabled === false && !opts.force && action === "start") {
|
|
647
|
+
logger.error(
|
|
648
|
+
`hu_board.enabled es false en kj.config.yml — no arranco el HU Board (kj doctor ya lo reporta como omitido).\n` +
|
|
649
|
+
` Para arrancarlo igualmente: kj board ${action} --force\n` +
|
|
650
|
+
` Para dejarlo activado siempre: pon hu_board.enabled: true en kj.config.yml`
|
|
651
|
+
);
|
|
652
|
+
process.exitCode = 1;
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
590
655
|
const port = Number(opts.port) || config.hu_board?.port || 4000;
|
|
591
656
|
const bind = opts.bind || config.hu_board?.bind || "127.0.0.1";
|
|
592
657
|
await boardCommand({ action, port, bind, logger });
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj claims check --transcript <path>` (CLM-B, KJC-TSK-0802) — checks the data
|
|
3
|
+
* the AI states in a turn against what actually ran in that same turn.
|
|
4
|
+
*
|
|
5
|
+
* Deterministic and free: no model in the loop. Exit 2 only when a datum is
|
|
6
|
+
* DENIED by its own source — a proven hallucination. Everything else is
|
|
7
|
+
* reported, per the accepted ADR: inform always, block almost never.
|
|
8
|
+
*
|
|
9
|
+
* It fails OPEN. A verifier that cannot read the transcript says so and gets out
|
|
10
|
+
* of the way: a broken check must never hold a session hostage.
|
|
11
|
+
*/
|
|
12
|
+
import { readTurn } from "../claims/turn.js";
|
|
13
|
+
import { crossCheck, formatClaimReport } from "../claims/cross-check.js";
|
|
14
|
+
|
|
15
|
+
export async function claimsCommand({ flags = {}, logger = console, readTurnFn = readTurn } = {}) {
|
|
16
|
+
const path = flags.transcript;
|
|
17
|
+
if (!path) {
|
|
18
|
+
logger.error("kj claims check: --transcript <path> is required");
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
let turn;
|
|
22
|
+
try {
|
|
23
|
+
turn = readTurnFn(path);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
// Not observable: the transcript could not be read. Never reported as clean.
|
|
26
|
+
const note = `kj claims: transcript not readable (${err.message}) — nothing checked`;
|
|
27
|
+
if (flags.json) logger.log(JSON.stringify({ ok: true, checked: false, reason: note }));
|
|
28
|
+
else logger.error(note);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const result = crossCheck(turn);
|
|
33
|
+
if (flags.json) {
|
|
34
|
+
logger.log(JSON.stringify({ ok: true, checked: true, denied: result.denied, unbacked: result.unbacked, claims: result.claims.length }));
|
|
35
|
+
} else if (result.denied.length || result.unbacked.length) {
|
|
36
|
+
logger.error(formatClaimReport(result));
|
|
37
|
+
} else {
|
|
38
|
+
logger.log(`kj claims: ${result.claims.length} dato(s) comprobado(s), todos con respaldo en este turno`);
|
|
39
|
+
}
|
|
40
|
+
return result.denied.length ? 2 : 0;
|
|
41
|
+
}
|
package/src/commands/harden.js
CHANGED
|
@@ -23,6 +23,7 @@ import { installSentinelHooks } from "../harden/sentinel-hooks.js";
|
|
|
23
23
|
import { detectStackRoots } from "../harden/stack-roots.js";
|
|
24
24
|
import { installWorkflows } from "../harden/workflow-engine.js";
|
|
25
25
|
import { detectTestFramework } from "../utils/project-detect.js";
|
|
26
|
+
import { ensureIdentity } from "../identity/bootstrap.js";
|
|
26
27
|
|
|
27
28
|
const JS_TEST_CMD = {
|
|
28
29
|
vitest: "npx vitest run",
|
|
@@ -191,5 +192,12 @@ export async function hardenCommand({
|
|
|
191
192
|
for (const w of out.workflows) logger.info?.(` • ${w.file}: ${w.action}`);
|
|
192
193
|
for (const g of out.guidelines) logger.info?.(` • ${g.file}: ${g.action}`);
|
|
193
194
|
if (!dryRun) logger.info?.("core.hooksPath set. Verify later with `kj check`.");
|
|
195
|
+
// IDN-A (KJC-TSK-0762): a hardened clone declares who works it. Captured
|
|
196
|
+
// only with a human confirming; headless runs get the pending command.
|
|
197
|
+
if (!dryRun) {
|
|
198
|
+
const id = await ensureIdentity({ projectDir, logger });
|
|
199
|
+
out.identity = id.declared ? id.identity : null;
|
|
200
|
+
if (id.pending) out.identityPending = id.pending;
|
|
201
|
+
}
|
|
194
202
|
return out;
|
|
195
203
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj identity show|set` (IDN-A, KJC-TSK-0762, epic KJC-PCS-0079).
|
|
3
|
+
*
|
|
4
|
+
* show — declared identity vs what is ACTIVE now; exit 2 on mismatch or
|
|
5
|
+
* when nothing is declared (scriptable: the gate and hooks reuse it).
|
|
6
|
+
* set — capture the effective gh account + git email (or --gh/--email),
|
|
7
|
+
* confirm on a TTY, write .karajan/identity.local.yml. Never invents:
|
|
8
|
+
* no session and no flags → exit 1.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
12
|
+
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
13
|
+
import { compareIdentity } from "../identity/compare.js";
|
|
14
|
+
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
15
|
+
|
|
16
|
+
export async function identityCommand({ action = "show", config, flags = {}, deps = {} }) {
|
|
17
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
18
|
+
const log = deps.log || console.log;
|
|
19
|
+
const tty = deps.isTTY || isTTY;
|
|
20
|
+
const detectOpts = { hostsPath: deps.hostsPath, gitFn: deps.gitFn };
|
|
21
|
+
const effective = {
|
|
22
|
+
gh_user: activeGhUser(detectOpts.hostsPath ? { hostsPath: detectOpts.hostsPath } : {}),
|
|
23
|
+
git_email: effectiveGitEmail(projectDir, detectOpts.gitFn ? { gitFn: detectOpts.gitFn } : {}),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
if (action === "set") {
|
|
27
|
+
const candidate = { gh_user: flags.gh || effective.gh_user, git_email: flags.email || effective.git_email };
|
|
28
|
+
if (!candidate.gh_user || !candidate.git_email) {
|
|
29
|
+
log(`kj identity: cannot declare — gh account: ${candidate.gh_user ?? "no session"}, git email: ${candidate.git_email ?? "none"}. Log in / set git config, or pass --gh <user> --email <email>.`);
|
|
30
|
+
return 1;
|
|
31
|
+
}
|
|
32
|
+
if (tty() && !flags.yes) {
|
|
33
|
+
const wizard = (deps.makeWizard || createWizard)();
|
|
34
|
+
try {
|
|
35
|
+
const ok = await wizard.confirm(`Bind this clone to gh ${candidate.gh_user} / git ${candidate.git_email}?`, true);
|
|
36
|
+
if (!ok) { log("kj identity: not declared."); return 1; }
|
|
37
|
+
} finally { wizard.close(); }
|
|
38
|
+
} else if (!flags.gh || !flags.email) {
|
|
39
|
+
// Proven live on day one: the active gh session had been flipped by
|
|
40
|
+
// ANOTHER session, and --yes bound the clone to the wrong account.
|
|
41
|
+
// Binding without confirmation is allowed, but never silent.
|
|
42
|
+
log("kj identity: WARNING — binding the ACTIVE session as-is without confirmation (another session may have switched it); prefer --gh/--email explicitly and verify with `kj identity show`.");
|
|
43
|
+
}
|
|
44
|
+
writeIdentity(projectDir, candidate);
|
|
45
|
+
log(`kj identity: declared gh ${candidate.gh_user} / git ${candidate.git_email} → .karajan/identity.local.yml (ignored by git).`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const declared = readIdentity(projectDir);
|
|
50
|
+
const result = compareIdentity(declared, effective);
|
|
51
|
+
if (!declared) {
|
|
52
|
+
log("kj identity: no identity declared for this clone — run `kj identity set`.");
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
const mark = (field) => (result.mismatches.some((m) => m.field === field) ? "DISTINTA" : "ACTIVA");
|
|
56
|
+
log(`gh_user ${declared.gh_user} [${mark("gh_user")}: ${effective.gh_user ?? "no session"}]`);
|
|
57
|
+
log(`git_email ${declared.git_email} [${mark("git_email")}: ${effective.git_email ?? "none"}]`);
|
|
58
|
+
for (const m of result.mismatches) log(` → ${m.remedy}`);
|
|
59
|
+
return result.ok ? 0 : 2;
|
|
60
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -862,7 +862,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
862
862
|
await installAiTrashHook(logger);
|
|
863
863
|
} else {
|
|
864
864
|
logger.warn("ai-trash kj-trash binary not found — destructive ops unprotected.");
|
|
865
|
-
logger.warn(" Install karajan-code globally (npm i -g karajan-code) so kj-trash is on PATH.");
|
|
865
|
+
logger.warn(" Install karajan-code globally (npm i -g @karajan-family/code) so kj-trash is on PATH.");
|
|
866
866
|
}
|
|
867
867
|
}
|
|
868
868
|
|
package/src/commands/policy.js
CHANGED
|
@@ -12,7 +12,10 @@ import { createHash } from "node:crypto";
|
|
|
12
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
import { checkStagedDiff, evalToolCall, loadPolicy } from "../policy/engine.js";
|
|
15
|
-
import { loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
15
|
+
import { loadExceptionRecords, loadStandingExceptions, recordPolicyException } from "../policy/exceptions.js";
|
|
16
|
+
import { buildPolicyReport } from "../policy/report.js";
|
|
17
|
+
import { policyFileHash, recordGateDecision } from "../policy/decisions.js";
|
|
18
|
+
import { readIdentity } from "../identity/store.js";
|
|
16
19
|
import { verifyDecisionChain } from "@karajan-family/governance";
|
|
17
20
|
|
|
18
21
|
const execFileAsync = promisify(execFile);
|
|
@@ -123,6 +126,39 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
123
126
|
}
|
|
124
127
|
}
|
|
125
128
|
|
|
129
|
+
// PL-E (KJC-TSK-0767): el informe — evidencia de proceso determinista
|
|
130
|
+
// sobre los dos jsonl. Cadena rota = exit 1: un informe sobre un log
|
|
131
|
+
// manipulado no es un informe. El resto es informativo (exit 0).
|
|
132
|
+
if (action === "report") {
|
|
133
|
+
let decisionLines = [];
|
|
134
|
+
try {
|
|
135
|
+
decisionLines = readFileSync(join(projectDir, ".karajan", "policy-decisions.jsonl"), "utf8").split("\n").filter((l) => l.trim());
|
|
136
|
+
} catch { /* sin decisiones aún: el informe lo dice con ceros */ }
|
|
137
|
+
const exc = loadExceptionRecords(projectDir);
|
|
138
|
+
const soonDays = Number(flags.soon ?? 7);
|
|
139
|
+
const report = buildPolicyReport({ decisionLines, exceptionRecords: exc.records, policy, soonDays: Number.isFinite(soonDays) ? soonDays : 7 });
|
|
140
|
+
if (flags.json) {
|
|
141
|
+
logger.info?.(JSON.stringify({ ...report, exceptions_discarded: exc.discarded }));
|
|
142
|
+
return report.chain.ok ? 0 : 1;
|
|
143
|
+
}
|
|
144
|
+
const { chain, decisions: d, rules, grants: g } = report;
|
|
145
|
+
if (chain.ok) logger.info?.(`✓ decision log: cadena íntegra (${chain.length} decisiones)`);
|
|
146
|
+
else logger.error?.(`✗ decision log: cadena rota en la entrada ${chain.at} (${chain.reason}) — el log ha sido manipulado`);
|
|
147
|
+
if (d.discarded > 0 || exc.discarded > 0) logger.warn?.(`⚠ líneas corruptas descartadas: ${d.discarded} en decisiones, ${exc.discarded} en excepciones`);
|
|
148
|
+
const cps = Object.entries(d.chokepoints).map(([k, v]) => `${k} ${v}`).join(" · ") || "ninguno";
|
|
149
|
+
logger.info?.(`decisiones: allow ${d.allow} · deny ${d.deny} · exempt ${d.exempt} · abiertas ${d.open} (chokepoints: ${cps})`);
|
|
150
|
+
logger.info?.(rules.length > 0 ? "reglas (ordenadas por fricción):" : "reglas: ninguna ha avisado ni denegado todavía");
|
|
151
|
+
for (const r of rules) {
|
|
152
|
+
const meta = [r.enforcement && `enforcement=${r.enforcement}`, r.class && `class=${r.class}`].filter(Boolean).join(" ");
|
|
153
|
+
logger.info?.(` [${r.rule_id}] ${meta} — warn ${r.warns} · deny ${r.denies} · exempt ${r.exempts} · abiertas ${r.open}`);
|
|
154
|
+
}
|
|
155
|
+
logger.info?.(`concesiones: vivas ${g.alive.length} (próximas a vencer ${g.soon.length}) · vencidas ${g.expired.length} · puntuales ${g.point}`);
|
|
156
|
+
for (const e of g.alive) logger.info?.(` [${e.rule_id}] hasta ${e.expiresAt} — ${e.who?.git ?? "?"}: ${e.justification ?? "sin justificación"}`);
|
|
157
|
+
logger.info?.(report.signals.length > 0 ? "señales:" : "señales: ninguna");
|
|
158
|
+
for (const s of report.signals) logger.warn?.(` ⚠ ${s}`);
|
|
159
|
+
return chain.ok ? 0 : 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
126
162
|
if (action === "eval") {
|
|
127
163
|
let input;
|
|
128
164
|
try {
|
|
@@ -131,9 +167,54 @@ export async function policyCommand({ action, config = {}, flags = {}, logger =
|
|
|
131
167
|
logger.error?.("policy eval: --input debe ser JSON válido");
|
|
132
168
|
return 1;
|
|
133
169
|
}
|
|
134
|
-
const
|
|
170
|
+
const role = flags.role || "coder";
|
|
171
|
+
const verdict = evalToolCall(policy, { role, tool: flags.tool, input, root: projectDir });
|
|
172
|
+
const strictDeny = verdict.decision === "deny" && Boolean(flags.strict);
|
|
173
|
+
// GOV-F (KJC-TSK-0768): el deny de tool-time (contrato --strict del
|
|
174
|
+
// Sentinel) entra en la MISMA cadena que las decisiones de commit — con
|
|
175
|
+
// el hash del tool_input como artefacto. Un sello fallido se dice ANTES
|
|
176
|
+
// del veredicto (la última línea de stdout sigue siendo el JSON que el
|
|
177
|
+
// Sentinel parsea) y el deny se mantiene: jamás un allow por fallo de registro.
|
|
178
|
+
if (strictDeny) {
|
|
179
|
+
try {
|
|
180
|
+
recordGateDecision(projectDir, {
|
|
181
|
+
decision: "deny", chokepoint: "tool", rule_ids: [verdict.rule_id], role, tool: flags.tool ?? null,
|
|
182
|
+
...(verdict.class ? { class: verdict.class } : {}),
|
|
183
|
+
// El hash es del payload CRUDO recibido (--input tal cual), no de su
|
|
184
|
+
// re-serialización: el mismo JSON con otro orden o espacios es otro
|
|
185
|
+
// artefacto para la auditoría (catch de codex).
|
|
186
|
+
policy_hash: policyFileHash(projectDir), artifact_hash: createHash("sha256").update(String(flags.input ?? ""), "utf8").digest("hex"),
|
|
187
|
+
});
|
|
188
|
+
} catch (err) {
|
|
189
|
+
logger.error?.(`policy eval: deny NO sellado en el decision log (${err.message}) — el deny se mantiene`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
135
192
|
logger.info?.(JSON.stringify(verdict));
|
|
136
|
-
return
|
|
193
|
+
return strictDeny ? 2 : 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// GOV-F (KJC-TSK-0768): un escape KJ_ALLOW_* usado en tool-time es una
|
|
197
|
+
// excepción consciente — se sella como exempt chokepoint=tool con la
|
|
198
|
+
// identidad DECLARADA del clon (identity.local.yml), para que el informe
|
|
199
|
+
// y el anchor la vean. Lo llama el Sentinel; cualquiera puede auditarlo.
|
|
200
|
+
if (action === "seal") {
|
|
201
|
+
if (!flags.escape) {
|
|
202
|
+
logger.error?.("policy seal: --escape <KJ_ALLOW_X> es obligatorio — un escape sin nombre no es auditable");
|
|
203
|
+
return 1;
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
const who = readIdentity(projectDir);
|
|
207
|
+
const rec = recordGateDecision(projectDir, {
|
|
208
|
+
decision: "exempt", chokepoint: "tool", escape: flags.escape, tool: flags.tool ?? null,
|
|
209
|
+
who: who ? { gh: who.gh_user ?? null, git: who.git_email ?? null, grade: "declarada" } : null,
|
|
210
|
+
policy_hash: policyFileHash(projectDir),
|
|
211
|
+
});
|
|
212
|
+
logger.info?.(`✓ escape ${rec.escape} sellado (chokepoint tool${rec.tool ? `, ${rec.tool}` : ""})`);
|
|
213
|
+
return 0;
|
|
214
|
+
} catch (err) {
|
|
215
|
+
logger.error?.(`policy seal: ${err.message}`);
|
|
216
|
+
return 1;
|
|
217
|
+
}
|
|
137
218
|
}
|
|
138
219
|
|
|
139
220
|
// check — staged por defecto, base...head con --range (CI). Warn salvo
|
|
@@ -289,8 +289,10 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
289
289
|
console.log(res.ok
|
|
290
290
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
291
291
|
: `✗ ${res.reason}`);
|
|
292
|
-
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella.
|
|
293
|
-
|
|
292
|
+
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella. PL-E
|
|
293
|
+
// (KJC-TSK-0767): con las reglas que AVISARON, para que "nace avisando y
|
|
294
|
+
// gana dientes" se decida con datos (kj policy report), no a ciegas.
|
|
295
|
+
if (res.ok) seal("allow", gate.warns.length > 0 ? { warn_rule_ids: gate.warns.map((w) => w.rule_id) } : {});
|
|
294
296
|
process.exitCode = res.ok ? 0 : 1;
|
|
295
297
|
return res;
|
|
296
298
|
}
|
|
@@ -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
|
|