karajan-code 4.29.1 → 4.31.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/package.json +1 -1
- package/scripts/postinstall.js +5 -1
- package/scripts/verify-pack-mcp.mjs +76 -0
- package/scripts/verify-pack.mjs +29 -0
- package/src/audit/deterministic-summary.js +24 -0
- package/src/audit/env-key-findings.js +162 -0
- package/src/checks/method.js +37 -2
- package/src/checks/rag-coverage.js +50 -0
- package/src/cli/register-pipeline.js +1 -0
- package/src/commands/check.js +16 -5
- package/src/commands/env.js +12 -0
- package/src/commands/go.js +17 -2
- package/src/commands/harden.js +4 -1
- package/src/commands/hu.js +13 -1
- package/src/commands/init.js +38 -5
- package/src/commands/review-gate.js +50 -2
- package/src/harden/harness-hooks.js +2 -0
- package/src/harden/hook-templates.js +3 -2
- package/src/harden/sentinel-hooks.js +92 -5
- package/src/harden/workflow-engine.js +6 -1
- package/src/harden/workflow-templates.js +1 -2
- package/src/identity/bootstrap.js +2 -2
- package/src/rag/coverage.js +48 -0
- package/src/review/one-shot-review.js +3 -0
- package/src/review/rag-ledger.js +41 -0
- package/src/review/rag-requirement.js +127 -0
- package/src/review/solomon-arbitration.js +6 -1
- package/src/roles/audit-role.js +12 -2
- package/src/utils/pending-user-action.js +7 -4
package/src/commands/init.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
1
2
|
import fs from "node:fs/promises";
|
|
2
3
|
import os from "node:os";
|
|
3
4
|
import path from "node:path";
|
|
@@ -743,6 +744,25 @@ export async function resolveConfigScope({ flags, interactive }) {
|
|
|
743
744
|
return { configPath: getConfigPath(), scope: "global" };
|
|
744
745
|
}
|
|
745
746
|
|
|
747
|
+
/**
|
|
748
|
+
* KJC-BUG-0187: the enforcement gates live in git hooks, so a project without
|
|
749
|
+
* a repo gets no harness at all. Init creates it, exactly like `kj run` does
|
|
750
|
+
* (src/orchestrator/config-init.js), and reports failure instead of carrying
|
|
751
|
+
* on half-installed. No seed commit: that is the bootstrap commit's job.
|
|
752
|
+
*/
|
|
753
|
+
export function ensureGitRepo({ projectDir, logger, gitFn = null }) {
|
|
754
|
+
if (isGitRepo(projectDir)) return true;
|
|
755
|
+
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, stdio: "pipe" }));
|
|
756
|
+
try {
|
|
757
|
+
run(["init"]);
|
|
758
|
+
logger?.info?.("Initialized git repository (no seed commit) — the quality harness needs it");
|
|
759
|
+
return true;
|
|
760
|
+
} catch (err) {
|
|
761
|
+
logger?.warn?.(`git init failed: ${err.message}`);
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
746
766
|
export async function initCommand({ logger, flags = {} }) {
|
|
747
767
|
// AB-B (KJC-TSK-0656): with --json, stdout is a machine contract — every
|
|
748
768
|
// human log moves to stderr so the only stdout line is the summary object.
|
|
@@ -888,20 +908,29 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
888
908
|
}
|
|
889
909
|
}
|
|
890
910
|
|
|
891
|
-
|
|
911
|
+
// KJC-BUG-0184: the container is a heavy side effect — opt-out: --no-sonar.
|
|
912
|
+
// Only the bootstrap is skipped; the review gate still demands Sonar.
|
|
913
|
+
const skipSonar = flags?.noSonar === true || flags?.sonar === false;
|
|
914
|
+
if (skipSonar) {
|
|
915
|
+
logger.info("SonarQube container not started (--no-sonar). Start it later with 'kj sonar start'.");
|
|
916
|
+
} else {
|
|
917
|
+
await setupSonarQube(config, logger, { interactive });
|
|
918
|
+
}
|
|
892
919
|
await scaffoldCiGateway(config, flags, logger);
|
|
893
920
|
|
|
894
921
|
// Install the quality harness (git hooks, lint/format/commit config, CI
|
|
895
922
|
// quality gates, agent guidelines) through the SAME engine as `kj harden`,
|
|
896
923
|
// so init and harden never drift. Opt-out: --no-harden. Needs a git repo.
|
|
897
924
|
const skipHarden = flags?.noHarden === true || flags?.harden === false;
|
|
925
|
+
let hardened = false;
|
|
898
926
|
if (skipHarden) {
|
|
899
927
|
logger.info("Quality harness skipped (--no-harden).");
|
|
900
|
-
} else if (
|
|
928
|
+
} else if (ensureGitRepo({ projectDir: process.cwd(), logger })) {
|
|
901
929
|
logger.info("Installing quality harness (kj harden)...");
|
|
902
930
|
await hardenCommand({ projectDir: process.cwd(), logger });
|
|
931
|
+
hardened = true;
|
|
903
932
|
} else {
|
|
904
|
-
logger.
|
|
933
|
+
logger.warn("Quality harness NOT installed: this is not a git repository and `git init` failed — Karajan's guarantees live in git hooks. Fix git, then run `kj harden`.");
|
|
905
934
|
}
|
|
906
935
|
|
|
907
936
|
// Persist any changes setupSonarQube made (token, etc.) back to disk.
|
|
@@ -925,7 +954,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
925
954
|
if (flags.json) {
|
|
926
955
|
console.log(JSON.stringify({
|
|
927
956
|
ok: true, configPath, scope, interactive,
|
|
928
|
-
skipped: ["ollama", "rtk", "squeezr", "qmd", "harden"].filter((t) => flags[t] === false),
|
|
957
|
+
skipped: ["ollama", "rtk", "squeezr", "qmd", "sonar", "harden"].filter((t) => flags[t] === false),
|
|
929
958
|
}));
|
|
930
959
|
return;
|
|
931
960
|
}
|
|
@@ -933,7 +962,11 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
933
962
|
// Clear close so a first-time user knows setup finished and what to do next,
|
|
934
963
|
// instead of being left at the end of a wall of detection logs (KJC-BUG-0088).
|
|
935
964
|
logger.info("");
|
|
936
|
-
|
|
965
|
+
// KJC-BUG-0187: never declare a setup that did not happen. Without the
|
|
966
|
+
// harness there are no gates, and saying otherwise is the worst outcome.
|
|
967
|
+
logger.info(hardened || skipHarden
|
|
968
|
+
? "✓ Karajan is set up in this project."
|
|
969
|
+
: "⚠ Karajan is set up EXCEPT the quality harness — without it there are no gates.");
|
|
937
970
|
logger.info(' Next: kj run "describe what to build or fix"');
|
|
938
971
|
logger.info(" More: kj --help · kj doctor (check your setup)");
|
|
939
972
|
}
|
|
@@ -14,6 +14,8 @@ import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
16
|
import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
|
|
17
|
+
import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
|
|
18
|
+
import { readRagLedger } from "../review/rag-ledger.js";
|
|
17
19
|
import { runMutationPregate, formatSurvivor } from "../review/mutation-pregate.js";
|
|
18
20
|
import { checkCardFirst } from "../review/card-first.js";
|
|
19
21
|
import { liftSealedSupervisorViolations } from "../policy/supervisor-verify.js";
|
|
@@ -62,6 +64,13 @@ const formatSonarGrant = (g) => {
|
|
|
62
64
|
return `⚠ sonar requirement lifted by a HUMAN grant [${SONAR_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
|
|
63
65
|
};
|
|
64
66
|
|
|
67
|
+
// KJC-TSK-0849 (ADR 0010): same for the rag requirement.
|
|
68
|
+
const formatRagGrant = (g) => {
|
|
69
|
+
const scope = g.origin === "global" ? " GLOBAL" : "";
|
|
70
|
+
const who = g.who?.git ?? "?";
|
|
71
|
+
return `⚠ rag requirement lifted by a HUMAN grant [${RAG_RULE_ID}]${scope} until ${g.expiresAt} — ${who}: ${g.justification || "sin justificación"}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
65
74
|
// KJC-TSK-0813 (AC3): la exención dice su PROCEDENCIA — un standing global
|
|
66
75
|
// (concedido para toda la máquina) no pasa por uno del proyecto. El texto
|
|
67
76
|
// de las de proyecto queda EXACTAMENTE como estaba.
|
|
@@ -355,13 +364,29 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
355
364
|
if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
|
|
356
365
|
else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
|
|
357
366
|
}
|
|
367
|
+
// KJC-TSK-0849 (ADR 0010, RAG-C): the verdict's rag block is the evidence
|
|
368
|
+
// that the session consulted the RAG about every staged source. The one
|
|
369
|
+
// place the rule cannot be verified is a pipeline verdict: the headless
|
|
370
|
+
// pipeline keeps no session ledger yet. It WARNS and the allow seals the
|
|
371
|
+
// warn under the rule id, so the exception is auditable (kj policy report)
|
|
372
|
+
// and gains teeth with data — never accepted in silence.
|
|
373
|
+
const warnIds = gate.warns.map((w) => w.rule_id);
|
|
374
|
+
if (res.ok && res.verdict.host === "kj-pipeline") {
|
|
375
|
+
console.log(`⚠ rag [${RAG_RULE_ID}]: the pipeline verdict carries no session ledger — the headless pipeline does not consult the RAG yet; sealed as a warn`);
|
|
376
|
+
if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
|
|
377
|
+
} else if (res.ok) {
|
|
378
|
+
const { harness, verified, mismatched } = readRagLedger(projectDir);
|
|
379
|
+
const rreq = checkRagVerdict({ config, stagedFiles: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, standingExceptions: std.standing });
|
|
380
|
+
if (!rreq.ok) res = { ok: false, verdict: res.verdict, reason: rreq.reason };
|
|
381
|
+
else if (rreq.mode === "granted" && rreq.grant) console.log(formatRagGrant(rreq.grant));
|
|
382
|
+
}
|
|
358
383
|
console.log(res.ok
|
|
359
384
|
? `✓ verdict ok — approved by ${res.verdict.reviewer} (diff ${res.verdict.diffHash.slice(0, 12)})`
|
|
360
385
|
: `✗ ${res.reason}`);
|
|
361
386
|
// GOV-C: el allow del chokepoint de COMMIT es evidencia — se sella. PL-E
|
|
362
387
|
// (KJC-TSK-0767): con las reglas que AVISARON, para que "nace avisando y
|
|
363
388
|
// gana dientes" se decida con datos (kj policy report), no a ciegas.
|
|
364
|
-
if (res.ok) seal("allow",
|
|
389
|
+
if (res.ok) seal("allow", warnIds.length > 0 ? { warn_rule_ids: warnIds } : {});
|
|
365
390
|
process.exitCode = res.ok ? 0 : 1;
|
|
366
391
|
return res;
|
|
367
392
|
}
|
|
@@ -434,6 +459,29 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
434
459
|
// human grant) — the method report reads it back.
|
|
435
460
|
sonarRecord.mode = sonarReq.mode;
|
|
436
461
|
|
|
462
|
+
// KJC-TSK-0849 (ADR 0010, RAG-C): the session's RAG ledger must show the
|
|
463
|
+
// RAG answered about every staged source (file or sibling), and the TWINS
|
|
464
|
+
// it returned that the diff does not touch travel to the reviewer by name —
|
|
465
|
+
// the 16-sep bugs were exactly the twin nobody touched.
|
|
466
|
+
const ledger = readRagLedger(projectDir);
|
|
467
|
+
const newFiles = (await rawDiff(flags.range, ["--name-only", "--diff-filter=A"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
468
|
+
const ragReq = checkRagRequirement({ config, stagedFiles: changedFiles, newFiles, ledger, standingExceptions: std.standing });
|
|
469
|
+
if (!ragReq.ok) {
|
|
470
|
+
console.log(`✗ ${ragReq.reason}`);
|
|
471
|
+
process.exitCode = 1;
|
|
472
|
+
return { verdict: "rejected", reviewer: "rag-first", issues: [{ severity: "high", file: undefined, description: ragReq.reason }] };
|
|
473
|
+
}
|
|
474
|
+
if (ragReq.mode === "granted") console.log(formatRagGrant(ragReq.grant));
|
|
475
|
+
if (ragReq.mode === "no-harness") console.log("⚠ rag: no Sentinel harness in this tree — no session ledger to check (kj harden installs it)");
|
|
476
|
+
const ragRecord = ragBlock(ragReq, ledger);
|
|
477
|
+
if (ragRecord.twinsUntouched.length > 0) {
|
|
478
|
+
const twins = ragRecord.twinsUntouched;
|
|
479
|
+
console.log(`ℹ rag twins: this session's queries also returned ${twins.length} file(s) the diff does not touch — ${twins.slice(0, 5).join(", ")}${twins.length > 5 ? "…" : ""}`);
|
|
480
|
+
task = `${task || "Review the following diff for correctness, security and maintainability."}\n\n`
|
|
481
|
+
+ "The session's RAG queries also returned these files, which the diff does NOT touch — the same concept may live there, so check the change is complete, not only correct:\n"
|
|
482
|
+
+ twins.map((t) => `- ${t}`).join("\n");
|
|
483
|
+
}
|
|
484
|
+
|
|
437
485
|
// MUT-A (KJC-TSK-0716): mutation pre-gate — opt-in (method_gates.mutation),
|
|
438
486
|
// SOLO en --staged (jamás en pre-commit: cuesta minutos; y jamás en --range:
|
|
439
487
|
// el scope es el ÍNDICE y anotaría trabajo ajeno — catch de codex). block
|
|
@@ -456,7 +504,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
456
504
|
}
|
|
457
505
|
}
|
|
458
506
|
|
|
459
|
-
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord });
|
|
507
|
+
const record = await runOneShotReview({ diff, task, config, logger, projectDir, sonar: sonarRecord, rag: ragRecord });
|
|
460
508
|
printVerdict(record);
|
|
461
509
|
process.exitCode = record.verdict === "approved" ? 0 : 1;
|
|
462
510
|
return record;
|
|
@@ -16,6 +16,8 @@ const SCRIPT_BODY = `#!/usr/bin/env node
|
|
|
16
16
|
// kj tool gate (KJC-TSK-0710) — managed by \`kj harden\`. Exit 2 blocks the
|
|
17
17
|
// tool call (stderr explains why); anything unexpected fails OPEN (exit 0)
|
|
18
18
|
// so a gate bug never bricks the session.
|
|
19
|
+
import console from "node:console";
|
|
20
|
+
import process from "node:process";
|
|
19
21
|
import { existsSync } from "node:fs";
|
|
20
22
|
let raw = "";
|
|
21
23
|
process.stdin.on("data", (d) => { raw += d; });
|
|
@@ -60,8 +60,9 @@ function chainToGlobal(hook, globalHooksDir) {
|
|
|
60
60
|
function baseBranchGuard(baseBranch) {
|
|
61
61
|
if (!baseBranch) return [];
|
|
62
62
|
return [
|
|
63
|
-
"# Branch-first guard — the base branch only moves via PR.",
|
|
64
|
-
|
|
63
|
+
"# Branch-first guard — the base branch only moves via PR. KJC-BUG-0186:",
|
|
64
|
+
"# the bootstrap commit is exempt, there is no commit to branch from yet.",
|
|
65
|
+
'if [ "$KJ_ALLOW_BASE_COMMIT" != "1" ] && git rev-parse --verify HEAD >/dev/null 2>&1; then',
|
|
65
66
|
' current_branch=$(git symbolic-ref --short HEAD 2>/dev/null || echo "")',
|
|
66
67
|
` if [ "$current_branch" = "${baseBranch}" ]; then`,
|
|
67
68
|
` echo 'kj harden: direct commits on ${baseBranch} are not allowed — create a branch and open a PR (KJ_ALLOW_BASE_COMMIT=1 to override)'; exit 1`,
|
|
@@ -28,6 +28,7 @@ export function resolveSentinelRoot(dir = process.cwd()) {
|
|
|
28
28
|
const LIB_BODY = `// kj sentinel shared lib (KJC-TSK-0714) — managed by \`kj harden\`.
|
|
29
29
|
// Single source for every sentinel script: state, branch, classification,
|
|
30
30
|
// violations, and escape recording.
|
|
31
|
+
import console from "node:console";
|
|
31
32
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
32
33
|
import { execSync, spawnSync } from "node:child_process";
|
|
33
34
|
import { dirname, join } from "node:path";
|
|
@@ -71,9 +72,18 @@ export const foreignLane = (targetDir) => {
|
|
|
71
72
|
// the tracker. The method does not advance until the board is true.
|
|
72
73
|
export const pendingMoves = (s) => (s && Array.isArray(s.pending_moves) ? s.pending_moves : []);
|
|
73
74
|
export const pendingText = (p) => (p.card ? p.card : "card sin identificar") + " (PR #" + p.pr + ") mergeada sin mover en el tracker — muevela a To Validate / Fixed con sus commits (update_card del PG o kj hu move)";
|
|
75
|
+
// KJC-BUG-0185: bootstrap phase = the repo has no commit yet. There is no base
|
|
76
|
+
// branch to protect and no coverage to demand over a tree that does not exist,
|
|
77
|
+
// and \`git diff HEAD\` cannot even run. The phase is a verifiable FACT, never a
|
|
78
|
+
// flag, and it ends at the first commit.
|
|
79
|
+
export const bootstrapPhase = () => {
|
|
80
|
+
try { execSync("git rev-parse --verify HEAD", { cwd: ROOT, stdio: ["ignore", "ignore", "ignore"] }); return false; }
|
|
81
|
+
catch { return true; }
|
|
82
|
+
};
|
|
74
83
|
export const violations = (s, branch) => {
|
|
75
84
|
const v = pendingMoves(s).map(pendingText);
|
|
76
85
|
if (!s || !(s.edited_sources || []).length) return v;
|
|
86
|
+
if (bootstrapPhase()) return v;
|
|
77
87
|
if (BASE_BRANCHES.has(branch)) v.push("Fuentes editadas en la rama base '" + branch + "' — crea una rama: git checkout -b feat/<CARD-ID>-descripcion");
|
|
78
88
|
else if (branch && !CARD.test(branch)) v.push("La rama '" + branch + "' no referencia ninguna card — usa feat/<CARD-ID>-descripcion (y una card VIVA en el board)");
|
|
79
89
|
if (!(s.edited_tests || []).length && sessionAddsCode(s)) v.push("Fuentes editadas sin tocar un solo test (" + s.edited_sources.join(", ") + ") — escribe o actualiza el test que prueba el cambio");
|
|
@@ -120,10 +130,11 @@ const POST_BODY = `#!/usr/bin/env node
|
|
|
120
130
|
// kj sentinel state writer (KJC-TSK-0713) — managed by \`kj harden\`.
|
|
121
131
|
// Records deterministic method facts per session; never blocks, never fails
|
|
122
132
|
// a tool call (PostToolUse, always exit 0).
|
|
133
|
+
import process from "node:process";
|
|
123
134
|
import { relative } from "node:path";
|
|
124
135
|
import { spawnSync } from "node:child_process";
|
|
125
|
-
import {
|
|
126
|
-
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD"];
|
|
136
|
+
import { CODE, TESTS, ROOT, CARD, branchOf, load, save, session } from "./sentinel-lib.mjs";
|
|
137
|
+
const ESCAPES = ["KJ_ALLOW_WRITE", "KJ_ALLOW_REWRITE", "KJ_ALLOW_NO_CARD", "KJ_ALLOW_NO_TESTS", "KJ_ALLOW_PII", "KJ_ALLOW_POLICY", "KJ_ALLOW_IDENTITY", "KJ_ALLOW_BOARD", "KJ_ALLOW_NO_RAG"];
|
|
127
138
|
let raw = "";
|
|
128
139
|
process.stdin.on("data", (d) => { raw += d; });
|
|
129
140
|
process.stdin.on("end", () => {
|
|
@@ -146,6 +157,44 @@ process.stdin.on("end", () => {
|
|
|
146
157
|
if (cardId && !(s.closed_cards ||= []).includes(cardId)) s.closed_cards.push(cardId);
|
|
147
158
|
save(state);
|
|
148
159
|
};
|
|
160
|
+
// KJC-TSK-0847 (ADR 0010, RAG-A): the ledger of what the RAG answered in
|
|
161
|
+
// this session — every kj_rag_query (MCP) and kj rag query (CLI) with the
|
|
162
|
+
// repo-relative sources it returned. Neither the CLI nor the MCP know the
|
|
163
|
+
// host's session id: the harness records, they do not. Hits outside the
|
|
164
|
+
// repo (another indexed project) are not ours and are dropped.
|
|
165
|
+
// KJC-BUG-0190: an index with nothing in it (a brand new project) answers
|
|
166
|
+
// every question with silence, so the gate would demand what no query can
|
|
167
|
+
// deliver. kj says so explicitly, in both its human and json forms.
|
|
168
|
+
const EMPTY_INDEX = /No chunks indexed yet|"empty"\\s*:\\s*true/;
|
|
169
|
+
const recordRag = (query, sources, answer = "") => {
|
|
170
|
+
const state = load();
|
|
171
|
+
const s = session(state, sid);
|
|
172
|
+
s.at = Date.now();
|
|
173
|
+
if (EMPTY_INDEX.test(String(answer))) s.rag_index_empty = true;
|
|
174
|
+
const hits = [];
|
|
175
|
+
for (const src of sources) {
|
|
176
|
+
const rel = relative(ROOT, String(src)).split(String.fromCharCode(92)).join("/");
|
|
177
|
+
if (!rel || rel.startsWith("..") || rel.startsWith("/") || hits.includes(rel)) continue;
|
|
178
|
+
hits.push(rel);
|
|
179
|
+
}
|
|
180
|
+
(s.rag_queries ||= []).push({ ts: Date.now(), text: String(query || "").slice(0, 200), hits });
|
|
181
|
+
const all = (s.rag_hits ||= []);
|
|
182
|
+
for (const h of hits) if (!all.includes(h)) all.push(h);
|
|
183
|
+
save(state);
|
|
184
|
+
};
|
|
185
|
+
// Sources are read STRUCTURALLY, never through the stringified response:
|
|
186
|
+
// a JSON document (the MCP {content:[{text}]} parts, the CLI --json array)
|
|
187
|
+
// is parsed and its hits[].source / [].source collected.
|
|
188
|
+
const sourcesOf = (doc) => {
|
|
189
|
+
const list = Array.isArray(doc) ? doc : Array.isArray(doc?.hits) ? doc.hits : [];
|
|
190
|
+
return list.map((h) => h?.source).filter((x) => typeof x === "string");
|
|
191
|
+
};
|
|
192
|
+
const parseJson = (t) => { try { return JSON.parse(t); } catch { return null; } };
|
|
193
|
+
if (/__kj_rag_query$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
194
|
+
const parts = Array.isArray(response?.content) ? response.content : [];
|
|
195
|
+
recordRag(input.text, parts.flatMap((p) => sourcesOf(parseJson(String(p?.text || "")))), parts.map((p) => String(p?.text || "")).join(String.fromCharCode(10)));
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
149
198
|
if (/__update_card$/.test(String(tool)) && /^mcp__/.test(String(tool))) {
|
|
150
199
|
// The MCP response reaches the hook as {content:[{text:"<json>"}]}: once
|
|
151
200
|
// stringified, the inner quotes are ESCAPED — match both forms (found live:
|
|
@@ -171,6 +220,18 @@ process.stdin.on("end", () => {
|
|
|
171
220
|
}
|
|
172
221
|
if (tool === "Bash") {
|
|
173
222
|
const cmdText = String(input.command || "");
|
|
223
|
+
// CLI form of the ledger: the human output lists one hit per line as
|
|
224
|
+
// "[kind · label · score=…] <path>"; --json prints an array with "source".
|
|
225
|
+
const ragCmd = /kj +rag +query/.exec(cmdText);
|
|
226
|
+
if (ragCmd) {
|
|
227
|
+
const out = typeof response === "string" ? response : String(response.stdout || "") + String(response.stderr || "");
|
|
228
|
+
const fromLines = out.split(String.fromCharCode(10))
|
|
229
|
+
.filter((l) => l.includes("score=") && l.includes("] "))
|
|
230
|
+
.map((l) => l.slice(l.lastIndexOf("] ") + 2).trim())
|
|
231
|
+
.filter(Boolean);
|
|
232
|
+
recordRag(cmdText.slice(ragCmd.index), fromLines.concat(sourcesOf(parseJson(out.trim()))), out);
|
|
233
|
+
process.exit(0);
|
|
234
|
+
}
|
|
174
235
|
// gh prints "merged pull request #N" only on a TTY: under a tool call the
|
|
175
236
|
// success line is ABSENT (found live on the first dogfood merge). The
|
|
176
237
|
// authoritative signal is the PR state itself: one gh pr view per merge.
|
|
@@ -230,6 +291,8 @@ const STOP_BODY = `#!/usr/bin/env node
|
|
|
230
291
|
// one with its remediation). Fails OPEN on corrupt state, git errors, or
|
|
231
292
|
// after 3 unresolved blocks — a sentinel bug never bricks the session — and
|
|
232
293
|
// the fail-open is recorded in the state. \`--status\` prints, never blocks.
|
|
294
|
+
import console from "node:console";
|
|
295
|
+
import process from "node:process";
|
|
233
296
|
import { spawnSync } from "node:child_process";
|
|
234
297
|
import { readFileSync } from "node:fs";
|
|
235
298
|
import { homedir } from "node:os";
|
|
@@ -250,7 +313,7 @@ if (process.argv.includes("--status")) {
|
|
|
250
313
|
const sessions = Object.entries(st.sessions || {});
|
|
251
314
|
if (!sessions.length) console.log("sentinel: sin actividad registrada en esta sesion");
|
|
252
315
|
for (const [sid, s] of sessions) {
|
|
253
|
-
console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : ""));
|
|
316
|
+
console.log("session " + sid + ": sources=[" + (s.edited_sources || []).join(", ") + "] tests=[" + (s.edited_tests || []).join(", ") + "] escapes=[" + (s.escapes || []).join(", ") + "] blocks=" + (s.blocks || 0) + ((s.errors || []).length ? " errors=" + s.errors.length : "") + " rag=" + (s.rag_queries || []).length + " queries/" + (s.rag_hits || []).length + " sources");
|
|
254
317
|
for (const x of violations(s, branch)) console.log(" ROJO: " + x);
|
|
255
318
|
}
|
|
256
319
|
process.exit(0);
|
|
@@ -337,6 +400,8 @@ const PRETOOL_BODY = `#!/usr/bin/env node
|
|
|
337
400
|
// before the damage, not in the post-mortem. Exit 2 blocks (stderr says the
|
|
338
401
|
// remediation); read-only tools are never wired here; every honored escape
|
|
339
402
|
// is recorded as an auditable event. Fails OPEN on anything unexpected.
|
|
403
|
+
import console from "node:console";
|
|
404
|
+
import process from "node:process";
|
|
340
405
|
import { dirname, join, relative, resolve } from "node:path";
|
|
341
406
|
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
342
407
|
import { spawnSync } from "node:child_process";
|
|
@@ -660,7 +725,7 @@ process.stdin.on("end", () => {
|
|
|
660
725
|
// LITERALES (tambien dentro de sustituciones) los escanea el bucle
|
|
661
726
|
// de tokens de abajo; el residuo (expansion anidada en segmentos
|
|
662
727
|
// runner) queda documentado como en la regla de ficheros PROTECTED.
|
|
663
|
-
if (/>{1,2}[ \\t]*["']?[
|
|
728
|
+
if (/>{1,2}[ \\t]*["']?[$\`]/.test(cmd)) {
|
|
664
729
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
665
730
|
else {
|
|
666
731
|
console.error("karajan sentinel: redireccion con destino tras variable/sustitucion — el guard de carriles no puede verificarlo (MONO-0); usa una ruta LITERAL (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
@@ -735,7 +800,7 @@ process.stdin.on("end", () => {
|
|
|
735
800
|
}
|
|
736
801
|
const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
|
|
737
802
|
const segs = cmd.split(/&&|\\|\\||[;|\\n]/).map((s) => s.trim()).filter(Boolean);
|
|
738
|
-
if (!segs.every((s) => !/[
|
|
803
|
+
if (!segs.every((s) => !/[$]/.test(s) || SAFE_EXP_SEG.test(s))) {
|
|
739
804
|
if (escOn("KJ_ALLOW_CROSS_LANE")) { recordEscape(sid, "KJ_ALLOW_CROSS_LANE", tool); }
|
|
740
805
|
else {
|
|
741
806
|
console.error("karajan sentinel: expansion de shell en una herramienta generica de fichero/interprete — el objetivo no es verificable por el guard de carriles (MONO-0); usa rutas LITERALES o un runner del toolchain (o KJ_ALLOW_CROSS_LANE=1, queda registrado)." + doc("cross-lane"));
|
|
@@ -835,6 +900,28 @@ process.stdin.on("end", () => {
|
|
|
835
900
|
: "karajan sentinel: la rama '" + branch + "' no referencia ninguna card — crea/mueve la card a running (kj hu add | kj hu move) y usa una rama feat/<CARD-ID>-descripcion. (KJ_ALLOW_NO_CARD=1 = excepcion consciente, queda registrada)" + doc("card-first"));
|
|
836
901
|
process.exit(2);
|
|
837
902
|
}
|
|
903
|
+
// KJC-TSK-0848 (ADR 0010, RAG-B): the RAG must have answered about this
|
|
904
|
+
// zone before the session touches it. Covered = the session ledger
|
|
905
|
+
// (RAG-A) holds this file or a sibling of its directory; a NEW file only
|
|
906
|
+
// needs the session to have consulted at all. Same file set as card-first.
|
|
907
|
+
const rs = load().sessions?.[sid] || {};
|
|
908
|
+
const hits = rs.rag_hits || [];
|
|
909
|
+
const dirOf = (p) => (p.includes("/") ? p.slice(0, p.lastIndexOf("/")) : "");
|
|
910
|
+
const fresh = !existsSync(String(file));
|
|
911
|
+
// A path outside this tree is another lane's business (the lane guard
|
|
912
|
+
// above rules on it) and the ledger only holds in-repo paths.
|
|
913
|
+
// KJC-BUG-0190: with nothing indexed, no query can cover anything —
|
|
914
|
+
// the gate asks for kj rag index instead of denying the impossible.
|
|
915
|
+
const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || (fresh && (rs.rag_queries || []).length > 0) || rs.rag_index_empty === true;
|
|
916
|
+
if (!covered) {
|
|
917
|
+
if (escOn("KJ_ALLOW_NO_RAG")) {
|
|
918
|
+
// Recorded ONCE per session: the escape is a conscious exception, not a per-edit tax.
|
|
919
|
+
if (!(rs.escapes || []).includes("KJ_ALLOW_NO_RAG")) recordEscape(sid, "KJ_ALLOW_NO_RAG", tool);
|
|
920
|
+
} else {
|
|
921
|
+
console.error("karajan sentinel: rag-first — el RAG no ha respondido sobre " + rel + " en esta sesion; consulta antes de tocarlo: kj_rag_query / kj rag query <que hace " + rel + " y donde mas vive ese concepto>. (KJ_ALLOW_NO_RAG=1 = excepcion consciente, queda registrada)" + doc("rag-first"));
|
|
922
|
+
process.exit(2);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
838
925
|
}
|
|
839
926
|
}
|
|
840
927
|
if (tool === "Bash") {
|
|
@@ -102,11 +102,16 @@ export function installWorkflows({
|
|
|
102
102
|
results.push({ file: label, action: "skipped" });
|
|
103
103
|
continue;
|
|
104
104
|
}
|
|
105
|
+
// KJC-BUG-0182: prettier's YAML printer puts a blank line between the last
|
|
106
|
+
// line of a block scalar (`run: |`) and a comment at column 0 — which is
|
|
107
|
+
// exactly our closing marker. Every template ends with a blank line, so the
|
|
108
|
+
// file harden writes is the file prettier would write (the pre-commit's
|
|
109
|
+
// format:check must accept its own harness).
|
|
105
110
|
const { content, action } = upsertManagedBlock({
|
|
106
111
|
source,
|
|
107
112
|
blockId: wf.blockId,
|
|
108
113
|
version: BLOCK_VERSION,
|
|
109
|
-
body: wf.body
|
|
114
|
+
body: wf.body.endsWith("\n") ? wf.body : `${wf.body}\n`,
|
|
110
115
|
style: "hash",
|
|
111
116
|
});
|
|
112
117
|
if (!dryRun && action !== "unchanged") {
|
|
@@ -89,7 +89,6 @@ export const policyWorkflowFor = (kjVersion, kjName = "karajan-code") => [
|
|
|
89
89
|
" run: |",
|
|
90
90
|
` if [ -f bin/kj.js ]; then npm ci --ignore-scripts && KJ="node bin/kj.js"; else KJ="npx --yes ${kjName}@${kjVersion}"; fi`,
|
|
91
91
|
' $KJ policy check --range "origin/${BASE_REF}...HEAD" --strict',
|
|
92
|
-
"", // línea en blanco pre-marcador: prettier la exige tras un block scalar
|
|
93
92
|
].join("\n");
|
|
94
93
|
|
|
95
94
|
const header = (steps) =>
|
|
@@ -263,7 +262,7 @@ export function mutationWorkflowFor(language, pm = "npm") {
|
|
|
263
262
|
"name: Mutation (nightly)",
|
|
264
263
|
"on:",
|
|
265
264
|
" schedule:",
|
|
266
|
-
|
|
265
|
+
' - cron: "0 4 * * 1"',
|
|
267
266
|
" workflow_dispatch:",
|
|
268
267
|
"permissions:",
|
|
269
268
|
" contents: read",
|
|
@@ -28,7 +28,7 @@ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
|
|
|
28
28
|
const why = !gh || !email ? "no active gh session or git email" : "non-interactive run";
|
|
29
29
|
const pending = `identity not declared for this clone (${why}) — run: ${hint}`;
|
|
30
30
|
logger?.warn?.(`kj identity: ${pending}`);
|
|
31
|
-
return { declared: false, identity: null, pending };
|
|
31
|
+
return { declared: false, identity: null, pending, command: hint };
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
const wizard = (deps.makeWizard || createWizard)();
|
|
@@ -37,7 +37,7 @@ export async function ensureIdentity({ projectDir, logger = null, deps = {} }) {
|
|
|
37
37
|
if (!ok) {
|
|
38
38
|
const pending = `identity not declared (declined) — run: ${hint}`;
|
|
39
39
|
logger?.warn?.(`kj identity: ${pending}`);
|
|
40
|
-
return { declared: false, identity: null, pending };
|
|
40
|
+
return { declared: false, identity: null, pending, command: hint };
|
|
41
41
|
}
|
|
42
42
|
} finally {
|
|
43
43
|
wizard.close();
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0850 (ADR 0010, RAG-D) — index coverage as a defect. A gate built
|
|
3
|
+
* on the RAG protects nothing it cannot see, so the sources the indexer's
|
|
4
|
+
* OWN matchers would take (src/, scripts/, bin/, packages/*: the walker
|
|
5
|
+
* takes the whole repo minus the skip segments) are compared against the
|
|
6
|
+
* chunks the store holds for this project, and against the commit the
|
|
7
|
+
* index was stamped with. No config declares the directories twice: the
|
|
8
|
+
* matchers are the single truth, here and in the indexer.
|
|
9
|
+
*/
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
import { isAbsolute, relative } from "node:path";
|
|
12
|
+
import { detectAdaptersForProject, buildMatchers } from "../lang/registry.js";
|
|
13
|
+
import { getLastIndexedCommit, projectSlug } from "./vec-store.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} projectDir
|
|
17
|
+
* @param {{db: object}} deps - an open vec store
|
|
18
|
+
* @returns {Promise<{project: string, total: number, indexed: number, missing: string[], stale: string[], lastIndexedCommit: string|null, absent: boolean}>}
|
|
19
|
+
*/
|
|
20
|
+
export async function ragIndexCoverage(projectDir, { db }) {
|
|
21
|
+
const project = projectSlug(projectDir);
|
|
22
|
+
const matchers = buildMatchers(detectAdaptersForProject(projectDir));
|
|
23
|
+
const { stdout } = await execa("git", ["-C", projectDir, "ls-files", "-z"]);
|
|
24
|
+
const sources = stdout.split("\0").filter((p) => p && matchers.isCodeFile(p) && !matchers.shouldSkip(p));
|
|
25
|
+
|
|
26
|
+
const rows = db.prepare("SELECT DISTINCT source FROM chunks WHERE kind = 'code' AND project_slug = ?").all(project);
|
|
27
|
+
const indexed = new Set(rows.map((r) => (isAbsolute(r.source) ? relative(projectDir, r.source) : r.source)));
|
|
28
|
+
const missing = sources.filter((p) => !indexed.has(p));
|
|
29
|
+
|
|
30
|
+
const lastIndexedCommit = getLastIndexedCommit(db, project);
|
|
31
|
+
let stale = [];
|
|
32
|
+
if (lastIndexedCommit) {
|
|
33
|
+
// Files the repo changed after the stamp: the delta update has not seen
|
|
34
|
+
// them yet, so what the store says about them is old.
|
|
35
|
+
// The stamp must be an ANCESTOR of HEAD: a commit that still exists but
|
|
36
|
+
// sits on a rewritten branch would diff fine and report selective
|
|
37
|
+
// staleness, when in truth freshness is unknown. Fail closed.
|
|
38
|
+
const ancestor = await execa("git", ["-C", projectDir, "merge-base", "--is-ancestor", lastIndexedCommit, "HEAD"], { reject: false });
|
|
39
|
+
if (ancestor.exitCode !== 0) {
|
|
40
|
+
throw new Error(`the RAG index stamp ${lastIndexedCommit.slice(0, 9)} is not reachable from HEAD — kj rag index --with-sources`);
|
|
41
|
+
}
|
|
42
|
+
const diff = await execa("git", ["-C", projectDir, "diff", "--name-only", lastIndexedCommit, "HEAD"]);
|
|
43
|
+
const changed = new Set(diff.stdout.split("\n").filter(Boolean));
|
|
44
|
+
stale = sources.filter((p) => indexed.has(p) && changed.has(p));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { project, total: sources.length, indexed: sources.length - missing.length, missing, stale, lastIndexedCommit, absent: indexed.size === 0 };
|
|
48
|
+
}
|
|
@@ -45,6 +45,8 @@ export async function runOneShotReview({
|
|
|
45
45
|
diff, task, config, logger, projectDir,
|
|
46
46
|
// KJC-TSK-0838: what the sonar pre-gate saw, persisted with the verdict.
|
|
47
47
|
sonar = null,
|
|
48
|
+
// KJC-TSK-0849 (ADR 0010): what the session's RAG ledger proved, same place.
|
|
49
|
+
rag = null,
|
|
48
50
|
hostAgent = detectHostAgent(),
|
|
49
51
|
createAgentFn = createAgent,
|
|
50
52
|
detectAgents = detectAvailableAgents,
|
|
@@ -126,6 +128,7 @@ export async function runOneShotReview({
|
|
|
126
128
|
suggestions: parsed.non_blocking_suggestions || [],
|
|
127
129
|
summary: parsed.summary || parsed.raw_summary || "",
|
|
128
130
|
...(sonar ? { sonar } : {}),
|
|
131
|
+
...(rag ? { rag } : {}),
|
|
129
132
|
confidence: parsed.confidence ?? null,
|
|
130
133
|
});
|
|
131
134
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0849 (ADR 0010, RAG-C) — the session's RAG ledger as the review
|
|
3
|
+
* sees it. The Sentinel records every RAG consultation per host session
|
|
4
|
+
* (RAG-A) in `.karajan/harness/sentinel-state.json`. `kj review` runs as a
|
|
5
|
+
* CLI and does not know the host's session id, so it takes the session with
|
|
6
|
+
* the most recent activity (`at`): MONO-0 forbids two sessions mutating one
|
|
7
|
+
* tree, so the last one that acted is the one now reviewing. Said here and
|
|
8
|
+
* in the verdict (`sessionId`), never assumed in silence.
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { verifySentinelScripts } from "../harden/sentinel-hooks.js";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} projectDir
|
|
16
|
+
* @returns {{harness: boolean, verified: boolean, mismatched: string[], available: boolean, reason?: string, sessionId?: string, queries: object[], hits: string[]}}
|
|
17
|
+
* `harness` false = no `.karajan/harness` in this tree: no host session can
|
|
18
|
+
* keep a ledger, so the requirement does not apply (said, not assumed).
|
|
19
|
+
* `verified` = the scripts on disk are byte-identical to what the INSTALLED
|
|
20
|
+
* kj writes (the tamper check's root of trust): an empty directory, an
|
|
21
|
+
* edited hook or one that predates the ledger all read as unverified, and
|
|
22
|
+
* the requirement fails closed until the human re-runs `kj harden`.
|
|
23
|
+
*/
|
|
24
|
+
export function readRagLedger(projectDir) {
|
|
25
|
+
const harnessDir = path.join(projectDir, ".karajan", "harness");
|
|
26
|
+
const harness = existsSync(harnessDir);
|
|
27
|
+
const check = harness ? verifySentinelScripts({ projectDir }) : { ok: false, mismatched: [] };
|
|
28
|
+
const base = { harness, verified: harness && check.ok === true, mismatched: check.mismatched || [], queries: [], hits: [] };
|
|
29
|
+
let state;
|
|
30
|
+
try {
|
|
31
|
+
state = JSON.parse(readFileSync(path.join(harnessDir, "sentinel-state.json"), "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return { ...base, available: false, reason: harness ? "no session activity recorded by the Sentinel yet" : "no Sentinel harness in this tree (kj harden installs it)" };
|
|
34
|
+
}
|
|
35
|
+
const sessions = Object.entries(state?.sessions || {});
|
|
36
|
+
if (sessions.length === 0) {
|
|
37
|
+
return { ...base, available: false, reason: "the sentinel state holds no session" };
|
|
38
|
+
}
|
|
39
|
+
const [sessionId, s] = sessions.sort((a, b) => (b[1]?.at || 0) - (a[1]?.at || 0))[0];
|
|
40
|
+
return { ...base, available: true, sessionId, queries: Array.isArray(s?.rag_queries) ? s.rag_queries : [], hits: Array.isArray(s?.rag_hits) ? s.rag_hits : [] };
|
|
41
|
+
}
|