karajan-code 4.38.0 → 4.40.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/packages/ai-trash/src/destructive-parser.js +34 -5
- package/packages/ai-trash/src/hook.js +28 -6
- package/src/audit/ai-slop-findings.js +4 -2
- package/src/audit/circular-deps.js +8 -7
- package/src/audit/webperf-input.js +3 -1
- package/src/checks/method.js +1 -1
- package/src/checks/release-check.js +3 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +112 -1
- package/src/cli/register-pipeline.js +2 -1
- package/src/commands/init.js +6 -2
- package/src/commands/review-gate.js +21 -22
- package/src/commands/rules-approve.js +59 -0
- package/src/commands/rules-compile.js +116 -0
- package/src/commands/rules-decide.js +51 -0
- package/src/commands/rules-review.js +44 -0
- package/src/commands/rules.js +169 -0
- package/src/config/loader.js +23 -1
- package/src/environment/adr.js +5 -2
- package/src/harden/config-templates.js +3 -0
- package/src/harden/harness-hooks.js +7 -85
- package/src/harden/hook-templates.js +6 -5
- package/src/harden/human-act.js +70 -0
- package/src/harden/phone-sign.js +26 -0
- package/src/harden/sentinel/pretooluse-rules.mjs +25 -0
- package/src/harden/sentinel/sentinel-bash-write.mjs +2 -6
- package/src/harden/sentinel/sentinel-discard.mjs +33 -4
- package/src/harden/sentinel/sentinel-rules.mjs +86 -0
- package/src/harden/sentinel/sentinel-shell.mjs +34 -2
- package/src/harden/sentinel/sessionstart.mjs +18 -9
- package/src/harden/sentinel-hooks.js +111 -194
- package/src/harden/supervisor-commit.js +13 -56
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/sovereignty-guard.js +16 -15
- package/src/orchestrator/preflight-checks.js +4 -3
- package/src/policy/supervisor-verify.js +20 -6
- package/src/privacy/scan.js +7 -0
- package/src/review/card-first.js +3 -5
- package/src/review/gate-gitignore.js +8 -0
- package/src/review/policy-gate.js +4 -6
- package/src/review/sonar-pregate.js +3 -3
- package/src/review/tests-with-code.js +3 -6
- package/src/rules/approval-view.js +49 -0
- package/src/rules/compiled.js +108 -0
- package/src/rules/coverage.js +23 -0
- package/src/rules/evaluate.js +60 -0
- package/src/rules/inventory.js +118 -0
- package/src/sonar/api.js +21 -0
- package/src/sonar/config-resolver.js +19 -1
- package/src/sonar/scanner.js +22 -3
- package/src/utils/run-log.js +74 -2
- package/src/utils/stack-detect.js +12 -0
package/package.json
CHANGED
|
@@ -35,14 +35,43 @@ function classifyMv(tokens) {
|
|
|
35
35
|
return { destructive: true, kind: "mv-overwrite", paths: positional.slice(-1), reason: "mv may overwrite destination" };
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// KJC-BUG-0240 (#1886): checkout and restore drop uncommitted work file by file,
|
|
39
|
+
// and a bundle keeps commits only, so the named files are kept as files and a
|
|
40
|
+
// discard of the whole tree marks it (worktree) for the hook to keep every dirty file.
|
|
41
|
+
function classifyDiscard(tokens, sub) {
|
|
42
|
+
const args = tokens.slice(2);
|
|
43
|
+
const has = (...flags) => flags.some((f) => args.includes(f));
|
|
44
|
+
const SAFE = { destructive: false, kind: "git-safe", paths: [] };
|
|
45
|
+
if (sub === "restore" && has("--staged", "-S") && !has("--worktree", "-W")) return { ...SAFE, reason: "restore --staged only unstages" };
|
|
46
|
+
// -B resets an existing branch: its commits need the bundle.
|
|
47
|
+
if (sub === "checkout" && has("-B")) return { destructive: true, kind: "git-checkout-reset-branch", bundle: true, paths: [], reason: "git checkout -B resets an existing branch" };
|
|
48
|
+
if (sub === "checkout" && has("-b", "--orphan")) return { ...SAFE, reason: "checkout -b creates a branch" };
|
|
49
|
+
const dd = args.indexOf("--");
|
|
50
|
+
const named = [];
|
|
51
|
+
let sourceValue = false;
|
|
52
|
+
args.forEach((a, i) => {
|
|
53
|
+
if (sourceValue) sourceValue = false;
|
|
54
|
+
else if (a === "-s" || a === "--source") sourceValue = true;
|
|
55
|
+
else if ((dd >= 0 && i > dd) || !a.startsWith("-")) named.push(a);
|
|
56
|
+
});
|
|
57
|
+
if (!named.length && !has("-f", "--force")) return { ...SAFE, reason: `${sub} with nothing to discard` };
|
|
58
|
+
const whole = has("-f", "--force") || named.includes(".") || named.includes(":/");
|
|
59
|
+
return { destructive: true, kind: `git-${sub}-discard`, paths: named.filter((p) => p !== "." && p !== ":/"), ...(whole ? { worktree: "tracked" } : {}), reason: `git ${sub} discards local edits` };
|
|
60
|
+
}
|
|
61
|
+
|
|
38
62
|
function classifyGit(tokens) {
|
|
39
63
|
const sub = tokens[1];
|
|
40
64
|
const has = (...flags) => flags.some((f) => tokens.includes(f));
|
|
41
|
-
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", paths: [], reason: "git reset --hard drops working tree" };
|
|
42
|
-
if (sub === "clean" &&
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
if (sub === "reset" && has("--hard")) return { destructive: true, kind: "git-reset-hard", bundle: true, worktree: "tracked", paths: [], reason: "git reset --hard drops working tree" };
|
|
66
|
+
if (sub === "clean" && tokens.some((t) => t === "--force" || /^-[a-zA-Z]*f/.test(t))) {
|
|
67
|
+
// -x / -X also remove ignored files.
|
|
68
|
+
const ignored = tokens.some((t) => /^-[a-zA-Z]*[xX]/.test(t));
|
|
69
|
+
return { destructive: true, kind: "git-clean", bundle: true, worktree: ignored ? "untracked+ignored" : "untracked", paths: [], reason: "git clean -f removes untracked files" };
|
|
70
|
+
}
|
|
71
|
+
if (sub === "branch" && has("-D")) return { destructive: true, kind: "git-branch-D", bundle: true, paths: [], reason: "git branch -D drops unmerged branch" };
|
|
72
|
+
if (sub === "push" && has("--force", "-f", "--force-with-lease")) return { destructive: true, kind: "git-push-force", bundle: true, paths: [], reason: "git push --force rewrites remote history" };
|
|
73
|
+
if (sub === "checkout" || sub === "restore") return classifyDiscard(tokens, sub);
|
|
74
|
+
if (sub === "switch" && has("-f", "--force", "--discard-changes")) return { destructive: true, kind: "git-switch-discard", worktree: "tracked", paths: [], reason: "git switch --discard-changes drops local edits" };
|
|
46
75
|
return { destructive: false, kind: "git-safe", paths: [], reason: "git op without destructive flags" };
|
|
47
76
|
}
|
|
48
77
|
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// throws, deny the op so Claude never destroys without a recovery copy.
|
|
5
5
|
|
|
6
6
|
import { promises as fs } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
7
8
|
import { isAbsolute, resolve } from "node:path";
|
|
8
9
|
import process from "node:process";
|
|
9
10
|
import { classifyCommand } from "./destructive-parser.js";
|
|
@@ -47,6 +48,29 @@ async function snapshotExistingPaths(root, paths, cwd, command) {
|
|
|
47
48
|
return snapshots;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
// KJC-BUG-0240 (#1886): the dirty files a whole-tree discard would drop, kept as
|
|
52
|
+
// files (a bundle keeps commits only). `which`: "tracked" changes, "untracked"
|
|
53
|
+
// files, or "untracked+ignored" (git clean -x/-X removes ignored files too).
|
|
54
|
+
async function snapshotDirtyFiles(root, cwd, command, which) {
|
|
55
|
+
if (!cwd) return [];
|
|
56
|
+
const top = spawnSync("git", ["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" });
|
|
57
|
+
if (top.status !== 0) return [];
|
|
58
|
+
const withIgnored = which === "untracked+ignored";
|
|
59
|
+
// traditional + all: every file inside an ignored directory is listed one by one.
|
|
60
|
+
const st = spawnSync("git", ["-C", cwd, "status", "--porcelain", "-z", "--untracked-files=all", ...(withIgnored ? ["--ignored=traditional"] : [])], { encoding: "utf8" });
|
|
61
|
+
if (st.status !== 0) throw new Error(`git status failed: ${st.stderr.trim()}`);
|
|
62
|
+
const wanted = (rec) => (which === "tracked" ? !rec.startsWith("??") && !rec.startsWith("!!") : rec.startsWith("??") || (withIgnored && rec.startsWith("!!")));
|
|
63
|
+
const files = [];
|
|
64
|
+
let renameSource = false; // a rename or copy record is followed by its source path
|
|
65
|
+
for (const rec of st.stdout.split("\0")) {
|
|
66
|
+
if (renameSource) { renameSource = false; continue; }
|
|
67
|
+
if (rec.length < 4) continue;
|
|
68
|
+
if (wanted(rec)) files.push(rec.slice(3));
|
|
69
|
+
renameSource = "RC".includes(rec[0]);
|
|
70
|
+
}
|
|
71
|
+
return snapshotExistingPaths(root, files, top.stdout.trim(), command);
|
|
72
|
+
}
|
|
73
|
+
|
|
50
74
|
async function snapshotGitRepo(root, cwd, command, kind) {
|
|
51
75
|
if (!cwd) return { snapshots: [], skipped: "no cwd" };
|
|
52
76
|
try {
|
|
@@ -86,11 +110,9 @@ export async function handleHookPayload(payload, { root, stdin }) {
|
|
|
86
110
|
await assertOwnedByCurrentUser(root);
|
|
87
111
|
let snapshots = [];
|
|
88
112
|
let skipped = null;
|
|
89
|
-
if (verdict.kind
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
snapshots = await snapshotExistingPaths(root, verdict.paths, data.cwd, command);
|
|
93
|
-
}
|
|
113
|
+
if (verdict.bundle) ({ snapshots, skipped } = await snapshotGitRepo(root, data.cwd, command, verdict.kind));
|
|
114
|
+
snapshots.push(...(await snapshotExistingPaths(root, verdict.paths, data.cwd, command)));
|
|
115
|
+
if (verdict.worktree) snapshots.push(...(await snapshotDirtyFiles(root, data.cwd, command, verdict.worktree)));
|
|
94
116
|
if (snapshots.length) {
|
|
95
117
|
const m = await loadManifest(root);
|
|
96
118
|
for (const s of snapshots) m.entries.push(s);
|
|
@@ -106,7 +128,7 @@ export async function handleHookPayload(payload, { root, stdin }) {
|
|
|
106
128
|
if (snapshots.length) {
|
|
107
129
|
return reply(
|
|
108
130
|
"allow",
|
|
109
|
-
`ai-trash: snapshotted ${snapshots.length} ${
|
|
131
|
+
`ai-trash: snapshotted ${snapshots.length} ${snapshots.some((s) => s.type === "git-bundle") ? "item(s), git bundle included," : "path(s)"} before ${verdict.kind}`
|
|
110
132
|
);
|
|
111
133
|
}
|
|
112
134
|
return reply(
|
|
@@ -8,9 +8,11 @@ import path from "node:path";
|
|
|
8
8
|
|
|
9
9
|
const FILE_EXTS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"]);
|
|
10
10
|
const IGNORE_DIRS = new Set([
|
|
11
|
-
"node_modules", "dist", "build", "coverage",
|
|
11
|
+
"node_modules", "dist", "build", "coverage", "vendor",
|
|
12
12
|
".git", ".karajan", "public", ".next", ".nuxt", ".vercel", ".cache",
|
|
13
13
|
]);
|
|
14
|
+
// KJC-BUG-0254 (#1902): a minified bundle is third-party output, not the project's prose.
|
|
15
|
+
const isMinified = (name) => /\.min\.[cm]?js$/.test(name);
|
|
14
16
|
const VERB = "(?:returns?|gets?|sets?|fetches?|loads?|saves?|creates?|builds?|makes?|computes?|handles?|checks?|validates?)";
|
|
15
17
|
const LINE_PATTERNS = {
|
|
16
18
|
"banner-separators": /\/[/*]\s*[=*\-_#~]{5,}/,
|
|
@@ -70,7 +72,7 @@ function listSourceFiles(root) {
|
|
|
70
72
|
if (e.isDirectory()) {
|
|
71
73
|
if (IGNORE_DIRS.has(e.name) || e.name.startsWith(".")) continue;
|
|
72
74
|
stack.push(full);
|
|
73
|
-
} else if (FILE_EXTS.has(path.extname(e.name))) out.push(full);
|
|
75
|
+
} else if (FILE_EXTS.has(path.extname(e.name)) && !isMinified(e.name)) out.push(full);
|
|
74
76
|
}
|
|
75
77
|
}
|
|
76
78
|
return out;
|
|
@@ -44,11 +44,12 @@ async function findTsConfig(projectDir) {
|
|
|
44
44
|
return undefined;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function pickEntrypoint(projectDir) {
|
|
48
|
-
// madge needs an entrypoint
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
47
|
+
async function pickEntrypoint(projectDir) {
|
|
48
|
+
// madge needs an entrypoint: `src/` covers most JS/TS projects. KJC-BUG-0267:
|
|
49
|
+
// a project with no src/ (a static site with its scripts in js/ or at the
|
|
50
|
+
// root) is scanned whole; excludeRegExp keeps node_modules and build output out.
|
|
51
|
+
const src = path.join(projectDir, "src");
|
|
52
|
+
try { await fs.access(src); return src; } catch { return projectDir; }
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
/**
|
|
@@ -65,9 +66,9 @@ export async function collectCircularDeps(projectDir, stack, config = {}, logger
|
|
|
65
66
|
return { available: false, reason: "no JS/TS sources detected — circular-dep scan skipped" };
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const entry = pickEntrypoint(projectDir);
|
|
69
|
+
const entry = await pickEntrypoint(projectDir);
|
|
69
70
|
try { await fs.access(entry); } catch {
|
|
70
|
-
return { available: false, reason: `
|
|
71
|
+
return { available: false, reason: `project directory ${entry} not found` };
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
let madge;
|
|
@@ -93,7 +93,9 @@ export function collectWebPerfInput(stack, config = {}) {
|
|
|
93
93
|
// a frontend layer to audit. Backend-only projects get nothing.
|
|
94
94
|
const isFrontendish = !stack || stack.isFrontend === true || stack.isFullstack === true;
|
|
95
95
|
if (!isFrontendish) {
|
|
96
|
-
|
|
96
|
+
// KJC-BUG-0267: no tier detected is not "backend-only"; say what was (not) seen.
|
|
97
|
+
const why = stack.isBackend ? "project is backend-only" : "no frontend layer detected (no frontend framework and no index.html)";
|
|
98
|
+
return { available: false, reason: `${why} — no frontend-perf hints to give` };
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
return { available: true, mode: "static-hints" };
|
package/src/checks/method.js
CHANGED
|
@@ -36,7 +36,7 @@ export async function collectMethodStats({ projectDir, run = runCommand, sample
|
|
|
36
36
|
const blocks = blocksRes.exitCode === 0
|
|
37
37
|
? blocksRes.stdout.split(/^@.*$/m).map((b) => b.split("\n").map((l) => l.trim()).filter(Boolean)).filter((b) => b.length > 0)
|
|
38
38
|
: [];
|
|
39
|
-
const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files
|
|
39
|
+
const offenders = blocks.filter((files) => checkTestsWithCode({ config: {}, stagedFiles: files }).mode !== "pass").length;
|
|
40
40
|
|
|
41
41
|
const verdicts = recentVerdicts(projectDir, sample);
|
|
42
42
|
const stamped = verdicts.filter((v) => v.workspace);
|
|
@@ -34,7 +34,9 @@ async function policyRangeCheck(projectDir) {
|
|
|
34
34
|
// protects the supervisor. `kj review` and `kj policy check` already lift
|
|
35
35
|
// what the sealed provenance backs; without this the release check blocked
|
|
36
36
|
// every version cut after a human `kj harden --commit`, by its own seal.
|
|
37
|
-
|
|
37
|
+
// KJC-BUG-0259: the history is judged on what is versioned (hooks, provenance);
|
|
38
|
+
// the machine-local guards were verified when the seal was reviewed.
|
|
39
|
+
const sup = liftSealedSupervisorViolations({ projectDir, violations: raw, trackedOnly: true });
|
|
38
40
|
const violations = sup.violations;
|
|
39
41
|
const hard = violations.filter((v) => v.enforcement === "deny");
|
|
40
42
|
const skipped = prScoped.size > 0 ? `; ${prScoped.size} diff-threshold invariant(s) skipped (PR-scoped)` : "";
|
|
@@ -34,7 +34,7 @@ export const ADVANCED_GROUPS = [
|
|
|
34
34
|
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
|
|
35
35
|
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
|
|
36
36
|
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
37
|
-
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "claims", "steward", "pr-size"] },
|
|
37
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "claims", "steward", "pr-size"] },
|
|
38
38
|
{ title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity"] },
|
|
39
39
|
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
|
|
40
40
|
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
|
package/src/cli/register-meta.js
CHANGED
|
@@ -43,6 +43,12 @@ import { verifySentinelScripts, resolveSentinelRoot } from "../harden/sentinel-h
|
|
|
43
43
|
import { boardGate } from "../review/board-pending.js";
|
|
44
44
|
import { panelDeviation } from "../environment/panel.js";
|
|
45
45
|
import { detectHostAgent } from "../utils/agent-detect.js";
|
|
46
|
+
import { listRules } from "../rules/inventory.js";
|
|
47
|
+
import { PROPOSAL_FILE, readToolInput, rulesCheck, rulesCoverage, rulesEval, rulesTest } from "../commands/rules.js";
|
|
48
|
+
import { rulesApprove } from "../commands/rules-approve.js";
|
|
49
|
+
import { rulesDecide } from "../commands/rules-decide.js";
|
|
50
|
+
import { rulesReview } from "../commands/rules-review.js";
|
|
51
|
+
import { rulesCompileBrief } from "../commands/rules-compile.js";
|
|
46
52
|
|
|
47
53
|
/**
|
|
48
54
|
* Register the "meta" / single-role / housekeeping commands: pre-pipeline
|
|
@@ -296,7 +302,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
296
302
|
.action(async (title, flags) => {
|
|
297
303
|
await withConfig(pkgVersion, "adr", flags, async ({ config }) => {
|
|
298
304
|
const res = await addAdr(config?.projectDir || process.cwd(), { title, ...flags });
|
|
299
|
-
console.log(flags.json ? JSON.stringify(res) : `✓ ADR ${res.number} created: ${res.file} — commit it`);
|
|
305
|
+
console.log(flags.json ? JSON.stringify(res) : `✓ ADR ${res.number} created as ${res.status}: ${res.file} — commit it and ask your user; accepting it is theirs (Status: accepted)`);
|
|
300
306
|
});
|
|
301
307
|
});
|
|
302
308
|
adr.command("list")
|
|
@@ -310,6 +316,111 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
310
316
|
});
|
|
311
317
|
});
|
|
312
318
|
|
|
319
|
+
// MDR-A2 (KJC-TSK-0942, ADR 0016): the rules of the governing MD files, each with an id.
|
|
320
|
+
const rules = program.command("rules").description("Rules written in the MD files that govern a session (ADR 0016)");
|
|
321
|
+
rules.command("list")
|
|
322
|
+
.option("--json", "Machine-readable output")
|
|
323
|
+
.action(async (flags) => {
|
|
324
|
+
await withConfig(pkgVersion, "rules", flags, async ({ config }) => {
|
|
325
|
+
const found = listRules(config?.projectDir || process.cwd());
|
|
326
|
+
if (flags.json) { console.log(JSON.stringify(found)); return; }
|
|
327
|
+
for (const r of found) console.log(`${r.id} ${r.file}:${r.line} ${r.text}`);
|
|
328
|
+
console.log(`${found.length} rule(s)`);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
// MDR-B2 (KJC-TSK-0943): the compiled rules (.karajan/rules.yml) against one tool call.
|
|
332
|
+
rules.command("eval")
|
|
333
|
+
.description("Evaluate ONE tool call against .karajan/rules.yml: prints the verdict as JSON; exit 2 on deny, 1 if it cannot be evaluated")
|
|
334
|
+
.option("--tool <tool>", "Tool name (Bash, Edit, mcp__server__tool…)")
|
|
335
|
+
.option("--input <json>", "tool_input as JSON, or - to read it from stdin", "{}")
|
|
336
|
+
.action(async (flags) => {
|
|
337
|
+
await withConfig(pkgVersion, "rules-eval", flags, async ({ config }) => {
|
|
338
|
+
const res = rulesEval({ projectDir: config?.projectDir || process.cwd(), tool: flags.tool, input: await readToolInput(flags.input) });
|
|
339
|
+
console.log(JSON.stringify(res.output));
|
|
340
|
+
process.exitCode = res.code;
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
// MDR-B3 (KJC-TSK-0945): a compiled rule proves its compilation with its own examples.
|
|
344
|
+
rules.command("test")
|
|
345
|
+
.description("Run every compiled rule's deny/allow examples against the rule itself; exit 1 on any failure")
|
|
346
|
+
.action(async (flags) => {
|
|
347
|
+
await withConfig(pkgVersion, "rules-test", flags, async ({ config }) => {
|
|
348
|
+
const res = rulesTest({ projectDir: config?.projectDir || process.cwd() });
|
|
349
|
+
for (const line of res.lines) console.log(line);
|
|
350
|
+
process.exitCode = res.code;
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
// MDR-D1 (KJC-TSK-0950): a proposal proves itself before a human reads it.
|
|
354
|
+
rules.command("check")
|
|
355
|
+
.description("Check a proposal of compiled rules: every rule is one of the MD files, cites its literal text and passes its own examples; exit 1 otherwise")
|
|
356
|
+
.option("--file <path>", "The proposal", PROPOSAL_FILE)
|
|
357
|
+
.action(async (flags) => {
|
|
358
|
+
await withConfig(pkgVersion, "rules-check", flags, async ({ config }) => {
|
|
359
|
+
const res = rulesCheck({ projectDir: config?.projectDir || process.cwd(), file: flags.file });
|
|
360
|
+
for (const line of res.lines) console.log(line);
|
|
361
|
+
process.exitCode = res.code;
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
// MDR-D (KJC-TSK-0940): the brief for whoever compiles the rules with no gate.
|
|
365
|
+
rules.command("compile")
|
|
366
|
+
.description("Print the brief to compile the rules with no gate into a proposal (.karajan/rules.proposed.yml); kj calls no model")
|
|
367
|
+
.action(async (flags) => {
|
|
368
|
+
await withConfig(pkgVersion, "rules-compile", flags, async ({ config }) => {
|
|
369
|
+
const res = rulesCompileBrief({ projectDir: config?.projectDir || process.cwd() });
|
|
370
|
+
for (const line of res.lines) console.log(line);
|
|
371
|
+
process.exitCode = res.code;
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
// MDR-G (KJC-TSK-0969): most rules take no condition; their kind is decided in one go.
|
|
375
|
+
rules.command("decide <ids...>")
|
|
376
|
+
.description("Give several rules of the proposal their kind at once: judgment (with --tool) or out-of-scope (with --reason). Deterministic rules are written by hand")
|
|
377
|
+
.requiredOption("--kind <kind>", "judgment | out-of-scope")
|
|
378
|
+
.option("--tool <glob...>", "judgment: the tool(s) the rule shows on (Bash, Edit, mcp__server__tool…)")
|
|
379
|
+
.option("--reason <text>", "out-of-scope: why no tool call breaks the rule")
|
|
380
|
+
.option("--file <path>", "The proposal", PROPOSAL_FILE)
|
|
381
|
+
.action(async (ids, flags) => {
|
|
382
|
+
await withConfig(pkgVersion, "rules-decide", flags, async ({ config }) => {
|
|
383
|
+
const res = rulesDecide({ projectDir: config?.projectDir || process.cwd(), file: flags.file, ids, kind: flags.kind, tools: flags.tool, reason: flags.reason });
|
|
384
|
+
for (const line of res.lines) console.log(line);
|
|
385
|
+
process.exitCode = res.code;
|
|
386
|
+
});
|
|
387
|
+
});
|
|
388
|
+
// MDR-F4 (KJC-TSK-0963): whoever proposes the compilation does not call it good.
|
|
389
|
+
rules.command("review")
|
|
390
|
+
.description("A DIFFERENT AI reviews a checked proposal, rule by rule: is each compilation as strong as its text? The verdict is tied to the proposal's exact content")
|
|
391
|
+
.option("--file <path>", "The proposal", PROPOSAL_FILE)
|
|
392
|
+
.action(async (flags) => {
|
|
393
|
+
await withConfig(pkgVersion, "rules-review", flags, async ({ config, logger }) => {
|
|
394
|
+
const res = await rulesReview({ projectDir: config?.projectDir || process.cwd(), file: flags.file, config, logger });
|
|
395
|
+
for (const line of res.lines) console.log(line);
|
|
396
|
+
process.exitCode = res.code;
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
// MDR-D3 (KJC-TSK-0952): the proposal becomes rules.yml by a human act only.
|
|
400
|
+
rules.command("approve")
|
|
401
|
+
.description("HUMAN act: show a checked and cross-reviewed proposal and install it as the project's rules; no agent session runs it (ADR 0009)")
|
|
402
|
+
.option("--file <path>", "The proposal", PROPOSAL_FILE)
|
|
403
|
+
.action(async (flags) => {
|
|
404
|
+
await withConfig(pkgVersion, "rules-approve", flags, async ({ config }) => {
|
|
405
|
+
const res = await rulesApprove({ projectDir: config?.projectDir || process.cwd(), file: flags.file });
|
|
406
|
+
for (const line of res.lines) console.log(line);
|
|
407
|
+
process.exitCode = res.code;
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
// MDR-E (KJC-TSK-0941): which rules of the MD files have a gate, and which do not.
|
|
411
|
+
rules.command("coverage")
|
|
412
|
+
.description("Every rule of the MD files against .karajan/rules.yml: deterministic, judgment, out of scope, with no gate; and the stale compiled ones")
|
|
413
|
+
.option("--strict", "Exit 1 while a rule has no gate or a compiled rule is stale")
|
|
414
|
+
.option("--json", "Machine-readable output")
|
|
415
|
+
.action(async (flags) => {
|
|
416
|
+
await withConfig(pkgVersion, "rules-coverage", flags, async ({ config }) => {
|
|
417
|
+
const res = rulesCoverage({ projectDir: config?.projectDir || process.cwd(), strict: flags.strict });
|
|
418
|
+
if (flags.json && res.output) console.log(JSON.stringify(res.output));
|
|
419
|
+
else for (const line of res.lines) console.log(line);
|
|
420
|
+
process.exitCode = res.code;
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
313
424
|
// AB-F (KJC-TSK-0655): self-healing — the brain files kj frictions upstream.
|
|
314
425
|
program
|
|
315
426
|
.command("report-issue")
|
|
@@ -201,7 +201,8 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
201
201
|
program
|
|
202
202
|
.command("solomon")
|
|
203
203
|
.description("Ask a third AI to arbitrate a rejected review verdict (brain ≠ reviewer ≠ solomon)")
|
|
204
|
-
.
|
|
204
|
+
.option("--position <text>", "Why the brain disagrees with the reviewer")
|
|
205
|
+
.option("--position-file <path>", "Read the position from a file (no quoting to get right)")
|
|
205
206
|
.option("--range <range>", "Arbitrate a git range instead of the staged diff")
|
|
206
207
|
.action(async (flags) => {
|
|
207
208
|
await withConfig(pkgVersion, "solomon", flags, async ({ config, logger }) => {
|
package/src/commands/init.js
CHANGED
|
@@ -414,7 +414,7 @@ async function runWizard(config, logger) {
|
|
|
414
414
|
* @param {object} config
|
|
415
415
|
*/
|
|
416
416
|
export async function writeInitConfig(configPath, config) {
|
|
417
|
-
|
|
417
|
+
return writeConfig(configPath, config);
|
|
418
418
|
}
|
|
419
419
|
|
|
420
420
|
async function handleConfigSetup({ config, configExists, interactive, configPath, logger }) {
|
|
@@ -938,7 +938,11 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
938
938
|
// Use writeInitConfig so the deprecated `sonarqube.enabled` key —
|
|
939
939
|
// which setupSonarQube still mutates as an in-memory hint — never
|
|
940
940
|
// reaches the YAML file.
|
|
941
|
-
await writeInitConfig(configPath, config);
|
|
941
|
+
const written = await writeInitConfig(configPath, config);
|
|
942
|
+
// KJC-BUG-0253: said out loud, so nobody looks for the token where it was not written.
|
|
943
|
+
if (written?.strippedSecrets?.length) {
|
|
944
|
+
logger.info(`Kept ${written.strippedSecrets.join(", ")} out of ${configPath} (versioned with the repo); it lives in ~/.karajan/kj.config.yml`);
|
|
945
|
+
}
|
|
942
946
|
|
|
943
947
|
// Telemetry: anonymous install event (non-blocking)
|
|
944
948
|
const { readFileSync } = await import("node:fs");
|
|
@@ -42,7 +42,6 @@ async function enforceCardFirst({ config, projectDir }) {
|
|
|
42
42
|
// KJC-TSK-0732: the verification LEVEL is part of the verdict — a pass on
|
|
43
43
|
// branch-ref alone must never read as a tracker-verified pass.
|
|
44
44
|
if (card.note) console.log(`⚠ card-first: ${card.note}`);
|
|
45
|
-
if (card.mode === "exempt" && card.reason.includes("KJ_ALLOW_NO_CARD")) console.log(`⚠ card-first exempt: ${card.reason}`);
|
|
46
45
|
if (!card.ok) {
|
|
47
46
|
console.log(`✗ card-first gate: ${card.reason}`);
|
|
48
47
|
process.exitCode = 1;
|
|
@@ -139,6 +138,14 @@ function printVerdict(record) {
|
|
|
139
138
|
*/
|
|
140
139
|
export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
141
140
|
const projectDir = config?.projectDir || process.cwd();
|
|
141
|
+
// KJC-BUG-0260: a position is prose with blanks and slashes; from a file it
|
|
142
|
+
// needs no quoting the lane guard has to tell apart from a path.
|
|
143
|
+
const position = flags.positionFile ? readFileSync(flags.positionFile, "utf8").trim() : flags.position;
|
|
144
|
+
if (!position) {
|
|
145
|
+
console.log("✗ kj solomon: give the brain's position with --position <text> or --position-file <path>");
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return { ruling: "reject", reasoning: "no position given" };
|
|
148
|
+
}
|
|
142
149
|
const diff = await rawDiff(flags.range);
|
|
143
150
|
// KJC-TSK-0734 (PL-B): un hallazgo de policy de clase seguridad no es
|
|
144
151
|
// arbitrable — solomon se niega antes de gastar un token. Y una policy
|
|
@@ -162,7 +169,7 @@ export async function solomonCommand({ config, logger = null, flags = {} }) {
|
|
|
162
169
|
process.exitCode = 1;
|
|
163
170
|
return { ruling: "reject", reasoning: "security-class policy denial — not arbitrable" };
|
|
164
171
|
}
|
|
165
|
-
const res = await runSolomonArbitration({ diff, position
|
|
172
|
+
const res = await runSolomonArbitration({ diff, position, config, logger, projectDir });
|
|
166
173
|
if (res.ruling === "approve") {
|
|
167
174
|
console.log(`⚖ Solomon (${res.solomon}) rules for the brain — verdict recorded, the gate is open.`);
|
|
168
175
|
} else {
|
|
@@ -256,10 +263,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
256
263
|
// partitioning is a decision, and it must never split code from its tests.
|
|
257
264
|
const split = testAdded > 0 ? ` (${added - testAdded} source + ${testAdded} accompanying tests — partition by feature, never code from its tests)` : "";
|
|
258
265
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work, or get your user's explicit OK and re-run with KJ_ALLOW_LARGE_PR=1`;
|
|
266
|
+
// ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
|
|
267
|
+
if (sizePolicy === "block") {
|
|
268
|
+
const reason = `${added} lines added${split} exceeds the ${sizeWarn}-line budget (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
|
|
263
269
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
264
270
|
process.exitCode = 1;
|
|
265
271
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
@@ -269,7 +275,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
269
275
|
}
|
|
270
276
|
}
|
|
271
277
|
// KJC-TSK-0705 (PV-B): the outbound privacy boundary at commit time.
|
|
272
|
-
// Denylist data rejects (
|
|
278
|
+
// Denylist data rejects with no escape (ADR 0015: false positives go in the allow list); generic PII
|
|
273
279
|
// warns (privacy.generic: "block" hardens). Added lines only — deletions
|
|
274
280
|
// don't publish. In the SEA binary the privacy module is stubbed and
|
|
275
281
|
// throws: the gate degrades with a note instead of crashing.
|
|
@@ -288,25 +294,19 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
288
294
|
for (const f of warns) console.log(`⚠ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked} — personal data? move it out before it ships`);
|
|
289
295
|
const hardened = config?.privacy?.generic === "block" && warns.length > 0;
|
|
290
296
|
if (blocks.length > 0 || hardened) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
console.log(`✗ privacy gate: ${reason}`);
|
|
297
|
-
process.exitCode = 1;
|
|
298
|
-
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
299
|
-
}
|
|
297
|
+
for (const f of blocks) console.log(`✗ privacy: [${f.type}] ${f.source}:${f.line} → ${f.masked}`);
|
|
298
|
+
const reason = `${blocks.length || warns.length} personal-data finding(s) in the staged diff — this must not reach the repo (a confirmed false positive goes in the allow list of ~/.karajan/privacy.yml)`;
|
|
299
|
+
console.log(`✗ privacy gate: ${reason}`);
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
300
302
|
}
|
|
301
303
|
} catch (err) {
|
|
302
304
|
// Known degradation: the SEA stub throws its install-from-npm message.
|
|
303
305
|
// Anything else fails CLOSED — a silent skip would defeat the guarantee.
|
|
304
306
|
if (/standalone binary/i.test(err?.message || "")) {
|
|
305
307
|
console.log("⚠ privacy gate unavailable in this build — skipping");
|
|
306
|
-
} else if (process.env.KJ_ALLOW_PII === "1") {
|
|
307
|
-
console.log(`⚠ privacy gate errored (${err.message}) — KJ_ALLOW_PII=1 (explicit escape hatch)`);
|
|
308
308
|
} else {
|
|
309
|
-
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked
|
|
309
|
+
const reason = `privacy gate error: ${err.message} — refusing to pass the boundary unchecked`;
|
|
310
310
|
console.log(`✗ ${reason}`);
|
|
311
311
|
process.exitCode = 1;
|
|
312
312
|
return { verdict: "rejected", reviewer: "privacy", issues: [{ severity: "high", description: reason }] };
|
|
@@ -330,9 +330,8 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
330
330
|
|
|
331
331
|
// KJC-TSK-0734 (PL-B): la policy declarativa como gate determinista, en
|
|
332
332
|
// --staged Y en --check — el pre-commit hereda los dientes sin regenerar
|
|
333
|
-
// hooks. enforcement=warn avisa; deny cierra
|
|
334
|
-
//
|
|
335
|
-
// del diff); class=security cierra sin escape y sin arbitraje.
|
|
333
|
+
// hooks. enforcement=warn avisa; deny cierra sin excepción por diff (ADR
|
|
334
|
+
// 0015); class=security cierra además sin arbitraje.
|
|
336
335
|
// KJC-BUG-0208: el mismo modulo que decide el presupuesto decide el metrico
|
|
337
336
|
// del invariante. Sumar aqui el numstat entero daba dos cifras para la misma
|
|
338
337
|
// regla en dos lineas seguidas del mismo comando.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kj rules approve (KJC-TSK-0952, MDR-D3, ADR 0016): a proposal of compiled rules
|
|
3
|
+
* becomes .karajan/rules.yml only by a HUMAN act, with the layers of the
|
|
4
|
+
* supervisor's seal (ADR 0009). The agent the rules will watch may propose how
|
|
5
|
+
* they are compiled; it does not decide.
|
|
6
|
+
*
|
|
7
|
+
* The proposal is read ONCE: what is checked, what is shown and what is
|
|
8
|
+
* installed are the same rules, whatever happens to the file meanwhile.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import yaml from "js-yaml";
|
|
14
|
+
|
|
15
|
+
import { confirmHuman, refuseAgentSession } from "../harden/human-act.js";
|
|
16
|
+
import { checkVerdict } from "../review/verdict-store.js";
|
|
17
|
+
import { approvalView } from "../rules/approval-view.js";
|
|
18
|
+
import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } from "./rules.js";
|
|
19
|
+
|
|
20
|
+
const ACT = "kj rules approve";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{projectDir: string, file?: string, home?: string, env?: object, tty?: boolean,
|
|
24
|
+
* deps?: {confirm?: Function, ancestry?: object}, log?: (line: string) => void}} opts
|
|
25
|
+
* @returns {Promise<{code: 0|1, lines: string[]}>}
|
|
26
|
+
*/
|
|
27
|
+
export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env, tty, deps = {}, log = console.log }) {
|
|
28
|
+
refuseAgentSession(ACT, { env, tty, ancestry: deps.ancestry ?? {} });
|
|
29
|
+
const proposal = path.resolve(projectDir, file);
|
|
30
|
+
let text;
|
|
31
|
+
try { text = fs.readFileSync(proposal, "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to approve`] }; }
|
|
32
|
+
const checked = rulesCheck({ projectDir, home, text });
|
|
33
|
+
if (checked.code !== 0) return { code: 1, lines: checked.lines };
|
|
34
|
+
// KJC-TSK-0963: whoever wrote the proposal does not call it good. A different
|
|
35
|
+
// AI must have approved these exact bytes; a touched proposal is reviewed again.
|
|
36
|
+
const reviewed = await checkVerdict(projectDir, text);
|
|
37
|
+
if (!reviewed.ok) {
|
|
38
|
+
const found = (reviewed.verdict?.issues ?? []).map((issue) => ` - ${issue.description ?? issue.message ?? JSON.stringify(issue)}`);
|
|
39
|
+
const why = reviewed.verdict ? "rejected by " + reviewed.verdict.reviewer : "none recorded for its exact content";
|
|
40
|
+
return { code: 1, lines: [`✗ this proposal has no approved cross-AI review (${why}): run \`kj rules review\``, ...found] };
|
|
41
|
+
}
|
|
42
|
+
log(`Reviewed by ${reviewed.verdict.reviewer}, a different AI from the one that wrote it: ${reviewed.verdict.summary || "approved"}`);
|
|
43
|
+
const { rules, local } = checked;
|
|
44
|
+
// KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
|
|
45
|
+
const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
|
|
46
|
+
for (const line of view) log(line);
|
|
47
|
+
confirmHuman(ACT, deps.confirm);
|
|
48
|
+
// KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
|
|
49
|
+
// is not versioned. Where it goes is the inventory's word, not the proposal's.
|
|
50
|
+
const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
|
|
51
|
+
for (const [name, part] of parts) {
|
|
52
|
+
const target = path.join(projectDir, name);
|
|
53
|
+
if (part.length) fs.writeFileSync(target, yaml.dump({ version: 1, rules: part }, { lineWidth: -1 }));
|
|
54
|
+
else fs.rmSync(target, { force: true }); // no rule left for this file: none stays behind
|
|
55
|
+
}
|
|
56
|
+
fs.rmSync(proposal, { force: true });
|
|
57
|
+
const [[, versioned], [, kept]] = parts;
|
|
58
|
+
return { code: 0, lines: [`✓ ${versioned.length} rule(s) approved into ${RULES_FILE} (commit it, it travels with the repo) and ${kept.length} into ${LOCAL_RULES_FILE} (not versioned, yours alone)`] };
|
|
59
|
+
}
|